5 · 工具抽象层与工具执行编排

5.1 Claude Code 的工具接口:一个教科书级抽象

先说清楚这一节在讲什么。「工具接口」是一份契约,它规定「任何一个想被模型调用的东西,必须提供哪些能力」。Claude Code 把这份契约写在 Tool.ts 文件里,全文 793 行,其中光是这个契约的类型定义就占了 330 行。

为什么这么长?因为它把一个「工具」需要回答的所有问题,切成了七组互不重叠的能力

Tool<Input, Output, Progress> 三个尖括号里的是"泛型参数",意思是 "这个工具的输入类型、输出类型、进度类型 由具体的工具自己决定" │ ├─ ① 执行 call(参数, 上下文, 权限检查函数, 父消息, 进度回调) │ 这是唯一真正"干活"的方法 │ ├─ ② 契约 inputSchema 用 Zod 库描述"参数长什么样" │ inputJSONSchema 给 MCP 外部工具用的原始格式 │ outputSchema 输出长什么样 │ ├─ ③ 提示词 prompt() 写给模型看的完整说明(进系统提示词) │ description() 单次调用时的简短描述 │ searchHint 关键词,供"工具搜索"功能匹配 │ ├─ ④ 安全谓词 isReadOnly() 这次调用是只读的吗? │ isDestructive() 会不可逆地破坏东西吗? │ isConcurrencySafe() 可以和别的工具同时跑吗? │ isEnabled() 当前环境下这个工具可用吗? │ isOpenWorld() 会访问外部网络吗? │ requiresUserInteraction() 必须有人在场才能完成吗? │ ├─ ⑤ 权限 validateInput() 参数合法吗?(不合法时告诉模型为什么) │ checkPermissions() 该放行吗?(工具特有的判断逻辑) │ preparePermissionMatcher() 给钩子的条件匹配器 │ ├─ ⑥ 预算与生命周期 │ maxResultSizeChars 结果超过多少字符就落盘、只回摘要 │ interruptBehavior() 被中断时是'取消'还是'继续跑完' │ shouldDefer 这个工具的说明可以延迟加载吗 │ alwaysLoad 永不延迟加载 │ backfillObservableInput() 只改可观测副本,不动原件 │ └─ ⑦ 渲染(10 个以上的方法) renderToolUseMessage 调用进行中怎么显示 renderToolUseProgressMessage 进度怎么显示 renderToolResultMessage 结果怎么显示 renderToolUseRejectedMessage 被拒绝时怎么显示 renderToolUseErrorMessage 出错时怎么显示 renderGroupedToolUse 多个并行调用怎么合并显示 extractSearchText 供对话记录搜索用的纯文本 mapToolResultToToolResultBlockParam → 转换成回传给模型的格式

为什么要把「安全谓词」单独划成一组

因为这一组方法不是给人看的,是给调度器看的

谓词调度器拿它来做什么决定
isConcurrencySafe(参数)决定这个调用能不能和相邻的调用并行执行
isReadOnly(参数)决定能不能走权限判定的快速通道(只读操作通常可以自动放行)
isDestructive(参数)决定要不要额外弹一次确认
isOpenWorld(参数)决定要不要按「访问外网」的策略处理
requiresUserInteraction()决定后台任务里能不能用这个工具(后台没人在场,弹不出确认框)

这样一来,调度策略就从工具实现里被完全剥离出来了。调度器完全不认识任何具体工具 —— 它不知道什么是 Bash、什么是 Read,它只会问这几个真/假问题,然后根据答案安排执行顺序。

新增一个工具时,你不需要修改调度器的任何一行代码。你只需要在新工具里如实回答这几个问题。

注意一个容易被忽略的细节:isConcurrencySafe(参数)接收参数的,不是一个静态标记。同一个 Bash 工具,执行 ls(列出文件)是并发安全的,执行 rm(删除文件)就不安全。安全性取决于这次具体要做什么,而不取决于工具类型。

最值得抄走的 20 行:失败时倒向保守的默认值

