Pi Agent Loop 源码解析:Context、Streaming、Tool Calling、Steering 与停止条件
Meta Description
Pi 的 Agent Loop 不只是一个 while 循环。它需要处理 AgentMessage 与 LLM Message 转换、流式 AssistantMessage、Tool 参数验证、串并行执行、Steering、Follow-up、Abort、错误结果和生命周期事件。本文基于 v0.82.1 固定源码重建一次用户输入的完整运行链。
版本与证据说明
Pi 源码事实基线: v0.82.1(短提交b4f2936,发布日期 2026-07-25)。固定实现事实使用版本 Tag;滚动文档、竞品和 Provider 事实的统一访问日期为 2026-07-29。 动态事实风险等级: 低。高风险文章发布前必须再次核对官方来源。命令、Demo 或兼容性若未明确标为 PASS-RUNTIME,不得理解为已在真实 Pi Runtime 中执行通过。
本篇定位
系列编号:PI-05。
前置文章:PI-04。
核心问题:
用户调用
prompt()之后,Pi 如何持续调用模型、执行工具、接收结果并决定下一轮,直到 Agent 真正结束?
预计阅读时间:35—45 分钟。
研究基线:
仓库: earendil-works/pi;版本: v0.82.1;Release 短提交: b4f2936;查询日期:2026-07-28; 主要源码: packages/agent/src/agent.ts与packages/agent/src/agent-loop.ts;本篇使用的是源码重建与伪代码,没有声称在本轮环境中完成实际运行追踪。
固定源码:
agent.ts: https://github.com/earendil-works/pi/blob/v0.82.1/packages/agent/src/agent.tsagent-loop.ts: https://github.com/earendil-works/pi/blob/v0.82.1/packages/agent/src/agent-loop.tsAgent Core README: https://github.com/earendil-works/pi/blob/v0.82.1/packages/agent/README.md
一、Agent Loop 不是“模型没回答完就继续调用”

最简单的 Tool Calling 示例通常写成:
while (true) {const response = await callModel(messages);if (response.toolCalls.length === 0) break;const results = await executeTools(response.toolCalls);messages.push(response, ...results);}
这段代码只展示了骨架。
真实 Runtime 必须解决:
用户在 Agent 运行时又发送消息怎么办; AssistantMessage 还在流式生成时如何展示; Tool Call 参数被截断后能不能执行; 多个 Tool Call 串行还是并行; Tool 执行过程中如何上报进度; Tool Hook 如何阻止或改写结果; Abort 后如何保存部分内容; 模型返回错误时是否抛异常; 当前 Turn 结束后是否立即停止; Follow-up Message 何时进入下一轮; Context Transform 何时发生; UI 和持久化层如何观察一致的事件顺序。
Pi 的实现可以看成两个协作层:
Agent 类(状态、队列、订阅、生命周期)├─ agent-loop(模型流与工具循环)│ ├─ pi-ai(模型流式协议)│ └─ Tool Runtime└─ Application / UI
Agent 类管理长期状态和调用入口;agent-loop 管理一次执行过程的细粒度循环。
二、先区分四种消息

