Claude Code 的工具系统
这一章解决的问题是:Claude Code 怎么把“模型想做事”变成“运行时可控制的动作入口”。
模型本身不会读文件、跑命令、搜索代码、编辑文件。它能做的是在 API 响应里生成 tool_use。Claude Code 的工具系统负责把这些可调用能力先定义成统一契约,再按构建配置、运行环境、权限规则和 MCP 扩展装配成最终工具池,最后只把 工具名、描述、输入 schema 暴露给模型。
核心直觉
工具系统不是“给模型一堆函数”。它是把外部世界切成一组受控入口:模型只能选择入口和参数,真正的副作用、权限、并发、结果预算都留在运行时处理。
先看源码入口
读工具系统,先抓这些文件和符号:
| 源码定位点 | 关键符号 | 负责什么 |
|---|---|---|
restored-src/src/Tool.ts:362-695 | Tool 接口 | 工具的统一契约:描述、schema、权限、执行、渲染、预算 |
restored-src/src/Tool.ts:757-769 | TOOL_DEFAULTS | 工具默认行为,尤其是 fail-closed 的只读和并发判断 |
restored-src/src/Tool.ts:783-792 | buildTool() | 把默认值和工具定义合并成可运行工具 |
restored-src/src/tools.ts:16-135 | feature() 条件加载 | 构建期或启动期决定工具模块是否存在 |
restored-src/src/tools.ts:193-251 | getAllBaseTools() | 收拢内置工具候选池 |
restored-src/src/tools.ts:271-327 | getTools() | 按 deny 规则、REPL 模式、isEnabled() 过滤当前会话工具 |
restored-src/src/tools.ts:345-367 | assembleToolPool() | 合并内置工具和 MCP 工具,稳定排序并去重 |
restored-src/src/constants/toolLimits.ts | DEFAULT_MAX_RESULT_SIZE_CHARS、MAX_TOOL_RESULTS_PER_MESSAGE_CHARS | 控制工具结果大小 |
restored-src/src/tools/BashTool/BashTool.tsx | isReadOnly()、isConcurrencySafe()、PROGRESS_THRESHOLD_MS | 输入感知的风险判断和进度渲染 |
restored-src/src/tools/GrepTool/GrepTool.ts | isReadOnly()、isConcurrencySafe()、maxResultSizeChars | 只读并发工具的典型实现 |
这一章只讲“工具怎么定义和暴露”。下一章再进 runTools()、runToolUse()、hook、权限链和流式执行器。
总流程图
这张图里有两个关键边界:
Tool对象是运行时内部契约,包含call()、权限、渲染、预算等执行细节。- API tool schema 是模型可见契约,主要暴露工具名、描述和输入结构。
模型看到的是“怎么调用”,不是“怎么实现”。
Tool 接口:把能力拆成可治理字段
原文把 Tool 接口定位在 restored-src/src/Tool.ts:362-695。它不是一个简单的函数类型,而是一份完整行动契约。
| 字段 | 类型或形态 | 模型/运行时影响 |
|---|---|---|
name | readonly string | 工具唯一标识,权限匹配、日志、API 传输都靠它 |
description | (input, options) => Promise@@INLINE_0@@ | 发送给模型的工具描述,可按权限上下文动态变化 |
prompt | (options) => Promise@@INLINE_0@@ | 工具级提示词,用来补充局部使用规则 |
inputSchema | z.ZodType | 运行时校验参数,并生成 API JSON Schema |
call | (args, context, canUseTool, parentMessage, onProgress?) => Promise@@INLINE_0@@ | 真正执行副作用 |
checkPermissions | (input, context) => Promise@@INLINE_0@@ | 工具自己的权限检查 |
validateInput | (input, context) => Promise@@INLINE_0@@ | schema 之后的语义校验 |
maxResultSizeChars | number | 单工具结果预算 |
isConcurrencySafe | (input) => boolean | 调度器能否和其他工具并发执行 |
isReadOnly | (input) => boolean | 权限系统和并发系统判断副作用风险 |
isEnabled | () => boolean | 当前环境下是否可用 |
这张表要读出三个层次:
| 层次 | 字段 | 说明 |
|---|---|---|
| 模型选择层 | name、description、prompt、inputSchema | 模型根据这些信息决定要不要调用、怎么传参 |
| 执行控制层 | call、checkPermissions、validateInput、canUseTool | 运行时决定是否允许执行,以及如何处理子权限 |
| 调度和呈现层 | isConcurrencySafe、isReadOnly、maxResultSizeChars、渲染方法 | 决定并发、预算、UI 可见性 |
容易误解
description 不是给人看的注释,inputSchema 也不是普通文档。它们会进入模型上下文,直接影响模型是否选择这个工具、能生成什么参数。
为什么 description 是函数
原文特别强调 description 是函数而不是字符串。这个设计说明工具描述不是静态文案,它可以根据权限、模式、上下文变化。
例如当前会话中某些 Bash 子命令被 deny,工具描述就可以提前提醒模型不要尝试。这样做的价值是:在模型规划之前降低无效工具调用概率。
行为链路是:
读取当前 permissionContext
-> 生成工具 description
-> description 进入 API tool schema
-> 模型基于描述选择工具
-> 被禁止的路径尽量不进入计划
这比“模型先调用,运行时再拒绝”更省 token,也让任务路径更稳定。
inputSchema 同时服务模型和运行时
inputSchema 使用 Zod v4。原文提到它可以通过 z.toJSONSchema() 转换为发送给 API 的 JSON Schema,而 z.strictObject() 能阻止模型传入未定义字段。
这说明 schema 有双重用途:
| 用途 | 发生位置 | 结果 |
|---|---|---|
| 模型约束 | API tool schema | 模型知道应该生成哪些字段 |
| 运行时校验 | 工具执行前 safeParse | 无效参数不会进入真实副作用 |
核心直觉
工具 schema 是模型世界和运行世界之间的接口。模型靠它组织参数,运行时靠它拒绝错误参数。
call 接收 canUseTool,说明权限不是一次性门禁
call 的签名里有 canUseTool 回调。这个细节很重要:工具执行过程中也可能需要再次做权限判断。
典型例子是 AgentTool:父工具启动子 Agent 时,子 Agent 还会使用其他工具。运行时不能因为父工具被允许,就默认子过程里的所有动作都允许。
这证明权限链不是:
执行前检查一次 -> 放行后随便跑
而更接近:
执行前检查
-> 工具内部遇到子动作
-> 再调用 canUseTool
-> 子动作继续受当前权限上下文约束
buildTool():把 fail-closed 默认值写进底座
具体工具不是裸对象直接导出,而是通过 buildTool() 构建。原文给出的源码:
export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
return {
...TOOL_DEFAULTS,
userFacingName: () => def.name,
...def,
} as BuiltTool<D>;
}
这段代码运行时只是对象展开,但它证明了一个工程约束:每个工具都会先拿到统一默认值,然后由具体定义覆盖。
TOOL_DEFAULTS 的关键默认值:
| 默认方法 | 默认值 | 证明了什么 |
|---|---|---|
isEnabled | () => true | 工具默认存在,除非显式禁用 |
isConcurrencySafe | () => false | 没声明并发安全,就按不安全处理 |
isReadOnly | () => false | 没声明只读,就按可能写入处理 |
isDestructive | () => false | 破坏性需要工具额外说明 |
checkPermissions | 返回 { behavior: 'allow' } | 默认交给通用权限系统,工具可追加判断 |
toAutoClassifierInput | () => '' | 默认不参与自动安全分类 |
userFacingName | () => def.name | UI 默认显示工具名 |
这里最重要的是 isConcurrencySafe: false 和 isReadOnly: false。它们体现了 fail-closed:新工具忘记声明安全属性时,系统不会乐观地并发执行,也不会把它当只读工具。
GrepTool:主动声明只读和并发安全
原文给出的 GrepTool 片段:
export const GrepTool = buildTool({
name: GREP_TOOL_NAME,
searchHint: 'search file contents with regex (ripgrep)',
maxResultSizeChars: 20_000,
strict: true,
// ...
isConcurrencySafe() { return true },
isReadOnly() { return true },
// ...
});
这段代码证明 GrepTool 明确把自己从默认保守值里“解锁”出来。因为搜索不会修改文件系统,所以它可以被并发调度,也可以在权限判断里被视为只读。
BashTool:并发安全取决于输入
BashTool 的判断更细:
isConcurrencySafe(input) {
return this.isReadOnly?.(input) ?? false;
},
isReadOnly(input) {
const compoundCommandHasCd = commandHasAnyCd(input.command);
const result = checkReadOnlyConstraints(input, compoundCommandHasCd);
return result.behavior === 'allow';
},
这段代码证明 isConcurrencySafe 不是工具级静态标签,而是输入感知的运行时判断。git status、ls、cat 这类命令可能只读;git checkout、rm、npm install 这类命令会改变环境,就不能并发。
读源码抓手
看一个工具是否安全,不要只看工具名。先看它有没有覆盖 isReadOnly(input) 和 isConcurrencySafe(input),再看判断是否真的解析了输入。
tools.ts:工具注册不是罗列,而是多层过滤
工具定义完成后,还要经过 tools.ts 的装配管线。这里回答的是:当前这次会话里,模型到底能看见哪些工具?
第一层:构建期和启动期条件加载
原文给出的例子:
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,
require('./tools/ScheduleCronTool/CronListTool.js').CronListTool,
]
: [];
这说明实验工具不是“总在产物里,只是 UI 隐藏”。如果 feature() 在构建期为 false,对应模块树可能直接不进入 bundle。
还有运行身份过滤:
const REPLTool =
process.env.USER_TYPE === 'ant'
? require('./tools/REPLTool/REPLTool.js').REPLTool
: null;
这证明内部工具即使代码存在,也会按运行身份决定是否进入当前工具池。
第二层:getAllBaseTools() 收集候选池
getAllBaseTools() 是内置工具注册表。原文给出的片段:
export function getAllBaseTools(): Tools {
return [
AgentTool,
TaskOutputTool,
BashTool,
...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
FileReadTool,
FileEditTool,
FileWriteTool,
// ... 省略 30+ 个工具
...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []),
];
}
这里要注意 hasEmbeddedSearchTools()。如果构建里已经嵌入了搜索工具,独立的 GlobTool、GrepTool 就不进入基础工具池。这个判断说明工具注册不只是“功能越多越好”,还要避免重复能力。
第三层:getTools() 做会话过滤
getTools() 会继续处理三类过滤:
| 过滤 | 行为 | 影响 |
|---|---|---|
| deny 规则 | filterToolsByDenyRules() 移除被 alwaysDeny 覆盖的工具 | 被明确禁止的工具不发送给模型 |
| REPL 模式 | 隐藏 Bash、Read、Edit 等基础工具 | 通过 REPLTool 的 VM 上下文间接暴露 |
isEnabled() | 每个工具最后自检 | 当前环境不可用的工具退出工具池 |
这里的关键是“前置过滤”。如果用户配置了 "Bash": "deny",更好的行为不是让模型看到 Bash 后调用失败,而是工具 schema 里根本没有 Bash。这样模型不会把它纳入计划。
容易误解
权限系统当然还要在执行时检查。但工具列表前置过滤同样重要,因为它控制模型的可见世界。模型看不到的工具,通常就不会消耗推理路径去尝试。
第四层:assembleToolPool() 合并 MCP 工具
最终合并入口:
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',
);
}
这段代码证明三个行为:
| 行为 | 源码证据 | 设计意义 |
|---|---|---|
| MCP 工具也过 deny 规则 | filterToolsByDenyRules(mcpTools, permissionContext) | 外部工具不能绕过本地权限边界 |
| 内置工具先排序再拼接 | [...builtInTools].sort(byName).concat(...) | 内置工具形成稳定前缀 |
| 名称冲突时内置工具胜出 | uniqBy(..., 'name') 保留第一次出现项 | MCP 不能覆盖内置工具 |
排序还有缓存意义。工具 schema 是 prompt 的一部分,顺序抖动会影响 prompt cache。内置工具作为稳定前缀,MCP 工具作为后缀按名称排序,可以降低外部工具增减带来的缓存扰动。
工具结果预算:不能让工具输出淹没上下文
工具结果最终要回写成 tool_result,给下一轮模型看。但工具输出可能非常大:构建日志、搜索结果、网页内容、子 Agent 总结都可能把上下文挤爆。
Claude Code 用两级预算控制。
单工具预算:maxResultSizeChars
每个工具声明自己的结果上限:
| 工具 | maxResultSizeChars | 设计含义 |
|---|---|---|
McpAuthTool | 10,000 | 认证结果通常较短 |
GrepTool | 20,000 | 搜索结果应精简,太多说明范围过宽 |
BashTool | 30,000 | 构建/测试输出需要保留足够错误上下文 |
GlobTool | 100,000 | 文件列表可能较大 |
AgentTool | 100,000 | 子 Agent 结果可能包含多步总结 |
WebSearchTool | 100,000 | 搜索结果需要较宽上下文 |
FileReadTool | Infinity | 避免 Read 结果被替换为磁盘引用后再次 Read 的循环 |
FileReadTool 的 Infinity 很有代表性。它不是“不控制文件读取大小”,而是选择由文件读取工具自己的 maxTokens、maxSizeBytes 等机制控制输出,避免结果存储层把文件内容换成“请再读这个磁盘文件”的引用。
单消息聚合预算
并行工具会把多个 tool_result 放进同一条 user message。原文提到:
| 常量 | 值 | 定义位置 |
|---|---|---|
DEFAULT_MAX_RESULT_SIZE_CHARS | 50,000 字符 | constants/toolLimits.ts:13 |
MAX_TOOL_RESULT_TOKENS | 100,000 token | constants/toolLimits.ts:22 |
MAX_TOOL_RESULT_BYTES | 400,000 字节 | constants/toolLimits.ts:33 |
MAX_TOOL_RESULTS_PER_MESSAGE_CHARS | 200,000 字符 | constants/toolLimits.ts:49 |
TOOL_SUMMARY_MAX_LENGTH | 50 字符 | constants/toolLimits.ts:57 |
这说明预算不是只防单个工具失控,还要防并发工具的结果总量一起爆炸。
核心直觉
工具结果不是越完整越好。Agent 需要的是“足够继续判断的证据”,不是把整个外部世界塞进上下文。
渲染契约:工具调用在 UI 里分三段出现
Tool 接口还定义了终端 UI 的渲染方法:
renderToolUseMessage
renderToolUseProgressMessage
renderToolResultMessage
它们对应工具生命周期里的三个时刻:
意图展示:Partial@@INLINE_0@@ 是流式 UI 的证据
原文给出签名:
renderToolUseMessage(
input: Partial<z.infer<Input>>,
options: { theme: ThemeName; verbose: boolean; commands?: Command[] },
): React.ReactNode;
input 是 Partial,说明工具参数在 API 流里可能还没完整到达,UI 也要能先渲染。这不是类型细节,而是流式交互要求:用户应该尽早看到 Agent 准备调用什么工具,而不是等 JSON 参数全部解析完。
进度展示:长任务不能黑箱运行
renderToolUseProgressMessage 是可选的,但对 BashTool、AgentTool 这类长任务很关键。原文提到 BashTool 在命令执行超过 PROGRESS_THRESHOLD_MS = 2000 后开始显示进度。
这说明工具 UI 不是只显示最终结果,而是让用户在危险或耗时动作中间保持可见性。对终端 Agent 来说,这直接影响信任感。
结果展示:verbose 和 condensed 是两种阅读模式
renderToolResultMessage 可以根据 style?: 'condensed' 决定展示细节。搜索类工具在非 verbose 模式下可以显示摘要,在 verbose 模式下展示完整结果。工具还可以用 isResultTruncated(output) 告诉 UI 结果是否被截断。
这条契约把“给模型的结果”和“给用户看的结果”分开了:模型需要可继续推理的 tool_result,用户需要能判断 Agent 在做什么的终端视图。
延迟加载:工具太多时,不要一次把 schema 全塞给模型
当 MCP 工具很多时,完整 schema 会占用大量 token。原文提到 Claude Code 支持 deferred loading:
| 字段 | 行为 |
|---|---|
shouldDefer: true | 初始提示只发送工具名和 defer_loading: true,不发送完整参数 schema |
searchHint | 用 3-10 个词描述能力,帮助 ToolSearchTool 搜索 |
alwaysLoad: true | 核心工具永远发送完整 schema |
ToolSearchTool | 模型先搜索工具定义,再调用延迟工具 |
这个机制说明工具系统还承担 token 预算职责。工具越多,不代表越应该一次性全部暴露。核心工具应该常驻,低频或外部工具可以搜索后加载。
状态和数据结构
工具系统里最重要的数据结构可以压成这几组:
| 结构 | 关键字段 | 影响 |
|---|---|---|
Tool | name、description、inputSchema、call | 定义模型可见能力和运行时执行入口 |
Tool | checkPermissions、validateInput、canUseTool | 控制执行前和执行中的安全边界 |
Tool | isReadOnly、isConcurrencySafe、isEnabled | 控制会话过滤和执行调度 |
Tool | maxResultSizeChars | 控制单工具结果进入上下文的大小 |
TOOL_DEFAULTS | isReadOnly: false、isConcurrencySafe: false | fail-closed 默认值 |
Tools | 内置工具数组、MCP 工具数组 | 被 assembleToolPool() 合并成最终工具池 |
| API tool schema | name、description、JSON Schema | 模型实际看见的能力表面 |
设计取舍
| 取舍点 | 源码体现 | 工程判断 |
|---|---|---|
| 描述函数化 | description(input, options) | 工具说明要根据权限和上下文动态变化,提前影响模型计划 |
| schema 双用 | inputSchema 同时 Zod 校验和 JSON Schema 输出 | 一份结构既约束模型,也约束运行时 |
| fail-closed 默认值 | isReadOnly: false、isConcurrencySafe: false | 新工具忘记声明时按危险处理 |
| 输入感知属性 | BashTool.isConcurrencySafe(input) | 同一工具不同输入风险不同,不能只按工具名判断 |
| 前置过滤工具 | getTools()、filterToolsByDenyRules() | 被拒工具不该进入模型可见世界 |
| 内置工具优先 | sort().concat(...).uniqBy('name') | MCP 不能覆盖内置工具,工具顺序也更利于 prompt cache |
| 分层结果预算 | maxResultSizeChars + message aggregate limit | 既防单个工具失控,也防并发工具合力挤爆上下文 |
| 渐进渲染 | Partial@@INLINE_0@@、progress、result renderer | 用户能看到意图、进度和结果,而不是黑箱等待 |
读源码抓手
建议按这个顺序读第 3 章相关源码:
- 先看
restored-src/src/Tool.ts的接口字段,不追具体工具实现。 - 再看
TOOL_DEFAULTS和buildTool(),确认默认值如何合并。 - 选
GrepTool和BashTool对比:一个静态只读,一个输入感知。 - 进入
restored-src/src/tools.ts,只追feature()条件加载、getAllBaseTools()、getTools()、assembleToolPool()。 - 找
filterToolsByDenyRules(),确认 deny 规则如何影响模型可见工具。 - 看
constants/toolLimits.ts,把单工具预算和单消息预算分开。 - 最后看渲染方法签名,理解工具在终端 UI 里如何从意图流到结果。
小结
- Tool 接口把一个能力拆成模型描述、输入 schema、执行入口、权限、预算和渲染契约。 - buildTool() 的默认值体现 fail-closed:没声明只读和并发安全,就按危险处理。 - tools.ts 不只是注册表,它通过构建期、环境、权限、模式和 MCP 合并决定模型能看见什么。 - assembleToolPool() 的排序和内置优先策略,同时服务安全边界和 prompt cache 稳定性。 - 工具结果预算和三阶段渲染说明:工具系统不仅负责执行,还负责控制上下文体积和用户可见性。