Pi 的 Agent 循环:159 行,两个 while,四条退路

原文:pi-agent-core · agent-loop.ts 作者:Mario Zechner(pi-mono / pi-ai 作者) 来源:earendil-works/pi 仓库,@earendil-works/pi-agent-core v1.4.0 阅读环境:2026-10-26 对照 v1.4.0(2026-10-22 发布),agent-loop.ts 第 172–330 行

2026 年 10 月,我想把 Pi 接到自己项目里,顺手翻了一遍它的 agent 循环。

这个仓库半年里从 v0.62.0 涨到 v1.4.0,核心那个 runLoop 从 78 行长到 159 行,骨架却一个字没动。多出来的 80 多行,全是留给上层插嘴的。

【关键词:#Pi #Agent循环 #源码阅读】

01|骨架:两个 while,各管一件事

Pi Agent 循环骨架:外层管队列,内层管 turn

Pi Agent 循环骨架

函数叫 runLoop,在 packages/agent/src/agent-loop.ts 第 172 到 330 行。一个 while (true)(188 行)管外层,一个 while (hasMoreToolCalls || pendingMessages.length > 0)(192 行)管内层。

外层只问一件事:follow-up 队列里还有没有活。有就塞进 pendingMessages,continue 再来一轮;没有就 break。

内层每轮干四件事:

  1. 准备——调 prepareNextTurn 钩子,再把 steer 消息注入上下文。
  2. 请求——调 prepareRequest 钩子,然后向 LLM 发一次请求。
  3. 执行——有 toolCall 就执行,toolResult 写回上下文。
  4. 收尾——调 finishTurn 钩子做决策,然后 poll 一次 steer 队列。

换成大白话:换姿势、把新话插进来、问一次模型、该动手就动手、要收摊就问一句能不能收。

关键在第 1、4 步那两个钩子。循环自己不知道外面要干什么,只是把控制权递出去等结果。v0.62.0 那版内层只有“注入消息、调 LLM、跑工具、poll steer”四件事,现在的 80 行新代码都在处理“外面想改我的上下文、换我的模型、或者让我别跑了”。

02|两个队列:steer 改方向,follow-up 开新轮

Pi 把外部消息分成两类,触发时机完全不同。

  • steer 每个 turn 结束就查一次,查到就塞进下一轮 LLM 的输入——但它不会让外层 continue。
  • follow-up 只在内层自然退出、外层检查时才查,查到就当作新一轮的“用户输入”再开。

举个人话的例子:steer 像同事写代码时你拍他肩膀说“诶这个改成 X”——任务没断,只是中途改了个方向;follow-up 像同事写完一行你才说“等下这步之后还要加 Y”——得等当前任务告一段落,新指令才进。

steer 永远只走内层,follow-up 永远只走外层。翻译一下:steer 只能改“现在这一轮”的方向,不会让循环多开一轮;follow-up 才是“开新一轮”的入口。

这避免了我以前踩过的坑:用户连发 5 条消息,如果每条都让外层再开一轮,循环会变成消息放大器。Pi 这两条路径物理隔离,不会串扰。两个队列默认 one-at-a-time,一次只吐一条。

03|一次任务走完全程

一次任务从 prompt 到 agent_end 的时间线

一次任务走完全程

拿个具体任务走一遍。假设我让 Pi 去看一个仓库的启动命令。

第 0 步|进来。 prompt() 把我这句话塞进上下文,agent_start、turn_start 先后发出去。

第 1 轮|内层。 LLM 说要调 bash。工具跑完,toolResult 写回上下文,turn_end 发出,poll 一次 steer —— 空的。但 hasMoreToolCalls 还是 true,内层接着转。

第 2 轮|内层。 这次 LLM 直接给文字答案,没有 toolCall。turn_end 发出,poll steer —— 还是空的。hasMoreToolCalls 变 false,pending 也空,内层条件不满足,内层退出。

外层。 poll follow-up —— 空的。break,emit agent_end。

全程两次 LLM 请求、两轮 turn、一次 agent_start、一次 agent_end。终端里滚动的那些字,就是这个事件流。

如果我在两轮之间敲一句“改成用 pnpm”,它会在第 2 轮开始前被塞进去——这就是 steer 走的路。等整个任务结束才敲的那句,得等外层 poll 到才开新一轮——这是 follow-up 走的路。

04|三个插嘴点

v1.4.0 的循环里多了三个钩子,是上层唯一能改循环走向的入口:

  • prepareNextTurn(下一轮之前):换上下文、换模型、换思考级别、追加消息。
  • prepareRequest(每次请求之前):换这次请求的模型和上下文。
  • finishTurn(一轮结束之后):返回 end 就收摊,返回 continue 就保证再来一次请求,哪怕两个队列都是空的。

现成用法就在 packages/coding-agent/src/core/agent-session.ts 里:自动压缩挂 prepareNextTurn(_compactBeforeNextAssistantResponse),上下文快撑爆时先压一轮再发请求;虚拟模型路由挂 prepareRequest,发请求前把这次请求转给另一个模型;第三方扩展挂 finishTurn,能在 turn 边界要求“别停”;自动重试挂 agent_end,回头看最后那条 assistant 消息是不是可重试的网络错误。

核心循环一条策略都没有,它只负责在正确的时机把决定权递出去。那 80 多行新代码的用途就在这儿——不是给循环加功能,是给上层加插嘴的地方。

05|四条退路

很多人以为“Agent 退出 = LLM 不想再调 tool 就完事”。Pi 有四条路径,最后都指向同一个 agent_end:

  • LLM 返回 stopReason 是 error 或 aborted → 立刻退出,连队列都不查。(模型报错或用户强行中断,立刻收摊。)
  • finishTurn 返回 {action: "end"} → 同样直接退出。(上层说“够了”,循环就停。)
  • 没有 tool call、steering 也空 → 内层自然结束;follow-up 也空 → 外层 break。(模型说“我干完了”,也没人排队。)
  • 任何异常 → 进 catch,写一条带 errorMessage 的 assistant 占位,再发 agent_end。(出 bug 也要体面收场。)

第四条那个 try/catch 不在 runLoop 里,在 packages/agent/src/agent.ts 的 Agent 类里。核心循环自己不兜底,兜底的是外面那层。这条我也踩过:我以前写 Agent 总在循环里塞 try/catch,错误全被吞掉,排查时根本分不清是模型报错还是我的代码炸了。Pi 让异常一路冒到最外层,变成一条看得见的错误消息。

agent_end 是“这一轮对话正式结束”的广播——终端、SDK、扩展都会收到,而且一次运行只发一次。


写在最后

这 159 行我可以打印出来贴墙上。核心就一句话:Agent 是一个被严格节制的循环,不是被赋能的管道。

半年里它从 78 行长到 159 行,长出来的全是接口,不是分支。把“什么时候该停”分清楚,比把执行过程变复杂重要得多。

你在用哪个 Agent 框架?它退出的边界条件让你卡过吗?


AI 协作声明:本文由作者在 AI 助手协助下阅读 Pi 源码并整理素材,作者完成观点与判断。事实部分已对照 packages/agent/src/agent-loop.ts、agent.ts 与 packages/coding-agent/src/core/agent-session.ts 源码核查(@earendil-works/pi-agent-core v1.4.0);不涉及未在源码中出现的实测行为。