吃透 AI Agent 开发 · 第 06 篇 · 第二章 · Agent Loop
模型看一眼、决定下一步、系统执行,再把结果交回模型。真正的难点在消息演进和停止条件。
把 Agent Loop 翻译成中文,就是“模型看一眼,决定下一步,系统执行,再让模型看一眼”。它很像维修师傅处理漏水:先观察,拧一个阀门,再观察水还漏不漏。区别在于,模型不会自动看到现实变化,工具结果必须被系统写回消息。
看起来只是循环,真正实现时却有不少细节:一轮可能同时返回文字和多个工具调用;工具会失败;用户可能中途取消;模型还可能连续十次做同一件事。
先看一条完整消息链
假设用户说:“读取 package.json,告诉我用了哪个测试框架。”
消息可能这样变化:
1. user: 读取 package.json,告诉我用了哪个测试框架
2. assistant: tool_call read_file({ path: "package.json" })
3. tool: { content: "... vitest ..." }
4. assistant: 项目使用 Vitest 作为测试框架
第三条工具消息不能省。只在程序变量里保存文件内容,模型下一轮并不知道工具执行过什么。
工具调用也不是普通文字。SDK 往往把一次模型响应拆成多个 part:文本、reasoning、tool call、引用或其他 Provider 特有字段。Agent Loop 要保留协议结构,而不是把所有内容拼成一个字符串。
一个够用的循环骨架
async function runAgent(input: RunInput): Promise<string> {
const messages = [...input.messages]
for (let step = 0; step < input.maxSteps; step++) {
const response = await input.model.generate({
system: input.system,
messages,
tools: input.tools.toModelTools(),
signal: input.signal
})
messages.push(response.message)
if (response.toolCalls.length === 0) {
return response.text
}
for (const call of response.toolCalls) {
const result = await input.tools.execute(call, input.signal)
messages.push(toToolResultMessage(call, result))
}
}
throw new Error('Agent reached the step limit')
}
这段代码比第一篇多了消息记录、步数和中断信号,但仍只是骨架。生产实现还要回答下面几个问题。
什么情况下应该停止
最自然的停止条件是:模型没有再请求工具,并给出了最终文本。但只靠这一条不够。
- 用户主动取消,应立即停止模型请求和正在运行的工具。
- 单步请求超过总超时,应结束或进入明确的恢复流程。
- 达到局部步数预算,应返回可诊断错误,而不是无限运行。
- 连续重复相同工具和参数,说明可能陷入循环。
- 工具返回不可恢复错误时,不应假装成功继续。
步数限制是最后一道保险,不是任务管理方式。正常任务应该因为完成目标而停止,而不是撞上 maxSteps。
重试要分对象
“失败就重试三次”听起来稳妥,实际可能制造更大问题。
模型请求遇到 429、临时网络中断,可以指数退避后重试。参数校验失败说明输入有问题,原样重试没有意义。写文件、发消息、创建订单等带副作用的工具,如果没有幂等键,自动重试可能执行两遍。
function canRetry(error: unknown): boolean {
return error instanceof RateLimitError
|| error instanceof TemporaryNetworkError
}
重试策略应该靠错误类型和操作语义决定,而不是只看“有没有抛异常”。
reasoning 为什么要保留但不一定展示
一些模型会返回独立的 reasoning part。它可能是后续请求协议的一部分,需要跟 assistant 消息一起回传。如果系统把它丢掉,下一步可能报协议错误或失去推理连续性。
但“需要回传给模型”不等于“需要原样展示给用户”。运行消息和界面消息是两个边界。界面可以只显示最终文本、工具状态和必要的过程摘要。
工具可以并行吗
如果模型同时请求读取三个互不相关的文件,并行通常没问题。如果它先创建目录再写文件,并行就会打乱依赖。判断依据不是“模型一次返回了几个调用”,而是工具是否只读、是否存在数据依赖、是否会竞争同一资源。
一种保守做法是:只读工具允许并发,写工具默认串行;需要更高并发时,再显式声明资源和依赖。
如何发现“忙了很久但没进展”
下面这些轨迹都值得警惕:
- 连续读取同一个文件,却没有新的用户输入或文件变化。
- 工具 A 报错,模型不调整参数,原样再次调用。
- 两个工具来回切换,但最终状态没有变化。
- 模型只输出“我继续检查”,没有文字结论也没有工具行动。
可以为每次调用生成一个指纹:工具名、规范化参数和结果摘要。如果相同指纹连续出现,就提醒模型重新评估;达到阈值后终止并返回轨迹摘要。
别把长结果直接塞回去
Shell 打印十万行日志时,全部回填会迅速挤满上下文。工具层可以把完整输出写入文件,只返回前后片段、总行数、内容哈希和恢复路径。模型需要更多内容时,再按范围读取。
这不是 Agent Loop 自己裁剪字符串,而是 Loop 与工具结果协议共同约定“哪些内容内联,哪些内容句柄化”。
用 Final Text 做个小练习
为循环增加重复调用检测:
- 使用
toolName + JSON.stringify(args)生成调用指纹。 - 连续出现两次时,向消息中加入一次警告。
- 连续出现三次时终止循环。
- 用假模型依次返回三次相同调用,验证只执行两次还是三次,并写清你的选择。
最后一步没有唯一答案,关键是终止规则必须清晰且可测试。
User Message 到 Final Text 的小结
Agent Loop 管理的不是一个 while,而是一条消息账本。模型响应、工具调用和工具结果都必须按协议进入账本;超时、重试、并发和终止则保护这条链不会失控。
下一篇讨论每次请求最容易被低估的部分:究竟要把哪些信息放进模型上下文,以及为什么一股脑塞进去通常不是好主意。
解剖一份消息账本
假设模型第一次返回 read_file,工具读取成功,第二次模型给出总结。正确 transcript 里至少有 user、assistant tool call、tool result、assistant final 四个部分。少了 assistant tool call,工具结果会变成没有来源的消息;少了 tool result,第二轮模型只能猜执行是否成功。
user: 请读取 package.json 并说明 scripts
assistant: tool_call(read_file, { path: "package.json" })
tool: tool_result(call_01, { status: "ok", preview: "..." })
assistant: 项目提供 build、test 和 serve 三类脚本……
这份账本既是模型的下一轮上下文,也是恢复和测试的输入。UI 可以把工具消息折叠成一行,但不能把协议级记录压成最终文字。
Loop 的源码落点
| 追踪序号 | 路径 | 验证点 |
|---|---|---|
| 1. 主循环 | src/agent/loop.ts |
每次模型响应如何决定继续或停止 |
| 2. 重试策略 | src/agent/retry.ts |
provider 请求和工具副作用是否分开重试 |
| 3. 循环检测 | src/agent/loop-detection.ts |
重复调用怎样被识别成无进展 |
“循环没报错”不代表任务完成
执行工具后只把结果拼成字符串,没有保留模型协议要求的 assistant tool call。这样的实现可能在单轮 demo 中回答正确,却无法继续第三轮,也无法在会话恢复时还原调用关系。
停止条件要同时看模型完成原因、待执行工具、取消信号、循环检测和任务状态。任何一个分支决定结束时,都应该写明 whyStopped;否则 Eval 只能看到最终文本,无法区分正常完成和提前放弃。
练习:制造两次无进展
让假模型连续两次调用同一个只读工具,并返回完全相同的参数。第一次允许执行,第二次记录重复信号,第三次必须停止或改变策略。断言 transcript 中的消息顺序、工具次数和停止原因,不能只断言最终回答包含某个词。
多工具调用时,账本顺序不能靠完成速度决定
模型一次返回两个只读调用:读取 package.json 和 tsconfig.json。前者磁盘缓存未命中,后者先完成。如果应用按完成先后直接 push tool result,消息顺序就可能每次不同;有的 provider 还要求 tool result 与原始 callId 一一对应。
一种做法是并发执行、按声明顺序入账:
const calls = response.toolCalls
const settled = await Promise.allSettled(
calls.map((call) => tools.execute(call, signal))
)
for (let index = 0; index < calls.length; index++) {
messages.push(toToolResultMessage(calls[index], settled[index]))
}
界面仍可以在每个工具完成时立即收到进度事件,transcript 则保持确定顺序。这再次说明 UI 事件和模型账本不是同一个数组。若两个调用存在依赖,就不应该仅因为它们出现在同一响应中而并行;工具元数据或上层计划必须能表达只读性、资源范围和依赖。
sequenceDiagram
participant M as Model
participant L as Loop Ledger
participant A as read package
participant B as read tsconfig
M->>L: call A + call B
par 并行执行
L->>A: execute A
L->>B: execute B
end
B-->>L: B 先完成
A-->>L: A 后完成
L->>L: 按 call 声明顺序记 A、B
L->>M: assistant calls + results A、B
一轮请求里至少有三种“结束”
模型返回 final text,只说明模型步骤结束;工具进程退出,只说明某个动作结束;用户任务完成,则需要目标和副作用都达到要求。这三种结束混成一个 done,会出现很典型的假完成:模型说“已经修改并测试”,实际 transcript 中没有写工具或测试退出码。
可以让停止记录携带原因和证据:
type StopRecord =
| { reason: 'final-text'; step: number; messageId: string }
| { reason: 'user-cancelled'; step: number; pendingCalls: string[] }
| { reason: 'loop-detected'; fingerprint: string; repeats: number }
| { reason: 'request-timeout'; step: number; elapsedMs: number }
| { reason: 'budget-exhausted'; step: number; usage: Usage }
final-text 并不自动等于 Eval 通过,它只是 Loop 的正常终态。任务层还会检查输出、文件和测试;二者分开后,Loop 不需要理解每个业务目标,Eval 也不需要猜循环为什么停。
取消要沿一棵树传播
用户按下 Ctrl+C 时,当前模型请求、正在执行的工具、工具启动的子进程和后台子任务可能同时存在。只在最外层设一个 cancelled = true,无法停止已经拿到引用的工作。
运行时可以为本轮创建一个根 AbortController,模型和前台工具共享它;后台任务若被定义为“离开本轮也继续”,则拥有独立 controller 和持久 taskId。这个选择要在启动任务时决定,不能取消时才猜。
取消后的结果也要入账。模型请求收到 abort 可以记为 cancelled-before-response;Shell 收到信号但未确认退出,则记为 cancellation-requested,保留 jobId 供后续查询。只有确认没有副作用继续运行,界面才显示“已取消”。
崩溃恢复不是从最后一句继续生成
进程在 tool result 写入会话前崩溃,磁盘文件却已经修改。恢复时若只读取 transcript,会看到最后状态是 assistant 请求写文件,于是可能再次执行。可靠恢复需要把执行小票与副作用证据放在调用边界上。
恢复流程可以按 callId 查三件事:会话里是否已有 tool result;审计里是否有执行完成事件;外部状态是否能验证。三者一致时补写缺失账本,三者冲突时进入 unknown 并要求检查。不能为了让 transcript 看起来完整而伪造一个成功结果。
对于本地写文件,写前快照、写后 hash 和 callId 能帮助确认;对于远端发消息,最好使用幂等键或查询 API;对于无法查询的动作,系统只能诚实报告不确定。这是 Loop 与领域工具的共同合同。
用不变量测试 Loop,而不是测试某个回答
假模型非常适合验证账本不变量。无论文本内容是什么,下面几条都应该成立:
- 每个 tool result 都能找到此前同 callId 的 assistant tool call。
- 同一个 callId 最多产生一份终态结果。
- 模型下一轮不会看到尚未完成的半个调用。
- 用户取消后不再发起新的模型步骤。
- 达到循环阈值时留下最后一次不同结果和停止原因。
- 长工具输出以内联摘要加恢复句柄表示,不把全文复制进历史。
再随机生成工具成功、拒绝、超时、取消和流中断的序列,让 reducer 处理数百次。比起断言“回答包含 Vitest”,这些不变量更能保护 Loop 的长期行为。
一次手工走查应该打印什么
调试时可以为每步打印一行摘要,不打印 Prompt 和文件正文:
run=r18 step=1 model.finish=tool-calls calls=2 ttft=812ms
run=r18 call=c1 tool=read_file policy=allow status=ok bytes=1420
run=r18 call=c2 tool=read_file policy=allow status=ok bytes=980
run=r18 step=2 model.finish=stop output=356t
run=r18 stop=final-text total=3.2s tools=2
这五行足以判断执行骨架,又不会把项目内容泄露到普通日志。需要深挖时,再通过 runId、callId 找本地 trace 和受控 artifact。Loop 因此不只是能跑,还能在失败后还原“为什么跑到了这里”。