5 · 工具执行

这一章讲:模型给出一批工具调用之后,程序是怎么把它们跑完的。

5.1 执行链路总览

模型返回若干个 tool_use(工具调用) │ ▼ 【编排层】toolOrchestration.ts · 189 行 partitionToolCalls() 贪心分区:相邻的安全工具合并成并行批 │ ├─ 并行批 → runToolsConcurrently() 最多 10 个同时跑 └─ 串行批 → runToolsSerially() 一个一个来 │ ▼ 【单次执行】toolExecution.ts · runToolUse() ① 按名字找工具(支持别名,用于已改名的老工具) ② 工具不存在 → 造一条错误结果返回 ③ Zod 参数格式校验 → 失败则返回 InputValidationError ④ tool.validateInput() → 工具自己的参数合法性检查 ⑤ 投机性地提前启动 bash 分类器(和下面的步骤并行跑) ⑥ PreToolUse 钩子 ⑦ canUseTool() 权限判定 → 第 7 章 ⑧ tool.call() 真正执行 ⑨ PostToolUse / PostToolUseFailure 钩子 ⑩ 结果超限则落盘 → 第 6 章 ⑪ mapToolResultToToolResultBlockParam() 序列化成回传格式

5.2 并发分区:贪心算法

function partitionToolCalls(toolUseMessages, 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 {
            // 如果判定函数抛异常(比如 shell 引号解析失败),
            // 保守地当成"不安全"
            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
  }, [])
}

claude-code/src/services/tools/toolOrchestration.ts

执行效果:

模型返回的 6 个调用(按模型给出的顺序,不能打乱): Read(a.ts) Read(b.ts) Grep("foo") Edit(a.ts) Read(c.ts) Bash("npm test") └──── 只读,并发安全 ────┘ └ 写,不安全 ┘ └ 安全 ┘ └── 不安全 ──┘ 分区结果: 批次 1【并行】Read(a.ts) + Read(b.ts) + Grep("foo") ← 三个同时跑 批次 2【串行】Edit(a.ts) ← 单独跑 批次 3【并行】Read(c.ts) ← 只有一个,也算一批 批次 4【串行】Bash("npm test") ← 单独跑
为什么是「贪心分区」而不是「全排序」

一种直觉的做法是:把所有安全的挑出来一起并行,不安全的最后串行。但那样会打乱模型隐含的顺序语义

上面这个例子里,Read(c.ts) 排在 Edit(a.ts) 后面。如果把它提到前面和批次 1 合并,就变成了「先读 c 再改 a」—— 万一模型的意图是「改完 a 之后读 c 来验证」,逻辑就错了。

贪心分区只合并「相邻」的安全工具,从不跨越不安全的边界。顺序语义完整保留。

5.3 上下文修改要排队到批次结束

有些工具会修改共享的上下文对象(比如 EnterPlanMode 会切换权限模式)。并行批里如果每个工具立刻改,就有竞态。

if (isConcurrencySafe) {
  const queuedContextModifiers: Record<string, ((ctx) => ToolUseContext)[]> = {}

  for await (const update of runToolsConcurrently(blocks, ...)) {
    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 }
}

Tool.ts 里有一条对应的兜底约束:

「contextModifier is only honored for tools that aren't concurrency safe.」
译:只有声明自己「不是并发安全」的工具,它的上下文修改才会被采纳。

这是一条很干脆的规则:要改共享上下文的工具,就别声明自己并发安全。两者不可兼得,在接口层面直接堵死,而不是留到运行时靠排队去缓解。上面那段排队逻辑是双保险

5.4 单次执行:runToolUse 的完整流程

第 ① 步:按名字找工具,支持别名

// 先在"模型能看到的工具"里找
let tool = findToolByName(toolUseContext.options.tools, toolName)

// 找不到 → 检查是不是一个已废弃的名字(老对话记录里可能还在用旧名)
// 例如老记录里调用 "KillShell",而它现在是 "TaskStop" 的别名
// 只有当名字匹配的是"别名"而不是"主名"时才回退