理解 Agent Loop 前,需要先区分不同层的消息。
1. 用户消息
表示用户当前任务或后续方向。
2. AssistantMessage
模型生成的消息,可以包含:
普通文本; Thinking/Reasoning; Tool Call; Usage; Stop Reason; Error 或 Aborted 状态。
3. ToolResultMessage
工具执行后返回给模型的观察结果。
4. 自定义 AgentMessage
应用可以定义模型原生协议不认识的消息,例如:
UI 通知; 外部任务状态; 领域事件; 需要经过转换才能进入模型的业务消息。
因此,Pi 不直接把整个应用状态当成模型消息。
它使用以下边界:
AgentMessage[]→ transformContext()→ AgentMessage[]→ convertToLlm()→ Message[]→ pi-ai
transformContext() 适合:
裁剪历史; 压缩上下文; 动态注入资料; 按当前模型调整消息。
convertToLlm() 适合:
过滤 UI-only Message; 将自定义消息转换成标准用户消息; 将多个业务事件合并成模型可读文本。
这一边界保证:
Agent 保存的状态,可以比模型真正看到的上下文更丰富。
三、Agent 类管理什么状态
固定版本的 Agent 类维护一组可变状态,核心包括:
System Prompt; Model; Thinking Level; Tools; Messages; 当前是否 Streaming; 当前 Partial AssistantMessage; Pending Tool Calls; Error。
它还持有:
Steering Queue; Follow-up Queue; Event Subscribers; 当前运行 Promise; AbortController; Context Transform; Message Converter; Tool Hooks 与执行配置。
这说明 Agent 不是无状态函数。
它更像一个受控状态机:
Idle├─ prompt() → Streaming│ ├─ assistant tool calls → ExecutingTools → next model turn → Streaming│ ├─ final response → Idle│ └─ abort() → Aborting → Idle└─ ExecutingTools├─ no queued work / aborted / error → Idle└─ abort() → Aborting → Idle
真实实现没有必要严格使用这个枚举,但行为上存在这些状态边界。
四、调用 prompt() 时发生什么
prompt() 是外部应用最容易接触的入口。
它首先要防止同一个 Agent 同时启动两个独立主循环。
如果 Agent 正在运行,新的输入不应该再次调用 prompt()。Pi 会要求调用者使用:
steer():改变当前执行方向; followUp():排到当前任务之后。
这避免两条主循环同时修改同一个 messages 和工具环境。
一次新的 prompt() 可以概括为:
检查 Agent 是否空闲→ 标准化用户输入→ 写入 Agent Message→ 创建 AbortController→ 生成上下文快照与 Loop Config→ 启动 Agent Loop→ 消费事件并更新 State→ 等待结束
Agent Loop 启动时会发出:
agent_startturn_startmessage_start(user)message_end(user)
随后才进入模型调用。
事件顺序不是装饰。应用可能在事件上执行:
Session 持久化; UI 更新; 审计; 成本统计; 日志; Extension Hook。
五、上下文快照为什么重要
Agent 在运行时允许外部修改某些配置,例如模型、工具或 System Prompt。
如果模型调用过程中直接读取一组不断变化的引用,会产生不稳定行为:
一次 Turn 开始时使用模型 A; Tool 执行后外部切换到模型 B; 同一轮事件却无法判断由哪个配置产生。
Pi 会在启动 Loop 时建立 Context Snapshot,并通过配置与后续准备函数控制何时允许模型、Thinking 或 Context 发生变化。
这是一项通用 Runtime 原则:
一次不可分割的执行单元,应当拥有可解释的配置快照;动态更新应发生在明确边界,而不是任意时刻。
在 Agent 系统里,这个边界通常是 Turn。
六、Pi 实际上有内外两层循环


