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):
几个关键设计:
一个 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 的边界上:
这个"边界转换"是 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,只发事件。完整目录:
一次带工具调用的运行,事件序列是:
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-time或all);- 与低层 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 层 →