逐段读开源编程 Agent pi 的主循环:一轮里发生了什么,循环靠什么停
本文基于 pi v0.82.1(仓库 commit 027a584,2026-07-28)梳理,该项目仍在快速迭代,具体行为以官方文档 https://pi.dev/docs/latest 与仓库最新代码为准。
pi 的 Agent 主循环里没有任何”最多跑 N 轮”的计数器。 在 packages/agent/src/agent-loop.ts 里搜不到轮数上限这类字段,循环继续还是结束,完全由三件事决定:这一轮模型有没有发出工具调用、工具结果有没有集体给出停止提示、宿主传进来的几个可选回调怎么答。把这一点搞清楚,你再看它的事件流、并行执行、队列注入,逻辑会一下子顺过来。
站内已经有两篇讲通用方法论的文章:Agent 状态机与自由循环的取舍讲什么时候该把 Agent 约束成状态机,Agent 无限循环怎么停机讲停机条件该怎么设计。本篇不重复那些判断,而是拿 pi 这一个真实项目当标本,看它把这些抽象决定落成了哪几行具体代码、留了哪些口子给你接。pi 采用 MIT 许可证,仓库在 https://github.com/earendil-works/pi ,截至 2026 年 7 月 GitHub 上约 8 万 star,代码你随时能打开对照。
一、先认清”一轮”的边界,别把它和”一次对话”混为一谈
读循环代码最容易翻车的地方,是把”轮”理解成”用户说一句、模型回一句”。pi 里不是这样。
在 packages/agent/src/types.ts 定义的 AgentEvent 里,注释写得很直白:一个 turn 是一次助手响应加上它带出来的工具调用与结果。也就是说,你发一句”帮我把配置文件改了”,模型读文件、改文件、再总结,这中间可能走了三四个 turn,但只有一次 agent_start 和一次 agent_end。
对应到事件序列就是:agent_start 只在整段运行开头发一次,turn_start / turn_end 成对地重复出现,agent_end 收尾。turn_end 事件带着这一轮的助手消息和 toolResults 数组一起抛出来,所以你想统计”这次任务一共调了几次工具、跑了几轮”,监听 turn_end 累加就够了,不用自己去猜边界。
做进度条、做用量统计、做超时中断的时候,你得先想清楚管的是哪一层:整段运行(agent_start 到 agent_end)、单轮(turn_start 到 turn_end),还是单个工具(tool_execution_start 到 tool_execution_end)。pi 把三层事件都发全了,选错层级是你自己的事。
二、循环骨架:一个双层 while,外层管”本来该停了但又有活”
agent-loop.ts 里真正干活的是私有函数 runLoop。它的骨架长这样:
while (true) {
let hasMoreToolCalls = true;
// Inner loop: process tool calls and steering messages
while (hasMoreToolCalls || pendingMessages.length > 0) {
内层 while 是我们通常理解的”Agent 循环”:只要上一轮还有工具调用要接着处理,或者还有待注入的消息,就再跑一轮。外层 while (true) 解决的是另一个问题——内层已经判定该停了,但用户在 Agent 干活期间又敲了新的一句进来。这时外层去问一次 getFollowUpMessages,拿到了就把它塞回 pendingMessages 然后 continue,等于把刚要熄火的循环重新点着。
一轮的内部顺序固定分四段。第一段注入待处理消息:pendingMessages 有内容就逐条发 message_start / message_end 并推进上下文,注入点在下一次模型请求之前,所以插进来的话能影响这一轮的输出。
第二段流式取助手响应,对应内部函数 streamAssistantResponse。这里有个容易忽略的顺序:先跑可选的 transformContext(在 AgentMessage 这一层做裁剪或注入),再跑必需的 convertToLlm(把 AgentMessage[] 转成模型认识的 Message[]),然后才组装出 Context 交给流函数。API key 也在这一步现取,getApiKey 每次请求都调一次,注释里说明这是为会过期的令牌准备的。
第三段处理工具调用(下一节展开)。第四段发 turn_end,然后依次问 prepareNextTurn 和 shouldStopAfterTurn,最后轮询一次 getSteeringMessages。
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
agentLoop / agentLoopContinue | 低层入口,返回 EventStream,分别用于新提示和从现有上下文续跑 | packages/agent/src/agent-loop.ts | 你不想要状态管理、只想自己驱动循环时 |
runLoop | 双层 while 主体,一轮四段的顺序都在这里 | packages/agent/src/agent-loop.ts | 排查”为什么它多跑了一轮”时 |
streamAssistantResponse | 上下文变换、转换成模型消息、流式接收并原地更新部分消息 | packages/agent/src/agent-loop.ts | 做流式 UI、排查上下文被改坏时 |
Agent 类 | 有状态封装:持有 transcript、两个消息队列、订阅者、中断控制 | packages/agent/src/agent.ts | 大多数应用集成的实际入口 |
AgentLoopConfig | 所有回调口子的类型定义与契约注释 | packages/agent/src/types.ts | 想插手循环任意环节时先读这个 |
AgentEvent | 十种事件的联合类型 | packages/agent/src/types.ts | 做 UI、做日志、做用量统计时 |
getDefaultStreamFn / setDefaultStreamFn | 未显式传流函数时的兜底注册点 | packages/agent/src/stream-fn.ts | 报”No default stream function configured”时 |
三、工具调用在哪一步接回来:准备、执行、收尾三段式
这是整个循环最值得细读的部分。助手消息拿到之后,代码先做两个判断:
if (message.stopReason === "error" || message.stopReason === "aborted") {
await emit({ type: "turn_end", message, toolResults: [] });
await emit({ type: "agent_end", messages: newMessages });
return;
}
// Check for tool calls
const toolCalls = message.content.filter((c) => c.type === "toolCall");
const toolResults: ToolResultMessage[] = [];
hasMoreToolCalls = false;
if (toolCalls.length > 0) {
const executedToolBatch =
message.stopReason === "length"
? await failToolCallsFromTruncatedMessage(toolCalls, emit)
: await executeToolCalls(currentContext, message, config, signal, emit);
toolResults.push(...executedToolBatch.messages);
hasMoreToolCalls = !executedToolBatch.terminate;
第一个判断是硬出口:助手消息的 stopReason 是 error 或 aborted,立刻发 turn_end 加 agent_end 走人,不看队列、不问回调。
第二个判断是个很有分量的细节。stopReason 为 length 意味着输出被输出长度上限截断了,这时候流里的工具调用参数可能是残缺的。pi 的处理是:这一批工具一个都不执行,全部走 failToolCallsFromTruncatedMessage 造成错误结果返回给模型,错误文案里明确让模型带完整参数重发。循环里的注释只写了一句”与其执行可能已经坏掉的调用,不如整批判失败”,但去 packages/ai/src/utils/json-parse.ts 看一眼就明白它防的是什么:流式工具参数是靠 parseStreamingJson 边收边拼的,这个函数先尝试带修复的解析,失败再退到部分解析,最后还有兜底。也就是说,一段被截断的参数照样能拼出一个合法对象——它能解析、能通过 schema 校验,内容却悄悄缺了一半。如果你自己写循环时只看”参数校验过了就执行”,这就是个真实存在的坑。
正常路径走 executeToolCalls。它先看模式:配置里的 toolExecution 是 sequential,或者这一批里任何一个工具在自己的定义上标了 executionMode: "sequential",整批就串行执行;否则走并行。注意是”一个带毒全批串行”,不是混着来。仓库里 packages/coding-agent/examples/extensions/tic-tac-toe.ts 就是靠这个标记来防止两个工具同时改共享光标状态。并行编排的取舍可以对照Agent 并发编排怎么做那篇看。
不管哪种模式,单个工具都走同样的三段:
准备(prepareToolCall):按名字在上下文的 tools 里找工具,找不到直接返回一个错误结果;找到了先跑工具自己的可选 prepareArguments(类型注释里写明是”schema 校验之前作用于原始工具调用参数的兼容垫片”,返回值必须符合参数 schema),再用 validateToolArguments 校验,然后才轮到 beforeToolCall 钩子。钩子返回 { block: true } 就拦下,reason 变成错误结果里的文本。这三步里任何一步抛异常,都被 catch 成错误结果,而不是把整个循环炸掉。
执行(executePreparedToolCall):调工具的 execute,把 onUpdate 回调传进去用于流式部分结果。这里有个我很喜欢的小设计——acceptingUpdates 标志位,工具的 promise 一 settle 就置 false,之后再迟到的 update 回调直接丢弃,所以工具写得再糙也不会在结束后继续往事件流里灌数据。
收尾(finalizeExecutedToolCall):跑 afterToolCall 钩子。它的合并是逐字段整体替换,不做深合并——content 给了就整个数组换掉,details 给了就整块换掉。想在这里做”给结果加个前缀”,你得自己把原内容读出来重新拼。工具返回值该怎么设计,可以参考Agent 工具返回值设计。
三段跑完,createToolResultMessage 把结果包成 toolResult 消息推回上下文和新消息数组,工具调用就算正式”接回”循环了。并行模式下还有个顺序上的分工:tool_execution_end 按工具实际完成的先后发,但落进 transcript 的 toolResult 消息严格按助手消息里工具调用的原始顺序排。UI 上你看到谁先跑完谁先亮,历史记录里顺序却是稳定的。
四、循环停下来的五个出口
把 runLoop 从头到尾捋一遍,退出点一共这么几个:
出口一,助手消息报错或被中断。 stopReason 为 error / aborted,立即 agent_end。
出口二,这一轮没有工具调用。 hasMoreToolCalls 初始化为 false,只有进了工具分支才可能被置回 true。模型纯文本回答,内层 while 的条件就不成立了。这是最常见的正常结束。
出口三,整批工具集体要求停。 判定函数只有一行:
function shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]): boolean {
return finalizedCalls.length > 0 && finalizedCalls.every((finalized) => finalized.result.terminate === true);
}
every 这个词是关键:必须这一批全部工具结果都带 terminate: true 才生效,混着来就照常继续。仓库的 packages/coding-agent/examples/extensions/structured-output.ts 演示的就是这个用法——最后一个结构化输出工具调完直接收工,省掉一次没意义的模型请求。
出口四,shouldStopAfterTurn 返回 true。 它在 turn_end 之后被调用,返回 true 就发 agent_end 退出,而且是在轮询两个队列之前。README 里补了三句很重要的话:它不会中断正在进行的模型流、不会取消正在跑的工具、也不会改写助手消息的 stopReason。它是”这一轮好好收尾,然后别再开下一轮”,不是急刹车。典型用途是上下文快满了、准备做压缩之前先优雅停一下。
出口五,两个队列都空了。 内层退出后,外层问一次 getFollowUpMessages,返回空数组就 break,发 agent_end。
还有一条不算出口的路径:abort()。Agent 类持有 AbortController,signal 被传到流函数和每个工具的 execute 里。串行执行时每跑完一个工具会检查 signal?.aborted 并 break;并行模式下 preflight 阶段也会检查。但工具本身认不认这个 signal,得看工具自己写得怎么样——这是宿主的责任,不是循环能替你兜的。
两个队列的区别值得单独记一下:steer() 排的队在当前助手轮结束、工具跑完之后注入,用于中途改方向;followUp() 排的队要等到 Agent 本来都要停了才轮到,用于”等它忙完再说”。两个队列各有 QueueMode,默认都是 one-at-a-time,也就是每次只放一条出来,剩下的留到下一个排水点。想改成一次全放,设成 all。人在环里的介入时机怎么选,Agent 人在环中怎么设计那篇讨论得更细。
五、边界与代价:它明确不管的那些事
这个循环写得克制,克制就意味着有东西被推给了你。
它不管轮数和预算。 没有轮数上限、没有 token 预算、没有工具调用次数上限。一个反复调工具的模型可以让它一直转下去。想设上限,你得自己在 shouldStopAfterTurn 里数。
它不管上下文压缩。 transformContext 的注释里点名了”上下文窗口管理(裁剪旧消息)“是这个口子的用途,但循环本身不提供任何裁剪策略。什么时候裁、裁哪些、要不要摘要,全是你的事。
它不管重试。 循环遇到 stopReason: "error" 是直接退出的。重试要靠外面调 agentLoopContinue 或 Agent.continue() 从当前上下文接着跑,而且有个硬约束:上下文最后一条消息必须能转成 user 或 toolResult,是 assistant 会直接抛错。源码注释还提醒,这个约束在低层入口那里没法提前校验,因为 convertToLlm 每轮只调一次——所以它只能在运行时被模型服务商拒绝。
它不管权限,也不带模型目录。 beforeToolCall 只给拦截点,按什么规则拦循环一概不知;stream-fn.ts 的注释说明兜底注册点的存在就是为了让核心包不依赖模型目录和兼容层,没显式传流函数又没注册过,第一次跑就抛”No default stream function configured”。
什么场景不适用。 如果你要的是严格状态机——第一步必须调 A,A 成功才能调 B,失败走 C——这个自由循环给不了你结构性保证,你只能靠工具描述和钩子去”劝”。那种需求更适合在循环之外再包一层编排。
顺带说一句模型服务商:海外几家对中国大陆有区域限制、官方不支持直连,市面上存在第三方中转但本文不做背书也不给具体渠道;各家的规则不同而且会调整,以官方最新说明为准。
六、上手清单:这几处最容易踩
别在 convertToLlm 里抛异常。 类型注释写明了契约:不许抛、不许 reject,出问题返回安全兜底值。为什么会踩——你很自然会写”遇到不认识的消息类型就 throw”,而抛出去会打断低层循环,事件序列不完整,agent_end 都不一定发得出来,UI 卡在半路。怎么避——不认识的消息 return 空数组过滤掉。同样的契约也适用于 transformContext、getApiKey、shouldStopAfterTurn 和两个队列回调。
terminate: true 只对整批生效。 为什么会踩——你给某个”完成任务”工具加了这个标记,测试时它单独被调用,一切正常;上线后模型把它和别的工具放在同一条助手消息里发出来,循环照样继续跑。怎么避——要么接受这个语义,要么在 shouldStopAfterTurn 里再判一次这一批里有没有出现终结性工具。
并行是默认值。 Agent 构造时 toolExecution 缺省为 parallel。为什么会踩——你写了两个都会改同一个文件的工具,本地单条调用测不出问题,模型一次发两个就撞车。怎么避——在工具定义上标 executionMode: "sequential"。看 executeToolCalls 的分支条件就知道为什么优先靠工具标记:全局配置是 parallel 也没关系,只要这一批里有任何一个工具带了 sequential 标记,整批就走串行分支,这个约束跟着工具走,不会因为换了个宿主的全局配置就失效。
afterToolCall 不做深合并。 为什么会踩——你只想改 details 里的一个字段,返回了 { details: { foo: 1 } },结果原来的 details 整个没了。怎么避——把原 result.details 读出来展开再改。
turn_end 之后还有事发生。 为什么会踩——你以为 turn_end 就是一轮的终点,在监听器里做了收尾清理;但 prepareNextTurn 可以在这之后替换掉上下文、模型和思考等级,getSteeringMessages 还会注入新消息。怎么避——真正的运行终点只有 agent_end,而且用 Agent 类时还要注意:agent_end 只代表循环不再发事件,waitForIdle() 要等到所有订阅者的 agent_end 监听器都 settle 之后才 resolve。
流式过程中上下文数组是被原地改的。 streamAssistantResponse 会把部分助手消息 push 进 context.messages,然后每来一个增量就替换最后一个元素。为什么会踩——你在别处持有了同一个数组引用并遍历它,会读到半成品。怎么避——Agent 类的 createContextSnapshot 已经用 slice() 做了浅拷贝,直接用低层入口时你得自己注意。
收尾:接下来读哪个文件
按这个顺序读,两小时能把这套机制吃透:先 packages/agent/src/types.ts(所有回调的契约注释比代码本身信息量还大),再 packages/agent/src/agent-loop.ts 的 runLoop 一个函数,最后 packages/agent/src/agent.ts 看有状态封装怎么把队列、订阅、中断挂上去。想看真实用法就翻 packages/coding-agent/examples/extensions/ 下那几个例子。
给自己一份自检清单:你的循环有没有轮数或预算的兜底?助手消息被输出长度截断时,你是执行还是拒绝那批工具调用?并行执行的工具之间有没有共享状态?中断信号有没有真的传到每个工具内部?停机条件是靠模型自觉,还是有一处代码替你把关?这五个问题里任意一个答不上来,都不是 pi 的问题,是你那套循环的问题。
本篇属于一个把开源编程 Agent 项目 pi 逐层拆开讲的系列,整体地图见开源编程 Agent pi 是什么;沿着这条线往下,还可以看 开源编程 Agent pi 的服务商层 和 开源编程 Agent pi 的基础工具层。