claudecode学习 第 3 章 · Agent 循环与工具协议
claudecode学习 第 3 章 · Agent 循环与工具协议的重点在于把前置条件、操作顺序和容易误判的地方分清楚。
3.1 消息格式
回顾第 0 章伪代码
复制代码messages = [system_prompt, user_message]while True: a = llm.complete(messages) if not a.tool_calls: break ...里面的 system_prompt、user_message、a、tool_result 到底长什么样?本章拆开。
四种消息角色
| role | 谁产生的 | 内容 |
|---|---|---|
system | runtime 组装 | 系统提示词(人格、规则、可用工具说明) |
user | 你(或 runtime 注入) | 你的话、工具执行结果、文件内容 |
assistant | LLM | LLM 的回复(文字 + 工具调用) |
反直觉点:工具结果不是单独的 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 内部记录关键观察:
工具结果全记在
userrole 下。本样本中 assistant: 89,user: 44——但注意 user = 你的话 + 所有工具结果。在工具调用密集的会话里 user 数量通常会反超 assistant,因为每调一次工具就多一条 user(tool_result)。这段样本工具调用少所以 user < assistant,不代表常态。除四种标准 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 要做:
- 读 jsonl
- 过滤出发给 LLM 的那部分(system/user/assistant/tool_result)
- 重建
messages数组 - 同时恢复本地状态(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"}}四个字段:
type: 固定"tool_use"id: 全局唯一 ID(toolu_开头),tool_result 用它关联回来name: 工具名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.json 的 permissions,决定要不要问(第 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 工具 schema(sdk-tools.d.ts:602):
复制代码export interfaceFileReadInput {file_path: string; // 必填:绝对路径 offset?: number; // 可选:从第几行开始(大文件分块读) limit?: number; // 可选:读几行 pages?: string; // 可选:PDF 页码范围}工程细节:
file_path必填(不带?),其他可选- 路径必须绝对路径(注释明说)——防止 LLM 用相对路径搞错工作目录
- 大文件支持
offset/limit分块读——防止一次性塞爆 context - 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", ...}细节印证:
id格式:call_00_xxx、call_01_xxx——同一轮里多个工具调用按序编号- 混合工具类型:同一段会话里有
Bash/Read/Write/Edit——LLM 灵活组合 input符合 schema:Read是{"file_path": "..."},Edit是{"file_path": "...", "old_string": "...", "new_string": "...", "replace_all": false}- 每个 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_stopruntime 收完所有 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 时需多处理 thinking 和 redacted_thinking 两种类型,不能假设只有 text 和 tool_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 收到错误后自己决定怎么办:
- 文件不存在 → 改用 Glob 搜正确路径
- 命令失败 → 看错误信息,换命令试
- 网络超时 → 重试或换方案
这就是 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 次调用 → 成功用户看不到重试,只看到最终结果。
不可重试错误:
- Context 超限:messages 太长超过模型 context window。runtime 提示用
/compact压缩历史。 - 鉴权失败:API key 无效。runtime 报错让你重新认证。
CHANGELOG 2.1.219 修过:
以前 context 超限后 runtime 会傻乎乎重试同样请求(注定失败)。修复后不再重试这种不可恢复的错误。
第四类:会话恢复(--resume)
这不是"出错",但是中断的一种——昨天聊到一半关了终端,今天想接着聊。
--resume 做的事:
- 找到对应的 jsonl 转录文件
- 逐行解析
- 过滤出"发给 LLM 的消息"(system/user/assistant/tool_result)
- 重建 messages 数组
- 恢复本地状态(mode、permission-mode、file-history 等)
- 进入 REPL,等你下一句
工程难点:转录里可能有残缺状态:
- 最后一条是 assistant 消息含 tool_use,但没有对应 tool_result(上次中断时工具没执行完)
- 中间被打断的 content_block
CHANGELOG 2.1.219:
resume 要处理"转录里的畸形数据"——runtime 要能识别并跳过残缺块,否则恢复后每一轮都崩。
错误处理的总体设计哲学
这跟传统脚本编程的"出错即停"完全不同。Agent 循环把错误当成信息源,让 LLM 自己消化——这是 Agent 模式相对传统自动化的核心优势之一。
本章核心带走
- 四种消息角色:system(人格/规则)、user(你的话 + 工具结果)、assistant(LLM 回复 + 工具调用)、tool_result(塞在 user 里)。转录里还有 runtime 内部记录(mode/permission-mode 等)不发给 LLM。
- 工具调用五步:LLM 输出 tool_use(结构化 JSON)→ runtime 解析 → 查注册表找实现 → 执行(副作用+权限+hook)→ 包成 tool_result 喂回。每个工具的 input schema 来自
sdk-tools.d.ts,既指导 LLM 怎么调,也指导 runtime 怎么验。 - streaming 用 SSE:LLM 逐 token 生成,runtime 边收 delta 边显示,收完一个 content_block 才组装成完整块执行。流式是为了长任务可观测性和可中断,不是为了好看。
- 错误处理四类:工具失败喂回 LLM 自愈;Esc 停输出保留内容,Ctrl+C 彻底中断;API 错误分可重试(退避)和不可重试(报错);
--resume要清理转录残缺状态。 - 容错哲学:把错误当信息源,让 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
-
08.03
魔兽世界虫咬任务攻略
-
08.03
燕云十六声江南牛死谁了任务攻略
-
08.03
提升跨文化沟通能力的五个步骤:pdf文件翻译成中文
-
08.03
提升工作效率的免费工具:pdf文件翻译成中文版免费
-
08.03
pdf如何翻译与选择合适工具提升效率
-
08.03
燕云十六声江南多情一笔任务攻略
-
-
下载
- |
-
-
下载
- 《行尸走肉第一章》免安装中文汉化硬盘版下载
- 单机|436 MB
- 一款以动作冒险为主题的游戏
-
-
下载
- 《街头霸王X铁拳》免安装中文汉化硬盘版下载
- 单机|111MB
- 一款非常好玩的格斗游戏
-
-
下载
- |
-
-
下载
- 《暗黑破坏神3》免安装繁体中文正式版下载
- 单机|7630 MB
- 一款以角色扮演为主题的游戏
-
-
下载
- 《马克思佩恩3》免安装硬盘版下载
- 单机|27033 MB
- 一款以第三人称射击为主题的游戏