返回博客列表

agentd:用操作系统的思路驯服 AI Agent 的 Rust 运行时

2026-10-07T22:00:00+08:00
AI AgentRustMCPAgent Runtime

AI Agent 框架这两年越做越重:内置几十个工具、插件生态、prompt 管理一应俱全。但一个现实问题始终没被认真回答——当模型陷入死循环、被 prompt injection 劫持,或者开始烧你的 token 预算时,谁来按下终止键?

agentd 给出的答案相当"反框架":一个 8.5 MiB 的 Rust 静态二进制,不内置任何工具,连 HTTP 客户端都是手写的,整个运行时连 tokio 这样的 async runtime 都不用。它把 Agent 当成一个操作系统进程管理问题来解决:推理跑在随时可以 killpg(SIGKILL) 的子进程里,而负责杀进程的 supervisor 本身永远不跟 LLM 说一句话。

本文带你过一遍 agentd 的核心设计。

本文大纲:

  1. 一个不内置任何工具的 Agent 运行时
  2. 两个循环:supervisor 不推理,推理跑在能被杀掉的子进程里
  3. Daemon 而非脚本:状态持久化与重启恢复
  4. 手写的边界:哪些自己写,哪些用 SDK
  5. 安全模型:模型是不可信的输入
  6. 上手:60 秒跑起第一个 Agent

一个不内置任何工具的 Agent 运行时

agentd 定位很克制:它一次只运行一个 agent。你给它一条指令和一个 LLM endpoint,它就跑 agentic loop——思考、调用工具、观察、重复——直到任务到达终止状态,或者有新事件把它唤醒。

它和常见 Agent 框架有三个本质区别,都写在官方 overview 里:

第一,它不 ship 任何工具。 Agent 的每一项能力都来自你指定的远程 MCP server。没有内置的文件系统工具、没有 shell、没有 HTTP 工具库。这意味着一次运行的影响范围(blast radius)恰好等于你接入的 server 集合。本地命令执行(exec)存在,但默认在两层独立机制下关闭,开启后还有围栏。

第二,推理跑在"能被杀死"的地方。 一个从不与模型对话的 supervisor 负责 lifecycle、限额和进程树;真正的 agentic loop 跑在子进程里。一个死循环、乱花钱或被 jailbreak 的模型,会被一个"无法被提示词说服"的进程控制住。

第三,它是 daemon,不是脚本。 状态存在 store 里,重启是恢复(resume)而不是重新开始(restart)。终端和浏览器可以作为瘦客户端挂到同一个运行中的 agent 上,多个客户端可以同时观察同一个会话。

两个循环:supervisor 不推理,推理跑在能被杀掉的子进程里

agentd 的架构核心是"两个循环"的刻意分离:一个确定性的 supervisor 循环,和一个活在子进程里的 think-act-observe 循环。

