pi-agent-core:agent loop 的设计

如果说 pi-ai 解决了"怎么跟模型说话",pi-agent-core 解决的就是"怎么让模型干活":驱动"LLM → 工具 → LLM"的循环。它的全部源码就是 packages/agent/src/agent-loop.ts(约 800 行)加一个 Agent 类(约 600 行)——比很多框架的一个模块还小,但承载了 pi 的全部运行时行为。本篇逐块拆解。

loop 的解剖

agent-loop.ts 的核心是嵌套的两层循环(以下为示意,细节见源码 runLoop):

外层 while(true)  —— 处理 follow-up(后续消息)
└─ 内层 while(还有 toolCall 或 steering 插队消息)
   ├─ 注入 steering 消息(如果有)
   ├─ 调 LLM,流式产出 assistant 消息
   ├─ 出错/被中止 → 收尾退出
   ├─ 有 toolCall → 执行 → toolResult 进上下文
   └─ turn_end → 检查 shouldStopAfterTurn → 取下一轮 steering
取 follow-up 队列,空则退出,非空则继续外层

几个关键设计:

一个 turn = 一次 LLM 调用 + 由它触发的所有工具执行。loop 转到模型不再发起 toolCall 为止——没有 max steps 旋钮。作者在手记里说得很直白:他从没遇到过需要强制截断的真实场景,多一个旋钮就多一份复杂性和隐藏行为。这与你在实战篇第 02 章写的 while 循环是同一个形状。

steering 与 follow-up 是两种队列。steering(插队消息)在当前 turn 的工具执行完后、下一个 LLM 调用前注入——"等等,先别这样做";follow-up(后续消息)只在 agent 本来要停的时候接管——"做完了再帮我把这个也做了"。队列的存在让"用户随时说话"和"loop 连续运转"解耦,这是交互式 agent 与脚本式 agent 的分水岭。

上下文管线:在调用边界才转换

loop 内部始终流转 AgentMessage[]——pi 自己的消息类型(可以带 usage、stopReason、thinking 块,甚至可以通过 declaration merging 加入应用自定义的消息类型)。LLM 只认识 user/assistant/toolResult。两者的桥接发生在每个 turn 调 LLM 的边界上:

AgentMessage[] → transformContext(可选:裁剪/注入) → convertToLlm(必须:过滤/转换) → 发给 provider

这个"边界转换"是 pi 上下文工程能力的结构基础:

  • 历史里可以放 UI 专属消息(通知、 bash 执行记录、压缩摘要),发送前过滤掉;
  • 裁剪、注入、压缩都挂在 transformContext 这一个钩子上,loop 本身无感;
  • 落盘格式(完整、含一切元数据)与发送格式(精简、provider 专属)彻底分离。

你在 mini-pi 里存的直接是 wire 格式,等于把两者合并了——简单场景没问题,但 pi 的分法在需要"历史里有 LLM 不该看到的东西"时就会显出价值。

工具执行:校验、钩子、并行、截断保护

executeToolCalls 一段值得精读,它处理了真实世界的一堆边角:

  • 参数先校验再执行:工具参数用 TypeBox schema 校验(validateToolArguments),校验失败直接变成错误 toolResult 回给模型——模型看到"参数不符合 schema"会自己修正重发。
  • beforeToolCall / afterToolCall 钩子:执行前可拦截(权限门的挂点),执行后可改写结果或返回 terminate: true 提前收尾。实战篇第 09 章的权限门就是用前者写的。
  • 默认并行执行:一批 toolCall 并发跑(事件按完成顺序发,落盘的 toolResult 消息仍按 assistant 消息里的顺序),但任何工具可以声明 executionMode: "sequential" 强制整批串行——写文件类工具需要这种确定性。
  • length 截断保护:assistant 消息以 stopReason === "length" 结束时,流式拼出来的工具参数可能是残缺 JSON。pi 的处理是把这一整批 toolCall 判失败,回报"参数可能被截断,请重新发起",让模型重试。这是只有真正运营过 agent 的人才会写的代码——mini-pi 在第 04 章照搬了它。

事件:loop 对外的唯一契约

loop 不碰 UI,只发事件。完整目录:

agent_start / agent_end          —— 一轮运行的首尾
turn_start / turn_end            —— 每个 turn 的首尾
message_start / message_update / message_end
tool_execution_start / tool_execution_update / tool_execution_end

一次带工具调用的运行,事件序列是:

agent_start → turn_start
  message_start(user) → message_end
  message_start(assistant) → message_update(text_delta × N) → message_end
  tool_execution_start → (update) → tool_execution_end
  message_start(toolResult) → message_end
turn_end → turn_start → message_start(assistant) → … → turn_end → agent_end

pi 的四种运行模式(TUI 交互、print、JSON、RPC)都只是这套事件的不同消费者;Agent.subscribe() 的监听器按注册顺序被 await,agent_end 的监听器构成运行结束前的最后一道屏障。mini-pi 第 08 章的渲染器不过十几行,正是因为渲染策略被完全推出了 loop

Agent 类:loop 之上的状态与队列

低层 agentLoop() 是纯事件流;日常用的是 Agent 类,它在 loop 之上加了:

  • state:systemPrompt/model/thinkingLevel/tools/messages 全部可读写,运行中可换模型、换工具;
  • prompt() / continue()(从当前上下文续跑,错误恢复用)/ abort() / waitForIdle();
  • steer() / followUp() 两个队列的入队口,投递模式可配(one-at-a-timeall);
  • 与低层 loop 的一个关键语义差别:Agent 把 assistant 的 message_end 处理当作工具预检前的屏障(barrier),保证 beforeToolCall 看到的 state 已经包含发起调用的那条 assistant 消息——直接用裸 agentLoop() 则没有这个保证。

给造 agent 的你的启示

  • loop 的本质很小:两层 while + 一个事件出口。复杂性应该住在工具里、钩子里、UI 里,而不是 loop 里
  • 上下文在边界转换,而不是在源头统一——落盘留住一切,发送按需裁剪。
  • 为"脏数据"设计:参数校验失败、length 截断、工具抛错,全部变成给模型的文本,而不是让程序崩溃。模型是 loop 的一部分,错误信息是它的输入
  • steering/follow-up 这类"交互态"能力,从第一天就影响 loop 的形状;后补很痛苦。

动手对照:mini-pi 裸写版(第 02 章)是这张解剖图的极简版,pi 栈版(第 08 章)直接使用了本篇的 Agent。下一篇:coding-agent:harness 层 →