这个设计解决的问题是:工具改名之后,用户用 --resume 恢复的旧对话里还有旧名字的调用记录。如果直接报「工具不存在」,那条历史消息就永远无法被正确处理。

第 ③ 步:参数格式校验,附带一句诚实的注释

// Validate input types with zod
// (surprisingly, the model is not great at generating valid input)
const parsedInput = tool.inputSchema.safeParse(input)
if (!parsedInput.success) {
  let errorContent = formatZodValidationError(tool.name, parsedInput.error)
  const schemaHint = buildSchemaNotSentHint(tool, ...)   // 见第 4.5 节
  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 }],
    ... }) }]
}

括号里那句 「surprisingly, the model is not great at generating valid input」(意外的是,模型并不太擅长生成合法参数)—— 这是源码里少见的直白吐槽,但它说明了一个重要事实:即使是最强的模型,工具参数也需要严格校验,不能信任。

注意错误结果的形式:它不是抛异常,而是作为一条正常的「工具结果」返回给模型,只是标记了 is_error: true,内容用 <tool_use_error> 标签包裹。这样模型能看到自己错在哪,下一轮自己改正。

第 ⑤ 步:投机性地提前启动分类器

// Speculatively start the bash allow classifier check early so it runs in
// parallel with pre-tool hooks, deny/ask classifiers, and permission dialog
// setup. The UI indicator (setClassifierChecking) is NOT set here — it's
// set in interactiveHandler.ts only when the permission check returns `ask`
// with a pendingClassifierCheck. This avoids flashing "classifier running"

译:投机性地提前启动 bash 放行分类器的检查,让它和「工具前钩子」「拒绝/询问分类器」「权限对话框的准备」并行跑。界面上的「分类器运行中」指示器不在这里设置 —— 只有当权限检查返回「需要询问」且带着一个待定的分类器检查时,才在交互处理器里设置。这避免了指示器闪一下就消失。

这里有两个独立的优化

① 投机执行。分类器要调一次模型(约 1 秒)。与其等权限判定走到「需要分类器」那一步再启动,不如一开始就启动 —— 反正大部分情况下都会用到。如果最后发现不需要,丢弃结果即可。这样分类器的耗时被前面几步的耗时覆盖掉了。

② 界面反馈的延迟设置。如果在启动分类器的同时就点亮「分类器运行中」的指示器,那么在「分类器其实没被采纳」的情况下,用户会看到指示器闪一下就消失 —— 这是一种糟糕的视觉噪音。所以指示器的点亮时机被推迟到「确认真的要用分类器结果」的那一刻。

投机执行提升性能,延迟反馈保护体验。两者互不干扰。

5.5 流式工具执行器

常规做法是等模型的整个响应流完再开始跑工具。Claude Code 的做法是:模型每写完一个工具调用就立刻开始执行它。

为什么能这么做

模型是一个 token 一个 token 往外吐的。如果这一轮要写三个工具调用,那么第一个写完时第二个还没开始 —— 这中间有几秒钟空档。

// query.ts 的流式循环内部
if (message.type === 'assistant') {
  const msgToolUseBlocks = message.message.content.filter(c => c.type === 'tool_use')
  if (msgToolUseBlocks.length > 0) {
    toolUseBlocks.push(...msgToolUseBlocks)
    needsFollowUp = true
  }
  if (streamingToolExecutor && !toolUseContext.abortController.signal.aborted) {
    for (const toolBlock of msgToolUseBlocks) {
      streamingToolExecutor.addTool(toolBlock, message)     // ★ 一到手就入队
    }
  }
}
// 同一个循环里持续收割已完成的
if (streamingToolExecutor && !toolUseContext.abortController.signal.aborted) {
  for (const result of streamingToolExecutor.getCompletedResults()) {
    if (result.message) {
      yield result.message
      toolResults.push(...normalizeMessagesForAPI([result.message], ...))
    }
  }
}

