从设计者的角度理解源码--Claude Code的工具体系设计

王大爷 2026年10月06日 3次浏览

从设计者的角度理解源码--Claude Code的工具体系设计

引言

本篇,作为《从设计者的角度理解源码》系列的第五篇,试图回答一个看起来不该成为问题的问题:Claude Code 怎么用「一个接口」统一调度几十种语义完全不同的工具?

读文件的 Read、跑命令的 Bash、改代码的 Edit、搜代码的 Grep、发消息的 SendMessage、派子 Agent 的 AgentTool……它们的实现天差地别,一个要解析 shell 语法、一个要 diff 文件、一个要开网络请求。但它们共用同一个 Tool 接口,被同一套权限系统审查、被同一个执行器调度、被同一个 Hook 系统接管。

这个「统一对待异构工具」的难题,就是整套工具体系设计的原动力。而解开它,要顺着七层走——从「工具被建模成什么」,到「工具从哪来」,到「一次调用怎么跑完」,再到「用户逻辑怎么介入」「多个工具怎么分批」「流式下怎么边收边跑」,最后落到「一个代表性工具怎么落地」。

本文的核心隐喻:工具体系是一套「插座标准」。Tool 接口是统一的「插头规格」——规定好必填的引脚(call)和可选的信号线(自省属性、语义暴露方法);上层系统是「插座」——主循环、权限、Hook、并发调度、UI 各自是一排插孔。任何工具只要把插头做好,插上去就能被调度、被审查、被扩展,谁都不用为某一个工具写特例。

先给一张全局地图,后面每一章都是放大其中一层:

                        ┌─────────────────────────────────────────────┐
                        │  ① 契约层  src/Tool.ts                      │
                        │    Tool 接口 = 问卷(call 是唯一能力)        │
                        └──────────────────┬──────────────────────────┘
                                           │ 每个工具 buildTool 填问卷
                                           ▼
                        ┌─────────────────────────────────────────────┐
                        │  ② 注册层  src/tools.ts                     │
                        │    超集 → 收窄 → 装配(顺序即缓存键)          │
                        └──────────────────┬──────────────────────────┘
                                           │ 选出本次该摆出的工具池
                                           ▼
     ┌─────────────────────────────────────┴──────────────────────────────┐
     │  一个 tool_use 从 LLM 吐出,进入执行管线                             │
     │                                                                    │
     │   ③ 执行层 toolExecution.ts   "一次 tool_use 怎么跑完"(十阶段管线)│
     │      ├─ ④ Hook层 toolHooks.ts      "用户逻辑怎么介入"(翻译层)      │
     │      └─(穿插权限裁决,见第四篇)                                   │
     │                                                                    │
     │   ⑤ 编排层 toolOrchestration.ts   "多个工具怎么分批"                 │
     │   ⑥ 并发层 StreamingToolExecutor.ts "流式下怎么边收边跑"             │
     └─────────────────────────────────────┬──────────────────────────────┘
                                           │ 最终落到
                                           ▼
                        ┌─────────────────────────────────────────────┐
                        │  实例层  src/tools/FileReadTool/            │
                        │    一个工具怎么把问卷填满                      │
                        └─────────────────────────────────────────────┘

一、契约层:工具被建模成「问卷」

哎,Tool 接口有二十几个方法,这是不是意味着工具越做越臃肿、越做越像「万能对象」?

不是。这是最容易读错的地方。如果把二十几个方法当成并列的「能力」,你会觉得这是个上帝对象;但如果换成「它在回答谁的问题」,它们立刻收敛成四类(Tool.ts):

类别方法回答什么输出形态
Ⅰ 执行本体call()怎么干活动作(副作用)
Ⅱ 自省属性isConcurrencySafe()、isReadOnly()、isDestructive?()我是什么性质布尔标量
Ⅲ-a 交给决策者checkPermissions()、toAutoClassifierInput()、validateInput?()让我被安全审查策略 / 取景(要诚实)
Ⅲ-b 交给表达者description()、renderToolResultMessage?()让我被看懂文本 / 素材(要好看)

有了:二十几个方法里,真正「有副作用的动作」只有一个——call()。其余全是工具对自身性质的声明,以及把语义交出去给上层决策者/表达者的接口。工具不是「能力清单」,而是一张契约问卷:上层系统拿着这张问卷问每个工具,工具据实作答,上层的所有决策都建立在「每个工具都填好了自己那几栏」之上。

哎,call() 这个签名,怎么一眼看过去六个参数?这算不算接口设计失败?

恰恰相反,这六个参数不是按字母排的,是按一次工具调用的生命叙事排的(Tool.ts L532-538):

call(
  args: z.infer<Input>,            // 你要做什么(已校验)
  context: ToolUseContext,         // 你在什么世界(环境)
  canUseTool: CanUseToolFn,        // 谁批准你(门禁,必传 = fail-closed)
  parentMessage: AssistantMessage, // 你从哪来(因果锚点)
  onProgress?: ToolCallProgress<P>,// 你要怎么汇报(过程出口)
): Promise<ToolResult<Output>>     // 你还回什么(结果 + 话语 + 影响)

两个细节藏着设计意图:

  • canUseTool 必传(没有 ?):权限是硬依赖,签名上就不给「漏传即豁免」的口子——fail-closed 写在了类型里。一个工具想跑起来,就必须先拿到「谁批准我」这个函数。
  • 结果走 return,过程走回调:Promise<ToolResult> 只有一个最终值,而进度经 onProgress 旁路上报。两条通道分开,互不污染。

哎,为什么输入要搞两份 schema——inputSchema 和 inputJSONSchema?

因为它们服务两个完全不同的信任方向(Tool.ts L555-562):

readonly inputSchema: Input                 // Zod:代码,可执行、可反推类型
readonly inputJSONSchema?: ToolInputJSONSchema  // JSON Schema:数据,可序列化
维度inputSchema(Zod)inputJSONSchema(JSON)
本质代码(可执行校验器)数据(纯描述)
校验强度强(运行时逐项断言)弱(仅声明形状)
用途内部运行时校验发给 LLM / MCP 协议交换
能否反推类型能(z.infer)不能

有了:强校验留给「代码」(Zod),弱描述留给「数据」(JSON)。z.infer<Input> 的写法更是关键——它以 Zod schema 为唯一事实源,类型只是它的投影。改 schema,类型自动跟着变,结构上不可能出现「类型和校验对不上」。

哎,工具作者忘了实现 isReadOnly、isConcurrencySafe 怎么办?

这正是 buildTool 要解决的。它用一行展开 {...TOOL_DEFAULTS, ...def} 填充默认值(Tool.ts L975-994),而默认值是 fail-closed(失败时关闭) 的:

const TOOL_DEFAULTS = {
  isEnabled: () => true,                          // 默认启用
  isConcurrencySafe: (_input?) => false,          // 假设不安全,除非显式声明
  isReadOnly: (_input?) => false,                 // 假设有写入,除非显式声明
  isDestructive: (_input?) => false,              // 不声明不算破坏
  checkPermissions: (...) => ({ behavior: 'allow', ... }),  // 委托通用权限
  toAutoClassifierInput: (_input?) => '',         // 不参与分类器
  userFacingName: (_input?) => '',                // 默认用工具名
}

注意前两条是「宁可错杀」——不声明就假设不安全、有写入;后两条是「委托/跳过」。方向不一致不是疏忽,而是各自对着自己的风险敞口。

哎,那 validateInput、isSearchOrReadCommand、interruptBehavior、maxResultSizeChars 这些又是干嘛的?

它们各自是问卷上的一栏,回答一个特定「谁在问」:

方法回答的问题谁在问
validateInput?(input, ctx)这次输入业务上合法吗(类型之外)执行管线(在权限前拦截)
isSearchOrReadCommand?(input)这是搜索/读取操作吗UI(折叠为精简显示)
isOpenWorld?(input)结果是开放世界(无法预枚举)吗权限/分类器
interruptBehavior?()运行中用户输入新消息时该取消还是阻塞并发执行器
maxResultSizeChars结果最多多少字符才直接落盘结果持久化

有了:问卷的每一栏都有明确的「提问者」,工具填什么、不填什么,就决定了上层对它的接管能精细到什么粒度。「能力」收敛到一(call),是「统一」的前提;「声明」分门别类,是「精细」的前提。


二、注册层:工具从哪来、怎么筛

哎,getAllBaseTools() 就是返回一个数组字面量,为什么它看起来「乱糟糟」的——既不是按字母排,也不是按重要性排?

因为它不是「随便摆」,而是被一条远程缓存契约锁死了顺序。看 tools.ts L247-250 的 NOTE:

NOTE: This MUST stay in sync with ...claude_code_global_system_caching,
in order to cache the system prompt across users.
(工具清单的顺序会进入系统提示词;顺序一变,跨用户的 prompt 缓存即整体失效)

有了:工具的顺序本身就是缓存键的一部分。清单会序列化进 system prompt,顺序一变,所有用户的 prompt 缓存全部失效。所以新工具只能追加在末尾(append-only 冻结序),不能在中间插入。而清单里的 ...(条件 ? [X] : []) 展开表达式,是「门控分时相」——凡是要 require 的门控提到文件顶部(编译期 DCE 省包体积),已 import 的留在数组里(运行期判断换灵活性)。

哎,光有超集不够,工具到底怎么「筛」成最终该摆出的清单?

这是三层同心圆,不是一次决定(tools.ts):

getAllBaseTools()  超集:这个环境「可能」有哪些工具(只按环境门控)
        │  filterToolsByDenyRules(共享算子:剔整工具 deny)
        ▼
getTools(ctx)      收窄:当前权限/模式下「该给」哪些内置工具
        │  + MCP 工具,再 filterToolsByDenyRules(同一把尺)
        ▼
assembleToolPool() 装配:内置 + MCP 合并去重、排序

中间那层 getTools 的收窄逻辑,藏着几个容易忽略的细节(tools.ts L353-413):

// 1. 简单模式:只留 Bash/Read/Edit
if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { ... }

// 2. 摘掉「别处动态注入」的特殊工具(MCP 资源工具、SyntheticOutput)
const specialTools = new Set([ListMcpResourcesTool.name, ...])

// 3. REPL 模式:隐藏原始工具(它们在 VM 里被 REPL 包装)
if (isReplModeEnabled()) { ... }

// 4. 先 map 出所有 isEnabled() 再 filter —— 保证每个工具只求值一次
const isEnabled = allowedTools.map(_ => _.isEnabled())
return allowedTools.filter((_, i) => isEnabled[i])

最后那条 map 再 filter 的写法尤其有意思:如果写成 filter(tool => tool.isEnabled()),isEnabled 会在每次比较时重复调用。先 map 一次缓存结果,是「求值成本」的微观优化。

而 assembleToolPool(tools.ts L432-454)里还有一条更深的动机——排序是为了 prompt 缓存稳定:

内置工具保持为连续前缀,MCP 工具跟在后面,各自排序。
服务端的缓存策略会在「最后一个前缀匹配的内置工具」之后放一个全局断点;
如果平铺排序,MCP 工具会插进内置工具之间,一旦某个 MCP 工具排位变化,
它后面所有内置工具的缓存键全部失效。

有了:内置优先(uniqBy 保插入序,内置赢了命名冲突)、分区排序(内置一块、MCP 一块)、各自按名字排——这一切都不是为了「好看」,而是为了让缓存断点尽可能靠后、尽可能稳定。

哎,Bash(rm -rf:*) 这种「带内容」的规则,和光秃秃的 Bash 有什么区别?

这是「粒度纪律」。规则里 ruleContent 有没有值,编码了两种判断粒度(permissions.ts L281-312):

function toolMatchesRule(tool, rule): boolean {
  // 规则必须没有内容后缀才能匹配整个工具
  if (rule.ruleValue.ruleContent !== undefined) {
    return false    // 调用级规则 → 这一层判不了 → 移交运行时
  }
  // ... 按工具名 / MCP 层级匹配
}
规则ruleContent粒度判定需要什么「料」
Bashundefined工具级只需工具名
Bash(rm -rf:*)"rm -rf:*"调用级需要这次跑的具体命令串

而 filterToolsByDenyRules(tools.ts L330-337)手里只有 tool 对象,没有 input,判定不了调用级规则。所以那句 return false 不是「我批准」,而是「我不配判,交给有料的层」。若删掉这道闸门,Bash(git push:*) 会被静默升级成「禁用整个 Bash」,模型连 ls 都用不了——意图与后果彻底反了。


三、执行层:一次 tool_use 怎么跑完

前两层都是「静态」的(工具长什么样、摆哪些),这一层是「动态」的——一个 tool_use 从 LLM 吐出来,到最终执行或被拒绝,中间发生了什么。这是整个工具体系里最厚的一块,也是最容易看丢的一块。

入口是 runToolUse(toolExecution.ts L410-415),它先做两次查找:先在模型可见的工具池里找,找不到再去全局基础工具池里按别名回退(旧 transcript 里 KillShell 现在叫 TaskStop)。找到后,委托给核心管线 checkPermissionsAndCallTool。

这条核心管线有十个阶段(toolExecution.ts L599-1745):

tool_use + 原始 input
   │
   ① Zod safeParse ────────────── 失败 → <tool_use_error> 供模型修正
   │
   ② validateInput ────────────── 失败 → <tool_use_error>
   │
   ③ Bash 投机分类器 ──────────── 提前发 LLM 调用,与 hooks 并行
   │
   ④ _simulatedSedEdit 防御删除 ── 剥离内部字段(纵深防御)
   │
   ⑤ 分身校验再回填 ──────────── backfillObservableInput 克隆副本
   │
   ⑥ PreToolUse hooks ─────────── 拦截 / 改参 / 否决(见第四章)
   │
   ⑦ 权限决策 ─────────────────── resolveHookPermissionDecision → canUseTool
   │
   ⑧ callInput 确定 ───────────── 原始输入 vs 回填克隆 vs hook 修改
   │
   ⑨ tool.call() ──────────────── 唯一的动作
   │
   ⑩ PostToolUse / PostToolUseFailure hooks ── 事后通知 / 失败清理

这十个阶段里,①~⑤ 合起来其实是一套 LLM 防御体系——Claude Code 假设 LLM 吐出的每一个 input 都可能是错的、恶意的、或利用系统漏洞的,因此在「从模型输入到真正执行」之间,从形状、语义、内部字段、观察者视角、文件系统边界五个粒度,层层收窄信任面。后面 ⑥~⑩ 才是「执行 + 事后通知」。先记住这个分界:前半段是防御,后半段是执行。

哎,阶段①和阶段②不是重复吗?都叫「校验」。

不重复,是两道不同性质的闸。阶段① inputSchema.safeParse(L790-856)只查类型——模型经常把数组/布尔写成字符串,safeParse 失败时返回格式化的 Zod 错误,让模型下一轮修正;还会追加一个 buildSchemaNotSentHint(延迟工具的 schema 没发给模型时,补一句「该工具需要先搜索才能用」)。

阶段② validateInput(L858-911)查的是业务语义——类型对了,但「文件路径不存在」「PDF 页码范围超了」「这是二进制文件」这类只有工具自己知道的事,得由工具自己判断。validateInput 返回 {result:false, message, errorCode},message 会直接喂给模型。

有了:类型校验是「通用层」的事(Zod schema 谁都看得懂),业务校验是「工具自己」的事(只有 Read 知道 pages 参数怎么算合法)。两道闸,一道挡「形状错」,一道挡「语义错」。

哎,阶段③那个「投机分类器」是什么?为什么要在权限检查前就发 LLM 调用?

这是整套设计里最容易被忽略的一处优化(L912-934):

// 提前启动 Bash 的 allow classifier 检查,使其与 PreToolUse hooks、
// deny/ask classifier 和权限对话框设置并行执行。
if (tool.name === BASH_TOOL_NAME && 'command' in parsedInput.data) {
  startSpeculativeClassifierCheck(command, ...)
}

分类器要调 API(慢),而后面还要跑 PreToolUse hooks、规则检查——既然这些互不依赖,就让分类器先跑起来,等真需要它时结果已经回来了。最坏情况只是白跑一次 API,最好情况是零等待。这就是「投机执行」:赌它会被用到,先用起来。

哎,阶段④那个 _simulatedSedEdit 防御删除又是干嘛的?

一个「纵深防御」的例子(L938-959)。_simulatedSedEdit 是权限系统内部注入的字段,正常流程里 schema 的 strict 校验应该已经把它拦掉,但代码在权限流之前又剥离了一次——防御未来回归。注释说得很直白:把防御前置到权限决策之前,最大限度减少内部字段泄露的风险面。

哎,阶段⑤的 backfill 为什么要「克隆」?直接改 input 不行吗?

因为这一步是「分身校验再回填」——不直接改原始输入,而是先造一个「分身」(浅克隆副本),在分身上做校验/规范化,再把分身交给观察者(L961-982):

let callInput = processedInput
const backfilledClone = tool.backfillObservableInput && ... 
  ? ({ ...processedInput }) : null          // 「分身」:浅克隆一个副本
if (backfilledClone) {
  tool.backfillObservableInput!(backfilledClone)  // 在「分身」上校验/规范化
  processedInput = backfilledClone                // 观察者(hook/权限)看「分身」
}
// callInput 仍是原始值(阶段⑧最终收敛)

为什么要多此一举「造分身」?因为它要同时满足两个相反的用途:

  • 观察者(hook/权限)要看「规范化」的:backfillObservableInput 会展开 ~、相对路径——如果 hook 的 allowlist 写的是绝对路径 /home/user/secret.md,模型传 ~/secret.md 就能绕过白名单。展开后两者落在同一坐标系,allowlist 才拦得住。
  • 执行者(tool.call)要拿「原始」的:如果规范化后的路径传进 tool.call(),工具结果字符串里嵌入的路径就变了,transcript 和 VCR fixture 的哈希会不稳定。

有了:一个「分身」,两条消费路径——观察者看规范化的分身(防 allowlist 绕过),执行者拿原始的本体(保哈希稳定)。这正是「分身校验再回填」的精髓:同一份数据,两个用途,用一个克隆来分离。阶段⑧的 callInput 确定(L1385-1408)就是在做这个「原始 vs 分身 vs hook 修改」的最终收敛。

哎,阶段⑦的权限决策和阶段⑩的 PostToolUse,顺序上有没有讲究?

有,而且藏着 MCP 与非 MCP 的分歧(L1611-1761):

  • 非 MCP 工具:先 addToolResult(把结果缓存进 API 格式)→ 再跑 PostToolUse hooks。
  • MCP 工具:先跑 PostToolUse hooks(hooks 可以改写 MCP 输出)→ 再 addToolResult。

有了:MCP 工具的产出是外部服务器给的,hook 可能要在呈现给用户之前改写它(比如脱敏、翻译),所以 hooks 必须先跑。而本地工具的输出由 Tool 实现自己负责,hook 的 updatedOutput 对它无效,所以直接先落结果。


四、Hook层:用户逻辑怎么介入

执行层的阶段⑥、⑦、⑩都要和 Hook 打交道,但 Hook 的逻辑不在 toolExecution.ts 里,而在翻译层 toolHooks.ts。它的文件头 banner 自述了定位:

本文件是 toolExecution.ts 核心管线与 utils/hooks.ts 底层执行器之间的中间层:把底层 execute*Hooks 产出的"原始结果流"翻译成管线易消费的面向事件的消息……本文件只做"翻译",不做权限裁决。

哎,什么叫「只翻译不裁决」?

toolHooks.ts 不决定「放行还是拦截」,它只把底层 execute*Hooks 吐出的原始结果(message / blockingError / permissionBehavior / updatedInput…)翻译成一个判别联合,交给调用方(toolExecution 的权限决策段)去 switch。翻译和裁决分离,各司其职。

看 runPreToolUseHooks 产出的 7 分支判别联合(toolHooks.ts L584-610):

{ type: 'message' }                透传底层消息(进度/附件)
{ type: 'hookPermissionResult' }   钩子的权限意图(allow/ask/deny)
{ type: 'hookUpdatedInput' }       钩子改参但未表态(passthrough)
{ type: 'preventContinuation' }    钩子要求停止流程
{ type: 'stopReason' }             停止原因
{ type: 'additionalContext' }      附加上下文
{ type: 'stop' }                   终止生成器(中止/出错)

而权限意图最终在 resolveHookPermissionDecision 归一为四路矩阵(toolHooks.ts L451-557):

hook 决策处理
allow先过规则检查,无规则才采纳;有 deny 规则则 deny 覆盖
deny直采早退(否决只会被更否决)
ask转 canUseTool + forceDecision(弹窗展示 hook 的 ask 文案)
passthrough只采纳 updatedInput,不表态

核心不变量是 「钩子 allow 不豁免 settings.json 的 deny/ask 规则」——hook 是「额外的放行来源」,不是「覆盖配置的特权」。而 runPostToolUseHooks(L101-264)产出 5 分支:cancelled / blocking_error / stopped_continuation / additional_context / updatedMCPToolOutput。

有了:Hook 层的全部价值在于「把用户写的逻辑,翻译成管线能安全消费的结构化意图」。Hook 系统本身是通用的(不懂任何工具),翻译层让它能插进工具生命周期,却不替工具做决定——决定权始终在调用方的权限决策段。

(Hook 系统自身的完整设计机制——契约双保险、数据/行为分离、事件订阅、同步异步——见系列第六篇《Hook 体系设计》,这里只讲它「怎么插进工具生命周期」。)


五、编排层:多个工具怎么分批

执行层管「一个 tool_use 怎么跑完」,但模型一次会吐多个 tool_use。谁来决定它们「几个一批、什么顺序、带不带共享上下文」?这是 toolOrchestration.ts 的事。

哎,为什么不能全并发、也不能全串行?

全串行浪费(只读工具互不干扰),全并发危险(写工具间有竞态)。所以编排层做的是分批:partitionToolCalls(L178-206)单趟 reduce,把一批 tool_use 切成 [单个写][一串读][单个写][一串读]… 的形态——批内并发、批间串行,批次本身就是同步屏障。

而「一个工具算不算并发安全」的判断,有三道门控,全部 fail-closed:

const isConcurrencySafe = parsedInput?.success
  ? (() => {
      try { return Boolean(tool?.isConcurrencySafe(parsedInput.data)) }
      catch { return false }  // isConcurrencySafe 抛异常 → 视为不安全
    })()
  : false  // 工具查不到 / schema 解析失败 → 一律串行

有了:宁可保守地串行,也不错误地并行——「错误地并行」的代价远大于「保守地串行」。工具查不到、输入解析失败、判定器抛异常,三种情况全归「不安全」。

哎,那批内并发执行时,工具改上下文(contextModifier)怎么办?完成顺序是随机的,最终上下文不就漂移了?

这是编排层最精妙的一处(L96-137)。ToolResult 允许工具返回一个 contextModifier(改文件快照、对话状态等),但并发完成顺序不可控:

并发批执行时:
  contextModifier 只入队(按 toolUseID 存进 queuedContextModifiers),不立即应用
  ↓
批次结束(同步屏障):
  按 blocks 的「声明顺序」逐条回放 modifier
  ↓
回放完成后,广播一次「新上下文已就绪」

有了:上下文的变更必须按 tool_use 块的声明顺序应用,而非按工具完成的运行时序。 并发时立即应用会让最终上下文随运行时序漂移(非确定性——同一次对话跑两遍结果不同)。所以延迟收集、按声明顺序回放。而串行批(runToolsSerially)顺序天然等于声明顺序,立即应用即可——两个执行器对外可观察的最终效果一致。


六、并发层:流式下怎么边收边跑

编排层管的是「静态的一批 tool_use」,但真实场景是流式的——模型一边吐 token,tool_use 一边到。你不能等整条回复结束才开始执行工具,那样白等。所以有 StreamingToolExecutor.ts:边收边跑。

哎,流式下怎么决定「这个工具现在能不能开始跑」?

用读写锁(L219-225):

