第 3 章:Agent Loop —— 让模型转动起来的引擎
架构只是"骨架",Agent 真正的生命力来自"循环"。本章回答三个问题:为什么需要循环?循环怎么转?什么时候停?
大模型的三种用法
在聊 Agent Loop 之前,先看"使用大模型"有几种模式:
| 维度 | 直接调用 | Workflow | Agent Loop |
|---|---|---|---|
| 决策者 | 用户 | 你的代码 | 模型 |
| 模型调用次数 | 1 次 | N 次(代码控制) | 不确定(模型控制) |
| 核心工作 | 写提示词 | 设计流程 | 定义工具和循环 |
| 模型角色 | 执行者 | 流水线环节 | 自主决策者 |
| 典型场景 | 翻译、摘要 | 文档流水线、RAG | 编程助手、自动化任务 |
Agent 模式的关键区别:步骤之间的流转不再由你写死,而是由模型的输出内容驱动。 你的代码只做两件事——把用户输入和工具结果喂给模型;如果输出里有工具调用请求就执行,没有就认为任务完成。至于"什么时候该停",那是人类定义的规则:模型一次输出中不再包含工具调用时,循环结束。
两个必须分清的概念:Trace 与 Turn
Trace 是从用户按下回车到 Agent 发出 agent_end 的整个过程,包含多个 Turn。Turn 的定义非常精确:一次模型调用 + 这次调用触发的所有工具执行,由一对 turn_start / turn_end 事件包裹。
Trace(agent_start → agent_end)
├── Turn 1:调模型 → 返回 toolUse(读文件)→ 执行 read
├── Turn 2:带结果再调模型 → 返回 toolUse(改文件)→ 执行 edit
└── Turn 3:带结果再调模型 → 返回 stop(无工具调用)→ 结束
关键点:一个 Turn 只有一次模型调用。 模型一口气要求 3 个工具(read + grep + find),它们都在同一个 Turn 里执行——都是同一次模型调用的产物;但把结果喂回去再调模型的那一刻,已经进入下一个 Turn 了。
实现细节:首轮 Turn 的
turn_start在runAgentLoop()入口就发出,runLoop()内用firstTurn标志跳过首圈的 turn_start,避免重复。
stopReason:唯一的信号灯
整个循环的"油门和刹车"集中在一个字段上:stopReason。但先澄清一个关键认知:模型不会说"我要停了"。 模型只是 token 预测器,不"知道"任务做完没有。stopReason 的值来自两个不同的地方:
模型 API 真正返回的三种:
| stopReason | 含义 |
|---|---|
"toolUse" |
模型输出了工具调用 JSON,API 检测到后返回 |
"stop" |
生成自然终止,没有工具调用 |
"length" |
token 数达到 maxTokens 上限,被截断 |
框架流式层注入的两种(模型 API 本身不会返回):
| stopReason | 含义 | 谁注入的 |
|---|---|---|
"error" |
调用过程异常(网络断了、API 报错) | 流式层 catch 块:output.stopReason = "error" |
"aborted" |
用户主动中止(AbortSignal 触发) | 流式层 catch 块:output.stopReason = "aborted" |
代码证据(
packages/ai/src/):streamSimple内部 API 调用抛异常时,catch 块执行output.stopReason = options?.signal?.aborted ? "aborted" : "error"。这不是模型说的,是框架替它"兜底"的。
一条规则驱动整个循环
Loop 实际上只看一件事——模型输出里有没有工具调用:
// 简化逻辑(实际见 agent-loop.ts:202-216)
const toolCalls = message.content.filter(c => c.type === "toolCall");
hasMoreToolCalls = false;
if (toolCalls.length > 0) {
const executedToolBatch = await executeToolCalls(...);
hasMoreToolCalls = !executedToolBatch.terminate; // 全部工具 terminate 才停
}
注意:实际驱动循环的不是
stopReason === "toolUse",而是toolCalls 数组长度 > 0 && !terminate。即使stopReason === "length"(被截断),只要 content 里有 toolCall 块,循环仍会执行工具;反之即使stopReason === "toolUse",如果所有工具结果都设了terminate: true,循环也会停。
内层循环条件是 while (hasMoreToolCalls || pendingMessages.length > 0),退出路径一共四条:
| 退出路径 | 触发条件 | 说明 |
|---|---|---|
| 正常退出 | stop / length + 无 followUp + 无 pendingMessages |
最常见 |
| 硬停止 | error / aborted |
立即退出整个循环,不检查 followUp(fail fast) |
| 外部钩子停 | shouldStopAfterTurn() 返回 true |
上下文快满、达到最大 Turn 数等 |
| 工具终止 | 一批工具的结果全部 terminate: true |
用 every 不是 some——保守策略 |
为什么不让代码更智能地判断"任务完成没"? 这正是 Agent 和 Workflow 的本质区别。Workflow 里你知道流程有几步,可以判断进度;Agent 模式下你不知道模型要读几个文件,唯一能稳定依赖的信号就是"输出里有没有工具调用"。这既是局限,也是优雅——不需要任何"完成度"判断逻辑。
源码详解:内核 + 叠加
最简 Loop:所有 Agent 的最小公约数
// 最简 Agent Loop(伪代码)
async function simpleLoop(messages, model, tools) {
while (true) {
const response = await callModel(model, messages, tools);
messages.push(response);
if (response.stopReason !== "toolUse") return messages; // 没要工具 → 结束
for (const toolCall of response.toolCalls) {
messages.push(await executeTool(toolCall)); // 执行并喂回去
}
}
}
十几行,这就是内核。Pi 的 coding-agent 作为交互式编程助手,在内核上叠加了四个设计:
| 真实需求 | 叠加的设计 |
|---|---|
| 用户在 Agent 工作时又输入新指令 | steering 消息注入:紧急消息在 Turn 之间插队 |
| 系统在 Agent 完成后想追加任务 | 外层 followUp 循环:内层停了外层可重启内层 |
| 不同复杂度任务想用不同档次模型 | prepareNextTurn 钩子:每 Turn 结束可换模型/上下文 |
| 上下文窗口快满需触发压缩 | shouldStopAfterTurn 钩子:外部安全阀 |
关键认知:这些都是 coding-agent 的功能选择,不是 Agent 的通用法则。做"一问一答带工具"的简单 Agent,整张表都是多余的。
runLoop() 骨架
async function runLoop(currentContext, newMessages, config, signal, emit, streamFn) {
// ① 首次 steering 检查(在进入内层循环之前!)
let pendingMessages = (await config.getSteeringMessages?.()) || [];
while (true) { // ===== 外层循环(followUp 续命)
let hasMoreToolCalls = true;
let firstTurn = true; // 首轮跳过 turn_start(入口已发)
while (hasMoreToolCalls || pendingMessages.length > 0) { // 内层循环
if (!firstTurn) emit({ type: "turn_start" });
firstTurn = false;
// A: 注入 pendingMessages(steering) ← 叠加
// B: 调 LLM → streamAssistantResponse() ← 内核
// C: 检查 stopReason ← 内核
// D: 执行工具 ← 内核
// E: emit turn_end ← 内核
// F: prepareNextTurn → shouldStopAfterTurn → 再查 steering ← 叠加
}
const followUpMessages = (await config.getFollowUpMessages?.()) || [];
if (followUpMessages.length > 0) {
pendingMessages = followUpMessages;
continue; // 内层循环重开
}
break; // 两个队列都空,真正退出
}
}
为什么 steering 要在进入循环之前先查一次?因为用户在等 LLM 首次响应时可能已输入了内容——消息已从外部排队但循环还没开始,不提前取出来这批消息就漏掉了。
步骤 B 的四个关键阶段
阶段 A:上下文预处理。 配了 transformContext(如压缩算法)就先处理消息,不配就跳过。
阶段 B:convertToLlm——两层消息的边界。 Agent 内部历史里不只有标准消息,还有它自己的"内部语言"(CompactionSummaryMessage、BashExecutionMessage、BranchSummaryMessage 等),LLM 根本不认识——它只认 UserMessage / AssistantMessage / ToolResultMessage 三种。convertToLlm 是站在边界上的翻译官,默认实现就是一个 filter:
function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
return messages.filter(
(m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult",
);
}
阶段 C:构建 Context 并调用模型。 每圈内层循环都重建一个全新的 llmContext 对象(systemPrompt / messages / tools 三部分)。注意 tools 是引用赋值、内容字节级稳定,只有 messages 真的在长。每圈重建 wrapper 的成本可忽略,但能避免共享引用导致的状态污染,也保证 prepareNextTurn 换模型、扩展系统动态注册工具后下一轮能生效。
这会不会破坏 prompt cache? 不会。Anthropic 的 cache 是内容寻址的——看的是字节不是对象 identity。Pi 在 anthropic-messages.ts 里显式在三个位置打 cache_control: { type: "ephemeral" }:system prompt 末尾、最后一个 tool、最后一条 user message。第三条最精妙——cache breakpoint 跟着最新 user message 走(rolling cache),旧前缀持续命中、新内容增量写入。OpenAI 体系走另一条路(openai-completions.ts:554):prompt_cache_key: sessionId,后端按 session 自动匹配前缀。
阶段 D:流式响应的"原地替换"。 收到 start 事件时先 push 一个"空壳"消息进 context,之后每个 delta 事件用更新后的部分消息覆盖数组最后一条(不是 push 新条目),done 时用最终完整消息替换:
case "start":
partialMessage = event.partial;
context.messages.push(partialMessage); // 空壳 push
break;
case "text_delta":
partialMessage = event.partial;
context.messages[last] = partialMessage; // ★ 原地替换
emit({ type: "message_update", ... }); // UI 逐字渲染
break;
case "done":
finalMessage = await response.result();
context.messages[last] = finalMessage; // ★ 最终替换
break;
这样 context 的消息数量不变,但最后一条在"长大"——UI 通过 message_update 事件做到逐字渲染,用户不必盯着空白屏幕等几秒。
步骤 C/D:硬停止与工具执行
拿到 AssistantMessage 后立即做硬停止判断(agent-loop.ts:196-200):error 或 aborted 直接发 turn_end + agent_end 然后 return,连工具都不执行、followUp 都不检查。
工具执行的调度策略:只要这批工具中任何一个声明了 executionMode: "sequential",整批都串行("一票否决"——判断工具间是否冲突很难,宁可多等不可出错)。并行模式则是三阶段设计:准备阶段始终顺序(验证和 beforeToolCall 不能并行——万一 B 被拦截,C 就不该执行)、只有 execute 并行(Promise.all)、事件有序(result 按调用顺序发,保证 LLM 收到的上下文正确)。edit 工具内部还有第二道防线 withFileMutationQueue,对同一文件的编辑串行化。
steering vs followUp
| 维度 | steering(叠加 1) | followUp(叠加 2) |
|---|---|---|
| 检查时机 | runLoop 开始前 + 内层循环每圈 | 内层循环全部结束后 |
| 语义 | "紧急插队"——在工具执行间隙插入 | "排队等叫号"——等当前任务完成 |
| 典型场景 | 用户在 Agent 工作时输入新指令 | 系统追加"顺便跑个测试" |
生活类比:steering 是开会时有人敲门递纸条——"紧急,先看这个";followUp 是开完会翻信箱——"不急,但需要处理"。followUp 让追加任务在同一个 Trace 内继续跑(同一个 newMessages 数组、同一个事件序列),比重启一个新 Loop 更连续。
总结:Loop 的四条核心设计
- ReAct 循环模式:Reason → Act → Observe → Reason。模型输出决定"调什么工具",但"什么时候停"是人类定义的规则——模型不再输出工具调用时,本轮结束。
- stopReason 驱动:实际驱动循环的不是 stopReason 本身,而是"输出里有没有 toolCall 块且未全部 terminate"。把决策外包给模型输出模式,代码只做最简单的信号判断。
- 内核 + 叠加:内核十几行就够;steering、followUp、prepareNextTurn、shouldStopAfterTurn 都是产品层的按需叠加。做自己的 Agent 时,先搭内核,再按场景加叠加。
下一章,我们拆开 Loop 调用的"模型"层:Pi 怎么用同一套代码调用 30+ 家不同供应商?
本章关键源码索引(v0.80.2):
agent-loop.ts:95-118(入口)、:155-269(双层循环)、:196-200(硬停止)、:275-368(流式响应)、:373-516(工具调度)、agent.ts:118-152(PendingMessageQueue)。本文改编自 CC-BY-SA-4.0 许可的开源教程。