Claude Code 深度拆解:工具系统——30+ 工具怎么统一注册、按需加载
文章摘要 《Claude Code源码解析:工具系统的统一注册与按需加载》深入剖析了30+工具如何被统一管理。系统通过Tool.ts定义50个字段的行为合约,确保异构工具遵循相同规范。buildTool()工厂函数采用Fail-Closed原则,强制显式声明安全属性,避免默认配置风险。工具池由内置工具、MCP动态工具和用户自定义工具三部分组成,通过编译时Feature Gate实现代码级隔离。延迟
Hi,大家好,欢迎来到维元码簿。
本文属于 《Claude Code 源码 Deep Dive》 系列,专注于工具系统中的 统一注册与按需加载 板块。如果你想了解整个系列,可以先看系列开篇 | Claude Code 源码架构概览:51万行代码的模块地图。
Claude Code 有 30 多个内置工具,还能通过 MCP 接入任意多个外部工具。 这么多工具怎么注册到系统里、怎么控制 token 成本?本文从源码中找到了答案:一份统一的行为合约、一个 Fail-Closed 工厂函数、一个延迟加载机制——不用的工具不花钱,用到了才按需展开。
读完全文,你将能回答这几个问题:
- 30+ 个功能完全不同的工具,怎么在同一个 Agent 回路里互不干扰?
- 新工具接入时要改多少代码?
- 配置了 20 个 MCP 工具,每次都要全量传 Schema 吗?
本篇覆盖的源码范围
| 模块 | 核心文件 | 核心代码行 | 文件总行 | 职责 |
|---|---|---|---|---|
| 工具接口定义 | src/Tool.ts |
L362-793(Tool 类型 + TOOL_DEFAULTS + buildTool) | 793 行 | Tool 泛型接口、buildTool()、TOOL_DEFAULTS |
| 工具注册 | src/tools.ts |
L193-390(getAllBaseTools + assembleToolPool) | 390 行 | getAllBaseTools()、assembleToolPool()、Feature Gate |
| 延迟加载 | src/utils/toolSearch.ts |
L49-330(三档配置 + ToolSearchTool + 阈值计算) | 757 行 | 三档配置、ToolSearchTool、auto 阈值 |
前情提要:模型收到了什么
在上下文编排系列中,我们拆解了发给模型的三大数据块:System Prompt(模型是谁)、Messages(对话历史)、Tools(工具 Schema)。在姊妹篇[工具能力声明](./02-Claude Code深度拆解-上下文里有什么-工具能力声明.md)中,我们看到了 Tools 在上下文中怎么呈现——30+ 个工具的 Schema 排成一个 tools[] 数组发给模型。
但那只是"模型看到了什么"。本文要回答的是更深一层的问题:这些工具在系统内部是怎么被定义、注册、组装、按需加载的? 工具系统不只是 tools[] 数组里的一堆 Schema,而是一个从注册到加载的完整工程体系。
工具系统有五层架构。本文聚焦前三层(接口合约、注册组装、延迟加载),后两层(权限系统、执行引擎)在姊妹篇中展开。

