Claude Code 源码解剖

Claude Code 的工具执行流程

从 partitionToolCalls、runTools、runToolUse、checkPermissionsAndCallTool、StreamingToolExecutor 和 toolResultStorage,拆解 tool_use 到 tool_result 的完整执行链。

第 4 章 16 分钟

Claude Code 的工具执行流程

第 3 章讲了工具如何被定义、注册和暴露给模型。这一章接着看真正的执行链:模型吐出 tool_use 以后,Claude Code 如何把它变成安全、可中断、可回写的 tool_result

核心问题不是“怎么调用函数”,而是五个更具体的问题:

  • 多个工具调用能不能并发?
  • 输入是否真的合法?
  • hook、权限规则、用户配置谁优先?
  • 流式工具执行时,进度和结果如何按序交付?
  • 结果太大、为空、被中断时,下一轮模型到底看到什么?

核心直觉

tool_use 是模型提出的意图,不是已经批准的命令。真正执行前,系统要做分区、校验、权限、hook、并发、中断和结果预算。

先看源码入口

源码定位点关键符号负责什么
restored-src/src/services/tools/toolOrchestration.ts:19-82runTools()批量工具执行入口,按分区调用并发或串行路径
restored-src/src/services/tools/toolOrchestration.ts:91-116partitionToolCalls()把工具调用分成并发安全批次和串行批次
restored-src/src/services/tools/toolOrchestration.ts:8-11CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY并发工具数量上限,默认 10
restored-src/src/services/tools/toolExecution.ts:337runToolUse()单个工具调用生命周期入口
restored-src/src/services/tools/toolExecution.ts:599checkPermissionsAndCallTool()输入校验、hook、权限、执行和结果处理
restored-src/src/services/tools/toolExecution.ts:740-752speculative classifierBash 权限分类器预启动
restored-src/src/services/tools/toolExecution.ts:800-862PreToolUse hooks执行前 hook,可改输入、给权限决策、阻止继续
restored-src/src/services/tools/toolHooks.ts:332-433resolveHookPermissionDecision()hook 决策和 settings deny/ask 规则的优先级
restored-src/src/services/tools/StreamingToolExecutor.tsStreamingToolExecutor流式响应中 tool_use 到达即调度
restored-src/src/utils/toolResultStorage.tspersistToolResult()ContentReplacementState大结果持久化、聚合预算和缓存稳定

这章主要追三条线:toolOrchestration.ts 管批次,toolExecution.ts 管单工具生命周期,StreamingToolExecutor.ts 管流式并发。

总流程图

flowchart TB A["Agent Loop 收到 tool_use blocks"] --> B["partitionToolCalls()<br/>按 isConcurrencySafe(input) 分区"] B --> C{"批次类型"} C -->|并发安全批次| D["runToolsConcurrently()<br/>all(..., maxConcurrency)"] C -->|串行批次| E["runToolsSerially()<br/>逐个执行并立即应用 contextModifier"] D --> F["runToolUse()"] E --> F F --> G["查找工具 / alias fallback"] G --> H["inputSchema.safeParse()<br/>validateInput()"] H --> I["PreToolUse hooks<br/>updatedInput / allow / deny / ask / preventContinuation"] I --> J["resolveHookPermissionDecision()<br/>hook 不能覆盖 settings deny/ask"] J --> K["checkPermissionsAndCallTool()<br/>tool.call() + progress stream"] K --> L["mapToolResultToToolResultBlockParam()"] L --> M["persistToolResult()<br/>单工具预算 / 聚合预算 / 空结果占位"] M --> N["PostToolUse / PostToolUseFailure hooks"] N --> O["tool_result 写回 messages"] O --> P["Agent Loop 下一轮或 hook stopped"]

注意: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]

会被分成:

批次工具原因
批次 1Read ARead BGrep C连续只读,可并发
批次 2Bash DBash 是否安全取决于命令,不确定或写入时独占
批次 3Read E前面被 Bash 切断,重新开批
批次 4Edit 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()。可以把单工具生命周期拆成九步:

flowchart TD A["runToolUse(toolUse)"] --> B["查找工具<br/>findToolByName / deprecated alias"] B --> C["Zod schema 校验<br/>inputSchema.safeParse"] C --> D["工具语义校验<br/>validateInput()"] D --> E["Bash speculative classifier<br/>提前启动分类器"] E --> F["PreToolUse hooks<br/>改输入 / 权限建议 / preventContinuation"] F --> G["权限决策链<br/>resolveHookPermissionDecision"] G --> H["tool.call()<br/>执行真实副作用"] H --> I["结果映射<br/>mapToolResultToToolResultBlockParam"] I --> J["大结果预算与持久化"] J --> K["PostToolUse hooks<br/>或 PostToolUseFailure hooks"] K --> L["返回 tool_result update"]

查找和输入校验

第一步是找工具。如果当前工具名找不到,还会检查已弃用工具 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 规则

可以画成:

flowchart TD A["PreToolUse hook 决策"] --> B{"hook 返回什么?"} B -->|deny| C["直接拒绝"] B -->|ask| D["进入正常权限流程<br/>带 forceDecision"] B -->|allow| E["继续检查 settings 规则"] B -->|无决策| F["正常权限流程"] E --> G{"settings 有 deny / ask?"} G -->|deny| C G -->|ask| D G -->|无匹配| H["允许跳过用户提示"] F --> I["tool.checkPermissions()<br/>通用规则<br/>分类器<br/>用户确认"] D --> I

