你有没有好奇过,当你对Claude Code说"帮我找一下这个函数在哪定义的",它怎么知道该用GrepTool搜索,而不是用BashTool执行grep命令?

或者当你说"看看这个文件",它为什么有时候直接用FileReadTool,有时候却先用GlobTool列出目录再决定读哪个?

这背后不是随机选择,而是一套精密的工具系统在运作。今天咱们就拆开这个系统,看看40多个工具是怎么"长"到AI手上的。

图:工具系统像乐高工厂,从零件到组装再到质检

Tool接口:所有工具的"通用语言"

不管你用的是内置的BashTool,还是通过MCP协议接入的第三方工具,它们都必须满足同一个TypeScript接口。这个接口定义在Tool.ts,是整个工具系统的基石。

一个工具长这样:

interface Tool<Input, Output> {
  readonly name: string;                    // 唯一标识符
  description: (input, options) => Promise<string>;  // 给模型的说明书
  prompt: (options) => Promise<string>;      // 系统提示词片段
  inputSchema: z.ZodType;                   // 参数结构定义
  call: (args, context, canUseTool, ...) => Promise<ToolResult>;  // 执行逻辑
  checkPermissions: (input, context) => Promise<PermissionResult>;  // 权限检查
  maxResultSizeChars: number;               // 结果大小上限
  isConcurrencySafe: (input) => boolean;    // 能否并发执行
  isReadOnly: (input) => boolean;           // 是否只读
  isEnabled: () => boolean;                 // 当前是否可用
}

这里有三个设计特别值得注意:

description是函数而非字符串。同一个工具在不同权限模式下可能需要不同描述。比如当用户配置了alwaysDeny规则禁止某些子命令时,工具描述可以主动告知模型"不要尝试这些操作",在提示层面就避免无效调用。

inputSchema用Zod v4定义。这让工具参数在运行时严格校验,同时通过z.toJSONSchema()自动生成发送给Anthropic API的JSON Schema。Zod的z.strictObject()确保模型不会传入未定义参数。

call接收canUseTool回调。工具执行过程中可能需要递归检查子操作的权限。比如AgentTool在启动子Agent时需要检查子Agent是否有权使用特定工具。权限检查不是一次性门禁,而是贯穿执行过程的持续验证。

图:Tool接口的7个核心字段及其职责

渲染契约:工具在终端的"表演"

工具接口还定义了一组渲染方法,构成工具在终端UI中的完整生命周期:

renderToolUseMessage       // 工具被调用时展示
renderToolUseProgressMessage  // 工具执行中展示进度
renderToolResultMessage    // 工具执行完成后展示结果

此外还有renderToolUseErrorMessagerenderToolUseRejectedMessage(权限被拒)和renderGroupedToolUse(并行工具的分组展示)。

注意renderToolUseMessage的签名:

renderToolUseMessage(
  input: Partial<z.infer<Input>>,  // 注意是Partial!
  options: { theme: ThemeName; verbose: boolean }
): React.ReactNode

input是Partial的原因是:API以流式方式返回工具参数的JSON,在JSON解析完成之前只有部分字段可用。UI必须在参数不完整时就能渲染——用户不应该看到空白屏幕。

buildTool工厂:失败关闭的哲学

每个具体工具都不是直接导出一个Tool对象,而是通过buildTool工厂函数构建:

export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
  return {
    ...TOOL_DEFAULTS,
    userFacingName: () => def.name,
    ...def,
  } as BuiltTool<D>;
}

运行时行为极简——就是对象展开。但类型层面精确模拟了语义:如果工具定义提供了某个方法,使用工具定义的版本;否则使用默认值。

TOOL_DEFAULTS的设计遵循一个安全原则:在不确定时假设最危险的情况

const TOOL_DEFAULTS = {
  isEnabled: () => true,           // 默认可用
  isConcurrencySafe: () => false,  // 默认不安全(失败关闭!)
  isReadOnly: () => false,         // 默认非只读(失败关闭!)
  isDestructive: () => false,      // 默认非破坏性
  checkPermissions: () => ({ behavior: 'allow' }),  // 交给通用权限系统
  toAutoClassifierInput: () => '', // 默认不参与自动安全分类
};

最重要的两个默认值是isConcurrencySafe: falseisReadOnly: false。这意味着:一个新工具如果忘记声明这两个属性,系统会自动将其视为"可能修改文件系统且不能并发执行"——这是最保守、最安全的假设。

只有当工具开发者主动声明安全时,系统才会放宽限制。

GrepTool vs BashTool:安全声明的对比

GrepTool明确覆盖了两个默认值:

export const GrepTool = buildTool({
  name: GREP_TOOL_NAME,
  searchHint: 'search file contents with regex (ripgrep)',
  maxResultSizeChars: 20_000,
  isConcurrencySafe() { return true },  // 搜索是安全的并发操作
  isReadOnly() { return true },         // 搜索不修改文件
  // ...
});

搜索操作天然是只读且并发安全的,GrepTool理直气壮地声明这一点。

相比之下,BashTool的并发安全性是有条件的:

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不行。这种输入感知的并发控制,实现了精确的安全管理。

