Claude Code 工具循环:选择、执行、权限、Hook、结果回写
一句话定位:Claude Code 的工具系统是一个 7 阶段管线——从模型输出 tool_use block 到结果回写进消息链,经过并发分区、权限检查、Hook 拦截、执行、结果预算、附件注入六道关卡。
调研时间:2026-06-22
依据版本:泄露源码包(2026-03-31)
核心文件:src/services/tools/(4 文件共 3117 行)+ src/utils/permissions/(24 文件)+ src/Tool.ts
证据等级汇总
| 结论 | 来源 |
|---|---|
| Tool 抽象接口 20+ 方法 | [源码] Tool.ts |
| 并发分区策略(isConcurrencySafe) | [源码] toolOrchestration.ts |
| StreamingToolExecutor 流式并发 | [源码] StreamingToolExecutor.ts |
| 权限管线 8 步检查 | [源码] permissions.ts |
| PreToolUse / PostToolUse Hook 生命周期 | [源码] toolHooks.ts |
| 工具结果预算(aggregate budget) | [源码] query.ts + toolResultStorage |
| ToolSearch 延迟加载 | [源码] claude.ts |
| 43+ 内置工具 | [源码] src/tools/ 目录 |
日常类比
把工具执行想象成医院的处方流程:
- 医生(模型)开了处方(tool_use block)
- 药房(toolOrchestration)先分类:哪些药可以同时配(并发安全),哪些必须按顺序(串行)
- 每种药都要过药剂师审核(权限检查):这个病人能不能用这个药?需不需要家属签字?
- 配药前后都有质检环节(PreToolUse / PostToolUse hooks)
- 配好的药打包送回医生(结果回写),医生决定是否需要加药(下一轮 tool_use)
类比边界:真实系统中”药房”是流式的——药还没全配完,已配好的就先送出去了(StreamingToolExecutor)。
七阶段工具管线
graph LR
A[模型输出<br>tool_use blocks] --> B[并发分区<br>partitionToolCalls]
B --> C[权限检查<br>hasPermissionsToUseTool]
C --> D[PreToolUse<br>Hooks]
D --> E[工具执行<br>tool.call]
E --> F[PostToolUse<br>Hooks]
F --> G[结果预算<br>applyToolResultBudget]
G --> H[回写消息链<br>+ 附件注入]
阶段 1:Tool 抽象接口
Tool.ts 定义了泛型接口 Tool<Input, Output, P> [源码],核心方法:
| 方法 | 用途 | 影响 |
|---|---|---|
call(args, ctx) |
执行入口 | 返回 ToolResult |
inputSchema |
Zod schema | API 工具定义 |
checkPermissions(input, ctx) |
工具级权限 | 返回 allow/deny/ask |
isReadOnly(input) |
是否只读 | 影响并发决策 |
isConcurrencySafe(input) |
是否可并发 | 影响分区策略 |
isDestructive?(input) |
是否不可逆 | 影响权限严格度 |
maxResultSizeChars |
结果大小上限 | 超限持久化到文件 |
shouldDefer? |
是否延迟加载 | ToolSearch 优化 |
backfillObservableInput? |
补全观察字段 | 保护 prompt cache |
interruptBehavior?() |
中断行为 | 'cancel' 或 'block' |
设计亮点:backfillObservableInput 只修改给 SDK/hook 看的副本,原始 input 不变——这保护了 prompt cache 不被无关字段变化 bust。
阶段 2:并发分区
toolOrchestration.ts(189 行)的核心逻辑 [源码]:
partitionToolCalls(toolUseBlocks):
遍历 blocks:
if tool.isConcurrencySafe(input):
加入当前并发批次
else:
如果当前批次非空 → 提交为并发批次
当前 block 单独作为串行批次
执行顺序:
并发批次 → Promise.all (受 MAX_CONCURRENCY=10 限制)
串行批次 → 逐个执行
StreamingToolExecutor(531 行)是更高级的执行器 [源码]:
- 工具到达即执行(不等所有 tool_use block 收齐)
- 并发控制:同时最多 10 个工具
- Sibling error 取消:一个工具失败时取消同批次其他工具
- Progress 即时 yield:工具执行中的进度更新实时推送给 UI
与 LangGraph 对比
| 维度 | Claude Code | LangGraph |
|---|---|---|
| 并发模型 | 运行时动态分区(isConcurrencySafe) | 编译时静态图(fan-out 节点) |
| 粒度 | 单个 tool_use block | 整个节点 |
| 流式 | 工具到达即执行 | 节点完成后才流转 |
| 取消 | Sibling error 级联取消 | 需手动实现 |
阶段 3:权限检查
permissions.ts(1487 行)实现了 8 步权限管线 [源码]:
graph TD
Start[工具请求] --> D1{Deny Rules?}
D1 -->|命中| DENY[拒绝]
D1 -->|未命中| D2{Ask Rules?}
D2 -->|命中| ASK[弹窗询问]
D2 -->|未命中| D3{tool.checkPermissions?}
D3 -->|deny| DENY
D3 -->|ask| ASK
D3 -->|allow| D4{bypassPermissions?}
D4 -->|是| ALLOW[允许]
D4 -->|否| D5{Always-Allow Rules?}
D5 -->|命中| ALLOW
D5 -->|未命中| D6{Passthrough→Ask 转换}
D6 --> D7{Auto Mode Classifier?}
D7 -->|允许| ALLOW
D7 -->|拒绝| ASK
权限规则来源(按优先级):
settings.json— 用户全局配置.claude/settings.json— 项目级配置- 运行时 session — 用户本次会话的临时授权
三种 PermissionHandler [源码]:
interactive— 弹窗询问用户(默认)coordinator— 协调器模式下先等自动检查再弹窗swarmWorker— 后台 worker 自动拒绝(shouldAvoidPermissionPrompts)
阶段 4-5:Hook 生命周期
toolHooks.ts(651 行)管理三种 hook [源码]:
| Hook | 时机 | 能力 |
|---|---|---|
PreToolUse |
工具执行前 | 可阻止执行、修改输入、注入上下文 |
PostToolUse |
工具执行后 | 可阻止继续、修改输出、触发副作用 |
PostToolUseFailure |
工具失败后 | 错误恢复、日志记录 |
Hook 的权限决策统一由 resolveHookPermissionDecision 处理:
- Hook 返回
allow→ 跳过后续权限检查 - Hook 返回
deny→ 直接拒绝 - Hook 返回
ask→ 进入正常权限流程 - Hook 返回
additionalContexts→ 注入额外上下文到消息链
Hook 配置格式(.claude/settings.json):
{
"hooks": {
"PreToolUse": [
{
"matcher": { "tool_name": "Bash", "input.command": "rm *" },
"action": "deny",
"message": "禁止批量删除"
}
]
}
}
阶段 6:工具执行
toolExecution.ts(1746 行)是单个工具的完整执行流程 [源码]:
executeToolUse(toolBlock, assistantMessage, canUseTool, context):
1. 查找工具定义 (findToolByName)
2. 解析输入 (parseToolInput → Zod validate)
3. 权限检查 (hasPermissionsToUseTool)
4. 运行 PreToolUse hooks
5. 执行工具 (tool.call)
- 超时控制
- 进度回调 (onProgress)
- 中止信号传递
6. 运行 PostToolUse hooks
7. 格式化结果
- 成功: tool_result content
- 失败: is_error=true + 错误信息
8. 结果大小检查
- 超过 maxResultSizeChars → 持久化到文件 → 替换为引用
9. 遥测记录
错误处理策略:
- 输入验证失败 → 返回 is_error + schema 提示
- 权限拒绝 → 返回 is_error + 拒绝原因
- 执行超时 → 返回 is_error + 超时信息
- Hook 阻止 → 返回 is_error + hook 消息
阶段 7:结果预算与回写
query.ts 中的 applyToolResultBudget [源码]:
- 对所有消息中的工具结果施加聚合大小预算
- 超预算的结果被持久化到 session storage,消息中替换为文件引用
- 某些工具(如 Read)设置
maxResultSizeChars = Infinity,豁免预算限制
回写后还有附件注入阶段:
getAttachmentMessages()注入文件变更通知、IDE 选区、queued commands- Memory prefetch 结果注入
- Skill discovery prefetch 结果注入
ToolSearch:延迟加载优化
当工具数量过多时(MCP 扩展场景),Claude Code 使用 ToolSearch 机制 [源码]:
初始状态:
- 核心工具: 始终加载 (Read, Write, Bash, Agent, ...)
- 延迟工具: 仅发送名称+描述给模型 (defer_loading: true)
模型需要延迟工具时:
1. 调用 ToolSearchTool
2. 返回匹配工具的完整 schema
3. 下一轮该工具变为可用
优化效果:
- 减少 prompt 中的工具 schema token 消耗
- 保持工具发现能力
工具目录概览
src/tools/ 下的工具按功能分组 [源码]:
| 分组 | 工具 | 说明 |
|---|---|---|
| 文件 | Read, Write, Edit, MultiEdit | 文件读写编辑 |
| 执行 | Bash, BashBackground | Shell 命令 |
| 搜索 | Grep, Glob, WebSearch, WebFetch | 代码/文件/网络搜索 |
| Agent | AgentTool, SendMessage, TaskStop | 子 agent 管理 |
| 记忆 | MemoryRead, MemoryWrite | 持久记忆 |
| MCP | 动态注册 | 外部工具协议 |
| 特殊 | Sleep, ToolSearch, Notebook | 辅助工具 |
关键设计决策
为什么用 AsyncGenerator 而不是回调/Promise?
[源码] 整个工具管线使用 async function* 实现,原因:
- 流式输出:工具执行中的进度可以即时 yield 给 UI
- 背压控制:消费者(UI)可以控制生产速度
- 取消传播:generator.return() 自然传播取消信号
- 组合性:yield* 可以嵌套子 generator
为什么权限检查在 Hook 之后还有一次?
[源码] Hook 可以返回 allow 跳过权限,但 checkRuleBasedPermissions 仍会在 hook allow 后做二次校验——这是纵深防御:即使 hook 被绕过,deny rules 仍然生效。
为什么 maxResultSizeChars 有 Infinity 选项?
[源码] Read 工具设置 maxResultSizeChars = Infinity,因为如果 Read 的结果被持久化到文件再引用,模型下次需要看内容时又会调用 Read → 无限循环。
局限性
- toolExecution.ts 1746 行中约 60% 是错误处理和遥测代码,核心执行逻辑约 300 行
- Hook 系统的
if条件匹配器支持 glob 和正则,但具体语法未在源码中找到文档 - ToolSearch 的延迟加载阈值(多少工具时启用)由 GrowthBook 远程配置控制,无法从源码确定
- MCP 工具的注册和生命周期管理分散在多个文件中,本文未深入
下一步
- 多 Agent 编排 — Swarm / Coordinator / Workflow 如何调度工具
- System Prompt 分层 — 工具 prompt 如何生成
- Dynamic Workflow — 工具编排从 model context 外置到 JS 脚本