const TOOL_DEFAULTS = {
  isEnabled:          () => true,
  isConcurrencySafe:  () => false,   // ← 默认"不能并行"
  isReadOnly:         () => false,   // ← 默认"会写入"
  isDestructive:      () => false,
  checkPermissions:   (input) => ({ behavior:'allow', updatedInput: input }),
  toAutoClassifierInput: () => '',   // ← 默认"跳过安全分类器"
  userFacingName:     () => '',
}

export function buildTool<D>(def: D): BuiltTool<D> {
  return { ...TOOL_DEFAULTS,                    // 先铺默认值
           userFacingName: () => def.name,
           ...def }                             // 再用具体工具的定义覆盖
}

claude-code/src/Tool.ts · buildTool 函数

{ ...A, ...B } 是 JavaScript/TypeScript 的「展开」写法,意思是「把 A 的所有字段铺开,然后用 B 的字段覆盖同名的」。所以工具没显式声明的字段就用默认值,声明了的就用自己的。)

这里的设计判断力

默认值全部指向「最保守的行为」,软件安全领域管这叫 fail-closed(失败时闭合,即出问题时倒向拒绝而非放行):

唯一看起来违反这个原则的是 toAutoClassifierInput,它默认返回空字符串,意思是「跳过安全分类器」。但源代码注释解释了:

「skip classifier — security-relevant tools must override」(跳过分类器 —— 有安全含义的工具必须自己重写这个方法)

逻辑是:安全分类器是给「有安全含义」的工具用的,一个工具如果没有显式声明自己有安全含义,它就不该占用分类器的 token 预算。安全性由前面的权限判定链保证(第 7 章讲),而不是靠分类器兜底。这个区分很细腻 —— 它把「省钱」和「保安全」这两件事的责任分清了。

5.2 渐进式工具加载:工具搜索机制

先说问题:每个工具的说明文字都要放进系统提示词里,模型才知道有这个工具、该怎么用它。而系统提示词是每一轮都要重发的(见第 1.1 节)。

如果一个用户接了十几个 MCP 外部工具服务,工具总数可能上百个。所有说明文字加起来能吃掉几万个 token —— 而且是每一轮都要付一遍

Claude Code 的解法叫 defer_loading(延迟加载):

程序启动时 ├─ 常用工具(Read / Bash / Edit …)→ 完整说明全部放进系统提示词 └─ 冷门工具 + MCP 外部工具 → 只放"名字 + 一句话关键词" (每个只占几十个 token) ↓ 模型觉得需要某个能力时 └─ 调用 ToolSearchTool(工具搜索工具),用关键词检索 ↓ 命中之后 └─ 那个工具的完整说明才被加载进上下文

另外有一个 alwaysLoad: true 标记,声明「这个工具永不延迟加载」—— 用于那些模型在第一轮就必须能看到的工具。对 MCP 外部工具,可以在服务端通过 _meta['anthropic/alwaysLoad'] 声明。

关键词怎么写,源码里有规范

Tool 接口里 searchHint 字段的注释:

「3–10 words, no trailing period. Prefer terms not already in the tool name (e.g. 'jupyter' for NotebookEdit).」

译:3 到 10 个词,末尾不加句号。优先用工具名里还没有的词(比如 NotebookEdit 这个工具的关键词应该写 'jupyter')。

为什么?因为模型如果搜 "notebook",工具名本身就能匹配上,不需要关键词帮忙。关键词的价值在于覆盖那些工具名里没体现的同义说法 —— Jupyter 是那类笔记本文件的实际产品名,模型很可能用这个词来描述需求。

5.3 工具清单的装配:一个只有深度用过缓存才知道的坑

这段代码只有 8 行,但它揭示的东西非常值钱:

export function assembleToolPool(permissionContext, mcpTools): Tools {
  const builtInTools    = getTools(permissionContext)          // 内建工具
  const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext)
                                                              // 外部 MCP 工具

  const byName = (a, b) => a.name.localeCompare(b.name)       // 按名字排序的规则
  return uniqBy(
    [...builtInTools].sort(byName)          // ★ 内建工具单独排序
      .concat(allowedMcpTools.sort(byName)), // ★ 外部工具单独排序,然后拼在后面
    'name',                                  // 按名字去重
  )
}