graph TB
    subgraph "agentd main process - Supervisor"
        S["Supervisor
never talks to the LLM"] ST["Durable Store"] end subgraph "Child Processes - flat tree" C1["Turn Worker
Agentic Loop"] C2["Subagent
Agentic Loop"] end LLM["LLM Endpoint"] MCP["MCP Servers"] S -->|owns| ST S -->|spawn + supervise| C1 S -->|spawn + supervise| C2 C1 -->|"one LLM endpoint"| LLM C1 -->|tool calls| MCP C2 -->|tool calls| MCP

Supervisor 的职责清单很短:解析并校验配置(非法配置在产生任何副作用前就以 exit 2 拒绝)、连接 MCP server、武装触发器然后闲置等待、spawn 并监督子进程。它自己不做任何推理。

子进程则是扁平的——无论逻辑上 agent 树多深,每个 turn worker 和 subagent 都是 supervisor 的直接子进程。子进程想创建子进程,只能通过回调 supervisor 拥有的 subagent 工具这个唯一咽喉点,深度、宽度和 spawn 速率限制都在这里强制执行。fork bomb 的下场是一条被拒绝的工具结果,而不是一场崩溃——限制器是一个默认 8/2s 的惰性令牌桶。

当一轮推理失控时,拆除流程是一条有界的 kill ladder:先尝试优雅取消 → 5 秒宽限期后 killpg(SIGTERM) → 再 2 秒后 killpg(SIGKILL) → 最后 waitpid 收尸。内核在三个点配合强制执行:

  • 每个子进程通过 setpgid(0, 0) 拥有自己的进程组,ladder 可以瞄准整棵子树;
  • 子进程在自己 main 里设置 PR_SET_PDEATHSIG(SIGKILL),supervisor 崩溃时进程树从叶到根自动坍塌;
  • supervisor 设置 PR_SET_CHILD_SUBREAPER,孤儿孙进程会被重新挂到 agentd 名下而不是宿主 init。

为什么要放弃 async runtime?agentd 作者的论证是关于正确性而非品味:出问题真正需要被取消的是一个子进程,而能可靠终结子进程的只有 killpg——future drop 做不到这件事。 共享的工作窃取线程池还会重新引入这个设计本要避免的故障模式:一个卡死的东西饿死一切。

于是整个运行时是一个"单写者反应器":一个线程排空所有 channel、触发定时器、派发 turn、做 checkpoint,然后阻塞在唯一一个 recv_timeout 上。持久状态的变更只发生在这一个线程。读线程、executor 线程、MCP 通知泵都存在,但它们一律不碰状态,只往 channel 里投事件。一台空闲 daemon 的账面:Threads: 1,RSS 约 5.5 MiB,6 秒累计 1 个 CPU tick——不到 0.2% 的一颗核。

Daemon 而非脚本:状态持久化与重启恢复

agentd 的持久状态是一个小到只有四个操作的 key-value 契约:

put(key, seq, envelope) → Ok | Conflict{latest_seq} | Err(io)
get(key[, seq]) → Some(envelope) | None | Err(io)
list(prefix) → [{key, seq}] | Unsupported | Err(io)
delete(key) → Ok | Unsupported | Err(io)

关键设计有两条:

put 是基于 seq 的 compare-and-set,冲突是致命的。 如果另一个写者持有某个 key,实例会停止接单而不是继续竞争。这是防脑裂(split-brain)守卫,也是多个副本共享一个命名空间仍然安全的原因。

"接受即持久"(accept means durable)。 入站消息或触发的 trigger 在被处理之前先写入 inbox,SendMessage 只在该写入完成后才确认。崩溃恢复时,未完成的 inbox 记录会以原始 event id 重新投递。你得到的是恰好一次的状态转移和至少一次的副作用——这是诚实的组合,因为 agentd 无法替你把一次远程工具调用变成幂等的,它只会把幂等键(_meta["agent/idempotency_key"])带上,让行为良好的服务端去折叠重放。

store 本身不绑定任何数据库——agentd 不链接任何数据库客户端,只定义了四个 adapter:file(本地每 key 一个文件,长驻实例的默认选择)、mcp(把四个操作映射到任意 MCP server 的工具上)、http(普通 HTTP)、memory(进程内,测试用)。容器里的进程随时可能被驱逐,容器的文件系统不是持久性边界,所以一旦进程是一次性的,正确答案就是让 store 指向一个远程端点——agentd 选择继承别人已有的运维故事,而不是发明一个更差的。

手写的边界:哪些自己写,哪些用 SDK

agentd 的工程哲学浓缩成一条规则:规格小、稳定、且只用得到一部分的东西,就手写;其余交给依赖。

手写组件 位置 替代依赖本来会是什么
HTTP/1.1 client + SSE reader net/src/http.rs,690 行 ureq → url → IDNA → ICU
X.509 字段提取 net/src/x509.rs,304 行 x509-parser
YAML 子集 reader instruction/src/yaml.rs,1,307 行 serde_yaml(已无人维护)
JSON Schema 子集 (2020-12) jsonschema.rs,803 行 校验器外加一个正则引擎
5 字段 UTC cron triggers/timer.rs,216 行 croner / cron
Prometheus 文本输出 obs/metrics.rs prometheus / metrics

这条规则同样划出了反方向的两条线:TLS 用 rustls——因为 TLS 规格既不小也不冻结;MCP 用官方 rmcp SDK,A2A 用从协议 protobuf 生成的 a2a-rs。这里有个非常诚实的工程反思:MCP 和 A2A 本来也小到可以手写,但他们实测发现,从自己对照规格书实现的协议,失败方式是"静默地、在对端失败"——你写的测试和你写的代码编码了同一种误读,它们互相一致,直到别人的客户端在生产环境里报出一个"什么都没发生"的错。最终结论是采用独立实现,让 wire shape 来自协议自身 schema 生成的类型,而不是靠第二个读者来 review。

最终的账目:发布产物是静态链接的 musl 二进制(LTO、stripped、opt-level = "z"),amd64 下约 8.5 MiB,压缩下载 3.6 MiB,可以直接 FROM scratch——没有 shell,没有 libc,没有包管理器。

安全模型:模型是不可信的输入

agentd 的安全页面开宗明义:模型是持有凭证的不可信输入。 prompt injection 成功后,agent loop 会用你的权限发出攻击者选定的工具调用。他们不做任何"识别恶意指令"的尝试——一个 95% 有效率的 guardrail 在安全语境下就是失败——而是把防御做成结构性的:限定被劫持的 loop 能碰到什么,而不是猜它想干什么。

核心机制是三个由运营者声明的 tag 和一条"三分法则"(Rule of Two 的变体):

  • untrusted_input:返回不可控来源内容的工具(网页、入站邮件、issue 文本)
  • sensitive:触及私有数据或特权系统的工具(密钥库、内部数据库、生产控制面)
  • egress:能把数据移出去或改变外部状态的工具(HTTP POST、发邮件、开 PR)

预算是跨所有工具做 OR 折叠(不是计数),规则就是 legs() < 3:任意两个 tag 可以共存,三个同时出现则拒绝启动——默认在配置校验阶段就 exit 2,不会产生任何副作用。一个能读密钥又能 POST 的工具没问题,只要同一个授权里没有工具在读不可信输入。

成本控制同样是结构性的。默认限额是 --max-steps 500、--max-tokens 2000000、--deadline 3600s,困惑或失控的 loop 不可能烧掉无限预算。而且 agentd 把退出码当成 API:脚本和外部调度器可以基于退出码分支处理——0 完成、4 LLM 不可达、5 模型拒绝执行、6 必需的 MCP server 宕机、7 预算耗尽(步数/token/超时)、124 supervisor 硬杀兜底、2 配置非法。

还有一个容易被忽视的细节:stdout 携带 agent 的结果,stderr 携带 JSON-lines 遥测,所有日志打上同一个 run_id/agent_id/agent_path 关联元组,密钥永远以 *** 出现—— intelligence token 不会出现在任何日志行和模型 transcript 里。

上手:60 秒跑起第一个 Agent

安装是一条命令(脚本会校验 release 的 SHA256SUMS,装到 /usr/local/bin 或 ~/.local/bin):

curl -fsSL https://agentd.dev/install.sh | sh
agentd --version

默认模式是 once:把指令跑到终止状态,stdout 打印结果,退出。比如给 agent 一个文件系统 MCP server,让它读报告写摘要:

agentd \
  --instruction "Read /data/report.md and write a 3-bullet summary to /data/summary.md" \
  --intelligence https://gw.example/v1 \
  --mcp fs=https://mcp-fs.internal/mcp

三个参数各司其职:--instruction 是任务(文本、文件路径或 URI 均可);--intelligence 是唯一的一个 LLM endpoint;--mcp name=endpoint 声明一个远程 Streamable-HTTP MCP server,agentd 会连上去通过 tools/list 发现工具——注意它不会 spawn 任何本地进程,MCP server 全是远程 HTTP 端点。

同一套指令还有两种长期形态。loop 起始节点让 agent 按定时器反复运行,适合轮询型任务,且迭代状态可跨重启存活;subscribe 起始节点则让 agent 在近乎零 CPU 的状态下闲置,直到它订阅的 MCP resource 发生变化才被唤醒——一个真正的响应式 daemon:

workflows:
  - name: react
    steps:
      s: { kind: subscribe, server: fs, uri: "file:///data/inbox" }
      w: { kind: agent, depends_on: [s],
           instruction: "Process the new inbox item into /data/done." }
      f: { kind: finish, depends_on: [w] }

想"对话式"地使用而不是"驾驶"它,一条命令起 daemon 加终端界面:agentd tui --config agent.yaml(或 agentd ui 走浏览器)。客户端退出后 agent 继续工作——会话属于 daemon,不属于客户端,再挂一个界面进来,看到的还是同一份实时状态。

完整文档(含 architecture、subagents、workflows、A2A、deployment、scaling 等二十多个主题)在 agentd.dev/docs,源码中的 design/ 目录还保留了每一条关键决策的 decision record,包括被否决的方案。


作者: itech001 来源: 公众号:AI人工智能时代(the-ai-era) 网站: https://www.theaiera.top/ 关注每日最新AI新闻和技术博客,主页有更多的文章的AI 技术参考:https://www.theaiera.top

本文首发于 AI人工智能时代,转载请注明出处。

分享给朋友