一聚教程网:一个值得你收藏的教程网站

热门教程

claudecode学习 第 3 章 · Agent 循环与工具协议

时间:2026-08-03 10:27:04 编辑:袖梨 来源:一聚教程网

处理claudecode学习 第 3 章 · Agent 循环与工具协议这类问题时,先确认目标场景,再按步骤核对配置或玩法细节。


3.1 消息格式

回顾第 0 章伪代码

 复制代码messages = [system_prompt, user_message]while True:    a = llm.complete(messages)    if not a.tool_calls: break    ...

里面的 system_promptuser_messageatool_result 到底长什么样?本章拆开。

claudecode学习 第 3 章 · Agent 循环与工具协议

四种消息角色

role谁产生的内容
systemruntime 组装系统提示词(人格、规则、可用工具说明)
user你(或 runtime 注入)你的话、工具执行结果、文件内容
assistantLLMLLM 的回复(文字 + 工具调用)

反直觉点:工具结果不是单独的 role,而是塞在 user 消息里。Anthropic API 的设计:工具结果作为一种特殊的 user 消息回传给 LLM。

本机实证:真实会话转录的 role 分布

 复制代码文件: ~/.claude/projects/-Users-jsl-Desktop-thesis/a0bebaca-....jsonlassistant: 89    ← LLM 的回复user: 44         ← 你的话 + 工具结果attachment: 11   ← 附件system: 7        ← 系统提示mode / permission-mode / file-history-snapshot / last-prompt / ai-title ← runtime 内部记录

关键观察

  1. 工具结果全记在 user role 下。本样本中 assistant: 89,user: 44——但注意 user = 你的话 + 所有工具结果。在工具调用密集的会话里 user 数量通常会反超 assistant,因为每调一次工具就多一条 user(tool_result)。这段样本工具调用少所以 user < assistant,不代表常态。

  2. 除四种标准 role,还有一堆 runtime 内部记录attachment/mode/permission-mode/file-history-snapshot/last-prompt/ai-title。这些不是发给 LLM 的,是 Claude Code 自己存的会话元数据。

两层消息:发给 LLM 的 vs 存在本地的

 复制代码会话转录 jsonl 文件├── 发给 LLM 的消息(system /user/ assistant / tool_result)│     └── 这些进 messages 数组,每轮 API 调用都发└── runtime 内部记录(mode / permission-mode / file-history-snapshot / ...)      └── 这些不发给 LLM,只用于本地状态管理

消息流转图(一轮工具调用完整过程)

 复制代码┌──────────┐                              ┌──────────┐│  runtime │                              │   LLM    ││ (本地)   │                              │ (云端API)│└────┬─────┘                              └────┬─────┘     │                                         │     │  ① messages 数组 + 所有工具 schema       │     │  ────────────────────────────────────▶  │     │                                         │ (LLM 思考)     │                                         │     │  ② assistant 消息 (含 tool_use 块)       │     │  ◀────────────────────────────────────  │     │                                         │     │  ③ runtime 解析 tool_use                │     │     查注册表 → 找到实现                 │     │  ④ 执行工具 (副作用+权限+hook)          │     │     ↓                                   │     │  ⑤ 结果包成 tool_result                 │     │     塞进 user 消息                      │     │                                         │     │  ⑥ 新的 messages (含 tool_result)       │     │  ────────────────────────────────────▶  │     │                                         │ (LLM 继续想)     │  ... 循环 ...                           │

--resume 恢复会话时 runtime 要做:

  1. 读 jsonl
  2. 过滤出发给 LLM 的那部分(system/user/assistant/tool_result)
  3. 重建 messages 数组
  4. 同时恢复本地状态(mode、permission-mode、file-history 等)

这就是为什么 --resume 后能看到之前对话、还在同一个权限模式、文件改动历史也在——转录里这两层都存了。

四种角色的具体结构

system 消息

 复制代码{"type":"system","content":"你是 Claude Code...(几千字的系统提示)"}

runtime 组装,整个会话只发一次(作为 system 参数传,不在 messages 数组里重复)。

