首页 / 课程 / D01 深挖
Part 1: 核心架构 · 第 2 讲

24 讲路线 · 与 S01 配对

D01: Agent Loop 深挖 · 智能体循环

本讲在 S01 主线之上,聚焦实现细节、边界条件与自测;导图与主线相同模块,便于对照。

建议:先读完 S01,再按下方顺序走读源码与练习。

模块导图(与 S01 同源,便于对照):stop_reason、消息边界与错误恢复精读

🔬 深挖目标

把「一轮循环」拆成可调试的状态机:模型返回什么会触发继续转圈?什么会干净退出?工具结果如何无损回流到下一轮上下文?

📍 建议检索入口(以官方 Claude Code 仓库为准)

  • 从 S01 提到的 QueryEngine 一带入手,全文搜索 tool_usestop_reasonassistant 消息追加逻辑。
  • 区分两条链路:模型侧(流式 chunk 解析)与 宿主侧(执行工具、写回 tool_result)。

🧩 状态机要点

观察信号宿主通常行为
响应中含未完成 tool 调用等待参数收齐 → 走权限门 → 执行 → 构造 tool_result 消息
模型声明停止调用工具进入「仅展示/等待用户」分支,循环结束或等待新输入
流中断 / 解析异常区分可重试(网络)与不可重试(协议损坏);记录已消耗的 partial 状态

⚠️ 边界与坑

  • 消息顺序:tool_result 必须挂在对应 tool_use 之后,否则多工具并行时会乱序。
  • 嵌套与深度:子代理 / 任务系统可能在同一会话里叠加多圈 loop,注意「哪一层」在消费 tool_result。
  • 反压:长输出工具若一次灌满上下文,会提前触发压缩(见 D05)。

📖 走读顺序(90 min 量级)

  1. 找到「从模型响应解析出 tool 调用」的函数,列出它返回的结构体字段。
  2. 跟踪到「执行工具」唯一入口,确认是否权限再执行(应如此)。
  3. 找到「把结果写回 messages」的代码,标注与 D02、D03 的交界文件。

✏️ 实践练习 1:最小 Agent Loop(仅 Bash)

任务:写一个最小宿主:只注册一个 bash 工具,循环调用模型直到不再出现 tool_use(或达到最大轮数)。

参考答案(思路)

  1. 构造初始 messages = [user]tools 仅含 bash 的 JSON Schema(command: string)。
  2. 每轮:response = api.messages.create(..., messages, tools)
  3. 若响应的 stop_reasonend_turncontent没有 tool_use 块 → 退出循环,把 assistant 文本展示给用户。
  4. 否则对每个 tool_usespawn bash -c(或受限 shell)→ 追加一条 assistant 消息(含本轮全部 content 块)→ 再追加 tool_result 消息(tool_use_id 对齐)。
  5. 加保险: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_filewrite_fileapply_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 轮;③ 摘要/压缩用边界消息保留锚点(见 D05D05 · 工程侧补充 与专题 Compact 硬读);④ 外置记忆(把大段移出 prompt,按需再 read);⑤ 子任务拆分(D06)降低单会话长度。

如何处理工具执行失败?

  • 契约:向模型返回结构化失败——HTTP/SDK 的 is_error 或等价字段 + 可读错误摘要 +(可选)stderr 尾部;与成功路径同一消息类型,避免模型看不到失败。
  • 不要吞异常返回空字符串当成功;否则模型会基于错误状态继续瞎编。
  • 策略:可重试类(网络、429)在宿主层有限次退避;不可重试类(权限、命令不存在)直接交给模型决定换方案;对 bash 可附退出码。
  • 护栏max_tool_errors_per_turn 防止死循环打同一个坏命令。

✏️ 自测 1 · 参考答案:消息序列(最小 Loop)

题干

画出「用户 → 模型 → bash → tool_result → 模型 → end_turn」的消息序列。

参考答案(链式列举)

  1. user:用户目标描述。
  2. assistant:含 tool_use(bash + 参数)。
  3. user 角色的 tool_result:附在对应 tool_use_id 上,内容为命令输出或结构化错误。
  4. assistant:若仍需工具则继续带 tool_use;否则纯文本,stop_reason 常为 end_turn

多轮时重复 2–4,直到无未决 tool_use 或达到轮数上限。

✏️ 自测 2 · 参考答案:三道思考题一句话复述

题干

对照上文三道思考题,用自己的话各复述一句。

指向详答

  • stop_reason:别只靠扫字符串判断有没有工具,要用协议里的停止原因 + 结构化块。→ 全文见 上文 stop_reason 一节
  • 消息累积:越长越贵越慢且淹没重点,要截断、窗口、压缩或外置。→ 见 消息累积
  • 工具失败:必须把失败结构化喂回模型,禁止假成功。→ 见 工具失败

✏️ 自测(题干回顾)