这张图里最重要的是 allow -&gt; settings deny/ask 这条边。它说明用户配置的硬边界比 hook 的批准更高优先级。

核心直觉

Hook 可以扩展权限判断,但不能越过用户配置。否则一个项目级 hook 就可能把用户明确 deny 的操作重新放行。

执行阶段:tool.call() 和进度流合在一起

权限通过后,系统调用 tool.call()。原文提到执行过程会包在 startSessionActivity(&#39;tool_exec&#39;)stopSessionActivity(&#39;tool_exec&#39;) 之间,用于追踪会话活跃状态。

工具执行期间还会产生进度事件。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要按工具语义交付下一轮模型上下文需要稳定顺序

中断行为:cancelblock

每个工具可以声明中断行为:cancelblock

行为用户中断时怎么做适合什么
cancel立即收到取消消息,结果用合成拒绝消息替代可安全停止的工具
block继续运行到完成中途停止会破坏状态一致性的工具

StreamingToolExecutor 通过 updateInterruptibleState() 追踪当前是否所有执行中工具都可中断,并把这个信息传给 UI。UI 是否显示“按 ESC 取消”,不是凭感觉,而是来自工具执行状态。

结果管理:大输出、聚合预算和空结果都要处理

工具完成后,结果不能直接无脑写回。toolResultStorage.ts 负责结果预算和持久化。

单工具大结果持久化

阈值优先级是:

优先级来源
1GrowthBook 覆盖,比如 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)

这说明“没有输出”本身也需要被编码成明确事实。否则模型可能把空白理解成结构信号,而不是工具结果。

停止钩子:工具执行后的中断点

PreToolUsePostToolUse hooks 都可以设置 preventContinuation

关键行为是:

hook 设置 preventContinuation
-> 工具仍可能执行
-> 执行后追加 hook_stopped_continuation 附件消息
-> Agent Loop 检测到后终止当前迭代
-> 不再把结果继续送给模型推理

这条路径体现了一种精细控制:hook 不一定阻止工具本身执行,但可以阻止“执行结果继续驱动 Agent 自主行动”。这对安全策略非常重要。

hook 类型常见作用
PreToolUse执行前修改输入、给权限建议、声明执行后不要继续
PostToolUse检查执行结果,决定是否阻断下一轮
PostToolUseFailure工具失败后补充上下文或安全判断

状态和数据结构

结构关键字段或状态控制什么
ToolUseBlocknameinputid模型提出的工具调用
BatchisConcurrencySafeblocks分区调度的基本单位
ToolUseContextoptions.tools、权限、abort controller查找工具、权限和中断上下文
StreamingToolExecutor tool statequeuedexecutingcompletedyielded流式工具生命周期
siblingAbortControllerabort reason sibling_errorBash 兄弟错误级联
ContentReplacementState已替换工具结果记录结果持久化的确定性和缓存稳定
hook resultupdatedInputallow/deny/askpreventContinuation修改输入、影响权限、阻止继续

设计取舍

取舍点源码体现工程判断
保序贪心并发partitionToolCalls() 只合并连续安全批次不重排模型意图,同时尽量提升只读吞吐
并发 fail-closedschema 失败或 isConcurrencySafe 抛错返回 false不确定时串行,避免状态竞争
context modifier 延迟并发批次结束后统一应用避免并发工具同时修改上下文
hook allow 不越权resolveHookPermissionDecision() 继续检查 settings deny/ask用户配置是硬边界
流式提前执行StreamingToolExecutor tool_use 到达即入队把工具耗时和模型流式输出重叠
Bash 选择性级联siblingAbortController.abort(&#39;sibling_error&#39;)取消隐式依赖的兄弟 Bash,但不杀整个 loop
进度即时、结果守序pendingProgress 独立队列UI 及时反馈,模型上下文仍稳定
替换状态持久ContentReplacementState为 prompt cache 保持历史消息字节稳定
空结果占位tool_result 注入文本避免模型误判回合边界

读源码抓手

建议按这个顺序读:

  1. toolOrchestration.tsrunTools() 入口开始,不要先跳到具体工具。
  2. partitionToolCalls(),确认 schema 解析和 isConcurrencySafe(input) 如何决定批次。
  3. runToolsConcurrently()runToolsSerially(),重点看 context modifier 何时应用。
  4. 进入 toolExecution.tsrunToolUse(),画出查找工具、schema 校验、语义校验、hook、权限、执行、结果映射。
  5. resolveHookPermissionDecision(),确认 hook 决策和 settings deny/ask 的优先级。
  6. StreamingToolExecutorqueued -&gt; executing -&gt; completed -&gt; yielded 状态转移和 canExecuteTool()
  7. 最后读 toolResultStorage.ts,重点看 persistToolResult()、聚合预算、ContentReplacementState 和空结果占位。

小结

- tool_use 只是模型意图,真正执行前必须经过分区、校验、hook 和权限链。 - partitionToolCalls() 保留顺序,只合并连续并发安全工具;不确定时串行。 - runToolUse()checkPermissionsAndCallTool() 是单工具生命周期核心。 - StreamingToolExecutor 让工具在流式响应中提前执行,同时维护并发、中断和进度交付。 - toolResultStorage 不只是截断输出,还要维护结果替换的确定性,服务下一轮模型和 prompt cache。