我是安徽最忧郁程序员无隅

前言
读 Agent Loop,如果只看到"模型返回 ToolCall 就执行,没有 ToolCall 就停止",只能理解最小循环,理解不了一个真实编程 Agent 为什么能流式显示、连续工作、接受中途指令、并发执行工具,还能保持消息与事件顺序。本文基于
@earendil-works/pi-agent-core0.84.3 源码,沿着一条用户消息,从Agent.prompt()一直追到agent_end,完整拆开 Pi Agent Loop 的入口、状态、内外循环、模型流、工具管道、队列、钩子与退出路径。
一、先看全景:Agent Loop 不只是一个 while
最小 Agent Loop 确实可以写得很短:调用模型,发现 ToolCall 就执行工具,把 ToolResult 交回模型,然后继续循环。但 Pi 面对的是交互式编程场景,除了这个最小内核,还必须处理四类问题:
- 生命周期:一次任务从哪里开始,什么时候才算真正结束;
- 状态同步:运行中的上下文、公开的 Agent 状态、UI 流式状态如何保持一致;
- 工具安全:参数校验、执行前后钩子、串并行调度、截断调用如何处理;
- 产品交互:用户中途补充指令、任务结束后追加工作、动态切换下一轮配置。
所以阅读 Pi 的 Agent Loop,不能只盯着 while。更完整的调用链是:
text
Agent.prompt()
→ normalizePromptInput()
→ runPromptMessages()
→ runWithLifecycle()
→ runAgentLoop()
→ runLoop()
→ streamAssistantResponse()
→ executeToolCalls()
→ prepareNextTurn / shouldStopAfterTurn
→ steering / followUp
→ agent_end
→ finishRun()
下面这张图先给出整条主线。后文会沿着相同编号逐步展开。

贯穿整条链路的不是单一对象,而是四类数据同时变化:
| 对象 | 负责什么 | 典型内容 |
|---|---|---|
AgentMessage |
保存 Agent 可见的消息历史 | 用户消息、助手消息、工具结果、自定义内部消息 |
AgentContext |
提供本次低层 Loop 使用的上下文快照 | systemPrompt、messages、tools |
AssistantMessage / ToolResultMessage |
驱动模型与工具之间的闭环 | ToolCall、文本、工具结果、stopReason |
AgentEvent |
把运行过程同步给状态管理和 UI | message_update、turn_end、tool_execution_end |
模型负责产生下一步意图,工具负责制造外部效果,消息负责保存事实,事件负责把事实同步给运行时和界面。Agent Loop 的作用,是让这四者以确定顺序反复推进。
二、入口与生命周期:任务还没进 while,系统已经做了什么
1. Agent.prompt() 先阻止重入
用户调用 Agent.prompt() 后,代码先检查 activeRun:
typescript
if (this.activeRun) {
throw new Error(
"Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.",
);
}
这条限制很关键。同一个 Agent 正在运行时,不能再启动第二条独立执行链,否则两条链会同时修改消息历史、流式状态和工具状态。运行中的新需求必须明确选择:
steer():当前任务还在工作时插入;followUp():等当前任务自然结束后再处理;waitForIdle():等待当前运行彻底收尾后,再启动新任务。
接着,normalizePromptInput() 把字符串、单条消息或消息数组统一成 AgentMessage[]。字符串会被包装为带时间戳的用户消息,图片则追加到同一条消息的 content 中。
2. runWithLifecycle() 管理的不是 Loop 逻辑,而是运行所有权
runPromptMessages() 不会直接调用 runAgentLoop(),而是先进入 runWithLifecycle()。这个外层负责:
- 创建本次运行专用的
AbortController; - 设置
activeRun,使重入检查生效; - 把
isStreaming置为true; - 捕获低层 Loop 之外抛出的异常;
- 最终执行
finishRun(),清理流式消息、待执行工具和活动运行标记。
这里必须区分两个"结束":
agent_end:Loop 不会再发出新的业务事件;finishRun():所有agent_end监听器也已经执行完,Agent 才真正回到 idle。
因此,waitForIdle() 等待的是后者。UI、日志或持久化监听器即使在处理 agent_end,本次运行仍未完全结算。
3. 为什么传给 Loop 的是快照
createContextSnapshot() 会复制顶层消息数组和工具数组:
typescript
private createContextSnapshot(): AgentContext {
return {
systemPrompt: this._state.systemPrompt,
messages: this._state.messages.slice(),
tools: this._state.tools.slice(),
};
}
低层 Loop 在自己的 currentContext 中追加消息,不直接拿 Agent 的公开数组当工作区。这样做解决了两个问题:
- Loop 内部可以安全替换流式助手消息,不会把半成品直接写进持久状态;
- Agent 状态只通过事件归并,状态变化的入口更清晰。
运行中的 currentContext.messages 与 Agent._state.messages 会同时前进,但路径不同:前者由 Loop 直接维护,后者由 processEvents() 在 message_end 时追加。
4. runAgentLoop() 建立 Trace,首个 Turn 也从这里开始
进入 runAgentLoop() 后,首先创建两个集合:
typescript
const newMessages = [...prompts];
const currentContext = {
...context,
messages: [...context.messages, ...prompts],
};
currentContext.messages:模型下一次调用能看到的完整上下文;newMessages:只收集本次 Trace 新产生的消息,最终交给agent_end。
随后依次发出:
text
agent_start
turn_start
每条 prompt 的 message_start
每条 prompt 的 message_end
首个 turn_start 在入口发出,因此 runLoop() 中的 firstTurn 会跳过第一次重复发送。后续每次内层循环才会正常发出新的 turn_start。
5. Trace 与 Turn 的精确关系
理解后面的双层循环前,必须先固定两个边界:
- Trace :一次
agent_start到一次agent_end; - Turn:一次助手模型响应,加上该响应触发的一批工具执行。