执行器的内部状态机

type ToolStatus = 'queued' | 'executing' | 'completed' | 'yielded'
//                 排队中     执行中       已完成        已发出

type TrackedTool = {
  id: string
  block: ToolUseBlock
  assistantMessage: AssistantMessage
  status: ToolStatus
  isConcurrencySafe: boolean
  promise?: Promise<void>
  results?: Message[]
  pendingProgress: Message[]      // 进度消息单独存,立刻发出
  contextModifiers?: Array<(ctx: ToolUseContext) => ToolUseContext>
}

并发规则

private canExecuteTool(isConcurrencySafe: boolean): boolean {
  const executingTools = this.tools.filter(t => t.status === 'executing')
  return (
    executingTools.length === 0                                   // 没人在跑 → 随便跑
    || (isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
                                        // 或者:我安全 且 正在跑的全都安全
  )
}

private async processQueue(): Promise<void> {
  for (const tool of this.tools) {
    if (tool.status !== 'queued') continue
    if (this.canExecuteTool(tool.isConcurrencySafe)) {
      await this.executeTool(tool)
    } else {
      // 跑不了这个工具。而不安全的工具必须保序,所以直接停在这里,
      // 不去尝试它后面的工具
      if (!tool.isConcurrencySafe) break
    }
  }
}

类注释总结了三条规则:

「- Concurrent-safe tools can execute in parallel with other concurrent-safe tools
- Non-concurrent tools must execute alone (exclusive access)
- Results are buffered and emitted in the order tools were received」


译:并发安全的工具可以和其他并发安全的工具并行;非并发工具必须独占执行;结果会被缓冲,并按工具被接收的顺序发出。

第三条很重要 —— 执行可以乱序,但结果必须按原始顺序发出,否则模型看到的工具结果顺序会和它发出调用的顺序对不上。

5.6 兄弟中止控制器:最漂亮的一处设计

// Child of toolUseContext.abortController. Fires when a Bash tool errors
// so sibling subprocesses die immediately instead of running to completion.
// Aborting this does NOT abort the parent — query.ts won't end the turn.
private siblingAbortController: AbortController

constructor(...) {
  this.siblingAbortController = createChildAbortController(
    toolUseContext.abortController      // ★ 父控制器
  )
}

译:这是主中止控制器的一个子控制器。当某个 Bash 工具出错时触发它,让同批的兄弟子进程立刻死掉,而不是白白跑到结束。中止这个子控制器不会中止父控制器 —— 所以主循环不会结束本轮。

为什么需要两级

场景:模型一次发出三个 Bash 调用,是同一个构建流程的三个步骤。第一个失败了(编译报错)。

两级作用域同时解决了这两个问题:拉子开关 → 兄弟进程立刻死;父开关不动 → 本轮不结束 → 模型正常收到错误并重试。

任何有「批内失败」概念的并发执行器,都应该有一个可以独立触发的子作用域。

5.7 丢弃机制

/**
 * Discards all pending and in-progress tools. Called when streaming fallback
 * occurs and results from the failed attempt should be abandoned.
 * Queued tools won't start, and in-progress tools will receive synthetic errors.
 */
discard(): void {
  this.discarded = true
}

译:丢弃所有待定和进行中的工具。在流式请求失败回退时调用,此时失败那次尝试的结果应该被抛弃。排队中的工具不会启动,进行中的工具会收到合成的错误结果。

这个方法在两个地方被调用,而且两处的处理完全一样:

// 场景 1:流式请求失败,退回非流式重试
if (streamingFallbackOccured) {
  for (const msg of assistantMessages) yield { type:'tombstone', message: msg }
  logEvent('tengu_orphaned_messages_tombstoned', { orphanedMessageCount: ... })
  assistantMessages.length = 0; toolResults.length = 0; toolUseBlocks.length = 0
  if (streamingToolExecutor) {
    streamingToolExecutor.discard()
    streamingToolExecutor = new StreamingToolExecutor(...)    // ★ 重建一个新的
  }
}

// 场景 2:模型降级切换备用模型(见第 3.7 节)
// 同样的四步:打墓碑、清数组、丢弃执行器、重建

注释解释了为什么要重建而不是复用:

「Discard pending results from the failed streaming attempt and create a fresh executor. This prevents orphan tool_results (with old tool_use_ids) from being yielded after the fallback response arrives.」

译:丢弃失败那次流式尝试的待定结果,并创建一个全新的执行器。这防止了带着旧工具调用 id 的孤儿结果,在降级响应到达之后才被发出。

如果不重建:旧执行器里还有几个工具在跑,它们跑完后会发出带旧 id 的结果。而重试后的响应有全新的 id —— 于是历史里出现了「没有对应调用的结果」,同样会让接口报格式错误。

5.8 「墓碑」消息

上面出现了一个新概念:tombstone(墓碑)。它是一种控制信号消息,意思是「请从界面和对话记录里删除这条消息」

yield { type: 'tombstone' as const, message: msg }

为什么需要它?注释说明:

「Yield tombstones for orphaned messages so they're removed from UI and transcript. These partial messages (especially thinking blocks) have invalid signatures that would cause "thinking blocks cannot be modified" API errors.」

译:为孤儿消息发出墓碑,让它们从界面和对话记录里被移除。这些不完整的消息(尤其是思考块)带着无效的签名,会导致「思考块不可修改」的接口错误。

场景是:流式请求失败时,模型已经吐出了一部分内容,界面上也已经显示出来了。这些内容不能留着 —— 它们不完整、签名无效。所以要发一个墓碑把它们撤回。

这个设计对流式界面很重要:你已经把东西画到屏幕上了,现在需要一个「撤回」机制。而且撤回要同时作用于界面和落盘的记录。

5.9 结果的最终处理

工具执行完之后,结果还要经过两道处理才能回传给模型:

① 超限落盘

每个工具声明了 maxResultSizeChars。超限的结果被写到磁盘,模型收到的是「前 2000 字节预览 + 文件路径」。详见第 6.2 节。

② 序列化

mapToolResultToToolResultBlockParam(content: Output, toolUseID: string): ToolResultBlockParam

每个工具自己决定「我的输出该怎么变成给模型看的文字」。比如 Read 工具会加上行号,Bash 工具会分开标记标准输出和标准错误。

注意 Tool 接口里还有一个专门为对话记录搜索服务的方法:

/**
 * Flattened text of what renderToolResultMessage shows IN TRANSCRIPT MODE.
 * For transcript search indexing: the index counts occurrences in this string,
 * the highlight overlay scans the actual screen buffer. For count ≡ highlight,
 * this must return the text that ends up visible — not the model-facing
 * serialization from mapToolResultToToolResultBlockParam.
 *
 * Phantoms are not fine — text that's claimed here but doesn't render is a
 * count≠highlight bug.
 */
extractSearchText?(out: Output): string

译:这个方法返回「在对话记录模式下实际渲染出来的文字」的扁平化版本。用于搜索索引:索引统计这个字符串里的出现次数,而高亮层扫描的是真实的屏幕缓冲区。为了让「统计数」和「高亮数」相等,这里必须返回最终可见的文字 —— 而不是给模型看的那个序列化结果。……幽灵文本是不可接受的 —— 在这里声称存在但实际没渲染出来的文字,就是一个「统计数 ≠ 高亮数」的 bug。

这段注释揭示了一个很细的产品问题:用户在对话记录里搜索一个词,界面显示「找到 5 处」,但用户按 n 跳转时只高亮了 3 处 —— 因为索引统计的是「给模型看的文字」,而高亮扫描的是「渲染到屏幕上的文字」,两者不一致。

而且注释还明确了容错方向:漏统计(少报)可以接受,幽灵(多报)不可接受。因为少报只是搜不全,多报会让跳转功能直接失灵。