统一 Tool 接口——50 个字段的行为合约
一切工具都必须满足同一份合约。 这是 30+ 个异构工具能在同一个 Agent 回路里协作的前提。
BashTool 能执行 Shell 命令,ReadTool 能读文件,WebFetchTool 能抓网页——它们做的事情完全不同,但对 Claude Code 来说,它们都是"工具"。怎么做到的?答案是 src/Tool.ts 中定义的统一接口:
// src/Tool.ts L362
export type Tool<
Input extends AnyObject = AnyObject,
Output = unknown,
P extends ToolProgressData = ToolProgressData,
> = {
name: string // 工具名
inputSchema: ZodSchema<Input> // 参数 Schema
call(input, context): Promise<Output> // 核心执行方法
isConcurrencySafe(input): boolean // 能否并行
isReadOnly(input): boolean // 是否只读
isDestructive(input): boolean // 是否破坏性
checkPermissions(input, context) // 权限检查回调
// ... 约 50 个字段
}
Tool<Input, Output, P> 是一个泛型类型,大约 50 个字段。不是每个工具都有 50 个字段——实际上大部分工具只关心 5-10 个。这 50 个字段按职责分为五组:
| 字段组 | 代表字段 | 作用 |
|---|---|---|
| 基本身份 | name、inputSchema、shouldDefer |
工具叫什么、接受什么参数、是否延迟加载 |
| 执行生命周期 | validateInput() → checkPermissions() → call() → renderToolResultMessage() |
四阶段流水线 |
| 并发与行为标注 | isConcurrencySafe()、isReadOnly()、isDestructive() |
告诉调度器"我安全吗" |
| 权限辅助 | preparePermissionMatcher()、toAutoClassifierInput() |
序列化给 YOLO 分类器的输入 |
| 渲染接口 | renderToolUseMessage()、mapToolResultToToolResultBlockParam() |
终端 UI 怎么展示 |
执行生命周期是核心流水线。 每次工具调用都要经过四个阶段:先校验输入(validateInput),再检查权限(checkPermissions),然后执行(call),最后渲染结果(renderToolResultMessage)。不管工具是内置的还是 MCP 的,这四个阶段都必须走完。

说个比方:Tool 接口就像一份"用工合同"。不管你是程序员(BashTool)、图书管理员(ReadTool)、还是邮递员(SendMessageTool),签了这份合同,就得按合同规定的流程办事——校验身份、检查权限、执行任务、提交报告。合同保证每个"员工"的行为可预测。
buildTool():Fail-Closed 的工厂函数
50 个字段的接口,如果每个工具都要手写 50 个字段,没人受得了——而且漏写字段可能导致安全事故。系统需要一个方式,让开发者只写关心的字段,其余的自动填入安全默认值。
buildTool() 函数就是解决这个问题的:开发者只提供工具特有的定义,buildTool() 自动合入 TOOL_DEFAULTS 中的默认值——缺什么补什么,漏配的字段自动取安全默认。
TOOL_DEFAULTS = {
isConcurrencySafe: () => false, // 默认不允许并发
isReadOnly: () => false, // 默认假设写操作
isDestructive: () => false, // 默认非破坏性
checkPermissions: () => allow, // 默认允许(但通用权限系统会介入)
}
这里藏着两个关键设计哲学:
Fail-Closed(安全失败)。isConcurrencySafe 默认 false——新工具必须显式声明"我并发安全"才能并行执行。忘标了?那就不并发。这比 Fail-Open(默认允许并发,出 bug 再禁)安全得多。同理,isReadOnly 默认 false——除非你显式声明自己是只读的,否则系统假设你会写东西,走更严格的权限检查路径。
委托通用权限系统。checkPermissions 默认直接 allow,但不意味着无权限检查——通用权限系统的 8 层检查链会在 checkPermissions 之前和之后介入。工具自己的权限检查只是其中一层。这在姊妹篇[权限沙盒与错误处理](./03-Claude Code深度拆解-工具系统-权限沙盒与错误处理.md)中展开。
说人话:新工具接入时,默认"什么都不能做"——不能并发、不能跳过权限检查。每项能力都要显式声明才能开启。这防止了开发者漏配字段导致安全事故。
Feature Gate 死代码消除
在 src/tools.ts 中,你会看到大量这样的代码:
const SleepTool = feature('PROACTIVE') || feature('KAIROS')
? require('./tools/SleepTool/SleepTool.js').SleepTool
: null
feature() 是 Bun 的宏函数,在编译时求值。如果 PROACTIVE feature 没开启,这段代码根本不会出现在最终的 bundle 里——不是运行时跳过,而是编译时直接消除。
这意味着:没有开启 AGENT_TRIGGERS 的用户,永远不会下载 ScheduleCronTool 的代码。没有开启 KAIROS 的用户,永远不会看到 PushNotificationTool。零成本、零泄露。
为什么不直接用运行时 if?因为工具的 require() 是顶层调用——如果 bundle 包含了工具代码但运行时不使用,这些代码白白占用内存和启动时间。Feature Gate 在编译时就把不需要的工具代码从 bundle 中完全删除。
工具注册:三股来源汇成工具池
工具从哪来?Claude Code 的工具池由三股来源组成(三者的深度辨析见姊妹篇[内置工具全景与三方协作](./03-Claude Code深度拆解-工具系统-内置工具全景与三方协作.md)):
第一股:内置工具。 由 getAllBaseTools()(src/tools.ts)返回。这些是用 TypeScript 写死的 30+ 个工具——BashTool、ReadTool、EditTool 等。Feature Gate 在这里生效:不同编译版本包含不同的工具集。
第二股:MCP 工具。 用户通过 .mcp.json 配置的 MCP Server,运行时通过 tools/list 协议动态发现。这部分在姊妹篇[内置工具全景与三方协作](./03-Claude Code深度拆解-工具系统-内置工具全景与三方协作.md)中展开。
第三股:SkillTool。 它是内置工具的一员,但它的"子工具"——各个 Skill——不是独立注册的,而是全部通过 SkillTool 一个入口来调用。这是一个特殊的设计选择。
三股来源最终通过 assembleToolPool() 汇成一个统一的 tools[] 数组:

组装过程分三步:
getTools()获取内置工具,根据运行模式(标准/REPL/Simple)和权限规则过滤- 合并内置工具 + MCP 工具,按名称去重(内置优先),按字母排序
toolToAPISchema()将每个 Tool 对象转为 API 格式的 JSON Schema
排序不是随意的——缓存驱动的排序决策
内置工具排前面、MCP 工具排后面,这个看似简单的排序决策,背后是缓存工程的精密考量。
在姊妹篇[工具编排](./02-Claude Code深度拆解-上下文里有什么-工具能力声明.md)中讲过,Prompt Caching 是"前缀匹配"——一旦前缀变化,从变化点开始的所有缓存全部失效。如果 MCP 工具穿插在内置工具之间,每次 MCP 连接变化(新增/删除一个 MCP Server)都会导致内置工具的缓存失效。
内置工具是稳定的——同一版本的所有用户共享完全相同的内置工具列表。MCP 工具是不稳定的——每个用户配置不同,甚至同一用户的 MCP Server 可能随时连接/断开。
所以排序策略是:稳定的排前面(内置),不稳定的排后面(MCP)。服务端的系统提示缓存策略在内置工具列表末尾设置缓存断点——MCP 工具的变化不会影响前面内置工具的缓存。
这个排序决策还影响了 System Prompt 的缓存策略:有 MCP 工具时,System Prompt 全部降级为 org 级缓存(因为在姊妹篇[身份设定与环境感知](./02-Claude Code深度拆解-上下文里有什么-System Prompt工程.md)中讲过,MCP 工具的 Schema 会影响 System Prompt 的内容)。这就是跨板块的缓存联动——一个排序决策同时影响了 Tools 和 System Prompt 两个板块的缓存效率。
延迟加载——不用的工具不花钱
工具数量多了,全量传 Schema 会吃掉大量 token。 举个具体的例子:假设你配了 5 个 MCP Server(Slack、GitHub、Database、Jira、Sentry),每个 Server 平均提供 4 个工具,每个工具 Schema 约 800 tokens。那就是 5 × 4 × 800 = 16000 tokens——相当于每次 API 调用都要额外付 ~16000 tokens 的"工具介绍费"。如果模型只在一次对话中用到了 slack_send_message,那另外 19 个工具的 Schema 全白传了。
这就是**渐进式披露(Progressive Disclosure)**思想在工具系统中的应用。渐进式披露不是新概念——它在 UI 设计中早就被广泛使用:只展示当前需要的信息,详细内容按需展开。Claude Code 把这个思想用在了 API 调用层:初始只给模型看工具摘要,用到时再展开完整 Schema。这是一种"懒加载"的智慧——不是"要不要加载",而是"什么时候加载"。
延迟加载由 ENABLE_TOOL_SEARCH 环境变量控制,分三档:
| 档位 | 值 | 行为 |
|---|---|---|
| 默认 | tst |
所有 MCP 工具延迟加载,只传名称和 searchHint |
| 自动 | tst-auto |
超过 token 阈值(context_window × 10%)才延迟 |
| 关闭 | standard |
所有工具完整传入,不延迟 |
工作流程是这样的:
延迟加载模式(tst):
tools[] = [BashTool, EditTool, GlobTool, ..., ToolSearchTool]
↑ 只有搜索工具
ToolSearchTool.description 中包含所有 MCP 工具的名称列表
模型想用 Slack 工具时:
1. 调用 ToolSearch(keywords=["slack"])
2. 系统返回:已加载 slack_send_message 的完整 Schema
3. 下一轮 API 请求,slack_send_message 出现在 tools[] 中