同一个助手响应可以包含多个 ToolCall,所以一个 Turn 可以执行多个工具。工具结果写回上下文,再次调用模型时,才进入下一个 Turn。
换句话说,Turn 不是"一个工具步骤",而是模型对当前上下文做出一次完整决策的边界。
三、runLoop() 双层循环:内核、插队与续命如何组合
Pi 的 runLoop() 不是一层 while,而是外层和内层嵌套:
typescript
let firstTurn = true;
let pendingMessages = await getSteeringMessages();
while (true) {
let hasMoreToolCalls = true;
while (hasMoreToolCalls || pendingMessages.length > 0) {
// 注入 pendingMessages
// 调用模型
// 检查响应
// 执行工具
// Turn 收尾并再次检查 steering
}
const followUps = await getFollowUpMessages();
if (followUps.length > 0) {
pendingMessages = followUps;
continue;
}
break;
}
这两层循环解决的是两个不同问题。

内层循环:连续完成一个执行段
内层条件是:
typescript
hasMoreToolCalls || pendingMessages.length > 0
只要下面任意一项成立,就需要再进入一个 Turn:
- 上一轮产生了仍需处理的工具调用;
- 有 steering 或由 followUp 转入的 pending 消息需要注入。
hasMoreToolCalls 只描述工具链能否继续,pendingMessages 描述外部是否又给了模型新的输入。把两者放在同一个条件中,模型驱动和用户驱动就能汇入同一条执行路径。
外层循环:只负责 followUp 续命
当内层循环自然结束,说明当前已经没有 ToolCall,也没有 steering。此时才读取 followUp。
如果 followUp 不为空,它会被放进 pendingMessages,然后外层 continue,重新启动内层循环。整个过程仍属于同一个 Trace,共享同一个 newMessages 和事件序列。
如果产品没有"当前任务完成后自动追加工作"的需求,外层循环可以完全删除。外层循环是 Pi 的产品能力,不是 Agent Loop 的最小定义。
为什么进入 runLoop() 前就要读一次 steering
用户提交 prompt 后,模型调用真正开始前可能存在短暂间隔。若用户恰好在这段时间调用 steer(),消息已经进入队列。runLoop() 在进入外层循环前先读取一次 steering,可以避免这批消息一直等到首个 Turn 结束后才被发现。
每次 Turn 收尾时还会再次读取 steering。因此它有两个轮询位置:启动前一次,每轮结束后一次。
四、一个 Turn 的内部:消息如何变成流式模型响应
现在进入内层循环的一次迭代。它对应一个完整 Turn,主线可以分为六步:
text
注入 pending 消息
→ 预处理上下文
→ 转换为 LLM 消息
→ 构造 Context 并调用模型
→ 流式更新 AssistantMessage
→ 解析响应并进入工具阶段
1. pendingMessages 先进入两份集合
如果本轮有 pending 消息,Loop 会为每条消息发出 message_start 和 message_end,然后同时追加到:
currentContext.messages:保证下一次模型调用能看到;newMessages:保证本次 Trace 的返回结果包含它。
完成后清空本地 pendingMessages。队列本身已经在 drain() 时消费,不会重复注入。
2. transformContext 处理 Agent 层上下文
streamAssistantResponse() 首先取得当前消息:
typescript
let messages = context.messages;
if (config.transformContext) {
messages = await config.transformContext(messages, signal);
}
transformContext 的输入输出仍然是 AgentMessage[]。它适合做裁剪、压缩、外部上下文注入等操作,因为这些操作可能需要理解 Agent 自定义消息。
注意,它返回的是本次模型调用使用的消息视图,不会自动覆盖 context.messages。这样,模型输入可以压缩,而完整运行历史仍能保留。
3. convertToLlm 划出 Agent 与模型协议的边界
Agent 内部可能存在 UI 通知、压缩记录或其他扩展消息,模型供应商并不认识这些结构。convertToLlm() 负责把 Agent 语言转换成标准模型消息:
typescript
const llmMessages = await config.convertToLlm(messages);
默认实现只保留三类角色:
typescript
message.role === "user"
|| message.role === "assistant"
|| message.role === "toolResult"
这条边界让上层可以扩展消息系统,同时避免把内部对象原样发送给供应商 API。
4. 每个 Turn 都重新组装 LLM Context
转换完成后,Loop 创建新的请求上下文:
typescript
const llmContext = {
systemPrompt: context.systemPrompt,
messages: llmMessages,
tools: context.tools,
};
为什么每轮都重新组装?因为上一轮结束后,prepareNextTurn 可能替换 context,工具列表也可能发生变化,而 messages 一定已经增长。重新创建这个轻量 wrapper,能确保每次请求都反映最新状态。
随后按 provider 动态获取 API key,并把 signal、模型配置、传输配置等一起交给 streamFunction。
5. 流式消息不是不断 push,而是原地替换
模型流返回 start 时,Loop 先把一个部分完成的 AssistantMessage 放进 context.messages。收到文本、思考或工具调用增量时,用新版本替换最后一项:
typescript
context.messages[context.messages.length - 1] = partialMessage;
同时发出 message_update。UI 订阅者读取 event.message,就能逐步显示文本和工具调用。
当流结束时,完整 finalMessage 再次替换最后一项,并发出 message_end。因此在整个流式阶段:
text
数组长度:保持不变
最后一条消息:空壳 → 文本增量 → ToolCall 增量 → 完整响应
如果供应商没有发出 start,实现也会在拿到最终结果后补发 message_start,保证事件序列仍然完整。
6. processEvents() 把低层事件归并到 Agent 状态
Loop 的 emit 最终指向 Agent.processEvents()。这个函数先更新内部状态,再按订阅顺序等待所有监听器:
| 事件 | Agent 状态变化 |
|---|---|
message_start / message_update |
更新 streamingMessage |
message_end |
清空 streamingMessage,把消息追加到正式历史 |
tool_execution_start |
把工具 ID 加入 pendingToolCalls |
tool_execution_end |
从 pendingToolCalls 移除工具 ID |
turn_end |
若存在错误信息,更新 errorMessage |
agent_end |
确认不再保留流式消息 |
这解释了为什么 Loop 内部使用快照仍然能驱动真实 Agent 状态:低层代码维护执行上下文,事件归并器维护对外状态。
五、工具阶段:从 ToolCall 到 ToolResult 的完整安全管道
模型响应完成后,runLoop() 先把消息加入 newMessages,然后处理异常与工具调用。
1. error / aborted 先于所有工具处理
如果 stopReason 是 error 或 aborted,Loop 会立即发出当前 turn_end 和最终 agent_end,然后返回。即使响应内容里出现残留 ToolCall,也不会执行。
这是硬停止路径:模型调用已经失败或用户明确中止,继续制造副作用没有意义。
2. length 状态下的 ToolCall 不能执行
对其他响应,Loop 会从 message.content 中提取所有 ToolCall。但当前实现还有一道重要安全判断:
typescript
const executedToolBatch =
message.stopReason === "length"
? await failToolCallsFromTruncatedMessage(toolCalls, emit)
: await executeToolCalls(...);
length 表示输出因 token 上限被截断。流式 JSON 修复可能让参数在语法上可解析,却缺少模型原本要生成的后半段。如果这是写文件、删除数据或执行命令的工具,直接运行会产生难以察觉的错误。
因此 Pi 不执行这批调用,而是为每个 ToolCall 生成错误 ToolResultMessage,让下一 Turn 的模型看到原因并重新提交完整参数。
3. 串行与并行由整批最严格要求决定
正常 ToolCall 进入 executeToolCalls() 后,先判断执行模式:
typescript
const hasSequentialToolCall = toolCalls.some(
(call) => tools.find((tool) => tool.name === call.name)
?.executionMode === "sequential",
);
只要全局配置是 sequential,或者批次中任意工具要求串行,整批就走串行执行。这是一票否决:Loop 不尝试猜测哪些副作用可以安全交错。
4. 每个工具调用要经过五个阶段
无论串行还是并行,单个 ToolCall 的语义管道都可以概括为:
text
prepareArguments
→ validateToolArguments
→ beforeToolCall
→ tool.execute
→ afterToolCall
各阶段职责不同:
prepareArguments:兼容旧参数或做轻量规范化;validateToolArguments:根据工具 Schema 校验结构;beforeToolCall:权限检查、策略拦截,也可以返回terminate;tool.execute:真正产生读取、写入或命令执行等效果;afterToolCall:覆盖内容、详情、错误状态、用量或终止提示。
工具不存在、参数非法、前置钩子拦截或执行抛错,都会被规范成错误工具结果,而不是让整条 Agent Loop 直接崩溃。
5. 并行模式为什么仍然先顺序准备
Pi 的并行不是"整段工具管道并行"。准备循环仍按 ToolCall 的源码顺序运行,只有成功准备的 execute 阶段才通过 Promise.all() 并发。
text
阶段 A:A 准备 → B 准备 → C 准备
阶段 B:A.execute + B.execute + C.execute 并发
阶段 C:按原 ToolCall 顺序创建并发送 ToolResultMessage
tool_execution_end 会在每个工具完成并经过 afterToolCall 后立即发出,因此可能体现真实完成顺序;但最终工具结果消息按模型原始调用顺序写回。这样既保留并发效率,又保证下一轮上下文稳定。
6. terminate 是整批共识,不是单票否决
工具结果可以携带 terminate: true。Pi 只有在整批已完成结果全部要求终止时才停止工具链:
typescript
finalizedCalls.length > 0
&& finalizedCalls.every((call) => call.result.terminate === true)
这里用 every 而不是 some。单个工具只能判断自己的局部状态,不能替同一批其他工具宣告整个任务结束。
六、Turn 收尾与所有退出路径:循环究竟何时继续
工具执行完后,结果被依次追加到 currentContext.messages 和 newMessages,随后发出 turn_end。但一个 Turn 的收尾还没有结束。
1. prepareNextTurn 可以重写下一轮运行状态
prepareNextTurn 在 turn_end 之后执行,可以返回:
- 新的
context; - 新的
model; - 新的
thinkingLevel。
它不负责决定是否继续,只负责在确实还有下一轮时,下一轮应该使用什么状态。返回 undefined 就保持原配置。
2. shouldStopAfterTurn 是产品层安全阀
更新下一轮状态后,Loop 调用 shouldStopAfterTurn。它返回 true 时,直接发出 agent_end 并退出,且不会再读取 steering 或 followUp。
它适合实现最大 Turn 数、上下文预算、产品配额等规则。与 abort 不同,它不会打断模型或正在执行的工具,而是等当前 Turn 完整完成后优雅停止。
3. steering 与 followUp 的差别不在内容,而在时机
Agent 为两类消息分别维护 PendingMessageQueue,队列支持两种 drain 模式:
one-at-a-time:每个检查点只取最早一条,默认模式;all:一次取出当前全部消息。
| 机制 | 写入方式 | 读取时机 | 结果 |
|---|---|---|---|
| steering | agent.steer(message) |
Loop 启动前、每个 Turn 结束后 | 下一次模型调用前注入 |
| followUp | agent.followUp(message) |
内层循环自然结束后 | 重开内层循环,Trace 不结束 |
steering 也不会中断正在执行的工具。"插队"表示当前 Turn 完整结束后优先注入,而不是抢占一个正在发生的副作用。
4. stopReason 不是循环唯一的开关
最终的续转决策由响应状态、ToolCall、批次终止信号和队列共同决定。