工具注册管线:三级过滤

tools.ts是工具池的组装中心。一个工具从定义到最终可用,经历三级过滤:

第一级:编译期/启动期条件加载

大量工具通过Feature Flag进行条件加载:

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,
    ]
  : [];

feature()函数来自bun:bundle,在打包时求值。未启用的工具根本不会出现在最终的JavaScript bundle中——这比运行时if更彻底的死代码消除。

还有环境变量驱动的条件加载:

const REPLTool =
  process.env.USER_TYPE === 'ant'
  ? require('./tools/REPLTool/REPLTool.js').REPLTool
  : null;

USER_TYPE === ‘ant’标记Anthropic内部员工使用的特殊工具,在公开版本中不可用。这是A/B测试的"暂存区"模式。

第二级:getAllBaseTools()组装基础工具池

这个函数将所有通过第一级过滤的工具收集到一个数组中,是系统的"工具注册表":

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()运行时过滤

最终的过滤层执行三个操作:

  • 权限拒绝过滤:通过filterToolsByDenyRules()移除被alwaysDeny规则覆盖的工具
  • REPL模式隐藏:当REPL模式启用时,Bash、Read、Edit等基础工具被隐藏
  • isEnabled()最终检查:每个工具的isEnabled()方法是最后一道开关

图:工具从定义到可用的三级过滤流程

简单模式:给工具做"减法"

getTools()还支持一种"简单模式"(CLAUDE_CODE_SIMPLE),只暴露Bash、FileRead和FileEdit三个核心工具。这在一些集成场景下很有用——减少工具数量可以降低token消耗并减少模型的决策负担。

有时候,给AI的选择越少,它反而做得越好。

MCP工具的融合

最终的工具池由assembleToolPool()完成组装:

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工具的增减都会导致所有下游缓存键失效。

工具结果大小预算

当一个工具返回结果时,系统面临一个核心矛盾:模型需要看到完整信息,但上下文窗口有限。Claude Code通过两级预算解决这个问题。

第一级:单工具结果上限maxResultSizeChars

每个工具通过maxResultSizeChars声明结果大小上限:

工具maxResultSizeChars说明
McpAuthTool10,000认证结果,数据量小
GrepTool20,000搜索结果需要精简
BashTool30,000Shell输出可能较长
GlobTool100,000文件列表可能很多
FileReadToolInfinity永不持久化(避免循环)

FileReadTool的Infinity是个特殊设计——避免Read→持久化文件→Read的循环引用。

第二级:单消息聚合上限

当模型在一个回合中并行调用多个工具时,所有结果会作为同一个user message发送。MAX_TOOL_RESULTS_PER_MESSAGE_CHARS = 200,000限制了单条消息的工具结果总大小。

两层控制互相补充:单工具上限防止单点失控,消息上限防止并行调用的集体爆炸。

延迟加载:工具太多的解决方案

当工具数量超过一定阈值(尤其是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总是出现在初始提示中。这适用于模型在第一轮对话就必须能直接调用的核心工具。

从工具系统设计学到的

Claude Code的工具系统设计有几个值得借鉴的模式:

失败关闭的默认值:buildTool()的默认值假设最危险的情况,工具开发者必须主动声明安全属性。这将安全从"选择加入"翻转为"选择退出"。

分层预算控制:单工具结果有上限,单消息也有聚合上限。两层控制互相补充。

输入感知的属性:isConcurrencySafe(input)和isReadOnly(input)接收工具输入,而非全局判断。同一个BashTool,ls和rm有完全不同的安全属性。

渐进渲染:三阶段渲染(意图→进度→结果)让用户在工具执行的每个阶段都有可见性。

编译期消除vs运行时过滤:Feature Flag在编译期消除未启用的工具代码,权限规则在运行时过滤工具列表。两种机制服务不同目的。

这对你意味着什么

如果你在使用Claude Code,理解工具系统能帮你:

理解为什么有时候AI"笨"了。可能不是模型变笨了,而是工具被过滤掉了(权限规则、isEnabled返回false等)。

优化工具使用策略。如果你接入了很多MCP工具,考虑哪些标记为延迟加载,减少初始token消耗。

设计更好的MCP工具。工具描述是给模型看的指令,要写清楚"什么时候用这个工具",而不只是"这个工具做什么"。

如果你想构建自己的AI Agent工具系统,可以借鉴:

  • 采用失败关闭的默认值
  • 为每个工具设置结果大小上限
  • 实现输入感知的属性控制
  • 区分编译期条件和运行时过滤

工具系统是AI Agent的"双手"。Claude Code的这双手,通过精心设计的接口契约、权限模型和Feature Flag守卫,组成了一个可扩展、可控制、安全的能力体系。理解它,能让你更好地使用这双手,甚至打造属于自己的双手。


文章写到这儿,希望对你理解Claude Code的工具系统有所帮助。下一篇咱们深入Agent Loop,看看AI的"大脑"是怎么运转的。觉得有收获的话,欢迎点赞转发,关注梦兽编程,咱们下篇见。