runLoop 的核心不是一个单层 while。
它需要区分两种“继续”:
模型调用工具后,必须继续当前任务; 当前任务已经没有 Tool Call,但队列里还有 Follow-up,必须开始新任务。
可以重建成下面的伪代码:
emit("agent_start");while (true) { // 外层:Follow-up 生命周期while (true) { // 内层:Tool + Steering 生命周期const assistant = await streamAssistant(context);append(assistant);if (assistant.error || assistant.aborted) break;const calls = extractToolCalls(assistant);const results = await executeTools(calls);append(results);emit("turn_end");context = await prepareNextTurn(context);const steering = drainSteeringQueue();append(steering);if (calls.length === 0 && steering.length === 0) break;}const followUps = drainFollowUpQueue();if (followUps.length === 0) break;append(followUps);}emit("agent_end");
这不是源码逐字复制,而是按固定版本控制流整理的结构。
内层循环处理“同一个任务还没结束”。
外层循环处理“当前任务结束后还有下一项输入”。
七、模型调用发生前的最后一道边界
每次调用模型前,streamAssistantResponse 会完成以下工作:
当前 Agent Context→ transformContext→ convertToLlm→ 组装 systemPrompt + messages + tools→ 解析 API Key→ 调用 stream function
这一步才真正从 Agent 世界进入 LLM 世界。
Agent Messages→ transformContext→ convertToLlm→ LLM Context→ Model Provider Stream
为什么每一轮都执行转换,而不是只在 Session 创建时执行?
因为上下文可能随执行变化:
Tool Result 增加; Steering Message 到达; 历史接近窗口上限; 当前模型改变; 外部资源更新; 自定义消息需要按当前状态重写。
Context 是运行时产物,不只是静态 Prompt。
八、流式 AssistantMessage 不是最后才出现
模型响应通常通过 Event Stream 增量返回。
Pi 不会等待完整响应后一次性创建 AssistantMessage,而是维护一个 Partial Message:
message_start→ message_update(text delta)→ message_update(thinking delta)→ message_update(tool call delta)→ message_end
这样 UI 可以实时展示文字、Reasoning 和 Tool Call 参数生成过程。
但这也带来状态一致性问题。
Agent 必须区分:
已经写入永久历史的消息; 当前仍在 Streaming 的临时消息; 流结束后的完整消息。
如果请求被 Abort,部分内容也可能有价值:
用户可以看到模型已经分析到哪里; Session 可以记录中断; 调试时可以判断模型为何准备调用某个工具。
因此,错误与中断不应该简单抛弃全部流式状态。
九、Stop Reason 决定后续能否安全执行
统一的 AssistantMessage 会包含 Stop Reason,例如:
stop; length; toolUse; error; aborted。
其中最危险的情况之一是 length。
模型可能在生成 Tool Call JSON 时达到输出上限,留下一个语法上勉强可恢复、语义上却不完整的调用。例如:
{"path": "src/auth.ts","oldText": "...","newText": "尚未生成完整
即便 Partial JSON Parser 能把它修成结构,执行也可能破坏文件。
Pi 在输出因长度终止时,不会继续执行这些可能被截断的 Tool Call,而是把它们失败化处理。
这是一个重要安全原则:
“能够解析”不等于“足够完整,可以产生副作用”。
十、Tool Call 的完整执行管线

一个 Tool Call 从模型输出到 Tool Result,需要经过多个阶段。
1. Assistant Tool Call2. 查找 Tool3. 准备与规范化参数4. Schema Validation5. beforeToolCall Hook├─ 阻止 → Error Tool Result└─ 允许 → Tool.execute6. Progress Updates7. Raw Result / Error8. afterToolCall Hook9. ToolResultMessage10. 加入 Agent Context
1. 查找工具
模型可能调用不存在的工具。
Runtime 不能崩溃,而应生成模型能够理解的错误结果,让模型有机会修正。
2. 参数准备
工具可以拥有参数预处理逻辑,例如:
路径规范化; 默认值; 兼容旧字段; 将用户友好参数转换成内部表示。
3. Schema Validation
参数必须经过 Tool Schema 验证。
这可以阻止:
缺少必填字段; 错误类型; 非法枚举; 结构错误。
但 Schema 不能判断所有语义风险。例如,一个字符串路径类型正确,却可能指向不应访问的位置。
4. beforeToolCall
Hook 可以:
检查路径; 实现权限门; 屏蔽危险命令; 记录审计; 拒绝执行。
5. Tool 执行
Tool 接收参数、Abort Signal 与 Update Callback。
6. Progress Update
长工具可以持续发送:
当前阶段; 增量日志; 进度; 中间状态。
Runtime 将其转成 tool_execution_update 事件,而不是立即当成最终 Tool Result 送回模型。
7. 错误捕获
工具异常会被转换成 isError: true 的 Tool Result,而不是直接让整个 Agent Loop 丢失上下文。
8. afterToolCall
Hook 可以改写:
Content; Details; Usage; Error 标记; 是否为当前工具结果设置 terminate。固定版本只有当同一批次每一个最终 Tool Result 都设置terminate=true时,才跳过该批工具之后的自动模型调用;它不会直接结束整个 Agent Run,之后仍会检查 Steering 与 Follow-up。
9. ToolResultMessage
最终结果带着对应 Tool Call ID 回到消息历史,模型才能知道这是谁的执行结果。
十一、多个工具为什么有时串行、有时并行
一个 AssistantMessage 可能同时返回多个 Tool Call。
例如:
read file Aread file Bread file C
这些只读操作通常可以并行。
但下面这些操作可能存在顺序依赖:
edit package.jsonnpm installnpm test
如果并行执行,后两项可能使用旧文件或未完成的依赖。
Pi 的执行策略允许:
全局要求串行; 某个 Tool 标记自己必须串行; 否则并行执行。
Tool Calls→ sequential config 或任一 Tool 标记 sequential?├─ Yes → 按顺序执行 ─┐└─ No → 并行执行 ─┴→ 按调用顺序产生结果
即使并行,最终 Tool Result 仍需要维持可预测的对应关系。
并行不是默认越多越好。它必须考虑:
文件写冲突; 共享进程; 数据库事务; API 限流; 结果依赖; 日志顺序。
十二、Steering 与 Follow-up 为什么必须分开

假设 Agent 正在执行:
重构认证模块并运行测试。
用户中途说:
不要改登录接口,保持公开 API 不变。
这是一条 Steering Message。它需要尽快进入当前任务。
另一个输入:
完成以后再补一份迁移文档。
这是一条 Follow-up Message。它不应该干扰当前重构。
Pi 将两者放入不同队列,并允许配置队列取出策略,例如:
一次取全部; 一次取一条。
执行顺序大致是:
当前模型响应→ 当前 Tool Calls 完成→ Turn End→ 注入 Steering→ 继续当前任务→ 当前任务无 Tool、无 Steering→ 注入 Follow-up→ 开始后续任务
这里有一个重要边界:
Steering 通常不会强行回滚正在执行的 Tool。
若 Tool 运行时间很长,立即停止依赖:
Tool 是否监听 Abort Signal; 子进程是否能被终止; 终止后是否有清理; 写入是否原子化。
“消息已进入 Steering Queue”不等于“当前系统副作用已经立即停止”。
十三、prepareNextTurn 是运行时扩展点
工具完成后,Runtime 并不一定直接用原配置开始下一轮。
prepareNextTurn 可以在 Turn 边界调整:
Context; Model; Thinking Level; 其他下一轮配置。
这使得上层可以实现:
根据任务阶段切换模型; Tool 执行后加载新上下文; 进入低成本总结模型; 达到阈值后触发 Compaction; 根据错误类型提高 Reasoning; 动态路由。
Turn 边界是安全修改运行策略的位置,因为上一轮的 Assistant 与 Tool Result 已经形成完整记录。
十四、什么时候 Agent Loop 停止

不能只用“模型没有 Tool Call”作为唯一条件。
Pi 的控制流还需要考虑:
Assistant 返回 Error; Assistant 被 Abort; 当前工具批是否全部请求 terminate;这只会跳过工具后的自动模型调用,不等于结束整个 Agent Run;shouldStopAfterTurnHook; 当前没有 Tool Call; Steering Queue 为空; Follow-up Queue 为空。
可以写成一组概念条件:
若发生 fatal error / abort停止否则若整批 Tool Result 都 terminate=true跳过本批工具后的自动模型调用,但继续检查 steering / follow-up否则若 shouldStopAfterTurn 返回 true直接发出 agent_end 并结束 Agent Run;不再轮询 steering / follow-up否则若还有 tool calls继续当前任务否则若还有 steering继续当前任务否则若还有 follow-up开始后续任务否则agent_end
对于自研 Harness,还应加入:
最大 Turn; 最大 Token; 最大成本; 最大运行时间; 连续相同错误次数; 人工审核节点。
否则模型和工具可能进入无界循环。
十五、事件系统如何保证外部观察

Pi 的 Agent Runtime 会发出类似以下事件:
agent_startturn_startmessage_startmessage_updatemessage_endtool_execution_starttool_execution_updatetool_execution_endturn_endagent_end
User → Agent:promptAgent → Subscriber:agent_start → turn_startAgent → Model:streamModel → Agent:partial messageAgent → Subscriber:message_updateModel → Agent:tool call completeAgent → Subscriber:message_end → tool_execution_startAgent → Tool:executeTool → Agent:progressAgent → Subscriber:tool_execution_updateTool → Agent:resultAgent → Subscriber:tool_execution_end → turn_end
事件订阅的用途包括:
TUI 渲染; Session 保存; 追踪 Token; 记录 Tool Timeline; 建立 OpenTelemetry Span; 审计; 测试事件顺序; 外部控制面。
高级 Agent 类会串行等待异步 Listener,这意味着 Listener 可能成为执行屏障。
收益是状态一致性更强。
风险是某个缓慢 Listener 会拖慢 Runtime。Listener 必须区分:
必须在下一步前完成的关键逻辑; 可以异步投递的日志或遥测。
十六、错误为什么要回到模型,而不是只抛给程序员
Agent 的工具错误通常有两类消费者:
外部应用,需要记录异常; 模型,需要理解失败并修正策略。
如果 Tool 直接抛异常并终止循环,模型无法知道:
命令不存在; 文件路径错误; 测试失败; 参数不合法; 权限被拒绝。
Pi 将可恢复工具错误转换为 Tool Result:
{"isError": true,"content": "File not found: src/auth.ts"}
模型下一轮可以:
搜索正确路径; 修正参数; 改用其他工具; 向用户说明阻塞。
这不意味着所有错误都应该继续。
以下情况更适合终止:
Runtime 内部状态损坏; Context 无法序列化; 认证失效且无法恢复; 用户主动 Abort; 安全策略要求停止; 达到资源上限。
十七、从一次“修复测试”重建完整链路
用户输入:
修复认证模块中失败的刷新 Token 测试,不要修改公开 API。
完整链路可以表示为:
1. User → Agent:prompt(task)2. Agent → Session/UI:agent_start / user message3. Agent → Context Transform → Model:system + messages + tools4. Model → Agent:read(test file) → 校验并执行 → test source5. Agent → Model:assistant + tool result6. Model → Agent:read(implementation) → implementation source7. Model → Agent:edit → 校验 + before hook + 执行 → edit result8. Model → Agent:bash(test command) → 携带 abort signal 执行9. Bash → Agent:one test still fails → 失败结果回注模型10. Model → Agent:second edit call → execute → result11. Model → Agent:bash(full test suite) → all tests pass12. Agent → Model:final observation13. Model → Agent:final summary,no tool calls14. Agent → Session/UI:turn_end / agent_end15. Agent → User:result
这张图中,模型从未直接读取文件或执行命令。
它只能:
生成 Tool Call; 读取 Harness 构造的 Tool Result; 根据 Context 决定下一步。
现实世界始终由 Harness 中介。
十八、Agent Loop 的几个关键安全门

1. 工具白名单
只有当前注册的 Tool 才能执行。
2. 参数 Schema
阻止结构错误,但不能替代语义权限。
3. Length Stop Guard
防止截断 Tool Call 产生副作用。
4. beforeToolCall
实现路径、命令、网络和人工审批策略。
5. Abort Signal
允许模型调用与工具执行响应取消。
6. Turn Stop Hook
允许应用根据成本、安全或业务规则提前结束。
7. Tool Result Normalization
确保错误与结果以模型可理解的方式返回。
这些安全门仍然不能代替容器、权限隔离和 Secret 管理。Runtime 只能控制它知道的工具入口;一旦 Bash 在宿主机拥有广泛权限,Tool 内部可能产生任意副作用。
十九、自研 Agent Loop 最容易犯的错误
1. 同时运行两个主 Loop
会导致消息历史、Tool Result 和文件修改交错。
2. 在流结束前执行 Tool Call
参数可能仍未完整生成。
3. 把所有错误都抛出
模型失去自我修正机会。
4. 把所有错误都返回模型
严重 Runtime 错误可能导致无限重试。
5. 不区分 Steering 与 Follow-up
用户的后续任务会污染当前执行。
6. 没有明确 Turn 边界
模型切换、Compaction 和状态持久化变得难以解释。
7. 并行执行有副作用的 Tool
产生竞态、覆盖和不可复现状态。
8. 没有资源限制
Agent 可能无限调用模型或工具。
9. UI 直接修改内部消息
破坏 Runtime 的事件和状态一致性。
10. 忽视 Partial Message
中断、错误和调试信息会丢失。
二十、常见误解
误解一:Agent Loop 就是 ReAct Prompt
ReAct 是一种推理与行动组织方式。Agent Loop 是实际运行模型、工具、状态和事件的软件控制流,两者不在同一层。
误解二:模型返回 Tool Call 后可以立即执行
必须等待完整消息,检查 Stop Reason,并进行 Tool 查找、参数验证和 Hook 检查。
误解三:多个 Tool Call 应该全部并行
存在写入、进程和结果依赖时必须串行。
误解四:Steering 等于立即中断当前命令
Steering 是消息调度机制。立即停止还依赖 Abort Signal 和 Tool 的取消实现。
误解五:Tool 执行失败就是 Agent 失败
可恢复错误应成为 Tool Result,让模型修正。不可恢复 Runtime 错误才应该终止。
误解六:Session 只需保存最终 AssistantMessage
要重建行为,必须保存 Tool Call、Tool Result、错误、中断和必要的事件关系。
二十一、总结
Pi 的 Agent Loop 可以概括为五个连续闭环:
上下文闭环AgentMessage → LLM Message生成闭环Model Stream → Partial AssistantMessage行动闭环Tool Call → Validation → Execution → Tool Result交互闭环Steering / Follow-up → 下一轮输入状态闭环Events → Agent State → Session / UI
真正使模型成为 Agent 的,不是一个 while 关键字,而是这些边界:
谁可以启动执行; Context 何时转换; 消息何时算完整; 工具何时允许产生副作用; 错误何时可恢复; 用户输入何时进入; 动态配置何时改变; Loop 何时停止。
理解这一层之后,下一步自然是研究它调用的模型层:Pi 如何让同一个 Agent Loop 面向不同 Provider 工作,并在会话中切换模型。
下一篇
PI-06|让不同大模型共享一个 Agent:Pi 如何统一 Provider 与 Context Handoff
下一篇将分析:
Provider、Model 与 API Implementation 的区别; Models Collection 如何路由; Auth 与 Header 如何合并; Reasoning、Stop Reason 和 Usage 如何统一; 为什么跨 Provider 切换不能只把原始 JSON 原样发送; Thinking Block、Tool Call 和 Tool Result 如何完成 Handoff。
参考资料
Agent Core README: https://github.com/earendil-works/pi/blob/v0.82.1/packages/agent/README.mdAgent类: https://github.com/earendil-works/pi/blob/v0.82.1/packages/agent/src/agent.tsAgent Loop: https://github.com/earendil-works/pi/blob/v0.82.1/packages/agent/src/agent-loop.tsAgent 类型定义: https://github.com/earendil-works/pi/tree/v0.82.1/packages/agent/srcPi AI 消息与流式接口: https://github.com/earendil-works/pi/blob/v0.82.1/packages/ai/README.md
夜雨聆风