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

目录
-
- 前言
- 一、从一次调用到自主循环:差别究竟在哪里
- [二、Trace 与 Turn:先分清两层生命周期](#二、Trace 与 Turn:先分清两层生命周期)
- 三、最小内核:十几行代码如何让模型转起来
-
- 一条消息的完整旅程
- [为什么 AgentMessage 还要转换成 Message](#为什么 AgentMessage 还要转换成 Message)
- [四、循环何时停止:stopReason 不是唯一答案](#四、循环何时停止:stopReason 不是唯一答案)
-
- [1. error 与 aborted:立即硬停止](#1. error 与 aborted:立即硬停止)
- [2. 没有 ToolCall:只是"准备停止"](#2. 没有 ToolCall:只是“准备停止”)
- [3. length + ToolCall:不能直接执行](#3. length + ToolCall:不能直接执行)
- [4. terminate:必须整批工具都同意](#4. terminate:必须整批工具都同意)
- [五、从能运行到能交互:Pi 在最小内核上加了什么](#五、从能运行到能交互:Pi 在最小内核上加了什么)
-
- 流式响应:最后一条消息原地长大
- 工具调度:准备阶段串行,执行阶段才并行
- [steering 与 followUp:都是新消息,时机完全不同](#steering 与 followUp:都是新消息,时机完全不同)
- 两个钩子:下一轮改装与安全停止
- [六、如何判断自己的 Agent Loop 是否设计过度](#六、如何判断自己的 Agent Loop 是否设计过度)
- 参考资料
前言
大模型本身只会生成下一段内容,为什么接入工具后,却能连续读文件、改代码、运行命令,最后再给出答案?关键不在于模型突然拥有了"执行能力",而在于外部程序构造了一个可重复的 Agent Loop。本文以
@earendil-works/pi-agent-core0.84.3 的实现为依据,从 Trace、Turn、ToolCall、停止条件和消息队列五个角度,拆开这个循环真正的驱动力。
一、从一次调用到自主循环:差别究竟在哪里
先把三种常见的大模型使用方式放在一起看。
| 模式 | 下一步由谁决定 | 模型调用次数 | 适合的任务 |
|---|---|---|---|
| 直接调用 | 用户 | 1 次 | 翻译、摘要、单轮问答 |
| Workflow | 程序预先写好的流程 | 固定或可预测 | RAG、审核流水线、分阶段生成 |
| Agent Loop | 模型输出的 ToolCall | 运行时才知道 | 编程助手、开放式自动化任务 |
直接调用只有一条直线:输入交给模型,拿到输出后结束。Workflow 虽然会多次调用模型,但"先检索、再生成、最后校验"这些步骤已经由程序员写死。
Agent Loop 的不同之处是:程序不预先知道下一步是什么,只负责解释模型这一次输出的结构。
例如,用户要求"分析入口文件为什么启动失败",模型可能先输出一个 read 工具调用;看到文件内容后,再输出 grep;找到配置后,又调用 edit;直到某一次响应里不再出现 ToolCall,程序才进入结束判断。
这与 ReAct 思路一致:模型在"推理(Reason)---行动(Act)---观察(Observe)"之间循环。但工程实现并不神秘,核心只需要维护消息历史并重复三步:
- 把当前上下文交给模型;
- 执行模型返回的工具调用,把结果追加到上下文;
- 再次调用模型,直到没有新的工具调用。
因此,模型负责选择动作,Loop 负责执行、记录和控制边界。两者缺一不可。
二、Trace 与 Turn:先分清两层生命周期
读源码前最容易混淆的是 Trace 和 Turn。它们描述的是不同时间尺度。
- Trace :从
agent_start到agent_end的一次完整运行。 - Turn:一次模型响应,以及该响应触发的一批工具执行。

假设一次任务经历下面三步:
text
Turn 1:模型调用 → read + grep → 两个工具结果
Turn 2:模型调用 → edit → 一个工具结果
Turn 3:模型调用 → 最终文本 → 无工具调用
这仍然只是一个 Trace,因为从用户提交任务到最终回答,中间没有发生新的 agent_start。
这里有两个容易误判的点。
第一,模型一次返回三个 ToolCall,它们仍属于同一个 Turn。Turn 的边界由模型调用划分,而不是由工具数量划分。
第二,把工具结果重新交给模型时,才进入下一个 Turn。因为此时发生了新的一次模型调用。
Pi 在入口处先发出 agent_start 和首个 turn_start。进入 runLoop() 后,firstTurn 会避免重复发送第一轮的 turn_start;后续每次内层循环开始,再发出新的 turn_start。因此,事件序列和实际执行边界保持一致:
text
agent_start
turn_start
message_start / message_update / message_end
tool_execution_start / tool_execution_end
turn_end
turn_start
...
turn_end
agent_end
这套事件不只是日志。终端 UI 正是依靠它们显示流式文本、工具状态和一轮任务是否完成。
三、最小内核:十几行代码如何让模型转起来
抛开队列、钩子、动态模型切换等产品能力,一个最小 Agent Loop 可以写成下面这样:
typescript
async function simpleLoop(messages: Message[]) {
while (true) {
const response = await callModel(messages, tools);
messages.push(response);
if (response.stopReason === "error" || response.stopReason === "aborted") {
return messages;
}
const toolCalls = response.content.filter(
(item) => item.type === "toolCall",
);
if (toolCalls.length === 0) {
return messages;
}
for (const toolCall of toolCalls) {
const result = await executeTool(toolCall);
messages.push(result);
}
}
}
真正驱动循环的不是一句抽象的"模型认为任务还没完成",而是一个可以被程序检查的事实:本次响应中是否存在 ToolCall。
一条消息的完整旅程
以"读取入口文件并解释"为例,数据会经历下面的变化:
text
UserMessage
↓
第一次模型调用
↓
AssistantMessage(content: [ToolCall(read)])
↓
执行 read,生成 ToolResultMessage
↓
第二次模型调用(上下文已包含 ToolResultMessage)
↓
AssistantMessage(content: [TextContent])
↓
没有 ToolCall,进入停止判断
第一次模型调用并没有拿到文件内容。模型只根据工具名称、描述和参数 Schema,生成一个结构化请求。真正读取文件的是宿主程序。随后,宿主程序把结果包装成 ToolResultMessage,与原来的用户消息、助手消息一起送入下一轮。
所以工具并不是模型的"手"。更准确地说,模型只会填写操作申请,Loop 才是审核申请、执行动作、记录结果的运行时。
为什么 AgentMessage 还要转换成 Message
Agent 内部可能保存 UI 通知、压缩摘要、分支记录等自定义消息,但模型供应商只理解用户、助手和工具结果等标准消息。streamAssistantResponse() 在调用模型前会先执行:
typescript
const llmMessages = await config.convertToLlm(messages);
const llmContext = {
systemPrompt: context.systemPrompt,
messages: llmMessages,
tools: context.tools,
};
convertToLlm 是 Agent 内部状态与模型协议之间的边界。这个边界很重要:内部消息可以按产品需要扩展,但发给模型的内容仍然保持协议合法。
四、循环何时停止:stopReason 不是唯一答案
许多实现会把 Agent Loop 简化为"stopReason === toolUse 就继续"。这可以用于教学,但不足以描述真实系统。
Pi 当前实现会综合检查 stopReason、ToolCall、工具批次的 terminate 标记,以及两个消息队列。

1. error 与 aborted:立即硬停止
当模型响应的 stopReason 是 error 或 aborted 时,Loop 会发送当前 turn_end 和最终 agent_end,然后直接返回。
这两种状态代表模型调用本身失败或用户已经中止。此时继续执行工具、读取 steering 或 followUp 都没有意义,因此采用快速失败策略。
2. 没有 ToolCall:只是"准备停止"
当响应里没有 ToolCall,hasMoreToolCalls 会保持为 false。但 Loop 不能立刻结束,因为外部可能还有新消息:
steering:用户在 Agent 工作期间补充的紧急指令;followUp:当前任务自然结束后才处理的追加任务。
只有工具调用和两个消息队列都为空,才会正常发送 agent_end。
这说明"无 ToolCall 即结束"只是最小内核的规则。交互式产品还要把运行期间的新输入纳入判断。
3. length + ToolCall:不能直接执行
这是原始材料与当前 0.84.3 源码之间最值得注意的变化。
length 表示输出达到 token 上限。流式解析可能已经拼出一个形式上合法的 ToolCall,但参数仍可能被截断。假设模型原本要写入一段完整配置,截断后 JSON 恰好还能被修复和校验,直接执行会造成静默的数据损坏。
因此当前实现会调用 failToolCallsFromTruncatedMessage():
text
检测到 stopReason === "length"
↓
不执行本轮任何 ToolCall
↓
为每个调用生成错误 ToolResultMessage
↓
让模型在下一 Turn 重新提交完整参数
这不是可选的防御性装饰,而是工具具有副作用时必须守住的安全边界。
4. terminate:必须整批工具都同意
工具结果可以设置 terminate: true,表示它认为当前批次之后不应继续。但 Pi 使用的是 every,而不是 some:
typescript
function shouldTerminateToolBatch(finalizedCalls: FinalizedToolCallOutcome[]) {
return finalizedCalls.length > 0
&& finalizedCalls.every((item) => item.result.terminate === true);
}
如果同一轮有三个工具,只有一个要求终止,另外两个正常完成,Loop 仍会继续。只有整批结果都要求终止,hasMoreToolCalls 才会变为 false,随后进入消息队列检查。
这是一种保守策略:单个工具只能表达自己的局部判断,不能替其他工具决定整个任务已经完成。
五、从能运行到能交互:Pi 在最小内核上加了什么
最小 Loop 解决"模型---工具---模型"的闭环,实际编程助手还需要处理流式 UI、并行工具、用户插队和运行时安全阀。Pi 没有把这些逻辑混进模型调用函数,而是通过事件、队列和钩子叠加。
流式响应:最后一条消息原地长大
收到流式响应的 start 事件时,streamAssistantResponse() 会先把一个尚未完成的助手消息放进 context.messages。后续收到文本、思考或工具调用增量时,它不会不断追加新消息,而是替换数组中的最后一项:
typescript
context.messages[context.messages.length - 1] = partialMessage;
直到 done 或 error,最后一项才被完整的 finalMessage 替换。
这样设计解决了两个问题:
- UI 能通过
message_update实时刷新,不必等模型生成完毕; - 对话历史始终只有一条助手消息,不会把每个 token 误存成独立消息。
可以把它理解为:数组长度不变,最后一条消息的内容持续"长大"。
工具调度:准备阶段串行,执行阶段才并行
同一次模型响应可能包含多个 ToolCall。Pi 支持串行和并行两种模式:
- 配置为
sequential,或批次中任意工具声明executionMode: "sequential":整批串行; - 其他情况:准备阶段保持顺序,通过检查的工具才并发执行。
并行模式不是简单地把所有逻辑塞进 Promise.all()。它分成三段:
text
准备:按源码顺序查找工具、整理参数、校验 Schema、运行 beforeToolCall
执行:通过准备的工具并发调用 execute
归档:tool_execution_end 可按完成时间出现,ToolResultMessage 按原调用顺序写回
为什么结果消息必须保持原调用顺序?因为下一轮模型需要把每个结果与对应的 ToolCall 稳定配对。执行可以并发,写入上下文的协议顺序却不能随完成速度漂移。
steering 与 followUp:都是新消息,时机完全不同
steering 和 followUp 都能让 Loop 继续,但语义不同。
| 机制 | 检查时机 | 适合的场景 | 对循环的影响 |
|---|---|---|---|
| steering | 进入 Loop 前,以及每个 Turn 结束后 | 用户中途补充"也检查测试文件" | 在下一次模型调用前注入 |
| followUp | 内层循环已经自然停止后 | 系统追加"完成后再运行测试" | 让外层循环重新启动内层循环 |
steering 不会打断正在执行的工具。它的"紧急"指的是在当前 Turn 完整结束后,优先进入下一次模型调用。followUp 则更晚:只有当前工具调用和 steering 都耗尽,才会被读取。
双层循环正是为这两种时机服务:
typescript
while (true) {
while (hasMoreToolCalls || pendingMessages.length > 0) {
// 模型调用、工具执行、steering 检查
}
const followUps = await getFollowUpMessages();
if (followUps.length > 0) {
pendingMessages = followUps;
continue;
}
break;
}
如果你的 Agent 没有运行中输入,也没有任务结束后的追加工作,这个外层循环完全可以不要。它是产品能力,不是 Agent 的定义。
两个钩子:下一轮改装与安全停止
每个 Turn 完成后,Pi 还提供两个关键扩展点:
prepareNextTurn:替换下一轮的上下文、模型或思考等级;shouldStopAfterTurn:在本轮完整收尾后优雅停止,不再读取队列或发起下一次模型调用。
例如,上下文接近上限时,可以让 shouldStopAfterTurn 返回 true,把压缩或恢复交给更外层的会话管理器。这样停止策略不需要污染通用 Loop。
这体现了一条很实用的架构原则:内核只维护不可缺少的状态转换,产品差异通过窄接口叠加。
六、如何判断自己的 Agent Loop 是否设计过度
实现自己的 Agent 时,可以先回答四个问题:
- 模型输出什么结构时需要继续?通常是存在 ToolCall。
- 工具结果以什么消息格式回填?它必须能与原 ToolCall 稳定关联。
- 哪些状态必须硬停止?至少包括调用错误和用户中止。
- 是否真的需要运行中插队、结束后续命、动态换模型?没有实际需求就不要提前加入。
一个健康的设计应当满足:去掉 steering、followUp、动态模型切换等外层能力后,最小 Loop 仍然可以独立运行。
最终可以把 Agent Loop 归纳成一句话:
模型用 ToolCall 提议下一步,运行时安全地执行并回填结果;当没有可执行动作、没有排队消息,也没有异常需要处理时,这次 Trace 才真正结束。