5 · 工具抽象层与工具执行编排
5.1 Claude Code 的工具接口:一个教科书级抽象
先说清楚这一节在讲什么。「工具接口」是一份契约,它规定「任何一个想被模型调用的东西,必须提供哪些能力」。Claude Code 把这份契约写在 Tool.ts 文件里,全文 793 行,其中光是这个契约的类型定义就占了 330 行。
为什么这么长?因为它把一个「工具」需要回答的所有问题,切成了七组互不重叠的能力:
为什么要把「安全谓词」单独划成一组
因为这一组方法不是给人看的,是给调度器看的。
| 谓词 | 调度器拿它来做什么决定 |
|---|---|
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(延迟加载):
另外有一个 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.」
译:服务端的缓存策略在「最后一个前缀匹配成功的内建工具」之后放置一个全局缓存分界点。如果做统一排序,外部工具就会插进内建工具中间 —— 那么每当有一个外部工具的名字恰好排在两个内建工具之间时,分界点之后的全部缓存键都会失效。
用具体例子说明:假设内建工具按字母排序是 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) 是「取累加器里的最后一个元素」。)
执行的结果是一串批次,比如:
关键在于:相邻的安全工具合并成并行批,遇到不安全的就切断。顺序语义被完整保留。这一点很重要 —— 模型可能依赖「先 Edit 再 Read」的顺序(先改文件再读回来验证),如果打乱顺序,逻辑就错了。
并行的上限是 10 个,可以通过环境变量 CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY 调整。
失败时倒向保守的两处体现
上面代码里有两个 false 值得单独指出:
- 参数格式校验失败 → 当成不安全。因为格式都不对,说明我们根本不理解这次调用要干什么,不能假设它安全。
- 判定函数自己抛异常 → 当成不安全。源码注释说明了触发场景:「If isConcurrencySafe throws (e.g., due to shell-quote parse failure), treat as not concurrency-safe to be conservative」 —— Bash 工具判断自己安不安全时需要解析命令行的引号结构,如果用户写了个引号不配对的命令,解析器会抛异常。这时候保守处理。
会改动共享上下文的工具,修改要排队到批次结束
有些工具会修改共享的上下文对象(比如 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 系列模型输出的工具参数类型基本可靠 —— 说要数字就给数字。所以 Claude Code 可以直接调 inputSchema.parse() 校验,不合格就报错,让模型自己改。
但 Hermes 要支持 Qwen、DeepSeek、以及跑在用户本机的各种小模型。这些模型经常:
- 把布尔值
true写成字符串"true" - 把数字
5写成字符串"5" - 把一个嵌套对象整个塞进字符串里,变成
"{\"a\": 1}"
如果不矫正,直接按格式报错,那么在弱模型上的工具调用成功率会崩塌 —— 而这些弱模型正是 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 Code | Hermes |
|---|---|---|
| 工具怎么建模 | 一个富接口 Tool<输入,输出,进度>,40 多个成员方法,分七组能力 |
普通函数 + 中心分发 + 独立的参数格式字典 |
| 类型安全怎么保证 | 用 Zod 库端到端描述格式,编译期就能查出大部分错误 | 运行时强制矫正,用来兜住弱模型的输出 |
| 并发怎么决定 | 工具自己声明 isConcurrencySafe,调度器不认识具体工具 |
工具内部串行;需要并行时靠创建子智能体 |
| 调度策略 | 贪心分区 + 流式边收边执行 + 两级中止作用域 | 顺序执行 + 委派给子智能体做并行 |
| 工具怎么投放 | 权限规则过滤 + 延迟加载(工具搜索) | 按场景和信任边界配置的工具集(见第 3.2 节) |
| 结果怎么渲染 | 工具自带 10 多个渲染方法,和终端界面强耦合 | 由平台适配器负责(format_tool_event),工具本身不管显示 |
关键是别答「加锁」。加锁是在错误的层次上解决问题。正确的结构是三步:
- 让工具自己声明并发安全性,调度器不认识具体工具。而且这个判断必须是接收参数的 —— 同一个 Bash 工具,执行
ls安全,执行rm不安全。安全性取决于这次要做什么,不取决于工具类型。 - 贪心分区,不是全排序。把相邻的安全工具合并成一个并行批,遇到不安全的就切断并单独串行执行。这样既拿到了并行的速度收益,又完整保留了模型隐含的顺序语义(模型可能依赖「先改再读」的顺序)。
- 失败时倒向保守。参数格式解析失败、安全性判定函数自己抛异常 —— 全部当作不安全。Claude Code 专门为此写了 try/catch,注释说明是「Bash 命令的引号解析失败时保守处理」。
想再加一层深度,补两句:
- 会修改共享上下文的工具,其修改必须排队到整批结束后按原始顺序应用,或者干脆在接口层面禁止它声明自己并发安全(Claude Code 选了后者)。
- 并发执行器需要一个独立于全局的批内中止作用域。一批并行命令里有一个失败时,兄弟进程应该立刻死掉省资源,但不能因此终止整个轮次 —— 否则模型收不到错误、没法重试。