第2章:工具系统 — 40+ 个工具作为模型的双手
为什么工具系统是 Claude Code 的核心
大语言模型的“思考“发生在文本空间,而软件工程的操作发生在文件系统、终端和网络中。工具系统是连接这两个世界的桥梁:它将模型的意图翻译为真实的副作用,再将副作用的结果翻译回模型可消费的文本。
Claude Code 的工具系统管理着 40+ 个内置工具和不限数量的 MCP 扩展工具。这些工具不是一个平铺的数组——它们经历了一条精密的管线:定义 → 注册 → 过滤 → 调用 → 渲染。每一步都有明确的契约。本章将从 Tool.ts 的接口定义开始,逐层拆解这条管线的设计决策。
2.1 Tool 接口契约
所有工具——无论是内置的 BashTool 还是通过 MCP 协议加载的第三方工具——都必须满足同一个 TypeScript 接口。这个接口定义在 restored-src/src/Tool.ts:362-695,是整个工具系统的基石。
核心字段一览
| 字段 | 类型 | 职责 | 必需 |
|---|---|---|---|
name | readonly string | 工具的唯一标识符,用于权限匹配、分析埋点和 API 传输 | 是 |
description | (input, options) => Promise<string> | 返回发送给模型的工具描述文本;可根据权限上下文动态调整 | 是 |
prompt | (options) => Promise<string> | 返回工具的系统提示词(system prompt),详见第8章 | 是 |
inputSchema | z.ZodType (Zod v4) | 用 Zod schema 定义工具的参数结构,自动转换为 JSON Schema 发送给 API | 是 |
call | (args, context, canUseTool, parentMessage, onProgress?) => Promise<ToolResult> | 工具的核心执行逻辑 | 是 |
checkPermissions | (input, context) => Promise<PermissionResult> | 工具级权限检查,在通用权限系统之后执行 | 是* |
validateInput | (input, context) => Promise<ValidationResult> | 在权限检查之前验证输入的合法性 | 否 |
maxResultSizeChars | number | 单工具结果的字符数上限,超出后持久化到磁盘 | 是 |
isConcurrencySafe | (input) => boolean | 是否可以与其他工具并发执行 | 是* |
isReadOnly | (input) => boolean | 是否为只读操作(不修改文件系统) | 是* |
isEnabled | () => boolean | 当前环境下工具是否可用 | 是* |
标注 * 的字段由
buildTool()提供默认值,工具定义时可省略。
几个值得深究的设计选择:
description 是函数而非字符串。 同一个工具在不同的权限模式下可能需要不同的描述。例如,当用户配置了 alwaysDeny 规则禁止某些子命令时,工具描述可以主动告知模型“不要尝试这些操作“,从而在提示层面就避免无用的工具调用。
inputSchema 使用 Zod v4。 这让工具参数可以在运行时进行严格校验,同时通过 z.toJSONSchema() 自动生成发送给 Anthropic API 的 JSON Schema。Zod 的 z.strictObject() 确保模型不会传入未定义的参数。
call 接收 canUseTool 回调。 这是一个极其重要的设计——工具执行过程中可能需要递归地检查子操作的权限。例如 AgentTool 在启动子 Agent 时需要检查子 Agent 是否有权使用特定工具。权限检查不是一次性的门禁,而是贯穿执行过程的持续验证。
渲染契约:三组方法
Tool 接口中定义了一组渲染方法,它们构成了工具在终端 UI 中的完整生命周期表现(详见 2.5 节):
renderToolUseMessage // 工具被调用时展示
renderToolUseProgressMessage // 工具执行中展示进度
renderToolResultMessage // 工具执行完成后展示结果
此外还有 renderToolUseErrorMessage、renderToolUseRejectedMessage(权限被拒)和 renderGroupedToolUse(并行工具的分组展示)等可选方法。
2.2 buildTool() 工厂函数与失败关闭默认值
每一个具体工具都不是直接导出一个满足 Tool 接口的对象,而是通过 buildTool() 工厂函数构建。这个函数定义在 restored-src/src/Tool.ts:783-792:
// export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
return {
...TOOL_DEFAULTS,
userFacingName: () => def.name,
...def,
} as BuiltTool<D>
}
运行时行为极简——就是一个对象展开(spread)。但它的类型层面设计(BuiltTool<D> 类型)精确地模拟了 { ...TOOL_DEFAULTS, ...def } 的语义:如果工具定义提供了某个方法,使用工具定义的版本;否则使用默认值。
默认值与“失败关闭“哲学
TOOL_DEFAULTS(restored-src/src/Tool.ts:757-769)的设计遵循一个安全原则——在不确定时假设最危险的情况:
| 默认方法 | 默认值 | 设计意图 |
|---|---|---|
isEnabled | () => true | 除非明确禁用,工具默认可用 |
isConcurrencySafe | () => false | 失败关闭:假设不安全,禁止并发 |
isReadOnly | () => false | 失败关闭:假设会写入,需要权限 |
isDestructive | () => false | 默认非破坏性 |
checkPermissions | 返回 { behavior: 'allow' } | 交给通用权限系统处理 |
toAutoClassifierInput | () => '' | 默认不参与自动安全分类 |
userFacingName | () => def.name | 使用工具名称 |
其中最重要的两个默认值是 isConcurrencySafe: false 和 isReadOnly: false。这意味着:一个新工具如果忘记声明这两个属性,系统会自动将其视为“可能修改文件系统且不能并发执行“——这是最保守、最安全的假设。只有当工具开发者主动声明 isConcurrencySafe() { return true } 和 isReadOnly() { return true } 时,系统才会放宽限制。
实际工具如何使用 buildTool
以 GrepTool 为例(restored-src/src/tools/GrepTool/GrepTool.ts:160-194):
export const GrepTool = buildTool({
name: GREP_TOOL_NAME,
searchHint: 'search file contents with regex (ripgrep)',
maxResultSizeChars: 20_000,
strict: true,
// ...
isConcurrencySafe() { return true }, // 搜索是安全的并发操作
isReadOnly() { return true }, // 搜索不修改文件
// ...
})
GrepTool 明确覆盖了两个默认值,因为搜索操作天然是只读且并发安全的。相比之下,BashTool(restored-src/src/tools/BashTool/BashTool.tsx:434-441)的并发安全性是有条件的:
isConcurrencySafe(input) {
return this.isReadOnly?.(input) ?? false;
},
isReadOnly(input) {
const compoundCommandHasCd = commandHasAnyCd(input.command);
const result = checkReadOnlyConstraints(input, compoundCommandHasCd);
return result.behavior === 'allow';
},
BashTool 只有在被判定为只读命令时才允许并发——一个 git status 可以并发执行,但 git push 不行。这种输入感知的并发控制是 buildTool 的方法签名接收 input 参数的原因。
2.3 工具注册管线:tools.ts
restored-src/src/tools.ts 是工具池(Tool Pool)的组装中心。它回答一个核心问题:在当前环境下,模型可以使用哪些工具?
三级过滤
工具从定义到最终可用,经历三级过滤:
第一级:编译期/启动期条件加载。 大量工具通过 Feature Flag 进行条件加载(restored-src/src/tools.ts:16-135):
const SleepTool =
feature('PROACTIVE') || feature('KAIROS')
? require('./tools/SleepTool/SleepTool.js').SleepTool
: null
const cronTools = feature('AGENT_TRIGGERS')
? [
require('./tools/ScheduleCronTool/CronCreateTool.js').CronCreateTool,
require('./tools/ScheduleCronTool/CronDeleteTool.js').CronDeleteTool,
require('./tools/ScheduleCronTool/CronListTool.js').CronListTool,
]
: []
feature() 函数来自 bun:bundle,在打包时求值。这意味着未启用的工具根本不会出现在最终的 JavaScript bundle 中——这是一种比运行时 if 更彻底的死代码消除。
除了 Feature Flag,还有环境变量驱动的条件加载:
const REPLTool =
process.env.USER_TYPE === 'ant'
? require('./tools/REPLTool/REPLTool.js').REPLTool
: null
USER_TYPE === 'ant' 标记 Anthropic 内部员工使用的特殊工具(如 REPLTool、ConfigTool、TungstenTool),这些工具在公开版本中不可用。
第二级:getAllBaseTools() 组装基础工具池。 这个函数(restored-src/src/tools.ts:193-251)将所有通过第一级过滤的工具收集到一个数组中。它是系统的“工具注册表“——所有可能存在的工具都在这里登记。当前版本包含约 40+ 个内置工具,根据 Feature Flag 的启用情况动态增减。
// export function getAllBaseTools(): Tools {
return [
AgentTool,
TaskOutputTool,
BashTool,
...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
FileReadTool,
FileEditTool,
FileWriteTool,
// ... 省略 30+ 个工具
...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []),
]
}
注意一个有趣的条件:hasEmbeddedSearchTools()。在 Anthropic 内部构建中,bfs(fast find)和 ugrep 被嵌入到 Bun 二进制文件中,此时 shell 里的 find 和 grep 已经被别名到这些快速工具,独立的 GlobTool 和 GrepTool 就变得多余了。
第三级:getTools() 运行时过滤。 这是最终的过滤层(restored-src/src/tools.ts:271-327),它执行三个操作:
- 权限拒绝过滤:通过
filterToolsByDenyRules()移除被alwaysDeny规则覆盖的工具。如果用户配置了"Bash": "deny",BashTool在发送给模型的工具列表中根本不会出现。 - REPL 模式隐藏:当 REPL 模式启用时,
Bash、Read、Edit等基础工具被隐藏——它们通过REPLTool的 VM 上下文间接暴露。 isEnabled()最终检查:每个工具的isEnabled()方法是最后一道开关。
简单模式与完整模式
getTools() 还支持一种“简单模式“(CLAUDE_CODE_SIMPLE),只暴露 Bash、FileRead 和 FileEdit 三个核心工具。这在一些集成场景下很有用——减少工具数量可以降低 token 消耗并减少模型的决策负担。
MCP 工具的融合
最终的工具池由 assembleToolPool()(restored-src/src/tools.ts:345-367)完成组装:
// export function assembleToolPool(
permissionContext: ToolPermissionContext,
mcpTools: Tools,
): Tools {
const builtInTools = getTools(permissionContext)
const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext)
const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name)
return uniqBy(
[...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
'name',
)
}
这里有两个关键设计:
- 内置工具优先:
uniqBy保留第一次出现的名称,内置工具排在前面,因此在名称冲突时内置工具胜出。 - 按名称排序以稳定提示缓存:内置工具和 MCP 工具各自排序后拼接(而非混合排序),确保内置工具作为“连续前缀“出现。这与 API 服务端的缓存断点设计协作——如果 MCP 工具穿插在内置工具中间,任何 MCP 工具的增减都会导致所有下游缓存键失效。详见第13章。
2.4 工具结果大小预算
当一个工具返回结果时,系统面临一个核心矛盾:模型需要看到完整的信息来做出正确决策,但上下文窗口是有限的。Claude Code 通过两级预算解决这个问题。
第一级:单工具结果上限 maxResultSizeChars
每个工具通过 maxResultSizeChars 字段声明自己的结果大小上限。超出此上限的结果会被持久化到磁盘,模型只看到一个预览(preview)加上磁盘文件路径。
以下是不同工具的 maxResultSizeChars 对比:
| 工具 | maxResultSizeChars | 说明 |
|---|---|---|
McpAuthTool | 10,000 | 认证结果,数据量小 |
GrepTool | 20,000 | 搜索结果需要精简 |
BashTool | 30,000 | Shell 输出可能较长 |
GlobTool | 100,000 | 文件列表可能很多 |
AgentTool | 100,000 | 子 Agent 结果 |
WebSearchTool | 100,000 | 网页搜索结果 |
BriefTool | 100,000 | 简要总结 |
FileReadTool | Infinity | 永不持久化(见下文) |
FileReadTool 的 maxResultSizeChars: Infinity 是一个特殊设计——避免 Read → 持久化文件 → Read 的循环引用。系统还有一个全局上限 DEFAULT_MAX_RESULT_SIZE_CHARS = 50,000(restored-src/src/constants/toolLimits.ts:13),作为无论工具声明什么值都生效的硬顶。
第二级:单消息聚合上限
当模型在一个回合中并行调用多个工具时,所有工具结果会作为同一个 user message 的多个 tool_result 块发送。MAX_TOOL_RESULTS_PER_MESSAGE_CHARS = 200,000(restored-src/src/constants/toolLimits.ts:49)限制了单条消息的工具结果总大小,防止 N 个并行工具集体挤爆上下文窗口。
FileReadTool Infinity 设计理由、per-message 预算的持久化实现细节(包括 ContentReplacementState 决策持久性和 Infinity 豁免机制)详见第4章。
大小预算参数汇总
| 常量 | 值 | 定义位置 |
|---|---|---|
DEFAULT_MAX_RESULT_SIZE_CHARS | 50,000 字符 | constants/toolLimits.ts:13 |
MAX_TOOL_RESULT_TOKENS | 100,000 token | constants/toolLimits.ts:22 |
MAX_TOOL_RESULT_BYTES | 400,000 字节 | constants/toolLimits.ts:33(= 100K token x 4 bytes/token) |
MAX_TOOL_RESULTS_PER_MESSAGE_CHARS | 200,000 字符 | constants/toolLimits.ts:49 |
TOOL_SUMMARY_MAX_LENGTH | 50 字符 | constants/toolLimits.ts:57 |
2.5 三阶段渲染流程
工具在终端 UI 中的呈现不是一次性的,而是一个三阶段渐进过程。这三个阶段与工具执行的生命周期一一对应。
流程图
flowchart TD
A["模型发出 tool_use 块<br />(参数可能尚未流式完毕)"] --> B
B["阶段 1: renderToolUseMessage<br />工具被调用,显示名称和参数<br />参数是 Partial<Input>(流式传输中)"]
B -->|工具开始执行| C
C["阶段 2: renderToolUseProgressMessage<br />执行中,展示进度<br />通过 onProgress 回调更新"]
C -->|工具执行完成| D
D["阶段 3: renderToolResultMessage<br />完成,展示结果"]
阶段 1:renderToolUseMessage — 意图展示
当模型输出一个 tool_use 块时,这个方法立即被调用。注意其签名中的关键类型:
renderToolUseMessage(
input: Partial<z.infer<Input>>, // 注意是 Partial!
options: { theme: ThemeName; verbose: boolean; commands?: Command[] },
): React.ReactNode
input 是 Partial 的原因是:API 以流式方式返回工具参数的 JSON,在 JSON 解析完成之前只有部分字段可用。UI 必须在参数不完整时就能渲染——用户不应该看到空白屏幕。
以 BashTool 为例,即使 command 字段尚未完全接收,UI 已经可以显示 “Bash” 标签和已接收的部分命令文本。
阶段 2:renderToolUseProgressMessage — 过程可见性
这是一个可选方法。对于执行时间较长的工具(如 BashTool、AgentTool),进度反馈至关重要。BashTool 在 shell 命令执行超过 2 秒后开始显示进度(PROGRESS_THRESHOLD_MS = 2000,restored-src/src/tools/BashTool/BashTool.tsx:55)。
进度通过 onProgress 回调传递。每个工具的进度数据结构不同——BashTool 的 BashProgress 包含 stdout/stderr 片段,AgentTool 的 AgentToolProgress 包含子 Agent 的消息流。这些类型在 restored-src/src/types/tools.ts 中统一定义,通过 ToolProgressData 联合类型约束。
阶段 3:renderToolResultMessage — 结果呈现
这也是一个可选方法——省略时工具结果不在终端渲染(例如 TodoWriteTool 的结果通过专用面板展示,不出现在对话流中)。
renderToolResultMessage 接收 style?: 'condensed' 选项。在非 verbose 模式下,搜索类工具(GrepTool、GlobTool)显示精简摘要(如 “Found 42 files across 3 directories”),而在 verbose 模式下展示完整结果。工具可以通过 isResultTruncated(output) 方法告诉 UI 当前结果是否被截断,从而在全屏模式下启用“点击展开“交互。
分组渲染:renderGroupedToolUse
当模型在一个回合中并行调用多个同类型工具时(例如 5 个 Grep 搜索),逐个渲染会占据大量屏幕空间。renderGroupedToolUse 方法允许工具将多个并行调用合并为一个紧凑的分组视图——例如 “Searched 5 patterns, found 127 results across 34 files”。
这个方法只在非 verbose 模式下生效。verbose 模式下每个工具调用仍然在其原始位置独立渲染,确保调试时信息不丢失。
2.6 从具体工具看设计模式
BashTool:最复杂的工具
BashTool(restored-src/src/tools/BashTool/BashTool.tsx)是整个工具系统中最复杂的单一工具,因为 shell 命令的语义空间是无限的。它需要:
- 解析命令结构来判断是否只读(通过
checkReadOnlyConstraints和parseForSecurity) - 感知管道和复合命令(
ls && echo "---" && ls仍然是只读的) - 条件性并发:只有只读命令才能并发执行
- 进度追踪:超过 2 秒的命令显示 stdout 流式输出
- 文件变更追踪:通过
fileHistoryTrackEdit和trackGitOperations记录 shell 命令导致的文件修改 - 沙箱执行:在某些条件下通过
SandboxManager隔离执行
BashTool 的 maxResultSizeChars 设为 30,000——比 GrepTool 的 20,000 宽松,因为 shell 输出通常包含更多结构化信息(编译错误、测试结果等),模型需要看到足够的上下文才能做出正确判断。
GrepTool:并发安全的典范
GrepTool 的设计相对简洁。它无条件声明 isConcurrencySafe: true 和 isReadOnly: true,因为搜索操作永远不会修改文件系统。它的 maxResultSizeChars 设为 20,000——搜索结果超过这个长度说明模型的搜索范围太宽,持久化到磁盘并返回预览反而有助于模型调整策略。
FileReadTool:Infinity 的哲学
FileReadTool 将 maxResultSizeChars 设为 Infinity,选择通过自身的 maxTokens 和 maxSizeBytes 限制来控制输出大小。这避免了前文提到的循环读取问题,也意味着 FileReadTool 的结果永远不会被替换为磁盘引用——模型总是能直接看到文件内容。
2.7 延迟加载与 ToolSearch
当工具数量超过一定阈值时(尤其是 MCP 工具大量接入后),将所有工具的完整 schema 发送给模型会消耗大量 token。Claude Code 通过延迟加载(Deferred Loading)机制解决这个问题。
标记了 shouldDefer: true 的工具在初始提示中只发送工具名称(defer_loading: true),不发送完整的参数 schema。模型需要先调用 ToolSearchTool 按关键词搜索并获取工具的完整定义后,才能调用这些延迟加载的工具。
每个工具的 searchHint 字段就是为此设计的——它提供 3-10 个词的能力描述,帮助 ToolSearchTool 进行关键词匹配。例如 GrepTool 的 searchHint 是 'search file contents with regex (ripgrep)'。
标记了 alwaysLoad: true 的工具则永远不会被延迟——它们的完整 schema 总是出现在初始提示中。这适用于模型在第一轮对话就必须能直接调用的核心工具。
2.8 模式提炼
从 Claude Code 的工具系统设计中,可以提炼出几个对 AI Agent 构建者普遍有价值的模式:
模式 1:失败关闭的默认值。 buildTool() 的默认值假设最危险的情况(不可并发、非只读),工具开发者必须主动声明安全属性。这将安全从“选择加入“翻转为“选择退出“,大幅降低了遗漏导致的风险。
模式 2:分层预算控制。 单工具结果有上限,单消息也有聚合上限。两层控制互相补充——单工具上限防止单点失控,消息上限防止并行调用的集体爆炸。
模式 3:输入感知的属性。 isConcurrencySafe(input) 和 isReadOnly(input) 接收工具输入,而非全局判断。同一个 BashTool,ls 和 rm 有完全不同的安全属性。这种细粒度的输入感知是实现精确权限控制的基础。详见第4章。
模式 4:渐进渲染。 三阶段渲染(意图 → 进度 → 结果)让用户在工具执行的每个阶段都有可见性。Partial<Input> 的设计确保即使在参数流式传输期间,UI 也不会空白。这对用户信任至关重要——用户需要知道 Agent 正在做什么,而不是盯着一个旋转的加载图标。
模式 5:编译期消除 vs 运行时过滤。 Feature Flag 通过 bun:bundle 的 feature() 在编译期消除未启用的工具代码,而权限规则在运行时过滤工具列表。两种机制服务不同目的:前者减小 bundle 体积和攻击面,后者支持用户级配置。
用户能做什么
基于 Claude Code 工具系统的设计经验,以下是构建自己的 AI Agent 工具系统时可以采取的行动:
- 采用“失败关闭“默认值。 在你的工具注册框架中,将
isConcurrencySafe、isReadOnly等安全属性的默认值设为最保守的选项。让工具开发者主动声明安全属性,而非默认假设安全。 - 为每个工具设置结果大小上限。 不要让工具返回无限大的结果。设置单工具上限(如
maxResultSizeChars)和单消息聚合上限,超出时持久化到磁盘并返回预览。 - 让工具描述成为函数而非静态字符串。 如果你的工具在不同权限模式或上下文下有不同的行为限制,动态生成描述可以在提示层面引导模型避免无效调用。
- 实现三阶段渲染。 为长时间运行的工具提供进度反馈(意图展示 → 执行进度 → 最终结果),让用户始终知道 Agent 在做什么。支持
Partial<Input>在参数流式传输期间也能渲染。 - 使用条件加载减少工具集。 通过 Feature Flag 或环境变量在编译期/启动期过滤不需要的工具,减少 token 消耗和模型决策负担。对于大量 MCP 工具场景,考虑延迟加载(deferred loading)机制。
- 工具排序要稳定。 如果你使用 API 提示缓存,确保工具列表的顺序在请求之间保持稳定。将内置工具作为连续前缀,MCP 工具按名称排序追加,避免缓存键频繁失效。
小结
Claude Code 的工具系统是一个精心分层的架构:Tool 接口定义契约,buildTool() 提供安全默认值,tools.ts 的注册管线通过编译期和运行时两级过滤组装工具池,大小预算机制在单工具和单消息两个层面控制上下文消耗,三阶段渲染让工具执行过程对用户完全透明。
这套系统的设计哲学可以用一句话总结:让正确的事情容易,让危险的事情困难。 buildTool() 的失败关闭默认值让“忘记声明安全属性“成为一个安全的错误;分层预算让“工具返回过多数据“成为一个可控的降级;条件加载让“添加实验性工具“成为一个零风险的操作。
工具的调用和编排——包括权限检查的完整流程、并发执行的调度策略、流式进度的传播机制——将在第4章详细展开。