Claude Code 源码剖析 第4章:50 个工具的统一契约——Tool System 设计
Claude Code 有超过 50 个工具:Bash、文件读写、网络搜索、子 Agent 派生、MCP 扩展……它们形态各异,但共享一份类型契约
Tool<Input, Output, Progress>。这套设计让”增加一个工具”变成了填表格式的标准操作,同时把安全、权限、并发的复杂性全部下沉到框架层。

问题
一个 Agent 的能力边界取决于它能调用什么工具。Claude Code 面临的工具管理挑战是:
-
• 数量庞大:50+ 内置工具 + 无限 MCP 扩展工具 -
• 安全性差异巨大: Grep只读无害,Bash可以rm -rf / -
• 并发语义不同:两个 Read可以并行,但Bash写文件时其他工具必须等待 -
• 加载成本高:50 个工具的 Schema 约 15K Token,全部发给 API 浪费缓存 -
• 生命周期复杂:输入验证 → 权限检查 → 执行 → 结果格式化 → Hook 回调,每一步都可能中断
如果每个工具各自为政,这些横切关注点会被重复实现 50 遍。Claude Code 的解法是一份统一类型契约加一条标准化执行管线。
在整体架构中的位置
ReAct 循环的每一轮: Phase 1: 上下文准备 ← 工具 Schema 在这里注入 Phase 2: 模型调用 ← 模型从工具列表中选择调用 Phase 3: 工具执行 ← Tool System 在这里运行 Phase 4: 结果收集 ← 工具输出格式化后回传
工具系统横跨 Phase 1 到 Phase 4——从决定哪些工具可见,到执行调用、格式化结果。
Part 1:Tool<I, O, P> 统一类型契约
三个泛型参数
Claude Code 的每个工具都是一个 Tool<Input, Output, Progress> 类型的实例:
type Tool< Input extends AnyObject, // Zod Schema → 输入验证 Output, // call() 的返回数据类型 Progress extends ToolProgressData // 执行过程中的进度事件>
|
|
|
|
|---|---|---|
Input |
|
|
Output |
|
|
Progress |
ToolProgressData |
|
这意味着每个工具的输入在到达 call() 之前已经被 Zod 验证过——类型安全不依赖工具作者的自律,而是框架强制保证。
七大方法族
Tool 类型定义了约 30 个字段,可以归纳为七个功能族:
┌─────────────────────────────────────────────────────┐│ Tool<I, O, P> │├──────────┬──────────────────────────────────────────┤│ 执行 │ call(), validateInput() ││ Schema │ inputSchema, inputJSONSchema, outputSchema││ 元数据 │ name, aliases, searchHint ││ 自省 │ description(), prompt(), userFacingName() ││ 安全 │ checkPermissions(), isReadOnly(), ││ │ isDestructive(), toAutoClassifierInput() ││ 并发 │ isConcurrencySafe() ││ 渲染 │ renderToolUseMessage(), ││ │ renderToolResultMessage(), ││ │ mapToolResultToToolResultBlockParam() │└──────────┴──────────────────────────────────────────┘
每个工具都必须回答这些问题:
-
• 我怎么执行? → call() -
• 我的输入长什么样? → inputSchema -
• 我只读吗? → isReadOnly() -
• 我可以和别人并行吗? → isConcurrencySafe() -
• 执行我需要什么权限? → checkPermissions() -
• 我的结果怎么展示? → renderToolResultMessage()
buildTool() 与 Fail-Closed 默认值
工具作者不需要实现所有 30 个字段。buildTool() 函数提供了安全侧的默认值:
const TOOL_DEFAULTS = { isEnabled: () => true, isConcurrencySafe: () => false, // 假设不安全 isReadOnly: () => false, // 假设会写入 isDestructive: () => false, checkPermissions: (input) => ({ behavior: 'allow', updatedInput: input }), toAutoClassifierInput: () => '', userFacingName: () => '',}function buildTool<D extends ToolDef>(def: D): BuiltTool<D> { return { ...TOOL_DEFAULTS, userFacingName: () => def.name, ...def }}
关键设计——fail-closed(失败时关闭):
|
|
|
|
|---|---|---|
isConcurrencySafe → false |
|
|
isReadOnly → false |
|
|
toAutoClassifierInput → '' |
|
|
如果工具作者忘了声明并发安全性,系统会保守地串行执行——宁可慢,不可错。这和网络安全中的”默认拒绝”是同一个思想。
Part 2:从 50 个工具到 API 请求——注册与加载管线

集中注册:getAllBaseTools()
所有内置工具在 src/tools.ts 中集中注册。这个文件是工具系统的 Single Source of Truth:
// src/tools.ts — 条件导入模式const SleepTool = feature('PROACTIVE') || feature('KAIROS') ? require('./tools/SleepTool/SleepTool.js').SleepTool : nullfunction getAllBaseTools(): readonly Tool[] { return [ BashTool, FileReadTool, FileEditTool, FileWriteTool, GlobTool, GrepTool, AgentTool, WebSearchTool, WebFetchTool, // ... 50+ 工具 SleepTool, // 可能为 null WorktreeTool, // 可能为 null ].filter(Boolean) // 编译时死代码消除 + 运行时 null 过滤}
注意两个去除模式:
-
1. 编译时 DCE(Dead Code Elimination): feature()是编译时常量,关闭的功能在打包时被完全移除 -
2. 运行时过滤: .filter(Boolean)移除null值
四级过滤管线
从注册到实际发送给 API,工具经过四级过滤:
getAllBaseTools() 50+ 内置工具 │ ▼ Feature Flags / DCE 编译时移除未启用功能的工具 │ ▼ filterToolsByDenyRules 按权限规则移除被禁止的工具 │ ▼ getTools(permContext) 按 PermissionMode 过滤 │ + isEnabled() 运行时检查 ▼ assembleToolPool() 合并 MCP 工具,去重,排序 │ ▼ API Request 最终工具列表 → toolToAPISchema()
排序是关键细节:工具按名称排序,内置工具在前、MCP 工具在后。为什么?因为工具定义在 API 请求中占固定位置——任何字节变化都会打破 Prompt Cache。排序保证了同一组工具在不同轮次中的字节顺序一致。
Deferred Loading:延迟加载
50 个工具的 Schema 约 15K Token。全部发送不仅浪费 Token 预算,还占用宝贵的上下文窗口。Claude Code 的解法是延迟加载:
工具是否延迟? │ ├── alwaysLoad: true → 永不延迟(核心工具) ├── isMcp: true → 默认延迟(MCP 扩展工具) ├── shouldDefer: true → 明确标记延迟 └── ToolSearchTool 自身 → 永不延迟(元工具)
延迟的工具在 API 请求中只发送名称(标记 defer_loading: true),完整 Schema 在模型通过 ToolSearchTool 主动查询时才加载:
// API 请求中的延迟工具{ name: "mcp__slack__send_message", defer_loading: true // 只发名称,不发 Schema}// 模型需要时调用 ToolSearchToolToolSearch({ query: "slack send" })→ 返回完整 Schema,模型才能调用该工具
启用条件:
-
• 默认模式( tst):所有 MCP 和shouldDefer工具延迟 -
• 自动模式( tst-auto):延迟工具的 Token 超过上下文的 10% 时启用 -
• 关闭模式( standard):所有工具全量发送
Schema 缓存:保护 Prompt Cache
工具 Schema 转换为 API 格式时使用会话级缓存:
// src/utils/api.tsconst TOOL_SCHEMA_CACHE = new Map()function toolToAPISchema(tool) { const cacheKey = tool.inputJSONSchema ? `${tool.name}:${hash(tool.inputJSONSchema)}` : tool.name if (TOOL_SCHEMA_CACHE.has(cacheKey)) { return TOOL_SCHEMA_CACHE.get(cacheKey) } const schema = tool.inputJSONSchema ? tool.inputJSONSchema // MCP 工具直接用 JSON Schema : zodToJsonSchema(tool.inputSchema) // 内置工具从 Zod 转换 TOOL_SCHEMA_CACHE.set(cacheKey, schema) return schema}
为什么需要缓存? 工具定义在 API 请求中排在位置 2(system prompt 之前)。如果 Schema 的任何字节发生变化——哪怕是 GrowthBook 功能开关的翻转导致某个字段出现/消失——都会让下游约 11K Token 的工具块全部缓存失效。Schema 缓存把字节锁定在会话首次计算的结果上。
Part 3:工具执行管线
六步流水线
当模型决定调用一个工具时,执行流程如下:
模型请求: tool_use { name: "Bash", input: { command: "ls" } } │ ▼ ① Zod Schema 验证 ─── 失败 → 返回 InputValidationError │ ▼ ② 自定义验证 validateInput() ─── 失败 → 返回自定义错误 │ ▼ ③ Pre-Tool Hooks ─── 可覆盖权限 / 修改输入 / 阻止执行 │ ▼ ④ 权限检查 checkPermissions() ─── deny → 返回拒绝消息 │ ask → 弹窗询问用户 │ allow → 继续 ▼ ⑤ 执行 call() ─── 成功 → Post-Tool Hooks │ 失败 → PostToolUseFailure Hooks ▼ ⑥ 结果格式化 mapToolResultToToolResultBlockParam() │ ▼ 返回 tool_result 给模型
每一步都是独立的拦截点,失败时立即短路返回——工具作者只需要关注 call() 的实现,其余步骤由框架处理。
Lazy Schema 与语义类型转换
工具的输入 Schema 使用 Lazy Schema 模式——延迟到首次访问时才构造:
// src/utils/lazySchema.tsfunction lazySchema<T>(factory: () => T): () => T { let cached: T | undefined return () => (cached ??= factory())}// BashTool 中的使用const fullInputSchema = lazySchema(() => z.strictObject({ command: z.string(), timeout: semanticNumber(z.number().optional()), run_in_background: semanticBoolean(z.boolean().optional()), _simulatedSedEdit: z.object({...}).optional() // 内部字段}))// 暴露给模型的 Schema 排除了内部字段const inputSchema = lazySchema(() => fullInputSchema().omit({ _simulatedSedEdit: true }))
三个设计意图:
-
1. 避免循环依赖:模块加载时不构造 Schema,避免依赖链问题 -
2. 条件字段:可以根据功能开关动态排除字段(如 run_in_background) -
3. Schema 隐私: _simulatedSedEdit是内部字段,不暴露给模型
另一个精妙细节是 semanticNumber() 和 semanticBoolean()——它们允许模型发送字符串 "5000" 并自动转换为数字 5000。这降低了模型的格式错误率。
权限三决策模型
checkPermissions() 返回三种决策之一:
type PermissionResult = | { behavior: 'allow', updatedInput?: Input } // 放行,可修改输入 | { behavior: 'ask', message: string } // 询问用户 | { behavior: 'deny', message: string } // 拒绝
allow 可以修改输入——这不是理论上的可能性,而是实际使用的模式。例如 Bash 工具在沙箱模式下会把命令包装进沙箱前缀,通过 updatedInput 传回修改后的命令。
ask 附带建议:
// ask 决策可以附带权限规则建议{ behavior: 'ask', message: 'Allow running npm install?', suggestions: [ { toolName: 'Bash', pattern: 'npm install *', behavior: 'allow' } ]}
用户批准后,建议的规则可以被保存为永久权限——一次询问,永久放行同类操作。
Hook 生命周期
工具执行前后有三类 Hook:
|
|
|
|
|---|---|---|
PreToolUse |
|
|
PostToolUse |
|
|
PostToolUseFailure |
|
|
Hook 可以是 shell 命令、HTTP 端点或回调函数,超时时间 10 分钟。PreToolUse Hook 甚至可以绕过权限系统——这为 CI/CD 环境中的自动化场景提供了入口。
并发安全与 StreamingToolExecutor
上一篇提到的 StreamingToolExecutor 在工具层面的并发规则:
工具 A 请求执行: │ ├── 当前无执行中的工具 → 直接执行 │ ├── A.isConcurrencySafe() === true │ 且所有执行中的工具也是 concurrencySafe │ → 并行执行 │ └── 否则 → 排队等待
只有 Bash 工具的错误会取消兄弟工具。原因:Bash 命令之间可能有依赖关系(先 mkdir,后 cp),一个失败意味着后续也会失败。但 Read 失败不影响 Grep——它们是独立的。
Part 4:解剖一个工具

以 BashTool 和 GlobTool 为代表,看完整工具和最小工具的对比:
GlobTool——最小实现
export const GlobTool = buildTool({ name: 'Glob', searchHint: 'find files by name pattern or wildcard', maxResultSizeChars: 100_000, get inputSchema() { return inputSchema() }, // 自省声明 isConcurrencySafe: () => true, // 只读搜索,可并行 isReadOnly: () => true, // 无副作用 // 权限检查委托给通用读权限 async checkPermissions(input, ctx) { return checkReadPermissionForTool(GlobTool, input, ctx) }, // 执行——核心只有 5 行 async call(input, { abortController, globLimits }) { const start = Date.now() const { files, truncated } = await glob( input.pattern, this.getPath(input), { limit: globLimits?.maxResults ?? 100 }, abortController.signal ) return { data: { filenames: files, durationMs: Date.now() - start, numFiles: files.length, truncated } } }, // 结果格式化 mapToolResultToToolResultBlockParam(data, toolUseID) { return { type: 'tool_result', tool_use_id: toolUseID, content: [{ type: 'text', text: data.filenames.join('\n') }] } }, // ... 渲染方法})
18 行核心代码。buildTool() 处理了其余一切——验证、默认值、生命周期。
BashTool——完整实现
BashTool 是最复杂的工具,展示了类型契约的全部能力:
export const BashTool = buildTool({ name: 'Bash', // ① Schema 隐私:内部字段不暴露给模型 get inputSchema() { return fullInputSchema().omit({ _simulatedSedEdit: true }) }, // ② 自定义验证:阻止轮询模式 async validateInput(input) { if (isBlockedSleepPattern(input.command)) { return { result: false, message: 'Avoid polling loops...' } } return { result: true } }, // ③ 权限检查:AST 解析 + 语义安全 + 规则匹配 + AI 分类器 async checkPermissions(input, context) { return bashToolHasPermission(input, context) // 内部流程: // 1. 解析 Bash AST 提取子命令 // 2. 语义安全检查(沙箱、重定向) // 3. 路径约束检查 // 4. 匹配权限规则(支持 "git *" 通配符) // 5. AI 分类器预测安全性 // 6. 默认:询问用户 }, // ④ 执行:异步生成器 + 流式进度 async call(input, context, canUseTool, parentMessage, onProgress) { const generator = runShellCommand({ command: input.command, ... }) let result do { result = await generator.next() if (!result.done && onProgress) { onProgress({ toolUseID: `bash-progress-${counter++}`, data: { type: 'bash_progress', output: result.value.output, elapsedTimeSeconds: result.value.elapsed, totalLines: result.value.lines, } }) } } while (!result.done) return { data: result.value } }, // ⑤ 安全自省:动态判断 isConcurrencySafe: () => true, isReadOnly: (input) => isReadOnlyCommand(input.command), isDestructive: (input) => isDestructiveCommand(input.command), // ⑥ 大输出持久化:超过 30KB 写入磁盘 maxResultSizeChars: 30_000, // ... 渲染方法})
注意 BashTool 的 isReadOnly() 和 isDestructive() 是输入感知的——同一个工具,ls 是只读的,rm -rf 是破坏性的。这不是工具级别的静态声明,而是输入级别的动态判断。
ToolUseContext——执行上下文
每个工具的 call() 方法接收一个 ToolUseContext 对象,它是工具与系统交互的唯一接口:
type ToolUseContext = { // 执行控制 abortController: AbortController // 取消信号 messages: Message[] // 完整对话历史 // 状态管理 readFileState: FileStateCache // 文件读取 LRU 缓存 getAppState(): AppState // 全局应用状态 setAppState(f): void // 更新状态 // UI 能力 setToolJSX?: SetToolJSXFn // 渲染自定义 UI addNotification?: (notif) => void // 发送通知 // 环境信息 options: { tools: Tools // 所有可用工具(供 ToolSearch 查询) mcpClients: MCPServerConnection[] // MCP 服务器连接 mainLoopModel: string // 当前模型 isNonInteractiveSession: boolean // 是否非交互模式 // ... }}
注意 readFileState:这是一个 LRU 缓存,跟踪当前会话中已读过的文件。当 FileReadTool 发现文件内容没变时,返回 type: 'file_unchanged' 哨兵值而不是完整内容——避免在对话历史中重复填充相同的文件内容。
ToolResult<T>——输出不只是数据
type ToolResult<T> = { data: T // 工具输出数据 newMessages?: Message[] // 注入额外消息到对话 contextModifier?: (ctx: ToolUseContext) => ToolUseContext // 修改后续上下文 mcpMeta?: { ... } // MCP 协议元数据}
ToolResult 的设计让工具不只是”返回一个值”。通过 newMessages,工具可以向对话注入系统消息(例如 FileReadTool 注入文件元数据提示)。通过 contextModifier,工具可以改变后续工具的执行环境——但只有非并发安全的工具才能使用 contextModifier,防止并行执行时的竞态条件。
为什么这样设计
1. 类型契约 vs 接口继承
Claude Code 没有用 abstract class BaseTool,而是用 TypeScript 类型 + buildTool() 工厂函数。原因:
-
• 组合优于继承:工具是配置对象,不是类层次结构 -
• Fail-closed 默认值:通过对象展开(spread)实现,比 super()调用更不容易忘记 -
• Dead Code Elimination:对象字面量比类更容易被打包工具优化
2. 延迟加载的 Token 经济学
50 个工具的 Schema ≈ 15K Token。如果加上 MCP 扩展可能翻倍。延迟加载把大多数工具缩减为几个 Token 的名称,只在模型真正需要时加载完整 Schema。代价是多一次 ToolSearchTool 调用——但这一次调用的成本远低于每轮都发送完整 Schema。
3. 输入感知的安全声明
isReadOnly(input) 而不是 isReadOnly()——这个设计选择让 Bash 这样的”万能工具”可以根据具体命令动态调整安全等级。如果是工具级别的静态声明,Bash 只能永远标记为”非只读”,导致每次执行都需要权限确认。
4. Schema 缓存保护 Prompt Cache
这是上一篇(缓存分割)的延伸。工具 Schema 在 API 请求中排在系统提示之前,任何字节变化都会级联打破缓存。Session-scoped 的 Schema 缓存确保即使底层状态变化(GrowthBook 开关翻转、MCP 重连),发送给 API 的 Schema 字节保持一致。
5. Hook 作为扩展点而非改代码
PreToolUse / PostToolUse Hook 让外部系统可以介入工具执行,而不需要修改工具代码。CI/CD 系统可以通过 Hook 自动批准特定操作;审计系统可以通过 Hook 记录每次工具调用。这让工具系统成为开放的管线而非封闭的黑盒。
可借鉴的模式
模式一:Fail-Closed 默认值模式
规则:所有安全相关的默认值都应该偏向保守侧。实现:工厂函数提供默认值,工具作者只需覆盖确认安全的部分。适用场景:任何需要管理多个组件安全属性的系统。
不要期望每个贡献者都记得声明安全属性。把系统设计成”忘了声明 = 最保守行为”。
模式二:Schema 隐私与语义类型转换
规则:发给模型的 Schema 和内部使用的 Schema 可以不同。实现:fullSchema.omit({ internalField: true }) 创建公开 Schema。 semanticNumber() / semanticBoolean() 容忍格式偏差。适用场景:任何 LLM 驱动的工具调用系统。
模型不是完美的 JSON 生成器。容忍它的小错误(字符串 vs 数字),隐藏它不需要知道的字段。
模式三:注册-过滤-排序管线
规则:工具集合经过多级过滤后才发送给 API。实现:DCE → 权限规则 → 运行时检查 → 合并排序 → 延迟加载。 排序保证 Prompt Cache 稳定性。适用场景:任何需要动态工具集合且关注 API 成本的 Agent 系统。
工具列表不是静态配置,而是一条过滤管线。每一级过滤解决不同的关注点(编译时优化 vs 运行时权限 vs Token 成本),最终产出一个排序稳定、缓存友好的工具集合。
下一篇预告
工具让 Agent 有了”手脚”,但 Agent 的”大脑”需要记忆。Claude Code 的五层记忆体系——从毫秒级的会话消息到永久持久化的 Checkpoint——如何让一个无状态的 API 调用表现得像”记得一切”?下一篇,我们拆解记忆系统。
|
|
|
|
|---|---|---|
|
|
|
|
|
|
while(true) 里的五个阶段与七层恢复 |
|
|
|
|
|
| 04 | 50 个工具的统一契约:Tool System 设计
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
引用链接
[1] 01: 01-architecture-overview.md[2] 02: 02-react-loop.md[3] 03: 03-prompt-cache-compression.md
夜雨聆风