Claude Code 的工具执行流程
第 3 章讲了工具如何被定义、注册和暴露给模型。这一章接着看真正的执行链:模型吐出 tool_use 以后,Claude Code 如何把它变成安全、可中断、可回写的 tool_result。
核心问题不是“怎么调用函数”,而是五个更具体的问题:
- 多个工具调用能不能并发?
- 输入是否真的合法?
- hook、权限规则、用户配置谁优先?
- 流式工具执行时,进度和结果如何按序交付?
- 结果太大、为空、被中断时,下一轮模型到底看到什么?
核心直觉
tool_use 是模型提出的意图,不是已经批准的命令。真正执行前,系统要做分区、校验、权限、hook、并发、中断和结果预算。
先看源码入口
| 源码定位点 | 关键符号 | 负责什么 |
|---|---|---|
restored-src/src/services/tools/toolOrchestration.ts:19-82 | runTools() | 批量工具执行入口,按分区调用并发或串行路径 |
restored-src/src/services/tools/toolOrchestration.ts:91-116 | partitionToolCalls() | 把工具调用分成并发安全批次和串行批次 |
restored-src/src/services/tools/toolOrchestration.ts:8-11 | CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY | 并发工具数量上限,默认 10 |
restored-src/src/services/tools/toolExecution.ts:337 | runToolUse() | 单个工具调用生命周期入口 |
restored-src/src/services/tools/toolExecution.ts:599 | checkPermissionsAndCallTool() | 输入校验、hook、权限、执行和结果处理 |
restored-src/src/services/tools/toolExecution.ts:740-752 | speculative classifier | Bash 权限分类器预启动 |
restored-src/src/services/tools/toolExecution.ts:800-862 | PreToolUse hooks | 执行前 hook,可改输入、给权限决策、阻止继续 |
restored-src/src/services/tools/toolHooks.ts:332-433 | resolveHookPermissionDecision() | hook 决策和 settings deny/ask 规则的优先级 |
restored-src/src/services/tools/StreamingToolExecutor.ts | StreamingToolExecutor | 流式响应中 tool_use 到达即调度 |
restored-src/src/utils/toolResultStorage.ts | persistToolResult()、ContentReplacementState | 大结果持久化、聚合预算和缓存稳定 |
这章主要追三条线:toolOrchestration.ts 管批次,toolExecution.ts 管单工具生命周期,StreamingToolExecutor.ts 管流式并发。
总流程图
注意:StreamingToolExecutor 会把这条链路提前到模型流式响应期间执行。只要某个 tool_use block 已经可解析,它就能入队,而不是等整段 assistant 响应完全结束。
分区调度:partitionToolCalls() 先决定并发边界
模型可以一次返回多个 tool_use。如果全串行,读文件和搜索会很慢;如果全并发,写文件、改目录、跑命令可能互相污染。Claude Code 用 partitionToolCalls() 做折中:保留模型给出的顺序,但把连续的并发安全工具合成一个批次。
原文给出的核心片段:
function partitionToolCalls(
toolUseMessages: ToolUseBlock[],
toolUseContext: ToolUseContext,
): Batch[] {
return toolUseMessages.reduce((acc: Batch[], toolUse) => {
const tool = findToolByName(toolUseContext.options.tools, toolUse.name);
const parsedInput = tool?.inputSchema.safeParse(toolUse.input);
const isConcurrencySafe = parsedInput?.success
? (() => {
try {
return Boolean(tool?.isConcurrencySafe(parsedInput.data));
} catch {
return false;
}
})()
: false;
if (isConcurrencySafe && acc[acc.length - 1]?.isConcurrencySafe) {
acc[acc.length - 1]!.blocks.push(toolUse);
} else {
acc.push({ isConcurrencySafe, blocks: [toolUse] });
}
return acc;
}, []);
}
这段代码证明了三个执行策略:
| 源码行为 | 证明了什么 |
|---|---|
先 safeParse(toolUse.input) | 输入结构有效,才有资格判断并发安全 |
isConcurrencySafe() 抛错时返回 false | 安全判断异常时 fail-closed,退回串行 |
| 只合并到上一个并发安全批次 | 不重排模型工具顺序,只做贪心批次合并 |
举个具体序列:
[Read A] [Read B] [Grep C] [Bash D] [Read E] [Edit F]
会被分成:
| 批次 | 工具 | 原因 |
|---|---|---|
| 批次 1 | Read A、Read B、Grep C | 连续只读,可并发 |
| 批次 2 | Bash D | Bash 是否安全取决于命令,不确定或写入时独占 |
| 批次 3 | Read E | 前面被 Bash 切断,重新开批 |
| 批次 4 | Edit F | 写文件,独占 |
容易误解
并发安全不是“工具名看起来像读操作”。源码先用 schema 解析输入,再调用 isConcurrencySafe(parsedInput.data)。输入无效或判断异常,都按不安全处理。
runTools():并发批次和串行批次走不同路径
runTools() 是批量执行入口。它拿到分区后的批次,然后按批次类型分发:
partitionToolCalls()
-> 并发安全批次: runToolsConcurrently()
-> 串行批次: runToolsSerially()
并发路径的形状是:
async function* runToolsConcurrently(...) {
yield* all(
toolUseMessages.map(async function* (toolUse) {
yield* runToolUse(toolUse, ...);
markToolUseAsComplete(toolUseContext, toolUse.id);
}),
getMaxToolUseConcurrency(),
);
}
这段代码证明并发不是无限打开,而是受 getMaxToolUseConcurrency() 控制。原文定位到环境变量 CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY,默认值为 10。
并发路径还有一个容易漏掉的点:context modifier 延迟应用。并发工具可能各自产生对工具上下文的修改,但如果执行中立即写入共享上下文,就会产生竞态。所以并发批次会先收集修改,等整个批次结束后再按工具出现顺序应用。
串行路径则相反:每个工具执行后立即应用上下文修改。
| 路径 | 何时应用 context modifier | 为什么 |
|---|---|---|
| 并发批次 | 批次完成后按原顺序应用 | 避免多个并发工具同时改上下文 |
| 串行批次 | 每个工具完成后立即应用 | 后一个工具应看到前一个工具修改后的状态 |
这说明 runTools() 不只是调度性能,它还在维护“上下文状态一致性”。
单工具生命周期:runToolUse() 到 checkPermissionsAndCallTool()
每个工具最终都会进入 runToolUse() 和 checkPermissionsAndCallTool()。可以把单工具生命周期拆成九步:
查找和输入校验
第一步是找工具。如果当前工具名找不到,还会检查已弃用工具 alias。这个设计让旧会话记录里的工具调用不至于因为工具改名直接失效。
输入校验分两层:
| 校验 | 源码行为 | 失败结果 |
|---|---|---|
| 结构校验 | tool.inputSchema.safeParse(toolUse.input) | 返回参数错误,不进入副作用 |
| 语义校验 | tool.validateInput(parsed.data) | 返回工具特定错误 |
原文还提到 deferred tool 的特殊错误提示:如果某个工具 schema 没有发送给 API,Zod 错误会提示模型先通过 ToolSearch 加载 schema 再重试。这说明错误消息不是只给人看的,也会被模型用来修复下一轮动作。
命令分类器:提前为 Bash 权限判断做准备
在真正进入权限检查前,如果工具是 Bash,系统会推测性启动允许分类器。原文定位在 toolExecution.ts:740-752。
这个行为证明:权限系统不只追求正确,还追求等待体验。分类器和 hooks 可以并行跑,等用户需要权限判断时,分类器结果可能已经准备好。
预执行钩子:工具运行前先改输入和给权限建议
PreToolUse hooks 可以产生多种效果:
| hook 输出 | 影响 |
|---|---|
updatedInput | 替换原始工具输入 |
allow / deny / ask | 给权限链一个前置决策 |
preventContinuation | 工具后续不再进入下一轮 Agent Loop |
| 附加上下文 | 给模型或工具执行提供额外信息 |
hook 如果被 abort signal 中断,系统会立即返回取消消息。这条路径说明 hook 自身也是执行链的一部分,不是旁路观察者。
权限决策链:hook 的 allow 不能覆盖用户 deny
权限链由 resolveHookPermissionDecision() 协调。原文强调一个不变量:Hook 的 allow 不能绕过 settings.json 中的 deny/ask 规则。
可以画成:
这张图里最重要的是 allow -> settings deny/ask 这条边。它说明用户配置的硬边界比 hook 的批准更高优先级。
核心直觉
Hook 可以扩展权限判断,但不能越过用户配置。否则一个项目级 hook 就可能把用户明确 deny 的操作重新放行。
执行阶段:tool.call() 和进度流合在一起
权限通过后,系统调用 tool.call()。原文提到执行过程会包在 startSessionActivity('tool_exec') 和 stopSessionActivity('tool_exec') 之间,用于追踪会话活跃状态。
工具执行期间还会产生进度事件。streamedCheckPermissionsAndCallTool 把两类东西合并到一个异步可迭代对象里:
checkPermissionsAndCallTool的最终 Promise 结果。- 工具执行过程中的实时 progress 事件。
这说明执行链不只是“等结果”,而是边执行边向 UI 和上层循环提供可见信号。
StreamingToolExecutor:工具到达即入队,不等全量响应
批量 runTools() 是等 tool_use blocks 收集完以后执行。流式模式下,模型响应还没结束,某些 tool_use block 已经到达并可解析。StreamingToolExecutor 就负责把这些工具提前入队执行。
它为每个工具维护四个状态:
queued -> executing -> completed -> yielded
| 状态 | 含义 |
|---|---|
queued | 工具已注册,等待可执行窗口 |
executing | 工具正在运行 |
completed | 工具结果已完成并缓冲 |
yielded | 结果已交付给消费者 |
状态由 processQueue() 推进。每次新工具入队或某个工具完成,队列处理器都会尝试启动下一个可执行工具。
核心并发判断:
private canExecuteTool(isConcurrencySafe: boolean): boolean {
const executingTools = this.tools.filter(t => t.status === 'executing');
return (
executingTools.length === 0 ||
(isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
);
}
这段代码证明:
| 情况 | 能否启动新工具 |
|---|---|
| 没有工具执行中 | 可以,任何工具都能启动 |
| 已有工具执行中,且新工具和所有执行中工具都并发安全 | 可以 |
| 已有工具执行中,但新工具不安全或已有工具不安全 | 不可以,等待 |
这和 partitionToolCalls() 的策略是一致的:不确定就独占,确定只读才并发。
兄弟级联:一个 Bash 失败后取消同级命令
StreamingToolExecutor 对 Bash 有一条特殊错误策略:
if (tool.block.name === BASH_TOOL_NAME) {
this.hasErrored = true;
this.erroredToolDescription = this.getToolDescription(tool);
this.siblingAbortController.abort('sibling_error');
}
这段代码证明:一个 Bash 工具出错时,会取消同级并行 Bash 工具。原因是 shell 命令之间常有隐式依赖,比如前一个 mkdir 失败,后面的 cp 多半也没有意义。
但它使用的是 siblingAbortController,不是父级 toolUseContext.abortController。也就是说:
| 中断范围 | 行为 |
|---|---|
| sibling abort | 取消同级工具,尤其是相关 Bash 子进程 |
| parent abort | 中止整个 Agent Loop 或当前工具执行上下文 |
这体现的是选择性级联:局部失败不要扩散成全局崩溃。
进度可以先交付,结果必须守顺序
原文还指出:普通工具结果必须按序传递,但进度消息可以立即传递。StreamingToolExecutor 把进度消息放进独立的 pendingProgress 队列,getCompletedResults() 会优先 yield 进度。
当没有完成结果但仍有工具执行时,getRemainingResults() 用 Promise.race 等待“任一工具完成”或“新进度到达”,避免轮询。
这说明执行器区分两种时序:
| 数据 | 时序要求 | 原因 |
|---|---|---|
| progress | 可即时交付 | UI 需要实时反馈 |
| final result | 要按工具语义交付 | 下一轮模型上下文需要稳定顺序 |
中断行为:cancel 和 block
每个工具可以声明中断行为:cancel 或 block。
| 行为 | 用户中断时怎么做 | 适合什么 |
|---|---|---|
cancel | 立即收到取消消息,结果用合成拒绝消息替代 | 可安全停止的工具 |
block | 继续运行到完成 | 中途停止会破坏状态一致性的工具 |
StreamingToolExecutor 通过 updateInterruptibleState() 追踪当前是否所有执行中工具都可中断,并把这个信息传给 UI。UI 是否显示“按 ESC 取消”,不是凭感觉,而是来自工具执行状态。
结果管理:大输出、聚合预算和空结果都要处理
工具完成后,结果不能直接无脑写回。toolResultStorage.ts 负责结果预算和持久化。
单工具大结果持久化
阈值优先级是:
| 优先级 | 来源 |
|---|---|
| 1 | GrowthBook 覆盖,比如 tengu_satin_quoll |
| 2 | 工具声明的 maxResultSizeChars |
| 3 | 全局上限 DEFAULT_MAX_RESULT_SIZE_CHARS = 50,000 |
原文指出最终阈值取工具声明值和全局上限的较小者;如果工具声明 Infinity,则跳过持久化,例如 Read 工具自己管理输出边界。
当结果超过阈值,persistToolResult() 会把完整内容写入会话目录下 tool-results/,并生成预览:
<persisted-output>
Output too large (245.0 KB). Full output saved to: /path/to/tool-results/abc123.txt
Preview (first 2.0 KB):
[前 2000 字节的内容...]
...
</persisted-output>
预览会尽量在换行处截断,避免把错误日志或表格行切断在中间。
每消息聚合预算
如果一个回合里 10 个工具各返回 40K,单个工具都不一定超限,但合起来就是 400K。Claude Code 还有单消息聚合预算,默认 MAX_TOOL_RESULTS_PER_MESSAGE_CHARS = 200,000。
超出聚合预算时,系统从最大的工具结果开始持久化,直到总量回到预算内。
ContentReplacementState:为了缓存稳定,替换结果要可重复
原文提到聚合预算维护 ContentReplacementState。它记录哪些工具结果已经被持久化。
这证明持久化不是每次重新临时决定。一旦某个结果被替换,后续评估中继续使用同一个替换版本,即使当下总量没有超预算。
原因是 prompt cache:同一条消息如果一会儿是完整结果、一会儿是持久化摘要,前缀字节会抖动,缓存命中就会变差。
核心直觉
工具结果管理不只是省 token。它还要让同一段历史在多次 API 调用中保持稳定,避免 prompt cache 被结果替换策略打穿。
空结果也要补占位文本
原文指出空 tool_result 可能让某些模型误以为到了回合边界,输出 \n\nHuman: 停止序列。系统会给空结果注入占位文本,比如 (Bash completed with no output)。
这说明“没有输出”本身也需要被编码成明确事实。否则模型可能把空白理解成结构信号,而不是工具结果。
停止钩子:工具执行后的中断点
PreToolUse 和 PostToolUse hooks 都可以设置 preventContinuation。
关键行为是:
hook 设置 preventContinuation
-> 工具仍可能执行
-> 执行后追加 hook_stopped_continuation 附件消息
-> Agent Loop 检测到后终止当前迭代
-> 不再把结果继续送给模型推理
这条路径体现了一种精细控制:hook 不一定阻止工具本身执行,但可以阻止“执行结果继续驱动 Agent 自主行动”。这对安全策略非常重要。
| hook 类型 | 常见作用 |
|---|---|
PreToolUse | 执行前修改输入、给权限建议、声明执行后不要继续 |
PostToolUse | 检查执行结果,决定是否阻断下一轮 |
PostToolUseFailure | 工具失败后补充上下文或安全判断 |
状态和数据结构
| 结构 | 关键字段或状态 | 控制什么 |
|---|---|---|
ToolUseBlock | name、input、id | 模型提出的工具调用 |
Batch | isConcurrencySafe、blocks | 分区调度的基本单位 |
ToolUseContext | options.tools、权限、abort controller | 查找工具、权限和中断上下文 |
StreamingToolExecutor tool state | queued、executing、completed、yielded | 流式工具生命周期 |
siblingAbortController | abort reason sibling_error | Bash 兄弟错误级联 |
ContentReplacementState | 已替换工具结果记录 | 结果持久化的确定性和缓存稳定 |
| hook result | updatedInput、allow/deny/ask、preventContinuation | 修改输入、影响权限、阻止继续 |
设计取舍
| 取舍点 | 源码体现 | 工程判断 |
|---|---|---|
| 保序贪心并发 | partitionToolCalls() 只合并连续安全批次 | 不重排模型意图,同时尽量提升只读吞吐 |
| 并发 fail-closed | schema 失败或 isConcurrencySafe 抛错返回 false | 不确定时串行,避免状态竞争 |
| context modifier 延迟 | 并发批次结束后统一应用 | 避免并发工具同时修改上下文 |
| hook allow 不越权 | resolveHookPermissionDecision() 继续检查 settings deny/ask | 用户配置是硬边界 |
| 流式提前执行 | StreamingToolExecutor tool_use 到达即入队 | 把工具耗时和模型流式输出重叠 |
| Bash 选择性级联 | siblingAbortController.abort('sibling_error') | 取消隐式依赖的兄弟 Bash,但不杀整个 loop |
| 进度即时、结果守序 | pendingProgress 独立队列 | UI 及时反馈,模型上下文仍稳定 |
| 替换状态持久 | ContentReplacementState | 为 prompt cache 保持历史消息字节稳定 |
| 空结果占位 | 空 tool_result 注入文本 | 避免模型误判回合边界 |
读源码抓手
建议按这个顺序读:
- 从
toolOrchestration.ts的runTools()入口开始,不要先跳到具体工具。 - 看
partitionToolCalls(),确认 schema 解析和isConcurrencySafe(input)如何决定批次。 - 追
runToolsConcurrently()和runToolsSerially(),重点看 context modifier 何时应用。 - 进入
toolExecution.ts的runToolUse(),画出查找工具、schema 校验、语义校验、hook、权限、执行、结果映射。 - 看
resolveHookPermissionDecision(),确认 hook 决策和 settings deny/ask 的优先级。 - 读
StreamingToolExecutor的queued -> executing -> completed -> yielded状态转移和canExecuteTool()。 - 最后读
toolResultStorage.ts,重点看persistToolResult()、聚合预算、ContentReplacementState和空结果占位。
小结
- tool_use 只是模型意图,真正执行前必须经过分区、校验、hook 和权限链。 - partitionToolCalls() 保留顺序,只合并连续并发安全工具;不确定时串行。 - runToolUse() 和 checkPermissionsAndCallTool() 是单工具生命周期核心。 - StreamingToolExecutor 让工具在流式响应中提前执行,同时维护并发、中断和进度交付。 - toolResultStorage 不只是截断输出,还要维护结果替换的确定性,服务下一轮模型和 prompt cache。