claude-code/src/tools.ts · assembleToolPool 函数

请注意:内建工具和外部工具是分别排序、然后拼接的,不是合并成一个大数组统一排序。

对不了解缓存机制的人来说,这看起来是一个多余的复杂化 —— 为什么不直接把两组合在一起排个序?源代码注释给出了答案:

「The server's cache policy places a global cache breakpoint after the last prefix-matched built-in tool; a flat sort would interleave MCP tools into built-ins and invalidate all downstream cache keys whenever an MCP tool sorts between existing built-ins.」

译:服务端的缓存策略在「最后一个前缀匹配成功的内建工具」之后放置一个全局缓存分界点。如果做统一排序,外部工具就会插进内建工具中间 —— 那么每当有一个外部工具的名字恰好排在两个内建工具之间时,分界点之后的全部缓存键都会失效。

用具体例子说明:假设内建工具按字母排序是 BashToolGlobToolGrepToolReadTool。现在用户装了一个叫 brave_search 的外部工具。

统一排序的结果(错误做法): BashTool → brave_search → GlobTool → GrepTool → ReadTool ↑ 外部工具插进了内建工具区间中间 ↑ 内建工具不再是一个连续的整块 ↑ 服务端放的缓存分界点被劈开 → 分界点之后全部失效 分区排序的结果(Claude Code 的做法): BashTool → GlobTool → GrepTool → ReadTool | brave_search └────────── 内建工具连续整块 ──────────┘ └─ 外部工具 ─┘ ↑ 缓存分界点稳稳落在这里
这件事有多严重

同一个文件里还有一行注释,能说明这件事的严肃程度:

/**
 * NOTE: This MUST stay in sync with
 * https://console.statsig.com/.../claude_code_global_system_caching,
 * in order to cache the system prompt across users.
 */
export function getAllBaseTools(): Tools { ... }

译:注意:这个函数必须和某个线上配置保持同步,才能让系统提示词在所有用户之间共享缓存。

也就是说:系统提示词的缓存是跨用户共享的。工具清单的顺序是那份全局配置的一部分。如果排序逻辑出错,受影响的不是一个用户,而是所有用户的缓存一起崩

这是「深度绑定单一供应商」能换到的红利 —— 也是 Hermes 那种不绑定供应商的架构永远拿不到的东西。

5.4 执行编排:并发分区

模型一次可能返回好几个工具调用。全部串行执行太慢;全部并行执行会出「竞态」(两个操作同时改同一个文件,结果不可预测)。

Claude Code 的解法是贪心分区

function partitionToolCalls(toolUseMessages, ctx): Batch[] {
  return toolUseMessages.reduce((acc, toolUse) => {
    const tool = findToolByName(ctx.options.tools, toolUse.name)
    const parsed = tool?.inputSchema.safeParse(toolUse.input)   // 先校验参数格式

    const isConcurrencySafe = parsed?.success
      ? (() => {
          try { return Boolean(tool.isConcurrencySafe(parsed.data)) }
          catch { return false }        // ★ 判定函数抛异常也当成"不安全"
        })()
      : false                          // ★ 参数格式不合法也当成"不安全"

    if (isConcurrencySafe && acc.at(-1)?.isConcurrencySafe) {
      acc.at(-1).blocks.push(toolUse)   // 上一批也是安全的 → 并入上一批
    } else {
      acc.push({ isConcurrencySafe, blocks: [toolUse] })  // 否则 → 开一个新批次
    }
    return acc
  }, [])
}

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

reduce 是「归约」,意思是「遍历数组,把结果一点点累积到一个变量里」。acc 是 accumulator 累加器。acc.at(-1) 是「取累加器里的最后一个元素」。)

执行的结果是一串批次,比如:

模型返回的 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") ← 单独跑

关键在于:相邻的安全工具合并成并行批,遇到不安全的就切断。顺序语义被完整保留。这一点很重要 —— 模型可能依赖「先 Edit 再 Read」的顺序(先改文件再读回来验证),如果打乱顺序,逻辑就错了。

并行的上限是 10 个,可以通过环境变量 CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY 调整。