可以把所有出口整理为下表:
| 路径 | 条件 | 是否继续检查队列 |
|---|---|---|
| 硬停止 | error 或 aborted |
否 |
| 截断恢复 | length 且存在 ToolCall |
生成错误结果,进入下一 Turn |
| 工具续转 | 存在 ToolCall,且整批未全部 terminate |
继续内层循环 |
| 工具停止候选 | 整批结果全部 terminate |
仍会经过 Turn 钩子和 steering/followUp 检查 |
| 无工具停止候选 | 没有 ToolCall | 检查 steering,再检查 followUp |
| 钩子停止 | shouldStopAfterTurn() 返回 true |
否 |
| 正常结束 | 无 ToolCall、无 steering、无 followUp | 发出 agent_end |
因此,更准确的规则不是"stopReason === stop 就结束",而是:
先处理必须硬停止的响应,再安全处理 ToolCall;完成当前 Turn 后,如果工具链、steering 和 followUp 都无法驱动下一轮,Trace 才正常结束。
5. agent_end 之后,Agent 还要完成最终结算
agent_end 是最后一个 Loop 事件。processEvents() 会依次等待所有监听器处理完这个事件,runWithLifecycle() 的 finally 才调用 finishRun():
text
agent_end
→ 状态归并
→ 依次等待订阅者
→ finishRun()
→ isStreaming = false
→ 清理 pendingToolCalls
→ activeRun = undefined
→ waitForIdle() 完成
这最后一步保证下一次 prompt() 不会在持久化、日志或 UI 监听器尚未收尾时提前进入。
七、把整条链路重新串起来
现在用"读取入口文件并解释"为例,把所有层次压缩成一条执行轨迹:
text
1. Agent.prompt("读取入口文件并解释")
2. normalizePromptInput() 生成 UserMessage
3. runWithLifecycle() 建立 activeRun 和 AbortSignal
4. createContextSnapshot() 复制消息与工具数组
5. runAgentLoop() 发 agent_start、首个 turn_start、用户消息事件
6. runLoop() 读取初始 steering,进入内层循环
7. streamAssistantResponse() 转换上下文并流式调用模型
8. 模型返回 read ToolCall
9. executeToolCalls() 校验并执行 read,生成 ToolResultMessage
10. 发 turn_end,运行下一轮钩子并读取 steering
11. 因仍有工具结果需要模型观察,开始新 Turn
12. 模型读取 ToolResult,输出解释,不再请求工具
13. 内层循环结束,followUp 为空
14. 发 agent_end,等待监听器结算
15. finishRun() 清理运行态,Agent 回到 idle
这时再看最小 Loop,就能清楚区分哪些是通用内核,哪些是 Pi 为真实交互场景增加的能力:
text
通用内核
模型调用 → ToolCall → 工具执行 → ToolResult → 再次模型调用
Pi 的产品叠加
生命周期事件 + 流式状态 + 串并行调度
+ steering + followUp
+ prepareNextTurn + shouldStopAfterTurn
+ AbortSignal 与运行结算
理解 Pi Agent Loop 的关键,不是记住某个 while 条件,而是看清三条连续变化的链:
- 消息链保存模型下一轮必须看到的事实;
- 事件链把执行过程同步给 Agent 状态和 UI;
- 控制链用 ToolCall、队列、钩子和停止状态决定是否还有下一轮。
三条链同时闭合,Agent 才不是"一次带工具的模型调用",而是一个可以持续运行、接受干预并安全结束的执行引擎。