user 消息(你的话)

 复制代码{"type":"user","message":{"role":"user","content":"帮我重构这个函数"}}

assistant 消息(LLM 回复,含工具调用)

 复制代码{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"我先读一下这个文件"},{"type":"tool_use","id":"toolu_xxx","name":"Read","input":{"file_path":"/abs/foo.js"}}]}}

重点:assistant 消息的 content 是个数组,可以同时含文字和多个工具调用。LLM 一轮能调多个工具。

user 消息(工具结果)

 复制代码{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_xxx","content":"文件内容..."}]}}

"工具结果塞在 user 里"的实证——role: "user",但 content 里是 tool_result 类型,用 tool_use_id 关联回之前的工具调用。


3.2 工具调用协议

一个工具调用的完整生命周期(五步)

 复制代码┌─────────────────────────────────────────────────────────────────┐│ ① LLM 输出 tool_use 块                                          ││    {"type":"tool_use","id":"toolu_xxx","name":"Read",           │"input":{"file_path":"/abs/foo.js"}}                        │└────────────────────────────┬────────────────────────────────────┘                             ▼┌─────────────────────────────────────────────────────────────────┐│ ② runtime 解析 tool_use 块                                      ││    遍历 assistant.content,收集所有 tool_use                     │└────────────────────────────┬────────────────────────────────────┘                             ▼┌─────────────────────────────────────────────────────────────────┐│ ③ runtime 查工具注册表                                          ││    TOOL_REGISTRY["Read"] → {schema, impl}                       │└────────────────────────────┬────────────────────────────────────┘                             ▼┌─────────────────────────────────────────────────────────────────┐│ ④ runtime 执行工具实现  ← 副作用+权限+hook 都在这层              ││    ┌──────────────────────────────────────────┐                ││    │ PreToolUse hook → 权限检查 → 执行 impl    │                ││    │                            ↓              │                ││    │                    PostToolUse hook       │                ││    └──────────────────────────────────────────┘                │└────────────────────────────┬────────────────────────────────────┘                             ▼┌─────────────────────────────────────────────────────────────────┐│ ⑤ runtime 包成 tool_result 喂回                                 ││    {"type":"tool_result","tool_use_id":"toolu_xxx",             │"content":"文件内容..."}                                    ││    塞进 user 消息,回到循环顶部                                  │└─────────────────────────────────────────────────────────────────┘

① LLM 输出 tool_use 块

 复制代码{"type":"tool_use","id":"toolu_01ABCdefGH","name":"Read","input":{"file_path":"/abs/path/foo.js"}}

四个字段:

  1. type: 固定 "tool_use"
  2. id: 全局唯一 ID(toolu_ 开头),tool_result 用它关联回来
  3. name: 工具名
  4. input: 参数对象,schema 由工具定义决定

关键:LLM 输出符合 schema 的结构化 JSON,不是自然语言。runtime 不需解析自然语言,直接拿结构化数据分发。

② runtime 解析 tool_use 块

 复制代码for block in assistant_msg.content:if block.type == "tool_use":        pending_tool_calls.append(block)   # 收集所有工具调用

一个 assistant 消息可能含多个 tool_use 块——LLM 一轮能调多个工具(如同时读三个文件)。runtime 全收集,逐个执行。

③ runtime 查工具注册表

runtime 维护工具注册表:工具名 → 工具实现(含 input schema、执行函数)。

 复制代码TOOL_REGISTRY = {    "Read":     {"schema": FileReadInput,  "impl": read_file},"Edit":     {"schema": FileEditInput,  "impl": edit_file},"Bash":     {"schema": BashInput,      "impl": run_bash},"Glob":     {"schema": GlobInput,      "impl": glob_search},# ...}

注册表是动态的——这也是为什么 MCP 服务器、插件能"加新工具":注册时往表里加条目,LLM 下轮就能调到。第 12 章(MCP)细讲。

④ runtime 执行工具实现

 复制代码result = TOOL_REGISTRY["Read"]["impl"](input={"file_path": "/abs/path/foo.js"})

这一步产生真实副作用——读文件、改文件、跑命令、发网络请求。权限、沙箱、hook 拦截全插在这一层:

 复制代码执行工具前 → PreToolUse hook 检查(第 11 章)执行工具 → 真实副作用执行工具后 → PostToolUse hook(第 11 章)

权限检查也在这一层——runtime 看 settings.jsonpermissions,决定要不要问(第 5 章)。

⑤ runtime 包成 tool_result 喂回

 复制代码{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01ABCdefGH","content":"文件内容..."}]}}

tool_use_id 把结果关联回第①步的 id。然后加进 messages,回到循环顶部。

工具的 input schema 从哪来

每个工具自带 input schema。这就是 sdk-tools.d.ts(3807 行)的来历——所有内置工具的 schema 定义。

本机实证:Read 工具 schemasdk-tools.d.ts:602):

 复制代码export interfaceFileReadInput {file_path: string;      // 必填:绝对路径  offset?: number;        // 可选:从第几行开始(大文件分块读)  limit?: number;         // 可选:读几行  pages?: string;         // 可选:PDF 页码范围}

工程细节:

  1. file_path 必填(不带 ?),其他可选
  2. 路径必须绝对路径(注释明说)——防止 LLM 用相对路径搞错工作目录
  3. 大文件支持 offset/limit 分块读——防止一次性塞爆 context
  4. PDF 单独有 pages 参数——特殊文件类型特殊处理

工具注册表的真实形态

 复制代码{  name: "Read",                    // LLM 看到的名字inputSchema: FileReadInput,      // 参数 schema(发给 LLM 让它知道怎么调)impl: readFileFunction,          // 实际执行函数// 可能还有:权限规则、hook 配置、输出格式化器等}

inputSchema 也会发给 LLM。每次 API 调用时 runtime 把所有可用工具的 schema 打包发给 LLM,LLM 才知道"我能调哪些工具、每个工具接受什么参数"。这就是为什么加新工具(MCP/插件)后 LLM 下一轮就能用——schema 已经发过去了。

本机实证:真实 tool_use 块

从会话转录里看到的 tool_use 块:

 复制代码id:    call_00_kkvDgUBGb2VkBDDNUIcG4327name:  Bashinput: {"command": "ls -la /etc/resolver/ && echo "---" && cat ...", "description": "..."}id:    call_01_KXTdgIyl0BLvheyIS1fk0569name:  Bashinput: {"command": "dig @10.8.8.8 v.nuaa.edu.cn +short +time=3 2>&1", ...}

细节印证:

  1. id 格式call_00_xxxcall_01_xxx——同一轮里多个工具调用按序编号
  2. 混合工具类型:同一段会话里有 Bash/Read/Write/Edit——LLM 灵活组合
  3. input 符合 schemaRead{"file_path": "..."}Edit{"file_path": "...", "old_string": "...", "new_string": "...", "replace_all": false}
  4. 每个 tool_use 是完整 JSON:转录里存的是流式生成完毕后的完整块,delta 片段没存

3.3 streaming 与流式输出

物理事实:LLM 生成是逐 token 的

LLM 生成文本的本质是自回归:每次预测下一个 token,基于已生成的内容接着预测,一个一个吐出来。

 复制代码输入: "中国的首都是"LLM 逐 token 生成:  → "北"     (基于"中国的首都是")"京"     (基于"中国的首都是北")"。"     (基于"中国的首都是北京")

生成一个 token 大概几十到几百毫秒。200 字回复可能要 5-10 秒。

两种 API 模式

非流式(non-streaming)

 复制代码客户端 ──请求──→ LLM                  (生成 5-10 秒,全部完成)客户端 ←──完整响应── LLM

用户看到转圈圈,突然蹦出整段。

流式(streaming)

 复制代码客户端 ──请求──→ LLM客户端 ←─token1─ LLM  (0.1秒)客户端 ←─token2─ LLM  (0.2秒)...边生成边传...客户端 ←─tokenN─ LLM  (10秒)

用户看到文字边生成边显示,"打字机效果"。

Claude Code 用的是流式

为什么必须用流式

不是为了打字机好看,是两个硬需求:

需求一:长任务的可观测性

Agent 循环一轮可能跑 30 秒、调十几个工具。非流式时用户看到"转圈 30 秒然后突然一切完成"——中间发生了什么完全不知道。流式让你实时看到 AI 在干什么:读文件、搜索、改文件……出问题能立刻 Esc 打断。

需求二:提前打断

非流式要等整个响应生成完才能处理。但 Agent 循环里 LLM 可能生成到一半你就发现方向错了。流式允许在生成过程中就接收已生成的部分,runtime 可以随时中断。按 Esc 时 runtime 关闭流,已生成部分保留,未生成的不等了。

streaming 的数据格式:SSE

Claude API 用 SSE(Server-Sent Events) 推送流。每个事件是一行 event: xxx + data: {...}。关键事件:

 复制代码event: message_start        ← 消息开始event: content_block_start  ← 一个内容块开始(文字块 or 工具调用块)event: content_block_delta  ← 内容块增量(一个 token)event: content_block_stop   ← 内容块结束event: message_stop         ← 整个消息结束

SSE 事件流时序图(一轮含文字+工具调用的生成)

 复制代码LLM 端                  网络                    runtime 端  │                      │                         │  │  message_start       │                         │  │─────────────────────▶│─────────────────────▶   │  开始接收  │                      │                         │  │  content_block_start │                         │  │  (text 块)           │                         │  │─────────────────────▶│─────────────────────▶   │  文字块开始  │                      │                         │  │  content_block_delta │                         │  │  ("我")              │                         │  显示 "我"  │─────────────────────▶│─────────────────────▶   │  │  content_block_delta │                         │  显示 "先"  │  ("先")              │                         │  │─────────────────────▶│─────────────────────▶   │  │  ...更多 delta...    │                         │  │                      │                         │  │  content_block_stop  │                         │  │─────────────────────▶│─────────────────────▶   │  文字块结束  │                      │                         │  │  content_block_start │                         │  │  (tool_use 块)       │                         │  工具块开始  │─────────────────────▶│─────────────────────▶   │  │  content_block_delta │                         │  JSON 片段  │  ('{"type":"tool_u') │                         │  逐步显现  │─────────────────────▶│─────────────────────▶   │  │  ...更多 delta...    │                         │  │                      │                         │  │  content_block_stop  │                         │  │─────────────────────▶│─────────────────────▶   │  工具块结束  │                      │                         │  → 解析+执行  │  message_stop        │                         │  │─────────────────────▶│─────────────────────▶   │  整条消息完

runtime 收到这些事件流,边收边解析、边显示。关键在 content_block_delta——每收到一个 delta,就把新内容追加到屏幕上。

工具调用也是流式的

LLM 输出工具调用(tool_use 块)时,也是逐 token 流式生成的

比如 LLM 要输出:

 复制代码{"type":"tool_use","name":"Read","input":{"file_path":"/abs/foo.js"}}

流式过程:

 复制代码delta 1:{"type":"tool_udelta 2: se","name":"Redelta 3: ad","input":{"delta 4: file_path":"/abdelta 5: s/foo.js"}}

runtime 要做的事:边收 delta 边拼接,等到 content_block 完整了才解析成 tool_use 块去执行

这就解释了一个现象:工具调用时工具名和参数也是"逐步显现"的——因为它们就是流式生成的 JSON 片段。

多个内容块的流式

assistant 消息的 content 是数组、可含多个块(文字 + 多个工具调用)。流式时这些块顺序生成

 复制代码content_block_start (text 块)       ← "我先读一下文件"content_block_delta × N             ← 文字逐 tokencontent_block_stopcontent_block_start (tool_use 块)   ← Read 工具调用content_block_delta × N             ← JSON 逐 tokencontent_block_stopcontent_block_start (tool_use 块)   ← 另一个工具调用content_block_delta × Ncontent_block_stopmessage_stop

runtime 收完所有 content_block,才把整个 assistant 消息组装好,进入工具执行阶段。

Extended Thinking(推理令牌)

Claude 的 extended thinking 功能在 streaming 中引入额外的内容块类型:

 复制代码content_block_start (thinking 块)      ← 模型开始内部推理content_block_delta × N                ← 推理 token 逐步输出content_block_start (redacted_thinking) ← 被安全过滤的推理内容(不显示原文)content_block_stopcontent_block_start (text 块)          ← 正式回复开始

signature 字段伴随推理块出现,用于验证推理完整性。这些块不计入常规 output token(单独计费),也不在普通 streaming 解析中显示——除非开启了推理可见性。

对 runtime 的影响:解析 content_block 时需多处理 thinkingredacted_thinking 两种类型,不能假设只有 texttool_use

实证:转录里存的是完整块

会话转录 jsonl 里存的是最终完整消息(不是 delta 流)。runtime 收完一个 content_block 才组装成完整 tool_use 存下来——所以你在转录里看到的每个 tool_use 都是完整 JSON,看不到 delta 片段。


3.4 错误处理与中断恢复

Agent 循环会出三类问题,每种有专门恢复机制。

错误处理决策图

 复制代码                  Agent 循环运行中                        │          ┌─────────────┼─────────────┐          ▼             ▼             ▼     工具执行失败    用户打断      API 调用报错          │             │             │          ▼             ▼             ▼   ┌──────────────┐ ┌─────────┐ ┌─────────────────┐   │ 包成 tool_   │ │ Esc:    │ │ 可重试?          │   │ result with  │ │ 停输出  │ │ (网络/503/limit) │   │ is_error:true│ │ 保留已  │ └────┬───────┬────┘   │              │ │ 生成内  │      │ 是    │ 否   │ 喂回 LLM     │ │ 容      │      ▼       ▼   │ 让它自愈     │ │         │  指数退避  报错引导   │              │ │ Ctrl+C: │  自动重试  (context超限   │ (换路径/换   │ │ 彻底中  │           → /compact   │  命令/重试)  │ │ 断循环  │           鉴权失败   └──────────────┘ └─────────┘           → 重新认证)   第四类: 会话恢复 (--resume)   ┌──────────────────────────────────────────────┐   │ 1. 找 jsonl 转录                              │2. 过滤出发给 LLM 的消息(system/user/assistant│/tool_result)重建 messages                │3. 恢复本地状态(mode/permission-mode 等)     │4. 清理残缺块(半截 tool_use 无对应 result 等)│5. 进入 REPL 等下一句                         │   └──────────────────────────────────────────────┘

第一类:工具执行失败

工具执行(循环 step 3)可能失败——读不存在的文件、命令返回非零、网络超时。

关键认知:工具失败不终止循环,而是把错误当成 tool_result 喂回 LLM。

失败 tool_result 长这样:

 复制代码{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_00_xxx","content":"Error: ENOENT: no such file or directory, open '/abs/foo.js'","is_error":true                          ← 标记为错误}]}}

is_error: true 告诉 LLM "这次调用失败了"。LLM 收到错误后自己决定怎么办:

  1. 文件不存在 → 改用 Glob 搜正确路径
  2. 命令失败 → 看错误信息,换命令试
  3. 网络超时 → 重试或换方案

这就是 Agent 的"自愈"能力——失败不是终点,是新的输入。LLM 基于错误信息调整策略继续循环。

实证:会话里 LLM 自己加 2>/dev/null 吞掉错误输出——它知道文件可能不存在,预先处理。如果文件真不存在,命令返回非零,LLM 收到 tool_result 后判断"哦文件不在,换方案"。

第二类:用户中途打断

Esc:只停输出,循环状态保留

 复制代码LLM 正在流式生成第 N 个 token  ↓你按 Esc  ↓runtime 关闭 SSE 流,停止接收后续 token  ↓已生成的部分保留在 messages 里  ↓循环停在"等待下一轮用户输入"状态

关键:已生成的内容没丢。你打断了 AI 的废话,但前面说的话、调的工具都还在。可以接着说"别说了,直接给我结果",循环继续。

CHANGELOG 2.1.219 修过相关 bug:

工具调用到一半被打断,可能留下"半截 tool_use 块"(没有对应 tool_result)。runtime 要清理这种残缺状态,否则下一轮 LLM 会困惑"我有个工具调用没收到结果"。

Ctrl+C:彻底中断

比 Esc 更暴力。Agent 循环本身被打断,不只是当前这一轮。通常退出 REPL 或回到空提示状态。

第三类:API 调用报错

可重试错误(网络抖动、503、rate limit):runtime 自动重试,带指数退避:

 复制代码第 1 次调用 → 503等 1 秒第 2 次调用 → 503等 2 秒第 3 次调用 → 成功

用户看不到重试,只看到最终结果。

不可重试错误

  1. Context 超限:messages 太长超过模型 context window。runtime 提示用 /compact 压缩历史。
  2. 鉴权失败:API key 无效。runtime 报错让你重新认证。

CHANGELOG 2.1.219 修过:

以前 context 超限后 runtime 会傻乎乎重试同样请求(注定失败)。修复后不再重试这种不可恢复的错误。

第四类:会话恢复(--resume)

这不是"出错",但是中断的一种——昨天聊到一半关了终端,今天想接着聊。

--resume 做的事:

  1. 找到对应的 jsonl 转录文件
  2. 逐行解析
  3. 过滤出"发给 LLM 的消息"(system/user/assistant/tool_result)
  4. 重建 messages 数组
  5. 恢复本地状态(mode、permission-mode、file-history 等)
  6. 进入 REPL,等你下一句

工程难点:转录里可能有残缺状态

  1. 最后一条是 assistant 消息含 tool_use,但没有对应 tool_result(上次中断时工具没执行完)
  2. 中间被打断的 content_block

CHANGELOG 2.1.219:

resume 要处理"转录里的畸形数据"——runtime 要能识别并跳过残缺块,否则恢复后每一轮都崩。

错误处理的总体设计哲学

这跟传统脚本编程的"出错即停"完全不同。Agent 循环把错误当成信息源,让 LLM 自己消化——这是 Agent 模式相对传统自动化的核心优势之一。


本章核心带走

  1. 四种消息角色:system(人格/规则)、user(你的话 + 工具结果)、assistant(LLM 回复 + 工具调用)、tool_result(塞在 user 里)。转录里还有 runtime 内部记录(mode/permission-mode 等)不发给 LLM。
  2. 工具调用五步:LLM 输出 tool_use(结构化 JSON)→ runtime 解析 → 查注册表找实现 → 执行(副作用+权限+hook)→ 包成 tool_result 喂回。每个工具的 input schema 来自 sdk-tools.d.ts,既指导 LLM 怎么调,也指导 runtime 怎么验。
  3. streaming 用 SSE:LLM 逐 token 生成,runtime 边收 delta 边显示,收完一个 content_block 才组装成完整块执行。流式是为了长任务可观测性和可中断,不是为了好看。
  4. 错误处理四类:工具失败喂回 LLM 自愈;Esc 停输出保留内容,Ctrl+C 彻底中断;API 错误分可重试(退避)和不可重试(报错);--resume 要清理转录残缺状态。
  5. 容错哲学:把错误当信息源,让 LLM 消化——与传统"出错即停"根本不同。

本机实证命令汇总

 复制代码# 1. 找会话转录find ~/.claude/projects -name "*.jsonl" -type f# 2. 统计 role 分布cat <转录文件> | python3 -c "import sys, jsonroles = {}for line in sys.stdin:    try:        obj = json.loads(line)        r = obj.get('type') or obj.get('role') or 'unknown'        roles[r] = roles.get(r, 0) + 1    except: passfor r, c in sorted(roles.items(), key=lambda x:-x[1]):    print(f'  {r}: {c}')"# 3. 找 tool_use 块cat <转录文件> | python3 -c "import sys, jsonfor line in sys.stdin:    try:        obj = json.loads(line)        if obj.get('type') == 'assistant':            for block in obj.get('message',{}).get('content',[]):                if isinstance(block, dict) and block.get('type') == 'tool_use':                    print(block['id'], block['name'], json.dumps(block['input'], ensure_ascii=False)[:80])    except: pass"# 4. 看工具 schemagrep -A 15 "export interface FileReadInput" …/sdk-tools.d.ts

热门栏目