失败时倒向保守的两处体现

上面代码里有两个 false 值得单独指出:

会改动共享上下文的工具,修改要排队到批次结束

有些工具会修改共享的上下文对象(比如 EnterPlanMode 工具会切换权限模式)。并行批次里如果每个工具立刻修改,就有竞态。Claude Code 的处理是把修改动作排进队列,等整批跑完再按顺序应用

const queuedContextModifiers = {}                      // 修改动作的队列
for await (const update of runToolsConcurrently(...)) {
  if (update.contextModifier) {
    queuedContextModifiers[update.contextModifier.toolUseID] ||= []
    queuedContextModifiers[...].push(update.contextModifier.modifyContext)
  }
  yield { message: update.message, newContext: currentContext }  // 先用旧上下文
}
// 整批完成后,严格按工具调用的原始顺序应用修改
for (const block of blocks)
  for (const modifier of queuedContextModifiers[block.id] ?? [])
    currentContext = modifier(currentContext)

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

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

这是一条很干脆的兜底规则:要改共享上下文的工具,就别声明自己并发安全。两者不可兼得,接口层面直接堵死。

5.5 流式工具执行器:边流边执行

这是 Claude Code 一个明显的延迟优化。常规做法是等模型的整个响应流完,再开始跑工具。Claude Code 的做法是:模型每写完一张便条就立刻开始执行它。

回顾第 1.6 节:模型是一个 token 一个 token 往外吐的。如果它这一轮要写三张便条,那么第一张写完时第二张还没开始 —— 这中间有几秒钟的空档。

// query.ts 的流式循环内部
if (message.type === 'assistant') {
  const msgToolUseBlocks = message.message.content.filter(c => c.type === 'tool_use')
  for (const toolBlock of msgToolUseBlocks) {
    streamingToolExecutor.addTool(toolBlock, message)   // ← 一到手就入队执行
  }
}
// 同一个循环里持续收割已经跑完的
for (const result of streamingToolExecutor.getCompletedResults()) {
  if (result.message) { yield result.message; toolResults.push(...) }
}

执行器内部为每个工具维护四种状态:queued(排队中)→ executing(执行中)→ completed(已完成)→ yielded(已发出)。并发规则是:

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

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

并且结果按到达顺序缓冲后再发出,保证模型看到的工具结果顺序,和它当初发出工具调用的顺序一致。

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

// 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 = createChildAbortController(
    toolUseContext.abortController)

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

为什么这个设计值得单独讲

回顾第 1.9 节:中止信号是一个可以在程序各处传递的「取消开关」。绝大多数项目只有一个全局开关

但只有一个开关会遇到这个问题:一批并行的 Bash 命令里有一个失败了(比如编译报错),其他几个继续跑完毫无意义(它们多半是同一个构建流程的不同步骤)。你想让它们立刻停下省时间省钱 —— 但如果拉那个全局开关,整个轮次就结束了,模型收不到错误信息,也就没法重试。

Claude Code 的解法是两级中止作用域
· 建一个「子开关」,专门管这一批工具
· 批内失败 → 拉子开关 → 兄弟进程立刻死
· 父开关不动 → 本轮不结束 → 模型正常收到错误 → 可以重试

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

5.6 Hermes 的工具层:中心分发 + 参数强制矫正

Hermes 没有 Tool 类这样的抽象,它走的是函数注册 + 中心分发的路子:所有工具调用都进同一个函数 handle_function_call(name, args, ...),由它按名字分派。

但 Hermes 有一个 Claude Code 完全没有的东西,而且非常实用 —— 参数强制矫正层

def coerce_tool_args(tool_name, args) -> Dict[str, Any]:   # 第 845 行
def _normalize_json_strings_for_schema(value, schema)       # 第 974 行
def _coerce_value(value: str, expected_type, schema)        # 第 1051 行
def _coerce_json(value: str, expected_python_type)          # 第 1104 行
def _coerce_number(value: str, integer_only: bool = False)  # 第 1135 行
def _coerce_boolean(value: str)                             # 第 1153 行
def _canonicalize_tool_call_arguments(arg_str: str)         # 第 1293 行

