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 个字段按职责分为五组:

字段组 代表字段 作用
基本身份 nameinputSchemashouldDefer 工具叫什么、接受什么参数、是否延迟加载
执行生命周期 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[] 数组:

在这里插入图片描述

组装过程分三步:

  1. getTools() 获取内置工具,根据运行模式(标准/REPL/Simple)和权限规则过滤
  2. 合并内置工具 + MCP 工具,按名称去重(内置优先),按字母排序
  3. 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 源码感兴趣,欢迎关注本系列后续更新。有任何想法或疑问,欢迎评论区留言讨论 👋

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