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

函数叫 runLoop,在 packages/agent/src/agent-loop.ts 第 172 到 330 行。一个 while (true)(188 行)管外层,一个 while (hasMoreToolCalls || pendingMessages.length > 0)(192 行)管内层。
外层只问一件事:follow-up 队列里还有没有活。有就塞进 pendingMessages,continue 再来一轮;没有就 break。
内层每轮干四件事:
- 准备——调
prepareNextTurn钩子,再把 steer 消息注入上下文。 - 请求——调
prepareRequest钩子,然后向 LLM 发一次请求。 - 执行——有 toolCall 就执行,toolResult 写回上下文。
- 收尾——调
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);不涉及未在源码中出现的实测行为。