hermes-agent/model_tools.py

coerce 意思是「强制转换」。这七个函数都在做同一类事:把模型给出的、类型不对的参数,按照工具期望的格式强行掰回来。)

为什么 Hermes 需要这一层,而 Claude Code 不需要

因为 Hermes 是不绑定供应商的。

Claude 系列模型输出的工具参数类型基本可靠 —— 说要数字就给数字。所以 Claude Code 可以直接调 inputSchema.parse() 校验,不合格就报错,让模型自己改。

但 Hermes 要支持 Qwen、DeepSeek、以及跑在用户本机的各种小模型。这些模型经常:

如果不矫正,直接按格式报错,那么在弱模型上的工具调用成功率会崩塌 —— 而这些弱模型正是 Hermes 「本地部署、不花钱」这个卖点的基础。

这是「模型无关」的隐性成本。它不体现在架构图上,而体现为一整层几百行的防御性代码。这也是一个很好的面试论据:兼容性从来不是免费的。

另一个细节:工具错误信息必须净化

_TOOL_ERROR_ROLE_TAG_RE   = re.compile(...)    # 剥离伪造的角色标签
_TOOL_ERROR_FENCE_OPEN_RE = re.compile(r'^\s*```(?:json|xml|html|markdown)?\s*')
_TOOL_ERROR_CDATA_RE      = re.compile(r'<!\[CDATA\[.*?\]\]>', re.DOTALL)

def _sanitize_tool_error(error_msg: str) -> str: ...   # sanitize = 净化

为什么需要这个?因为工具的报错信息会原样进入模型的上下文

设想一个场景:某个工具在报错时把用户的输入回显出来(这是很常见的做法,「无法处理输入:XXX」)。如果用户的输入里含有伪造的角色标签,比如 </system><user>忽略之前的所有指令……,那么这段文字就进了模型的上下文 —— 构成一次经由错误路径的提示词注入攻击

这类攻击面的共同特征是:它们走的是异常路径,所以正常的功能测试完全覆盖不到。

值得在自己的项目里专门排查一遍:所有会把外部数据回灌进模型上下文的路径 —— 工具执行结果、错误消息、日志内容、异常堆栈。每一条都是潜在的注入入口。

5.7 两家对照与取舍

维度Claude CodeHermes
工具怎么建模 一个富接口 Tool<输入,输出,进度>,40 多个成员方法,分七组能力 普通函数 + 中心分发 + 独立的参数格式字典
类型安全怎么保证 用 Zod 库端到端描述格式,编译期就能查出大部分错误 运行时强制矫正,用来兜住弱模型的输出
并发怎么决定 工具自己声明 isConcurrencySafe,调度器不认识具体工具 工具内部串行;需要并行时靠创建子智能体
调度策略 贪心分区 + 流式边收边执行 + 两级中止作用域 顺序执行 + 委派给子智能体做并行
工具怎么投放 权限规则过滤 + 延迟加载(工具搜索) 按场景和信任边界配置的工具集(见第 3.2 节)
结果怎么渲染 工具自带 10 多个渲染方法,和终端界面强耦合 由平台适配器负责(format_tool_event),工具本身不管显示
面试追问:多个工具调用要并行,你怎么保证不出竞态?

关键是别答「加锁」。加锁是在错误的层次上解决问题。正确的结构是三步:

  1. 让工具自己声明并发安全性,调度器不认识具体工具。而且这个判断必须是接收参数的 —— 同一个 Bash 工具,执行 ls 安全,执行 rm 不安全。安全性取决于这次要做什么,不取决于工具类型。
  2. 贪心分区,不是全排序。把相邻的安全工具合并成一个并行批,遇到不安全的就切断并单独串行执行。这样既拿到了并行的速度收益,又完整保留了模型隐含的顺序语义(模型可能依赖「先改再读」的顺序)。
  3. 失败时倒向保守。参数格式解析失败、安全性判定函数自己抛异常 —— 全部当作不安全。Claude Code 专门为此写了 try/catch,注释说明是「Bash 命令的引号解析失败时保守处理」。

想再加一层深度,补两句: