4 · 工具模型

这一章讲「一个工具」在程序里被建模成什么样子,以及 40 个内建工具是怎么被组织和投放的。

4.1 Tool 接口:七组正交能力

Tool.ts 全文 793 行,其中类型定义 Tool<Input, Output, Progress> 占了 330 行。它把「一个工具需要回答的所有问题」切成了七组互不重叠的能力:

Tool<Input, Output, Progress> └─ 三个尖括号里是"泛型参数",意思是"输入类型、输出类型、 进度类型由每个具体工具自己决定" │ ├─ ① 执行 │ call(参数, 上下文, 权限检查函数, 父消息, 进度回调) → Promise<结果> │ 这是唯一真正干活的方法 │ ├─ ② 契约(描述参数长什么样) │ inputSchema 用 Zod 库写的运行时校验模式 │ inputJSONSchema 给 MCP 外部工具用的原始 JSON Schema │ outputSchema 输出的模式 │ strict 是否让接口更严格地遵守参数模式 │ ├─ ③ 提示词(给模型看的文字) │ prompt() 完整说明,进系统提示词,每轮都要重发 │ description(输入) 这一次调用的简短描述 │ searchHint 3~10 个关键词,供"工具搜索"匹配 │ ├─ ④ 安全谓词(给调度器看的布尔问题) │ isReadOnly(参数) 这次是只读的吗 │ isDestructive(参数) 会不可逆地破坏东西吗 │ isConcurrencySafe(参数) 能和别的工具同时跑吗 │ isEnabled() 当前环境下可用吗 │ isOpenWorld(参数) 会访问外部网络吗 │ requiresUserInteraction() 必须有人在场吗 │ isSearchOrReadCommand(参数) 界面上要不要折叠显示 │ ├─ ⑤ 权限 │ validateInput(参数, 上下文) 参数合法吗(不合法要告诉模型为什么) │ checkPermissions(参数, 上下文) 该放行吗(工具特有的判断) │ preparePermissionMatcher(参数) 给钩子条件用的匹配器 │ toAutoClassifierInput(参数) 给安全分类器的压缩表示 │ ├─ ⑥ 预算与生命周期 │ maxResultSizeChars 结果超多少字符就落盘 │ interruptBehavior() 被中断时是 'cancel' 还是 'block' │ shouldDefer / alwaysLoad 说明文字是否延迟加载 │ backfillObservableInput(输入) 只改可观测副本,不动原件 │ inputsEquivalent(a, b) 两次调用参数等价吗(去重用) │ └─ ⑦ 渲染(10 个以上方法,全部和终端界面耦合) renderToolUseMessage 调用进行中 renderToolUseProgressMessage 进度 renderToolUseQueuedMessage 排队中 renderToolResultMessage 结果 renderToolUseRejectedMessage 被拒绝 renderToolUseErrorMessage 出错 renderGroupedToolUse 多个并行调用合并显示 renderToolUseTag 调用后面的小标签(超时/模型等) getToolUseSummary(输入) 紧凑视图下的一行摘要 getActivityDescription(输入) 加载动画旁边的活动描述 isResultTruncated(输出) 非详细模式下是否被截断了 extractSearchText(输出) 供对话记录搜索的纯文本 mapToolResultToToolResultBlockParam(输出, id) → 回传给模型的序列化

4.2 为什么「安全谓词」值得单独成组

因为这一组不是给人看的,是给调度器看的。调度器完全不认识任何具体工具 —— 它不知道什么是 Bash、什么是 Read,它只会问这几个布尔问题,然后据此安排执行:

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

这样一来,调度策略就从工具实现里被完全剥离出来了。

新增一个工具时,不需要修改调度器的任何一行代码 —— 只需要在新工具里如实回答这几个问题。反过来,改进调度算法时也不需要碰任何工具的实现。

一个容易被忽略的细节:谓词接收参数

isConcurrencySafe(input)接收参数的方法,不是一个静态标记。

同一个 Bash 工具:执行 ls(列文件)是并发安全的,执行 rm -rf(删除)就不安全。安全性取决于这次具体要做什么,而不取决于工具类型。如果建模成静态标记,Bash 工具就只能永远声明「我不安全」,从而失去所有并行机会。

4.3 失败保守默认值

所有工具都通过一个工厂函数创建:

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

源码注释总结了设计原则:「Defaults (fail-closed where it matters)」(默认值在重要的地方倒向保守)。

工具作者忘了声明后果
并发安全性当成不安全 → 串行执行 → 慢一点,但绝不会出竞态
只读性当成会写入 → 多问一次权限 → 啰嗦一点,但绝不会误放行
破坏性当成不破坏 → 少一次确认

唯一一个看起来违反原则的默认值

toAutoClassifierInput 默认返回空字符串,意思是「这个工具不进安全分类器的视野」。注释解释了原因:

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

逻辑是:安全分类器是给「有安全含义」的工具用的。一个工具如果没有显式声明自己有安全含义,它就不该占用分类器的 token 预算。

安全性由前面那条 10 步权限判定链保证(第 7 章),不靠分类器兜底。这个区分把「省钱」和「保安全」两件事的责任分清了 —— 分类器是成本敏感的优化手段,不是安全防线。

4.4 40 个内建工具分类

类别工具
文件操作 FileReadTool 读 · FileWriteTool 写 · FileEditTool 精确替换 · NotebookEditTool 改 Jupyter 笔记本
搜索 GlobTool 按文件名模式找 · GrepTool 按内容找
注意:在内部版本里这两个会被去掉 —— 因为可执行文件里内嵌了更快的搜索程序,直接在 shell 里用
命令执行 BashTool(157 KB,最复杂的工具)· PowerShellTool(Windows,141 KB)· REPLTool(内部版,让模型写 JS 编排内部工具)
网络 WebFetchTool 抓网页 · WebSearchTool 搜索 · WebBrowserTool 浏览器(特性开关控制)
子智能体 AgentTool(228 KB)· TaskStopTool · TaskOutputTool · TeamCreateTool / TeamDeleteTool(多智能体群)· SendMessageTool
任务管理 TodoWriteTool 待办清单 · TaskCreateTool / TaskGetTool / TaskUpdateTool / TaskListTool(新版任务系统)
交互 AskUserQuestionTool 向用户提问 · EnterPlanModeTool / ExitPlanModeTool 计划模式进出
扩展接入 SkillTool 调用技能 · MCPTool · ListMcpResourcesTool / ReadMcpResourceTool · McpAuthTool · ToolSearchTool 工具搜索
工作树 EnterWorktreeTool / ExitWorktreeTool —— 让智能体在一份隔离的代码副本里工作
定时与远程 ScheduleCronTool(创建/删除/列出定时任务)· RemoteTriggerTool · SleepTool
其他 LSPTool 代码导航 · ConfigTool · BriefTool · SyntheticOutputTool 结构化输出 · SnipTool 历史裁剪

工具清单是条件组装的

export function getAllBaseTools(): Tools {
  return [
    AgentTool,
    TaskOutputTool,
    BashTool,
    // 内部原生构建版把快速搜索程序内嵌进了可执行文件,
    // shell 里的 find/grep 被别名指向它们,所以不需要独立的 Glob/Grep 工具
    ...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
    ExitPlanModeV2Tool,
    FileReadTool, FileEditTool, FileWriteTool, NotebookEditTool,
    WebFetchTool, TodoWriteTool, WebSearchTool, TaskStopTool,
    AskUserQuestionTool, SkillTool, EnterPlanModeTool,
    ...(process.env.USER_TYPE === 'ant' ? [ConfigTool] : []),      // 只给内部用户
    ...(isTodoV2Enabled() ? [TaskCreateTool, TaskGetTool, ...] : []),
    ...(isEnvTruthy(process.env.ENABLE_LSP_TOOL) ? [LSPTool] : []),
    ...(isWorktreeModeEnabled() ? [EnterWorktreeTool, ExitWorktreeTool] : []),
    ...(isAgentSwarmsEnabled() ? [getTeamCreateTool(), getTeamDeleteTool()] : []),
    ...cronTools,
    ...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []),
  ]
}

三种条件维度:编译期特性开关feature('XXX'))、运行时环境变量用户类型(内部 / 外部)。第 13 章会讲编译期开关怎么做到「外部版本里这些代码根本不存在」。

4.5 渐进式工具加载

问题

每个工具的完整说明文字都要放进系统提示词,而系统提示词每一轮都要重发。用户接了十几个 MCP 外部服务时,工具总数可能上百个,说明文字加起来几万 token —— 每轮都付一遍。

解法:defer_loading(延迟加载)

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

相关的两个字段:

关键词的写法规范

「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 是那类笔记本文件的实际产品名,模型很可能用这个词描述需求。

延迟加载失败时的补救

延迟加载有一个副作用:模型可能凭记忆调用一个它还没加载完整说明的工具,参数写错了。所以参数校验失败时有一个特殊提示:

const schemaHint = buildSchemaNotSentHint(tool, toolUseContext.messages,
                                          toolUseContext.options.tools)
if (schemaHint) {
  logEvent('tengu_deferred_tool_schema_not_sent', {
    toolName: sanitizeToolNameForAnalytics(tool.name), isMcp: tool.isMcp ?? false })
  errorContent += schemaHint    // 追加提示:"你还没加载这个工具的说明,先搜一下"
}

而且这个情况专门有埋点tengu_deferred_tool_schema_not_sent)—— 说明他们在监控「延迟加载导致的调用失败率」,用来判断这个优化的净收益。

4.6 工具清单装配:一个关于缓存的隐藏约束

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

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

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

claude-code/src/tools.ts

注意:两组是分别排序后拼接的,不是合并成一个大数组统一排序。对不了解缓存机制的人来说,这看起来是多余的复杂化。源码注释给出了答案:

「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 → 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 { ... }

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

系统提示词的缓存是跨用户共享的。工具清单的顺序是那份全局配置的一部分。

如果排序逻辑出错,受影响的不是一个用户,而是所有用户的缓存一起崩。这也解释了为什么这么一段看起来不优雅的代码值得存在。

4.7 backfillObservableInput:一个极致的缓存保护例子

有时工具需要给日志、钩子、开发工具包补充一些派生字段(比如把相对路径展开成绝对路径)。但那个要发回接口的原始参数对象绝对不能改 —— 改一个字节,缓存就没了。

/**
 * Called on copies of tool_use input before observers see it (SDK stream,
 * transcript, canUseTool, PreToolUse/PostToolUse hooks). Mutate in place
 * to add legacy/derived fields. Must be idempotent. The original API-bound
 * input is never mutated (preserves prompt cache).
 */
backfillObservableInput?(input: Record<string, unknown>): void

调用处的实现更讲究:

const originalInput = block.input as Record<string, unknown>
const inputCopy = { ...originalInput }        // 克隆
tool.backfillObservableInput(inputCopy)       // 只改克隆体

// ★ 只有当补充操作"新增了字段"时才产生克隆版消息;
//   如果只是覆写了已有字段,连克隆都不做
const addedFields = Object.keys(inputCopy).some(k => !(k in originalInput))
if (addedFields) {
  clonedContent ??= [...message.message.content]
  clonedContent[i] = { ...block, input: inputCopy }
}

为什么「只覆写已有字段」就不克隆?注释解释了:

「Overwrites change the serialized transcript and break VCR fixture hashes on resume, while adding nothing the SDK stream needs — hooks get the expanded path via toolExecution.ts separately.」

译:覆写会改变序列化后的对话记录,并且在恢复时破坏录制回放测试固件的哈希值,而它又没给开发工具包的流提供任何新东西 —— 钩子已经通过另一条路径拿到展开后的路径了。

录制回放测试:把真实的接口请求响应录下来,测试时回放,避免每次跑测试都真的调接口。它靠请求内容的哈希来匹配录制,所以序列化结果变了就匹配不上。)

这个级别的克制程度,能说明「保护缓存」在这个系统里是一等公民约束 —— 甚至连一个可能影响测试固件的字段覆写都要避免。