40多个工具,是怎么"长"到AI手上的?

你有没有好奇过,当你对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 // 工具执行完成后展示结果
此外还有renderToolUseErrorMessage、renderToolUseRejectedMessage(权限被拒)和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: false和isReadOnly: 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 | 说明 |
|---|---|---|
| McpAuthTool | 10,000 | 认证结果,数据量小 |
| GrepTool | 20,000 | 搜索结果需要精简 |
| BashTool | 30,000 | Shell输出可能较长 |
| GlobTool | 100,000 | 文件列表可能很多 |
| FileReadTool | Infinity | 永不持久化(避免循环) |
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的"大脑"是怎么运转的。觉得有收获的话,欢迎点赞转发,关注梦兽编程,咱们下篇见。