private canExecuteTool(isConcurrencySafe: boolean): boolean {
  const executingTools = this.tools.filter(t => t.status === 'executing')
  return (
    executingTools.length === 0 ||                              // 空闲,放行
    (isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
  )                                                             // 全是读锁,可共享
}

并发安全工具之间共享「读锁」(Read、Grep、Glob 可同时跑),非并发安全工具独占「写锁」(Edit、Bash 必须等前面的都跑完)。而队列是自驱动的——每个工具执行完,promise.finally(() => processQueue()) 自动触发下一轮扫描,不需要外部轮询(L580-582)。

哎,executeTool 那四个区块是干嘛的?

这是单个工具在流式执行器里的完整生命周期(L411-583):

【区块一】提前中止检查 —— 已被中止(错误/用户/回退)就生成合成错误,不真正执行
【区块二】per-tool AbortController 创建 —— 每个工具一个子控制器,监听冒泡
【区块三】runToolUse 循环 —— 双通道分拣:进度→pendingProgress(豁免保序)、结果→results(保序)
【区块四】完成标记 + promise.finally 触发下一轮队列

这里藏着三层 AbortController 级联(L134-136 / L452-469):

toolUseContext.abortController   最外层:用户中断 / 查询级中止
        │ 子控制器
        ▼
siblingAbortController           中间层:Bash 出错时中止兄弟工具
        │ 子控制器
        ▼
toolAbortController              最内层:每个工具独立的中止

有了:三层级联让「中止」有精确的粒度——最外层中止整个轮次,中间层只中止兄弟工具(一个 Bash 命令失败,mkdir 失败后面命令无意义,所以连带取消同批的兄弟),最内层中止单个工具。而 Bash 错误触发 sibling abort 是有意的不对称:Read/WebFetch 这类独立工具,一个失败不该影响其他。

哎,那流式回退(fallback)时,已经入队/执行中的工具怎么办?

discard()(L146-148)标记整个 executor 作废,已入队未开始的工具不再启动,执行中的工具收到合成错误消息(createSyntheticErrorMessage,L257-309)——区分三种原因:user_interrupted(用户拒绝)、streaming_fallback(流式回退)、sibling_error(被兄弟连带)。

最后,结果怎么拿?双出口:

  • getCompletedResults()(同步 generator,不等待)——SSE 流的每个事件后调用,「有就拿走,没有拉倒」,快工具不被慢工具卡住。
  • getRemainingResults()(异步 generator,Promise.race 双事件)——流结束后调用,确保所有工具都完成。

七、实例层:FileReadTool 怎么填问卷

前六层都是「框架」,这一层落到「血肉」——一个真实工具怎么把问卷填满。选 FileReadTool 因为它几乎实现了全部契约方法,是「怎么填问卷」的最佳范本(FileReadTool.ts L337)。

先看它怎么填「自省属性」那几栏:

isConcurrencySafe() { return true },        // 读文件,可并发
isReadOnly() { return true },               // 只读,无副作用
isSearchOrReadCommand() { return { isSearch: false, isRead: true } },  // UI 折叠为「读取」
toAutoClassifierInput(input) { return input.file_path },  // 分类器只看路径

哎,maxResultSizeChars: Infinity 是什么意思?为什么别的工具要设上限,Read 反而设无穷大?

看它的注释(L340-342):

// Output is bounded by maxTokens. Persisting to a file the model reads back
// with Read is circular — never persist.
maxResultSizeChars: Infinity,

有了:Read 的输出本就被 maxTokens 限制了,若再把它「因超限而持久化到文件」,模型就会「Read → 文件 → 再 Read」地循环。所以 Read 永远不落盘。这一栏的默认值不是随便填的,而是对着「持久化会不会造成循环」这个具体风险。

再看他怎么填「交给决策者」的 validateInput(L418-495)——这正是第三章「LLM 防御体系」里的第五层:路径/文件防御,一串纯字符串判断,不做 I/O:

// 1. pages 参数:纯字符串解析,校验页码范围
if (pages !== undefined) {
  const parsed = parsePDFPageRange(pages)
  if (!parsed) return { result: false, message: 'Invalid pages parameter...', errorCode: 7 }
  if (rangeSize > PDF_MAX_PAGES_PER_READ) return { result: false, ..., errorCode: 8 }
}
// 2. 路径展开 + deny 规则检查(无 I/O)
const fullFilePath = expandPath(file_path)
const denyRule = matchingRuleForInput(fullFilePath, ..., 'read', 'deny')
if (denyRule !== null) return { result: false, ..., errorCode: 1 }
// 3. UNC 路径检查(防 NTLM 凭据泄露,I/O 延后到授权后)
// 4. 二进制扩展名检查(字符串判断,PDF/图片/SVG 除外)
// 5. 设备文件拦截(/dev/zero 会无限输出、/dev/tty 会阻塞)
if (isBlockedDevicePath(fullFilePath)) return { result: false, ..., errorCode: 9 }

注意第 5 步的 BLOCKED_DEVICE_PATHS(L98-128)——/dev/zero、/dev/random 这类设备文件会无限输出(永不 EOF),/dev/stdin、/dev/tty 会阻塞等待输入。这是一个「纯路径判断、不做 I/O」的防御:读一个设备文件就可能挂死进程。而第 3 步的 UNC 路径检查(\\ 或 // 开头)更是刻意把文件系统操作延后到用户授权之后——否则读一个 \\attacker\share 的 UNC 路径,会触发 NTLM 凭据握手,把你的登录凭据泄露给远程主机。

最后是「交给表达者」和「语义暴露」那几栏:

checkPermissions(input, context) {         // 只读权限:读 + 通配匹配
  return checkReadPermissionForTool(FileReadTool, input, ...)
}
backfillObservableInput(input) {           // 展开路径,防 allowlist 绕过
  if (typeof input.file_path === 'string')
    input.file_path = expandPath(input.file_path)
}
preparePermissionMatcher({ file_path }) {  // 交出匹配闭包
  return pattern => matchWildcardPattern(pattern, file_path)
}

有了:FileReadTool 把问卷的每一栏都填得克制而精确——isConcurrencySafe 就一个 return true,因为它真的安全;validateInput 做了一串「无 I/O 的字符串判断」,因为它知道I/O 要留到用户授权之后(防 NTLM 凭据泄露);maxResultSizeChars 设无穷大,因为它知道「落盘会造成循环」。问卷的价值不在「填得多」,而在「每一栏都对着真实的风险敞口」。


八、可迁移的设计原则

回顾七层,有六条原则值得迁移到任何「要统一调度一批异构组件」的系统里。

第一,把接口收窄到「唯一能力 + 声明式自省」。

Tool 接口只有 call() 一个副作用动作,其余全是布尔自省和语义暴露。一个组件做得越多,统一调度就越难;把它收窄成「干一件事 + 回答若干问题」,上层才能无差别地对待它。「能力」收敛到一,是「统一」的前提。

第二,强校验留代码,弱描述留数据。

输入的两份 schema(Zod 代码 vs JSON 数据)说明:能执行的校验器和能传输的描述不是一回事。合并成一个,要么牺牲可执行性,要么牺牲可序列化性。信任方向决定校验强度,也决定它该是「代码」还是「数据」。

第三,顺序一旦进入缓存键,就冻结成契约。

工具清单的 append-only 冻结序、assembleToolPool 的「内置连续前缀 + 全局断点」提醒我们:当输出的「次序」会进入缓存键、哈希、协议时,次序就不再是「实现细节」而是「接口的一部分」。「看起来很乱但没人敢动」,本身就是一条设计信息。

第四,校验分两道闸:通用层管类型,组件层管语义。

safeParse(通用 Zod)挡「形状错」,validateInput(工具自实现)挡「语义错」。通用的归通用,专属的归组件——不要让通用层去猜组件的业务语义,也不要让每个组件重复造类型的轮子。

第五,并发安全的判定,宁可错杀。

isConcurrencySafe 默认 false、判定器抛异常也归「不安全」、编排层三道门控全 fail-closed——因为「错误地并行」比「保守地串行」危险得多。凡是「缺席会危险」的能力,默认值必须倒向安全的一边。

第六,上下文变更必须按声明顺序确定性回放。

并发工具完成顺序随机,但 contextModifier 按 tool_use 声明顺序回放——因为「影响后续」的变更一旦随运行时序漂移,系统就不可复现。凡是多个异步生产者共享可变状态,最终状态必须由「声明顺序」而非「完成顺序」决定。

贯穿这六条原则的,是整套工具体系最根本的设计哲学:

工具体系不是「管理一堆函数」,而是「把异构收敛成同构」。

它的目标不是「让每个工具更强」,而是「让几十个完全不同的工具,能被同一套系统调度、审查、扩展」。

而「收敛」靠的不是给工具加约束,恰恰相反——是把工具的「能力」收到最小(call),把工具的「语义」主动交出去(自省属性 + 语义暴露),让上层系统在一个统一的接口上,用「声明 + 外置 + 确定性」,拼出原本需要几十份特例才能实现的灵活。

这又绕回了整个系列反复出现的那个主题——用上下文代替状态机。工具不再是「被调用就干活的函数」,而是「把语义暴露给上游、让外置系统接管其生命周期的节点」。消息机制把「发生了什么」翻译成自然语言,权限系统把「该不该做」翻译成决策,而工具体系做的,是把「我是什么、我该怎么被看懂」翻译成接口上的声明。