每个工具的 searchHint 字段就是为这个场景设计的——一行能力描述(3-10 词),帮助 ToolSearch 做关键词匹配。比如 NotebookEdit 的 searchHint 是 "jupyter",因为工具名里没有 jupyter 这个词,但用户肯定会搜它。
延迟加载的缓存收益:延迟工具标记了 defer_loading,API 服务端在计算 prompt cache 时会忽略这些工具的 token——这意味着工具的增减不会影响 System Prompt 的缓存命中。不用工具不花钱,用到才加载,加载了也不破坏缓存。
本章小结
回头看这三层架构,我觉得最值得玩味的是它们的连贯性。Tool 接口 50 个字段乍一看吓人,但安全、并发、渲染、权限全塞在一份合约里,意味着只要工具签了这份"用工合同",后续注册、调度、权限检查都能统一处理。这个设计的前瞻性,是你单独看某一层时感受不到的。
Fail-Closed 的理念也让我想了不少。isConcurrencySafe 默认 false,isReadOnly 默认 false——“你不声明就不能做”,和很多框架的"默认允许,出事再禁"完全相反。在一个 AI 能执行 Shell 命令的系统里,保守是对的。新工具的作者可能根本没意识到并发安全问题,默认不允许反而是在保护他。
排序和延迟加载表面上是工程优化,本质上都围绕同一个矛盾:token 是要钱的。稳定的排前面保护缓存前缀,不用的工具不传 Schema 省实打实的钱。这种"抠门"的设计哲学贯穿了整个工具系统,在姊妹篇中你还会反复看到它的影子。
至于这些工具"到底有哪些"以及"Tools / MCP / Skills 三种扩展方式怎么协作",那是姊妹篇[内置工具全景与三方协作](./03-Claude Code深度拆解-工具系统-内置工具全景与三方协作.md)的主题。至于工具"怎么运行",那是姊妹篇[运行时流水线](./03-Claude Code深度拆解-工具系统-运行时流水线.md)的主题。
系列导航:
本文属于 《Claude Code 源码 Deep Dive》 系列中「工具系统」命题的子篇章,专注于 接口合约与注册组装。
工具系统姊妹篇 :
- Claude Code 深度拆解:工具系统——运行时流水线与并发调度
- Claude Code 深度拆解:工具系统——30+ 内置工具地图与 MCP / Skills 协作
- Claude Code 深度拆解:工具系统——权限、沙盒与错误处理
如果这篇文章对你有帮助,欢迎点赞收藏支持一下。如果你对 Claude Code 源码感兴趣,欢迎关注本系列后续更新。有任何想法或疑问,欢迎评论区留言讨论 👋
更多推荐



所有评论(0)