24 讲路线 · 与 S01 配对
D01: Agent Loop 深挖 · 智能体循环
本讲在 S01 主线之上,聚焦实现细节、边界条件与自测;导图与主线相同模块,便于对照。
建议:先读完 S01,再按下方顺序走读源码与练习。
🔬 深挖目标
把「一轮循环」拆成可调试的状态机:模型返回什么会触发继续转圈?什么会干净退出?工具结果如何无损回流到下一轮上下文?
📍 建议检索入口(以官方 Claude Code 仓库为准)
- 从 S01 提到的
QueryEngine一带入手,全文搜索tool_use、stop_reason、assistant消息追加逻辑。 - 区分两条链路:模型侧(流式 chunk 解析)与 宿主侧(执行工具、写回
tool_result)。
🧩 状态机要点
| 观察信号 | 宿主通常行为 |
|---|---|
| 响应中含未完成 tool 调用 | 等待参数收齐 → 走权限门 → 执行 → 构造 tool_result 消息 |
| 模型声明停止调用工具 | 进入「仅展示/等待用户」分支,循环结束或等待新输入 |
| 流中断 / 解析异常 | 区分可重试(网络)与不可重试(协议损坏);记录已消耗的 partial 状态 |
⚠️ 边界与坑
- 消息顺序:tool_result 必须挂在对应 tool_use 之后,否则多工具并行时会乱序。
- 嵌套与深度:子代理 / 任务系统可能在同一会话里叠加多圈 loop,注意「哪一层」在消费 tool_result。
- 反压:长输出工具若一次灌满上下文,会提前触发压缩(见 D05)。
📖 走读顺序(90 min 量级)
- 找到「从模型响应解析出 tool 调用」的函数,列出它返回的结构体字段。
- 跟踪到「执行工具」唯一入口,确认是否先权限再执行(应如此)。
- 找到「把结果写回 messages」的代码,标注与 D02、D03 的交界文件。
✏️ 实践练习 1:最小 Agent Loop(仅 Bash)
任务:写一个最小宿主:只注册一个 bash 工具,循环调用模型直到不再出现 tool_use(或达到最大轮数)。
参考答案(思路):
- 构造初始
messages = [user],tools仅含 bash 的 JSON Schema(command: string)。 - 每轮:
response = api.messages.create(..., messages, tools)。 - 若响应的
stop_reason为end_turn且content中没有tool_use块 → 退出循环,把 assistant 文本展示给用户。 - 否则对每个
tool_use:spawn bash -c(或受限 shell)→ 追加一条 assistant 消息(含本轮全部 content 块)→ 再追加tool_result消息(tool_use_id对齐)。 - 加保险:
max_turns、单次命令超时、工作目录白名单。
/**
* 最小 Agent Loop:只注册 bash,反复调用模型直到本轮响应里不再出现 tool_use。
* 与具体厂商无关:把 model.create / 消息结构换成你用的 SDK 即可。
*/
const MAX_TURNS = 40
let turn = 0
while (turn++ < MAX_TURNS) {
// ① 携带「当前完整 messages + 工具表」请求模型
const res = await model.create({ messages, tools: [bashTool] })
messages.push(assistantMessageFrom(res))
const uses = res.content.filter(isToolUse)
if (uses.length === 0) {
// ② 无 tool_use:模型本回合只输出文本或已 end_turn,退出循环
break
}
// ③ 每个 tool_use 必须执行并写回 tool_result,且 tool_use_id 与 assistant 块对齐
for (const u of uses) {
const out = await runBash(u.input.command) // 生产环境:此处前插 D03 canUseTool / 策略门
messages.push(toolResultMessage(u.id, out))
}
}
// ④ 建议另加:max_turns(上已示例)、单命令超时、工作目录白名单、工具错误计数防死循环
✏️ 实践练习 2:扩展 Read / Write / Edit
任务:在练习 1 的循环不变前提下,增加三个工具:read_file、write_file、apply_patch(或简化版「整文件覆盖写」)。
参考答案(要点):
- 注册:
tools数组并入三条 schema;模型同一轮可并行多个 tool_use,宿主按 id 顺序执行或按拓扑(本练习可先顺序执行)。 - 读:校验路径在 workspace 内;返回 UTF-8 文本或明确 binary 不可读。
- 写:先权限(高危)再写入;大文件写入前可要求模型先 read 再写,避免盲覆盖。
- Edit:最小实现是「搜索片段替换」;失败时把 diff 失败原因写进 tool_result,让模型重试,不要在宿主层伪造成功。
- 与 D02 对齐:每层工具都走同一套「校验 → 权限 → 执行 → ToolResult」。
🤔 思考题 · 参考答案
为什么用 stop_reason 而不是只看 content 里有没有工具调用?
- 协议层语义:
stop_reason(及厂商扩展字段)表达「这一轮模型为何停笔」,是 API 契约的一部分;content 只是载体,不同模型/版本可能把「停顿」表达成空 tool 块、未闭合流、额外 stop 序列等。 - 流式场景:chunk 到达过程中 content 不完整;若在最后一个 chunk 前用启发式扫字符串,容易把「还在传输的 tool JSON」误判成无工具。
- 多块与混排:一轮响应可同时含文本 + 多个 tool_use;终止条件应是「本回合模型声明结束且 tool 队列已消费」,而不是简单正则匹配。
- 工程结论:以 SDK 解析后的结构化结果 +
stop_reason作为状态机输入;content 仅作展示与 tool 参数来源。
消息累积会导致什么问题?如何解决?
- 问题:上下文窗口溢出;费用与延迟线性上涨;早期无关细节「淹没」当前任务;工具超长输出占满 budget。
- 解法谱系:① 工具侧截断(只返回 head/tail + 长度提示);② 滑动窗口保留最近 k 轮;③ 摘要/压缩用边界消息保留锚点(见 D05、D05 · 工程侧补充 与专题 Compact 硬读);④ 外置记忆(把大段移出 prompt,按需再 read);⑤ 子任务拆分(D06)降低单会话长度。
如何处理工具执行失败?
- 契约:向模型返回结构化失败——HTTP/SDK 的
is_error或等价字段 + 可读错误摘要 +(可选)stderr 尾部;与成功路径同一消息类型,避免模型看不到失败。 - 不要吞异常返回空字符串当成功;否则模型会基于错误状态继续瞎编。
- 策略:可重试类(网络、429)在宿主层有限次退避;不可重试类(权限、命令不存在)直接交给模型决定换方案;对 bash 可附退出码。
- 护栏:
max_tool_errors_per_turn防止死循环打同一个坏命令。
✏️ 自测 1 · 参考答案:消息序列(最小 Loop)
题干
画出「用户 → 模型 → bash → tool_result → 模型 → end_turn」的消息序列。
参考答案(链式列举)
user:用户目标描述。assistant:含tool_use(bash + 参数)。user角色的tool_result:附在对应tool_use_id上,内容为命令输出或结构化错误。assistant:若仍需工具则继续带tool_use;否则纯文本,stop_reason常为end_turn。
多轮时重复 2–4,直到无未决 tool_use 或达到轮数上限。
✏️ 自测 2 · 参考答案:三道思考题一句话复述
题干
对照上文三道思考题,用自己的话各复述一句。
指向详答
- stop_reason:别只靠扫字符串判断有没有工具,要用协议里的停止原因 + 结构化块。→ 全文见 上文 stop_reason 一节。
- 消息累积:越长越贵越慢且淹没重点,要截断、窗口、压缩或外置。→ 见 消息累积。
- 工具失败:必须把失败结构化喂回模型,禁止假成功。→ 见 工具失败。