《Claude Code 设计与实现》完整目录
- 前言
- 第1章 为什么需要理解 Claude Code
- 第2章 架构总览
- 第3章 CLI 启动与性能优化
- 第4章 Query 引擎:Agent 的心脏
- 第5章 流式消息与状态机
- 第6章 工具类型系统设计
- 第7章 工具编排与并发执行(当前)
- 第8章 核心工具实现剖析
- 第9章 多模式权限模型
- 第10章 Bash 安全与沙箱
- 第11章 MCP 协议集成
- 第12章 IDE Bridge 通信架构
- 第13章 LSP 与语言服务
- 第14章 多 Agent 协调与 Swarm
- 第15章 Skill 与插件系统
- 第16章 上下文管理与自动压缩
- 第17章 React + Ink 终端 UI
- 第18章 设计模式与架构决策
第7章 工具编排与并发执行
当模型在一次响应中同时调用 Grep、Read、Glob 三个工具搜索代码库时,这些调用是顺序执行还是并行执行?当模型一边读文件一边写文件时,系统如何保证写操作不会和读操作产生竞态条件?当某个 Bash 命令执行失败时,还在排队的其他工具调用该怎么处理?
这些问题的答案,隐藏在 Claude Code 的工具编排层中。这一层位于 API 响应解析和工具实际执行之间,它决定了哪些工具可以并行运行、哪些必须串行等待,以及在整个过程中如何维护上下文一致性。
本章将深入这个编排层的完整实现,从批次分区算法到并发执行引擎,从 Pre/Post Hooks 管道到文件状态追踪,揭示一个生产级 Agent 系统是如何高效且安全地管理数十个工具的并发调度的。理解工具编排的设计,不仅有助于理解 Claude Code 本身的行为特征(比如为什么有些操作明显更快),更能为构建自己的 Agent 系统提供可直接复用的工程模式。
本章要点
- 工具编排的核心是
runTools()async generator:它从 API 响应中提取工具调用块,分批执行并流式产出结果 - 批次分区算法:连续的读操作合并为一个并发批次,写操作独占一个串行批次
- 双模式执行引擎 :
runToolsConcurrently()利用all()并发调度器实现有上限的并行执行,runToolsSerially()保证写操作的顺序一致性 - 完整的工具执行管道:每个工具调用经历输入校验、Pre-Hook、权限检查、实际执行、Post-Hook、结果收集六个阶段
- 文件状态缓存 :
FileStateCache基于 LRU 策略缓存已读文件,避免重复 IO;FileHistory在每次写操作前创建备份,支持回退 - Hook 系统 :
PreToolUse和PostToolUse钩子允许用户自定义命令在工具执行前后介入,实现权限控制、日志记录、输入改写等高级功能
7.1 工具编排总览
从 API 响应到工具执行
在第5章中,我们分析了 Claude Code 的流式响应处理。当 API 返回的助手消息中包含 tool_use 类型的内容块时,查询引擎需要提取这些工具调用并编排它们的执行。这个提取过程发生在 query.ts 的主循环中:
typescript
// 源码位置: src/query.ts
const toolUseBlocks: ToolUseBlock[] = []
// 流式处理中,每当检测到 tool_use 块就收集起来
const assistantMessages: AssistantMessage[] = []
// 流结束后,将工具调用交给编排层
const toolUpdates = streamingToolExecutor
? streamingToolExecutor.getRemainingResults()
: runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext)
for await (const update of toolUpdates) {
if (update.message) {
// 将工具执行结果加入消息流
}
}
这里有一个关键的分支:系统可以使用 StreamingToolExecutor(流式工具执行器,在 API 响应流式传入过程中就开始执行工具)或者 runTools()(批量编排,等所有工具调用解析完毕后统一调度)。两者的编排逻辑相似,但 runTools() 是更核心的抽象,它清晰地展示了编排层的完整决策链路。
之所以存在两种模式,是因为实际场景中存在一个权衡:流式模式可以让工具尽早开始执行(API 还在返回后续 tool_use 块时,前面的工具已经在运行了),带来更低的端到端延迟;而批量模式拥有全部工具调用的全局视图,可以做出更优的分区决策。对于大多数交互式场景,流式模式的延迟优势更有价值;在某些需要精确控制执行顺序的场景(如查询辅助工具调用),批量模式更为可靠。
runTools() 的结构
runTools() 是整个编排层的入口,定义在 src/services/tools/toolOrchestration.ts 中。它是一个 async generator 函数,接收工具调用块列表,产出执行结果流:
typescript
// 源码位置: src/services/tools/toolOrchestration.ts
export async function* runTools(
toolUseMessages: ToolUseBlock[],
assistantMessages: AssistantMessage[],
canUseTool: CanUseToolFn,
toolUseContext: ToolUseContext,
): AsyncGenerator<MessageUpdate, void> {
let currentContext = toolUseContext
for (const { isConcurrencySafe, blocks } of partitionToolCalls(
toolUseMessages,
currentContext,
)) {
if (isConcurrencySafe) {
// 并发执行读操作批次
const queuedContextModifiers: Record<
string,
((context: ToolUseContext) => ToolUseContext)[]
> = {}
for await (const update of runToolsConcurrently(
blocks, assistantMessages, canUseTool, currentContext,
)) {
if (update.contextModifier) {
const { toolUseID, modifyContext } = update.contextModifier
if (!queuedContextModifiers[toolUseID]) {
queuedContextModifiers[toolUseID] = []
}
queuedContextModifiers[toolUseID].push(modifyContext)
}
yield { message: update.message, newContext: currentContext }
}
// 批次结束后,按工具顺序应用上下文修改
for (const block of blocks) {
const modifiers = queuedContextModifiers[block.id]
if (!modifiers) continue
for (const modifier of modifiers) {
currentContext = modifier(currentContext)
}
}
yield { newContext: currentContext }
} else {
// 串行执行写操作批次
for await (const update of runToolsSerially(
blocks, assistantMessages, canUseTool, currentContext,
)) {
if (update.newContext) {
currentContext = update.newContext
}
yield { message: update.message, newContext: currentContext }
}
}
}
}
这段代码的核心逻辑可以归纳为三步:分区 -> 判断 -> 执行 。首先调用 partitionToolCalls() 将工具调用分为若干批次,然后根据每个批次的 isConcurrencySafe 标志选择并发或串行执行路径。
值得注意的是 currentContext 的管理策略。对于并发批次,上下文修改器(contextModifier)不会在执行过程中立即生效,而是先缓存起来,等整个批次执行完毕后再按照工具的原始顺序依次应用。这保证了并发执行不会导致上下文状态的不确定性。对于串行批次,每个工具执行完毕后上下文修改立即生效,后续工具看到的是更新后的上下文。
使用 async generator 的设计哲学
选择 async generator 作为编排层的核心抽象并非偶然。这种模式带来三个关键优势:
第一,流式产出。工具执行的结果不需要等所有工具跑完才返回,而是执行一个就产出一个。对于 UI 层来说,用户可以实时看到每个工具的执行进度和结果。
第二,可组合性 。runTools() 产出的是 AsyncGenerator<MessageUpdate>,而 runToolsConcurrently() 和 runToolsSerially() 也是同样类型的 generator。它们可以自由嵌套和组合,而不需要复杂的回调或事件机制。
第三,背压控制。当消费者(UI 层或查询引擎)处理不过来时,generator 会自然地暂停产出,避免内存无限增长。这在工具大量并发执行时尤为重要。
第四,取消语义。async generator 天然支持取消------消费者只需要停止迭代(break 出 for-await-of 循环),generator 就会被垃圾回收。相比之下,基于 Promise.all 的并发方案在需要提前终止时会面临很大的复杂性。
在 Claude Code 的整个代码库中,async generator 是一种无处不在的模式。从 API 流式响应、工具编排、Hook 执行到 UI 更新,几乎所有涉及"随时间逐步产出结果"的场景都使用了这种抽象。这不是偶然的选择,而是经过反复验证的架构决策------它为复杂的异步流程提供了一种线性的、可组合的编程模型。
下图展示了 runTools() 的完整编排流程,从 API 响应中的工具调用块到最终结果产出的全链路:
7.2 批次分区与并发决策
partitionToolCalls 算法
批次分区是编排层最关键的决策点。partitionToolCalls() 函数将一组工具调用分为若干批次,每个批次要么全部并发执行,要么包含一个串行执行的工具:
typescript
// 源码位置: src/services/tools/toolOrchestration.ts
type Batch = { isConcurrencySafe: boolean; blocks: ToolUseBlock[] }
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
}, [])
}
这个算法使用了一次遍历的贪心策略,通过 reduce 累加器模式实现:从头到尾扫描所有工具调用,对每个工具判断其是否并发安全。如果当前工具是并发安全的,并且上一个批次也是并发安全的,就将当前工具追加到上一个批次(合并连续的读操作);否则开启一个新批次。注意这个算法是在线的(一次遍历),时间复杂度为 O(n),不需要回溯或全局优化。
一个重要的细节是,分区决策在执行前就确定了------它基于工具声明的并发安全性,而不是运行时的实际行为。这是一种静态分析策略,保守但可预测。
假设模型在一次响应中调用了以下工具序列:
rust
Grep -> Read -> Read -> Edit -> Glob -> Read -> Bash(git status)
分区结果将是:
ini
批次1 [并发]: Grep, Read, Read -- 三个只读操作并行
批次2 [串行]: Edit -- 写操作独占
批次3 [并发]: Glob, Read, Bash -- Bash(git status) 是只读的,可以并行
并发安全的判定标准
每个工具通过 isConcurrencySafe() 方法声明自己是否可以并发执行。这个判定与输入参数相关------同一个工具在不同输入下可能有不同的并发安全性。
以下是各类工具的典型并发安全性配置:
始终并发安全的工具(纯读操作):
typescript
// 源码位置: src/tools/GrepTool/GrepTool.ts
isConcurrencySafe() {
return true // 搜索操作总是安全的
}
// 源码位置: src/tools/FileReadTool/FileReadTool.ts
isConcurrencySafe() {
return true // 文件读取总是安全的
}
// 源码位置: src/tools/GlobTool/GlobTool.ts
isConcurrencySafe() {
return true // 文件匹配总是安全的
}
条件并发安全的工具(取决于输入):
typescript
// 源码位置: src/tools/BashTool/BashTool.tsx
isConcurrencySafe(input) {
return this.isReadOnly?.(input) ?? false;
}
isReadOnly(input) {
const compoundCommandHasCd = commandHasAnyCd(input.command);
const result = checkReadOnlyConstraints(input, compoundCommandHasCd);
return result.behavior === 'allow';
}
Bash 工具的并发安全性完全取决于命令本身是否只读。像 ls、cat、git status、wc -l 这样的命令被认为是只读的,可以并行执行;而 mkdir、rm、git commit 等则不行。这意味着相同的 Bash 工具,执行 git log 时可以和其他工具并行,执行 git push 时就必须独占。
Bash 工具的只读判定内部调用了 checkReadOnlyConstraints(),这是一个相当复杂的命令分析器,需要解析复合命令(管道、&&、|| 等)的每个子命令。它还需要特别处理 cd 命令------包含 cd 的复合命令(如 cd /tmp && git status)出于安全考虑被视为非只读,因为 cd 到恶意目录后执行的 git 命令可能触发 core.fsmonitor 攻击。
PowerShell 工具采用了与 Bash 相同的策略:isConcurrencySafe 直接委托给 isReadOnly。这意味着在 Windows 平台上,并发控制遵循完全一致的语义。
还有一类工具值得特别说明------AgentTool(子 Agent 工具)也声明了 isConcurrencySafe() { return true }。这看起来违反直觉,因为子 Agent 内部可能执行写操作。但设计者的考量是:每个子 Agent 有自己独立的执行上下文和 AbortController,它们之间的隔离已经在更高层保证了。将子 Agent 标记为并发安全,允许多个子 Agent 同时执行不同的任务,这是多 Agent 协作的基础。
始终不并发安全的工具(写操作或有副作用):
typescript
// 源码位置: src/Tool.ts (buildTool 默认值)
const TOOL_DEFAULTS = {
isConcurrencySafe: (_input?: unknown) => false, // 默认不安全
isReadOnly: (_input?: unknown) => false, // 默认假设有写操作
}
默认值设为 false 体现了失败保守 (fail-closed)的设计原则。如果一个工具的开发者忘记声明并发安全性,系统会将其当作不安全处理,确保不会出现意外的并发冲突。这与 isReadOnly 的默认值 false 和 isDestructive 的默认值 false 形成了一套一致的安全默认配置:未显式声明的工具被假定为"有写操作、不可并发、但非破坏性",这是一个合理的中间立场。
通过 buildTool() 工厂函数,所有工具定义都经过统一的默认值填充,保证了行为的一致性:
typescript
// 源码位置: src/Tool.ts
export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
return {
...TOOL_DEFAULTS,
userFacingName: () => def.name,
...def,
} as BuiltTool<D>
}
开发者只需要覆盖需要自定义的方法,其余自动获得安全的默认行为。
异常安全的判定过程
注意 partitionToolCalls 中判定并发安全性的代码被包裹在 try-catch 中:
typescript
const isConcurrencySafe = parsedInput?.success
? (() => {
try {
return Boolean(tool?.isConcurrencySafe(parsedInput.data))
} catch {
// 如果 isConcurrencySafe 抛异常(例如 shell-quote 解析失败),
// 保守地视为不安全
return false
}
})()
: false
这个设计处理了两个边界情况:如果输入校验失败(parsedInput 不成功),直接视为不安全;如果 isConcurrencySafe 方法本身抛出异常(例如 Bash 工具的命令解析器遇到格式错误的命令),同样视为不安全。这种防御式编程确保了判定过程本身不会成为系统的故障点。
7.3 工具执行管道
管道概览
每个工具调用从进入编排层到产出结果,要经过一条完整的六阶段执行管道。下面的时序图展示了各阶段之间的交互关系:
这条管道定义在 src/services/tools/toolExecution.ts 的 checkPermissionsAndCallTool() 函数中:
rust
输入校验(Zod) -> 工具级校验(validateInput) -> Pre-Hooks -> 权限解析 -> 实际执行 -> Post-Hooks -> 结果收集
下面逐个分析每个阶段。
第一阶段:输入校验
当模型产出工具调用时,参数可能不符合工具的 schema 定义。系统首先使用 Zod 做类型级校验:
typescript
// 源码位置: src/services/tools/toolExecution.ts
const parsedInput = tool.inputSchema.safeParse(input)
if (!parsedInput.success) {
let errorContent = formatZodValidationError(tool.name, parsedInput.error)
// 如果工具是延迟加载的,检查其 schema 是否已发送给 API
const schemaHint = buildSchemaNotSentHint(
tool, toolUseContext.messages, toolUseContext.options.tools,
)
if (schemaHint) {
errorContent += schemaHint
}
// 返回格式化的错误消息给模型
return [{
message: createUserMessage({
content: [{
type: 'tool_result',
content: `<tool_use_error>InputValidationError: ${errorContent}</tool_use_error>`,
is_error: true,
tool_use_id: toolUseID,
}],
}),
}]
}
错误消息被格式化为 <tool_use_error> XML 标签包裹的文本,通过 tool_result 类型回传给模型。模型收到这个错误后会自动尝试修正参数并重新调用。这种设计让模型具有"自纠错"能力------收到格式化的错误信息后,模型通常会在下一轮调用中修正参数格式。
Zod 校验错误的格式化也经过了精心设计。formatZodValidationError() 函数将 Zod 的结构化错误对象转换为人类(和模型)可读的文本,区分了三类问题:缺少必填参数、提供了未知参数、参数类型不匹配。每种错误都带有清晰的参数路径(如 todos[0].activeForm),帮助模型精确定位问题。
值得注意的是 buildSchemaNotSentHint() 的处理。对于延迟加载的工具(通过 ToolSearch 发现的工具),如果模型在没有获取完整 schema 的情况下就尝试调用,Zod 校验几乎必然失败(因为类型化参数如数组、布尔值会被当作字符串发送)。这时系统会追加一个提示,引导模型先调用 ToolSearch 加载工具 schema。
接下来是工具特定的业务校验:
typescript
// 源码位置: src/services/tools/toolExecution.ts
const isValidCall = await tool.validateInput?.(parsedInput.data, toolUseContext)
if (isValidCall?.result === false) {
return [{
message: createUserMessage({
content: [{
type: 'tool_result',
content: `<tool_use_error>${isValidCall.message}</tool_use_error>`,
is_error: true,
tool_use_id: toolUseID,
}],
}),
}]
}
validateInput() 是可选的工具级校验,用于检查 Zod 无法覆盖的语义约束。例如,文件编辑工具可以在这里验证目标文件路径是否在允许的工作目录内。
第二阶段:Pre-Hooks 执行
输入校验通过后,进入 Pre-Hook 阶段。这是用户自定义扩展的第一个注入点:
typescript
// 源码位置: src/services/tools/toolExecution.ts
let hookPermissionResult: PermissionResult | undefined
for await (const result of runPreToolUseHooks(
toolUseContext, tool, processedInput, toolUseID,
assistantMessage.message.id, requestId, mcpServerType, mcpServerBaseUrl,
)) {
switch (result.type) {
case 'message':
// 收集 Hook 产出的消息(进度、附件等)
break
case 'hookPermissionResult':
hookPermissionResult = result.hookPermissionResult
break
case 'hookUpdatedInput':
processedInput = result.updatedInput
break
case 'preventContinuation':
shouldPreventContinuation = result.shouldPreventContinuation
break
case 'stopReason':
stopReason = result.stopReason
break
case 'stop':
// Hook 要求停止执行,返回错误消息
return resultingMessages
}
}
Pre-Hook 的返回值使用了带类型标签的联合类型,每种标签对应不同的控制意图。Hook 可以做以下几件事:
- 修改输入 (
hookUpdatedInput):不改变权限决策,但替换工具的输入参数。比如一个 Hook 可以将相对路径自动展开为绝对路径。 - 控制权限 (
hookPermissionResult):返回 allow、deny 或 ask 来覆盖默认的权限判断。 - 注入上下文 (
additionalContext):向消息流中添加额外的上下文信息。 - 阻止执行 (
stop):完全中止工具调用,通常用于安全策略。 - 阻止后续循环 (
preventContinuation):执行当前工具,但不让模型继续迭代。
第三阶段:权限解析
Pre-Hook 产出的权限建议不是最终裁决。系统需要将 Hook 建议、配置规则和用户交互三者协调起来。这个协调逻辑封装在 resolveHookPermissionDecision() 中:
typescript
// 源码位置: src/services/tools/toolHooks.ts
export async function resolveHookPermissionDecision(
hookPermissionResult: PermissionResult | undefined,
tool: Tool,
input: Record<string, unknown>,
toolUseContext: ToolUseContext,
canUseTool: CanUseToolFn,
assistantMessage: AssistantMessage,
toolUseID: string,
): Promise<{ decision: PermissionDecision; input: Record<string, unknown> }> {
if (hookPermissionResult?.behavior === 'allow') {
const hookInput = hookPermissionResult.updatedInput ?? input
// Hook 的 allow 不能绕过 settings.json 中的 deny/ask 规则
const ruleCheck = await checkRuleBasedPermissions(tool, hookInput, toolUseContext)
if (ruleCheck === null) {
// 没有规则覆盖,Hook 的 allow 生效
return { decision: hookPermissionResult, input: hookInput }
}
if (ruleCheck.behavior === 'deny') {
// deny 规则优先于 Hook 的 allow
return { decision: ruleCheck, input: hookInput }
}
// ask 规则 -- 即使 Hook 批准了,仍需要弹出交互对话框
return {
decision: await canUseTool(tool, hookInput, toolUseContext, assistantMessage, toolUseID),
input: hookInput,
}
}
if (hookPermissionResult?.behavior === 'deny') {
return { decision: hookPermissionResult, input }
}
// 没有 Hook 决策或 Hook 返回 'ask' -- 走正常权限流程
return {
decision: await canUseTool(tool, input, toolUseContext, assistantMessage, toolUseID),
input,
}
}
这里有一个重要的安全不变量:Hook 的 allow 不能绕过 settings.json 中的 deny 或 ask 规则 。也就是说,即使一个 PreToolUse Hook 返回了 allow,如果用户在配置文件中明确 deny 了某个工具,deny 规则仍然会生效。这是一个经过深思熟虑的分层安全设计------用户的显式安全策略始终高于自动化 Hook 的决策。
第四阶段:工具实际执行
权限检查通过后,进入真正的工具执行阶段:
typescript
// 源码位置: src/services/tools/toolExecution.ts
startSessionActivity('tool_exec')
try {
const result = await tool.call(
callInput,
{
...toolUseContext,
toolUseId: toolUseID,
userModified: permissionDecision.userModified ?? false,
},
canUseTool,
assistantMessage,
progress => {
onToolProgress({
toolUseID: progress.toolUseID,
data: progress.data,
})
},
)
const durationMs = Date.now() - startTime
addToToolDuration(durationMs)
// ... 结果处理
} catch (error) {
// ... 错误处理
}
tool.call() 是每个工具的核心方法,接收经过校验和 Hook 处理后的输入,以及完整的上下文对象。进度回调函数允许长时间运行的工具(如 Bash 命令)实时报告执行状态。
第五阶段:Post-Hooks 与结果收集
工具执行完毕后(无论成功还是失败),进入 Post-Hook 阶段。成功时执行 runPostToolUseHooks(),失败时执行 runPostToolUseFailureHooks():
typescript
// 源码位置: src/services/tools/toolHooks.ts
export async function* runPostToolUseHooks<Input extends AnyObject, Output>(
toolUseContext: ToolUseContext,
tool: Tool<Input, Output>,
toolUseID: string,
messageId: string,
toolInput: Record<string, unknown>,
toolResponse: Output,
// ...
): AsyncGenerator<PostToolUseHooksResult<Output>> {
for await (const result of executePostToolHooks(
tool.name, toolUseID, toolInput, toolOutput,
toolUseContext, permissionMode, toolUseContext.abortController.signal,
)) {
// 处理 Hook 的各种返回
if (result.blockingError) {
yield { message: createAttachmentMessage({
type: 'hook_blocking_error',
hookName: `PostToolUse:${tool.name}`,
toolUseID, hookEvent: 'PostToolUse',
blockingError: result.blockingError,
}) }
}
if (result.preventContinuation) {
yield { message: createAttachmentMessage({
type: 'hook_stopped_continuation',
message: result.stopReason || 'Execution stopped by PostToolUse hook',
hookName: `PostToolUse:${tool.name}`,
toolUseID, hookEvent: 'PostToolUse',
}) }
return
}
if (result.updatedMCPToolOutput && isMcpTool(tool)) {
toolOutput = result.updatedMCPToolOutput as Output
yield { updatedMCPToolOutput: toolOutput }
}
}
}
Post-Hook 可以实现多种后处理逻辑:
- 阻止继续 (
preventContinuation):告诉查询引擎不要让模型继续迭代 - 修改 MCP 工具输出 (
updatedMCPToolOutput):在结果返回给模型之前对其进行转换 - 注入附加上下文 (
additionalContexts):向消息流中插入额外信息 - 记录阻断错误 (
blockingError):标记 Hook 发现的问题
管道架构图
lua
ToolUseBlock (来自 API 响应)
|
v
+-------------------+
| Zod 输入校验 | -- 类型级校验,失败则回传错误给模型
+-------------------+
|
v
+-------------------+
| validateInput() | -- 工具特定的业务校验
+-------------------+
|
v
+-------------------+
| runPreToolUseHooks| -- 用户自定义 Pre-Hooks
+-------------------+ -- 可修改输入、控制权限、阻止执行
|
+--------+--------+
| |
Hook allow Hook deny/stop
| |
v v
+----------------+ 返回错误消息
| resolveHookPerm|
| issionDecision | -- 协调 Hook/规则/用户交互
+----------------+
|
+----+----+
| |
allow deny
| |
v v
+-----------+ 返回拒绝消息
| tool.call |
+-----------+
|
+----+----+
| |
成功 失败
| |
v v
+-----------+ +-------------------+
|PostToolUse| |PostToolUseFailure |
| Hooks | | Hooks |
+-----------+ +-------------------+
| |
v v
结果收集,产出 MessageUpdate
7.4 并发执行引擎
runToolsConcurrently 与 all() 调度器
当一个批次被标记为 isConcurrencySafe 时,系统使用 runToolsConcurrently() 并发执行其中的所有工具:
typescript
// 源码位置: src/services/tools/toolOrchestration.ts
async function* runToolsConcurrently(
toolUseMessages: ToolUseBlock[],
assistantMessages: AssistantMessage[],
canUseTool: CanUseToolFn,
toolUseContext: ToolUseContext,
): AsyncGenerator<MessageUpdateLazy, void> {
yield* all(
toolUseMessages.map(async function* (toolUse) {
toolUseContext.setInProgressToolUseIDs(prev =>
new Set(prev).add(toolUse.id),
)
yield* runToolUse(
toolUse,
assistantMessages.find(_ =>
_.message.content.some(
_ => _.type === 'tool_use' && _.id === toolUse.id,
),
)!,
canUseTool,
toolUseContext,
)
markToolUseAsComplete(toolUseContext, toolUse.id)
}),
getMaxToolUseConcurrency(),
)
}
核心是 all() 函数------一个带并发上限的 async generator 调度器,定义在 src/utils/generators.ts 中:
typescript
// 源码位置: src/utils/generators.ts
export async function* all<A>(
generators: AsyncGenerator<A, void>[],
concurrencyCap = Infinity,
): AsyncGenerator<A, void> {
const next = (generator: AsyncGenerator<A, void>) => {
const promise: Promise<QueuedGenerator<A>> = generator
.next()
.then(({ done, value }) => ({ done, value, generator, promise }))
return promise
}
const waiting = [...generators]
const promises = new Set<Promise<QueuedGenerator<A>>>()
// 启动初始批次,直到达到并发上限
while (promises.size < concurrencyCap && waiting.length > 0) {
const gen = waiting.shift()!
promises.add(next(gen))
}
while (promises.size > 0) {
const { done, value, generator, promise } = await Promise.race(promises)
promises.delete(promise)
if (!done) {
promises.add(next(generator))
if (value !== undefined) {
yield value
}
} else if (waiting.length > 0) {
// 一个 generator 结束,从等待队列中启动下一个
const nextGen = waiting.shift()!
promises.add(next(nextGen))
}
}
}
all() 的设计极为精巧。它维护一个 promises 集合(活跃的并发任务)和一个 waiting 数组(等待启动的任务)。每次用 Promise.race() 等待任意一个任务产出值。当某个任务产出一个值时,立即 yield 出去并继续等待该任务的下一个值。当某个任务结束时,从等待队列中取出下一个任务填补空位。
并发上限通过环境变量 CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY 配置,默认为 10:
typescript
// 源码位置: src/services/tools/toolOrchestration.ts
function getMaxToolUseConcurrency(): number {
return (
parseInt(process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY || '', 10) || 10
)
}
runToolsSerially 的串行保证
对于写操作批次,系统使用 runToolsSerially() 确保严格的顺序执行:
typescript
// 源码位置: src/services/tools/toolOrchestration.ts
async function* runToolsSerially(
toolUseMessages: ToolUseBlock[],
assistantMessages: AssistantMessage[],
canUseTool: CanUseToolFn,
toolUseContext: ToolUseContext,
): AsyncGenerator<MessageUpdate, void> {
let currentContext = toolUseContext
for (const toolUse of toolUseMessages) {
toolUseContext.setInProgressToolUseIDs(prev =>
new Set(prev).add(toolUse.id),
)
for await (const update of runToolUse(toolUse, /* ... */, currentContext)) {
if (update.contextModifier) {
currentContext = update.contextModifier.modifyContext(currentContext)
}
yield { message: update.message, newContext: currentContext }
}
markToolUseAsComplete(toolUseContext, toolUse.id)
}
}
串行执行与并发执行在上下文管理上有本质区别:串行路径中,contextModifier 在收到时立即应用,后续工具能看到前面工具对上下文的修改。这对写操作至关重要------比如一个 Edit 操作修改了文件,后续的 Read 操作需要看到修改后的文件状态缓存。
StreamingToolExecutor:流式并发
除了 runTools() 的批量模式,Claude Code 还实现了 StreamingToolExecutor,它在 API 响应流式传入时就开始执行工具,不需要等待所有工具调用解析完毕:
typescript
// 源码位置: src/services/tools/StreamingToolExecutor.ts
export class StreamingToolExecutor {
private tools: TrackedTool[] = []
private hasErrored = false
private siblingAbortController: AbortController
addTool(block: ToolUseBlock, assistantMessage: AssistantMessage): void {
// 解析并发安全性
const isConcurrencySafe = /* ... */
this.tools.push({
id: block.id, block, assistantMessage,
status: 'queued', isConcurrencySafe, pendingProgress: [],
})
void this.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))
)
}
}
StreamingToolExecutor 的并发控制规则与 partitionToolCalls 一致,但采用了事件驱动模型而非批次模型。每当一个新的工具调用被添加到队列时,processQueue() 方法检查是否可以立即启动执行。判定条件是:没有工具在执行,或者新工具和所有正在执行的工具都是并发安全的。
这种流式模式有一个重要的优化:如果 API 先返回三个 Read 调用,再返回一个 Edit 调用,前三个 Read 在流式解析到 Edit 之前就已经开始并行执行了,而不需要等待 API 响应完全结束。
StreamingToolExecutor 还实现了精细的进度管理。每个工具维护一个 pendingProgress 队列,进度消息(如 Bash 工具的标准输出实时流)被立即分发给 UI 层,而不需要等待工具完成。这是通过一个基于 Promise 的唤醒机制实现的:当新的进度消息到达时,resolve 一个 progressAvailableResolve Promise 来唤醒正在等待的 getRemainingResults() 循环。
typescript
// 源码位置: src/services/tools/StreamingToolExecutor.ts
if (update.message.type === 'progress') {
tool.pendingProgress.push(update.message)
if (this.progressAvailableResolve) {
this.progressAvailableResolve()
this.progressAvailableResolve = undefined
}
}
结果产出顺序也是精心设计的。getCompletedResults() 遍历工具列表时保持了添加顺序:对于并发安全的工具,谁先完成谁先产出;但当遇到一个未完成的非并发安全工具时会停止遍历(break),确保该工具之后的所有工具的结果不会提前泄露。这保证了即使在流式并发模式下,结果的因果顺序也是正确的。
StreamingToolExecutor 还支持一个 discard() 方法,当流式传输失败需要回退到非流式模式时,调用此方法会丢弃所有已收集的结果和待执行的任务。被丢弃的正在执行的工具会收到合成的错误消息 "Streaming fallback - tool execution discarded",通知模型这些工具调用需要在新的上下文中重新发起。
7.5 超时与错误处理
错误传播与兄弟取消
在并发执行中,一个工具的失败需要正确地传播给其兄弟工具。StreamingToolExecutor 实现了一套兄弟取消机制:
typescript
// 源码位置: src/services/tools/StreamingToolExecutor.ts
if (isErrorResult) {
thisToolErrored = true
// 只有 Bash 错误才取消兄弟任务
if (tool.block.name === BASH_TOOL_NAME) {
this.hasErrored = true
this.erroredToolDescription = this.getToolDescription(tool)
this.siblingAbortController.abort('sibling_error')
}
}
这个设计做了一个关键区分:只有 Bash 工具的错误才会取消兄弟任务 。这是因为 Bash 命令之间往往有隐式的依赖链(例如 mkdir 失败后,后续向该目录写文件的命令毫无意义),而 Read、WebFetch 等工具之间通常是独立的------一个文件读取失败不应该影响其他文件的读取。
兄弟取消使用了单独的 siblingAbortController,它是 toolUseContext.abortController 的子控制器:
typescript
// 源码位置: src/services/tools/StreamingToolExecutor.ts
this.siblingAbortController = createChildAbortController(
toolUseContext.abortController,
)
这种层级化的 AbortController 设计确保了兄弟取消不会冒泡到查询层------工具执行失败应该让模型看到错误并决定下一步,而不是终止整个查询循环。
但在权限对话框拒绝的场景中,中止信号需要向上传播。StreamingToolExecutor 为每个工具创建的子 AbortController 监听了中止事件:如果中止原因不是 'sibling_error'(即不是来自兄弟任务),就将中止传播给父控制器。这解决了一个微妙的交互问题:用户在权限对话框中拒绝了某个操作,这个拒绝应该终止当前的整个查询轮次,而不仅仅是当前工具。
这种 AbortController 的层级化设计是 Claude Code 中一个反复出现的模式。从查询层的顶级控制器,到编排层的兄弟控制器,再到工具层的执行控制器,形成了一棵控制器树。中止信号沿着这棵树向下传播是自动的(子控制器继承父控制器的中止),向上传播则是有条件的(只在特定场景下冒泡)。
中止信号的分类处理
StreamingToolExecutor 区分了三种中止原因:
typescript
// 源码位置: src/services/tools/StreamingToolExecutor.ts
private getAbortReason(
tool: TrackedTool,
): 'sibling_error' | 'user_interrupted' | 'streaming_fallback' | null {
if (this.discarded) return 'streaming_fallback'
if (this.hasErrored) return 'sibling_error'
if (this.toolUseContext.abortController.signal.aborted) {
if (this.toolUseContext.abortController.signal.reason === 'interrupt') {
return this.getToolInterruptBehavior(tool) === 'cancel'
? 'user_interrupted'
: null
}
return 'user_interrupted'
}
return null
}
每种原因生成不同的合成错误消息:
sibling_error:"Cancelled: parallel tool call Bash(mkdir /tmp/test) errored"user_interrupted:用户发送了新消息,使用标准拒绝消息streaming_fallback:流式传输失败,正在回退到非流式模式
错误格式化
工具执行的错误通过 formatError() 函数规范化后回传给模型:
typescript
// 源码位置: src/utils/toolErrors.ts
export function formatError(error: unknown): string {
if (error instanceof AbortError) {
return error.message || INTERRUPT_MESSAGE_FOR_TOOL_USE
}
if (!(error instanceof Error)) {
return String(error)
}
const parts = getErrorParts(error)
const fullMessage =
parts.filter(Boolean).join('\n').trim() || 'Command failed with no output'
// 超过 10000 字符时截断中间部分
if (fullMessage.length <= 10000) {
return fullMessage
}
const halfLength = 5000
const start = fullMessage.slice(0, halfLength)
const end = fullMessage.slice(-halfLength)
return `${start}\n\n... [${fullMessage.length - 10000} characters truncated] ...\n\n${end}`
}
错误截断使用了"保留头尾、截断中间"的策略。这不是任意选择------错误消息的开头通常包含错误类型和原因,结尾通常包含最相关的上下文(如最后几行编译错误),中间的堆栈跟踪和冗余信息对模型决策帮助不大。
错误分类与遥测
系统还实现了错误分类,将运行时错误映射为遥测安全的字符串:
typescript
// 源码位置: src/services/tools/toolExecution.ts
export function classifyToolError(error: unknown): string {
if (error instanceof TelemetrySafeError_...) {
return error.telemetryMessage.slice(0, 200)
}
if (error instanceof Error) {
const errnoCode = getErrnoCode(error)
if (typeof errnoCode === 'string') {
return `Error:${errnoCode}` // 如 "Error:ENOENT", "Error:EACCES"
}
if (error.name && error.name !== 'Error' && error.name.length > 3) {
return error.name.slice(0, 60)
}
return 'Error'
}
return 'UnknownError'
}
注意 error.name.length > 3 这个检查。注释解释了原因:在构建产物中,构造函数名可能被代码压缩器缩短为 3 个字符的无意义标识符(如 nJT),这种情况下应该回退到通用的 'Error' 而不是记录一个无法解读的短名称。
7.6 文件状态追踪
下图展示了 FileStateCache(读取缓存)和 FileHistory(修改历史)在工具执行过程中的协作关系,以及它们如何支撑文件操作的一致性保证:
FileStateCache:读取缓存
工具编排过程中,模型频繁读取文件是常见的模式------先搜索找到文件,再读取内容,可能还会重复读取同一文件的不同部分。为避免冗余 IO,系统维护了一个文件状态缓存 FileStateCache:
typescript
// 源码位置: src/utils/fileStateCache.ts
export type FileState = {
content: string
timestamp: number
offset: number | undefined
limit: number | undefined
isPartialView?: boolean // 自动注入时内容可能不完整
}
export class FileStateCache {
private cache: LRUCache<string, FileState>
constructor(maxEntries: number, maxSizeBytes: number) {
this.cache = new LRUCache<string, FileState>({
max: maxEntries,
maxSize: maxSizeBytes,
sizeCalculation: value => Math.max(1, Buffer.byteLength(value.content)),
})
}
get(key: string): FileState | undefined {
return this.cache.get(normalize(key))
}
set(key: string, value: FileState): this {
this.cache.set(normalize(key), value)
return this
}
}
FileStateCache 有几个关键的设计决策:
基于 LRU 的双重限制。缓存同时受到条目数上限(默认 100)和总大小上限(默认 25MB)的约束。这防止了大文件导致的内存膨胀------一个 5MB 的 JSON 文件可能就会挤掉其他 20 个小文件。
路径规范化 。所有缓存键在存取时都经过 normalize() 处理,确保 /foo/../bar/file.ts 和 /bar/file.ts 命中同一个缓存条目。
部分视图标记 。isPartialView 字段标记了通过自动注入(如 CLAUDE.md)进入缓存的条目,这些条目可能经过了 HTML 注释剥离、frontmatter 剥离或截断处理。当模型尝试 Edit 或 Write 这样的文件时,系统会要求先进行显式 Read 以获取完整内容。
缓存作为 ToolUseContext 的一部分在整个工具编排过程中传递:
typescript
// 源码位置: src/Tool.ts
export type ToolUseContext = {
readFileState: FileStateCache
// ...
}
工厂函数允许创建带有自定义大小限制的缓存实例:
typescript
// 源码位置: src/utils/fileStateCache.ts
export function createFileStateCacheWithSizeLimit(
maxEntries: number,
maxSizeBytes: number = DEFAULT_MAX_CACHE_SIZE_BYTES,
): FileStateCache {
return new FileStateCache(maxEntries, maxSizeBytes)
}
缓存还支持克隆和合并操作,这在子 Agent 场景中非常有用------子 Agent 可以从父 Agent 克隆缓存起始状态,任务完成后将两个缓存按时间戳合并:
typescript
// 源码位置: src/utils/fileStateCache.ts
export function mergeFileStateCaches(
first: FileStateCache, second: FileStateCache,
): FileStateCache {
const merged = cloneFileStateCache(first)
for (const [filePath, fileState] of second.entries()) {
const existing = merged.get(filePath)
if (!existing || fileState.timestamp > existing.timestamp) {
merged.set(filePath, fileState)
}
}
return merged
}
FileHistory:修改历史追踪
文件读取有缓存优化,文件写入则需要历史追踪。FileHistory 系统在每次写操作前创建文件备份,支持用户回退到任意快照点:
typescript
// 源码位置: src/utils/fileHistory.ts
export type FileHistoryState = {
snapshots: FileHistorySnapshot[]
trackedFiles: Set<string>
snapshotSequence: number // 单调递增,用于 UI 活动检测
}
export type FileHistorySnapshot = {
messageId: UUID
trackedFileBackups: Record<string, FileHistoryBackup>
timestamp: Date
}
写操作追踪通过 fileHistoryTrackEdit() 在工具实际修改文件之前调用:
typescript
// 源码位置: src/utils/fileHistory.ts
export async function fileHistoryTrackEdit(
updateFileHistoryState: (updater: (prev: FileHistoryState) => FileHistoryState) => void,
filePath: string,
messageId: UUID,
): Promise<void> {
if (!fileHistoryEnabled()) return
// Phase 1: 检查是否需要备份(避免重复备份)
let captured: FileHistoryState | undefined
updateFileHistoryState(state => { captured = state; return state })
const mostRecent = captured.snapshots.at(-1)
if (mostRecent?.trackedFileBackups[trackingPath]) return
// Phase 2: 异步备份当前文件内容
let backup: FileHistoryBackup
try {
backup = await createBackup(filePath, 1)
} catch (error) {
logError(error); return
}
// Phase 3: 将备份信息写入状态
updateFileHistoryState((state: FileHistoryState) => {
// ... 更新快照中的备份记录
})
}
这个三阶段设计(检查 -> 备份 -> 提交)解决了一个微妙的并发问题:多个写操作可能同时触发追踪,Phase 1 的预检查避免了重复备份导致的数据损坏------第二次 trackEdit 会发现文件已经被追踪,直接跳过。
快照系统还实现了容量限制和 diff 计算:
typescript
// 源码位置: src/utils/fileHistory.ts
const MAX_SNAPSHOTS = 100
export type DiffStats = {
filesChanged?: string[]
insertions: number
deletions: number
} | undefined
每个消息轮次结束时,系统调用 fileHistoryMakeSnapshot() 为所有被追踪的文件创建一个新快照,并对修改过的文件进行增量备份。
fileHistoryEnabled() 函数根据运行模式决定是否启用:
typescript
// 源码位置: src/utils/fileHistory.ts
export function fileHistoryEnabled(): boolean {
if (getIsNonInteractiveSession()) {
return fileHistoryEnabledSdk() // SDK 模式需要显式开启
}
return (
getGlobalConfig().fileCheckpointingEnabled !== false &&
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING)
)
}
交互式会话默认开启文件检查点,SDK/非交互式会话需要通过环境变量 CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING 显式启用。这是合理的------交互式用户需要 undo 能力,而 CI/CD 场景中自动化脚本通常有自己的版本控制。
FileStateCache 与 FileHistory 的协作
这两个子系统分别服务于读和写两条路径,但它们之间存在隐含的协作关系。当一个文件被 Read 工具读取后,其内容进入 FileStateCache。当后续的 Edit 工具修改该文件时,FileHistory 会在修改前备份当前内容。如果用户触发回退,系统可以将文件恢复到备份版本。此时 FileStateCache 中的缓存条目变得过时,但这不会造成问题------因为下次读取时,工具会从磁盘重新加载并更新缓存。
这种设计遵循了一个原则:缓存可以过时,但不可以错误 。FileStateCache 是一个软状态(soft state),它的条目可以在任何时刻被驱逐或过期,系统的正确性不依赖于缓存的新鲜度。相比之下,FileHistory 是一个硬状态(hard state),它的备份必须是准确的,因为用户回退操作依赖于备份的完整性。这就是为什么 fileHistoryTrackEdit() 使用了三阶段的竞态安全协议,而 FileStateCache 只是一个简单的 LRU 缓存。
7.7 Pre/Post Hooks 详解
Hook 系统的架构
Claude Code 的 Hook 系统允许用户配置自定义的 shell 命令或回调函数,在工具执行的关键节点自动运行。Hook 的来源有两种:一种是用户通过 settings.json 配置的 shell 命令 Hook,另一种是系统内部通过 registerHookCallbacks() 注册的程序化 Hook(如会话文件访问追踪、遥测记录等)。
Hook 系统支持以下事件类型,形成了一个完整的工具生命周期覆盖:
- PreToolUse: 工具执行前触发,可修改输入、控制权限、阻止执行
- PostToolUse: 工具成功执行后触发,可记录日志、修改输出、注入上下文
- PostToolUseFailure: 工具执行失败后触发,可进行错误分析和清理
- PermissionDenied: 权限被拒绝后触发,可实现自动重试等高级策略
每个 Hook 可以通过 matcher 指定它监听的工具名称,实现精确的事件过滤。例如,一个只关心 Bash 工具的 Hook 可以设置 matcher: 'Bash',不会在 Read 或 Grep 执行时被触发。
runPreToolUseHooks 的实现
Pre-Hook 是整个 Hook 系统中最复杂的部分,因为它需要处理多种控制流:
typescript
// 源码位置: src/services/tools/toolHooks.ts
export async function* runPreToolUseHooks(
toolUseContext: ToolUseContext,
tool: Tool,
processedInput: Record<string, unknown>,
toolUseID: string,
// ...
): AsyncGenerator<
| { type: 'message'; message: MessageUpdateLazy<...> }
| { type: 'hookPermissionResult'; hookPermissionResult: PermissionResult }
| { type: 'hookUpdatedInput'; updatedInput: Record<string, unknown> }
| { type: 'preventContinuation'; shouldPreventContinuation: boolean }
| { type: 'stopReason'; stopReason: string }
| { type: 'additionalContext'; message: MessageUpdateLazy<AttachmentMessage> }
| { type: 'stop' }
> {
for await (const result of executePreToolHooks(
tool.name, toolUseID, processedInput, toolUseContext,
appState.toolPermissionContext.mode,
toolUseContext.abortController.signal,
undefined, // timeoutMs - 使用默认值
toolUseContext.requestPrompt,
tool.getToolUseSummary?.(processedInput),
)) {
// 处理各种 Hook 返回值...
}
}
Pre-Hook 的返回类型是一个七元素的 tagged union,每种标签代表 Hook 对工具执行流程的不同干预方式。这种设计使得 Hook 的行为完全声明式------Hook 不直接操作执行流程,而是产出"意图",由编排层统一解释。
Hook 权限语义的关键不变量
Pre-Hook 可以返回 permissionBehavior 来影响权限决策。系统根据三种行为产出不同的 hookPermissionResult:
typescript
// 源码位置: src/services/tools/toolHooks.ts
if (result.permissionBehavior === 'allow') {
yield {
type: 'hookPermissionResult',
hookPermissionResult: {
behavior: 'allow',
updatedInput: result.updatedInput,
decisionReason,
},
}
} else if (result.permissionBehavior === 'ask') {
yield {
type: 'hookPermissionResult',
hookPermissionResult: {
behavior: 'ask',
updatedInput: result.updatedInput,
message: result.hookPermissionDecisionReason || /* 默认消息 */,
decisionReason,
},
}
} else {
// deny - updatedInput 无关紧要,因为工具不会执行
yield {
type: 'hookPermissionResult',
hookPermissionResult: {
behavior: result.permissionBehavior,
message: result.hookPermissionDecisionReason || /* 默认消息 */,
decisionReason,
},
}
}
注意 allow 和 ask 都可以附带 updatedInput。对于 allow,updatedInput 代表 Hook 对输入的修改(例如路径重写)。对于 ask,updatedInput 会被传递到交互式权限对话框,让用户看到 Hook 修改后的输入进行决策。
中止信号感知
Hook 执行过程中需要感知中止信号。如果用户在 Hook 运行期间取消了操作,系统会产出一个取消消息并停止执行:
typescript
// 源码位置: src/services/tools/toolHooks.ts
if (toolUseContext.abortController.signal.aborted) {
logEvent('tengu_pre_tool_hooks_cancelled', { /* ... */ })
yield {
type: 'message',
message: { message: createAttachmentMessage({
type: 'hook_cancelled',
hookName: `PreToolUse:${tool.name}`,
toolUseID, hookEvent: 'PreToolUse',
}) },
}
yield { type: 'stop' }
return
}
runPostToolUseHooks 的错误隔离
Post-Hook 的错误处理采用了双层隔离策略:
typescript
// 源码位置: src/services/tools/toolHooks.ts
export async function* runPostToolUseHooks<Input extends AnyObject, Output>(
// ...
): AsyncGenerator<PostToolUseHooksResult<Output>> {
try {
for await (const result of executePostToolHooks(/* ... */)) {
try {
// 处理单个 Hook 结果...
} catch (error) {
// 内层 catch: 单个 Hook 失败,记录错误并继续
yield { message: createAttachmentMessage({
type: 'hook_error_during_execution',
content: formatError(error),
hookName: `PostToolUse:${tool.name}`,
toolUseID, hookEvent: 'PostToolUse',
}) }
}
}
} catch (error) {
// 外层 catch: Hook 基础设施失败,静默记录
logError(error)
}
}
内层 try-catch 捕获单个 Hook 的执行错误------一个 Hook 失败不应该阻止其他 Hook 的执行,也不应该影响工具结果的正常返回。外层 try-catch 捕获 Hook 基础设施本身的错误(如无法加载 Hook 配置),这种情况下静默失败是合理的,因为 Hook 是可选的增强功能,不应成为核心流程的单点故障。
这种双层错误隔离在 runPostToolUseFailureHooks() 中同样适用,遵循完全相同的模式。这种一致性不是巧合------工具 Hook 子系统采用了统一的错误处理策略:Hook 失败永远不应导致工具执行管道崩溃。即使所有 Hook 都失败了,工具的执行结果仍然应该正确地传递给模型。
Hook 的超时管理
Hook 作为用户自定义的 shell 命令,其执行时间不可预测。系统为 Hook 设置了默认超时,并在超时时产出取消消息。同时,Hook 的执行时间会被追踪和上报:
typescript
// 源码位置: src/services/tools/toolExecution.ts
const preToolHookDurationMs = Date.now() - preToolHookStart
getStatsStore()?.observe('pre_tool_hook_duration_ms', preToolHookDurationMs)
if (preToolHookDurationMs >= SLOW_PHASE_LOG_THRESHOLD_MS) {
logForDebugging(
`Slow PreToolUse hooks: ${preToolHookDurationMs}ms for ${tool.name}`,
)
}
当 Pre-Hook 总耗时超过 500 毫秒(HOOK_TIMING_DISPLAY_THRESHOLD_MS)时,系统会在 UI 中显示一个时间摘要。超过 2 秒(SLOW_PHASE_LOG_THRESHOLD_MS)时会记录调试日志。这些阈值经过了用户体验测试------500 毫秒是人类感知到"稍有延迟"的临界点,2 秒则意味着 Hook 执行有性能问题需要排查。
7.8 设计决策
为什么选择"读并发、写串行"而不是全部串行?
一个保守的设计方案是所有工具全部串行执行,完全避免并发问题。Claude Code 没有这样做,原因在于模型的使用模式。在典型的代码搜索场景中,模型会同时发起 3-5 个 Grep 或 Read 调用来查找信息。如果这些纯读操作串行执行,用户需要等待 3-5 倍的时间,而它们之间没有任何数据依赖。
"读并发、写串行"的策略在安全性和性能之间找到了精确的平衡点:读操作天然幂等且无副作用,并行执行不会产生任何一致性问题;写操作可能相互依赖(先 mkdir 再 write),必须保证执行顺序。
为什么上下文修改器在并发批次后才应用?
并发执行中,上下文修改器(contextModifier)被缓存起来,等批次结束后才按顺序应用。这个设计源于一个实际问题:如果在并发执行过程中立即应用上下文修改,由于执行顺序的不确定性,最终的上下文状态也是不确定的。延迟到批次结束后按工具的原始顺序应用,保证了上下文状态的确定性------无论工具的实际完成顺序如何,最终的上下文总是相同的。
为什么只有 Bash 错误才触发兄弟取消?
StreamingToolExecutor 中只有 Bash 工具的错误会取消兄弟任务,其他工具的错误不会。这看似不对称,但背后有清晰的工程理由:Bash 命令之间有隐式依赖链(mkdir /tmp/build && cp src/* /tmp/build/),一个命令失败意味着后续命令大概率也会失败。而 Read、Grep、WebFetch 等工具之间通常完全独立------读取 A 文件失败不影响读取 B 文件。不必要的兄弟取消只会让模型收到更多错误信息,增加修复成本。
为什么 Hook 的 allow 不能绕过 deny 规则?
resolveHookPermissionDecision() 的核心不变量是:Hook 的 allow 不能覆盖 settings.json 中的 deny 规则。这实现了一种分层安全模型:
- 配置层 (settings.json):代表用户的显式安全策略,优先级最高
- Hook 层 (PreToolUse):代表自动化决策,可以简化日常操作但不能降低安全底线
- 交互层 (权限对话框):代表运行时判断,用户可以逐次审批
这种设计确保了即使 Hook 被恶意利用(如一个恶意 MCP 服务器注册了一个自动批准的 Hook),用户的 deny 规则仍然是不可突破的安全防线。
7.9 小结
本章深入分析了 Claude Code 工具编排层的完整实现。这个层解决的核心问题是:在保证安全性和一致性的前提下,如何最大化工具执行的并行度。
runTools() async generator 作为编排入口,通过 partitionToolCalls() 将工具调用分为并发批次和串行批次。并发批次使用 all() 调度器在受限并发度下并行执行,串行批次保证严格的顺序执行和上下文一致性。每个工具调用经过六阶段管道:Zod 校验、业务校验、Pre-Hook、权限解析、实际执行、Post-Hook。Hook 系统提供了声明式的扩展点,但受到配置层安全策略的严格约束。文件状态缓存和修改历史追踪确保了高效的文件操作和可靠的回退能力。
回顾工具编排层的核心数据流,可以用一张全局架构图来总结:
scss
API 响应
|
v
+-------------------+ +-------------------------+
| query.ts | | StreamingToolExecutor |
| 提取 tool_use 块 | OR | 流式模式:边解析边执行 |
+-------------------+ +-------------------------+
| |
v v
+--------------------------------------------------+
| runTools() / processQueue() |
| (toolOrchestration.ts 编排入口) |
+--------------------------------------------------+
|
v
+--------------------------------------------------+
| partitionToolCalls() 批次分区 |
| [Read,Grep,Glob] -> 并发 | [Edit] -> 串行 |
+--------------------------------------------------+
| |
v v
+------------------+ +--------------------+
| runToolsConcur- | | runToolsSerially() |
| rently() | | 逐个执行,立即 |
| all() 调度器 | | 应用上下文修改 |
| 并发上限 10 | +--------------------+
+------------------+
| |
v v
+--------------------------------------------------+
| runToolUse() 单工具管道 |
| Zod校验 -> validateInput -> Pre-Hook |
| -> resolvePermission -> tool.call() |
| -> Post-Hook -> 结果映射 |
+--------------------------------------------------+
|
v
MessageUpdate 流式产出给查询引擎
这些设计模式------读并发写串行的分区策略、带并发上限的 generator 调度器、分层安全模型、延迟上下文应用------不仅适用于 AI Agent 的工具编排,也适用于任何需要管理异构操作并发执行的系统。数据库的事务调度器、CI/CD 的任务编排器、微服务的请求路由器,都面临着类似的"在安全性和性能之间寻找平衡"的核心挑战。Claude Code 的编排层提供了一套经过生产验证的解决方案,其核心思想是:通过静态声明让系统做出保守但正确的并发决策,通过 async generator 让执行过程可观测、可组合、可取消。
在下一章中,我们将深入各个核心工具的具体实现,看看 Bash、FileEdit、Grep 等工具是如何利用本章描述的编排框架来完成各自的任务的。