本文基于 Claude Code v2.1.88 源码,深入分析其工具调用(Tool Use)的准确性保障与可控制性机制,并探讨如何在自己的项目中实现类似能力。
一、问题背景
大语言模型(LLM)通过 function calling(工具调用)与外部系统交互。但模型的输出并不总是可靠的------参数类型可能错误,调用的命令可能危险,执行顺序可能引发竞态条件。
Claude Code 作为 Anthropic 官方的 CLI 工具,拥有一套多层防御体系来保证工具调用的准确性与安全性。下面逐层拆解。
二、工具调用流水线:从模型输出到实际执行
当模型生成一个 tool_use 块时,数据会经过以下六层检查:
模型输出 tool_use(工具名 + 参数)
│
▼
[1] 工具查找 ---→ 不存在?返回 "No such tool" 错误
│
▼
[2] Zod Schema 校验 ---→ 类型不匹配?返回 "InputValidationError" 错误
│
▼
[3] validateInput 业务校验 ---→ 不合法?返回工具特定的错误信息
│
▼
[4] Pre-Tool Hooks ---→ Hook 拦截?停止执行
│
▼
[5] 权限决策(规则 → Hook → AI分类器 → 用户弹窗)---→ deny?返回错误
│
▼
[6] 执行工具(并发/串行取决于安全性)
│
▼
返回结果给模型
每一层都能独立拦截不安全的操作。核心设计哲学是 fail-closed(不确定就拒绝) 和 defense-in-depth(纵深防御)。
三、六层防御机制详解
3.1 第一层:Zod Schema 强类型校验
每个工具都定义了严格的 Zod Schema。模型输出是松散的 JSON,必须在执行前通过类型校验。
源码中有一段耐人寻味的注释:
typescript
// toolExecution.ts:614
// "surprisingly, the model is not great at generating valid input"
// (令人惊讶的是,模型并不擅长生成合法的输入)
const parsedInput = tool.inputSchema.safeParse(input)
if (!parsedInput.success) {
return [{
message: createUserMessage({
content: [{
type: 'tool_result',
content: `<tool_use_error>InputValidationError: ${errorContent}</tool_use_error>`,
is_error: true,
tool_use_id: toolUseID,
}],
toolUseResult: `InputValidationError: ${parsedInput.error.message}`,
}),
}]
}
模型可能把数组输出为字符串,把数字输出为布尔值------这些错误在 Schema 层就被拦截,永远不会到达工具执行逻辑。
3.2 第二层:业务逻辑校验(validateInput)
Schema 只能检查类型。业务规则由每个工具自定义实现:
typescript
// toolExecution.ts:683-733
const isValidCall = await tool.validateInput?.(parsedInput.data, toolUseContext)
if (isValidCall?.result === false) {
// 返回工具特定的错误信息
return [{
message: createUserMessage({
content: [{
type: 'tool_result',
content: `<tool_use_error>${isValidCall.message}</tool_use_error>`,
is_error: true,
}],
}),
}]
}
例如:
- Bash 工具 会解析命令的 AST 结构,检查是否包含危险操作
- 文件工具 会校验路径是否在工作目录范围内
- Agent 工具 会检查嵌套深度是否超限
3.3 第三层:Hook 系统 --- 可插拔的前/后置拦截
每个工具执行前都会运行用户自定义的 PreToolUse hooks:
typescript
// toolExecution.ts:800-862
for await (const result of runPreToolUseHooks(toolUseContext, tool, ...)) {
switch (result.type) {
case 'message': // Hook 返回附加消息
case 'hookPermissionResult': // Hook 直接做出 allow/deny 决定
case 'hookUpdatedInput': // Hook 修改了工具输入参数
case 'preventContinuation': // Hook 阻止后续工具执行
case 'stop': // Hook 强制停止,立即返回
}
}
Hook 可以拦截执行、修改参数、注入上下文、甚至阻止后续所有工具的执行。这是用户自定义安全策略的核心入口。
3.4 第四层:权限系统 --- 多层决策
这是控制力的核心。权限决策的结果有三种:
typescript
// types/permissions.ts
type PermissionBehavior = 'allow' | 'deny' | 'ask'
决策来源是多层级的,按优先级依次尝试:
① 用户配置的规则(alwaysAllow / alwaysDeny)
↓ 没有匹配的规则
② Hook 的权限决策
↓ Hook 没有决策
③ AI 分类器自动判断(仅 auto 模式)
↓ 分类器不确定
④ 弹窗让用户手动确认(最终兜底)
权限来源的定义覆盖了所有可能的场景:
typescript
// types/permissions.ts
type PermissionDecisionReason =
| { type: 'rule', rule: PermissionRule } // 用户配置的规则
| { type: 'mode', mode: PermissionMode } // 权限模式决定
| { type: 'hook', hookName: string } // Hook 拦截
| { type: 'classifier', classifier: string, reason } // AI 分类器
| { type: 'permissionPromptTool', ... } // 用户弹窗
| { type: 'safetyCheck', reason: string } // 安全检查
| { type: 'sandboxOverride', reason: string } // 沙盒覆盖
| { type: 'workingDir', reason: string } // 工作目录限制
| ...
3.5 第五层:并发控制 --- 安全的工具并行,危险的操作串行
模型可能在一次响应中输出多个工具调用。系统会根据工具的安全性自动分组:
typescript
// toolOrchestration.ts:91-116
function partitionToolCalls(toolUseMessages, toolUseContext): Batch[] {
return toolUseMessages.reduce((acc, toolUse) => {
const tool = findToolByName(toolUseContext.options.tools, toolUse.name)
const isConcurrencySafe = /* 校验后 */ false
if (isConcurrencySafe && acc[acc.length - 1]?.isConcurrencySafe) {
acc[acc.length - 1].blocks.push(toolUse) // 合并到并发批次
} else {
acc.push({ isConcurrencySafe, blocks: [toolUse] }) // 新建串行批次
}
return acc
}, [])
}
执行策略:
typescript
// toolOrchestration.ts:26-81
for (const { isConcurrencySafe, blocks } of partitionedBatches) {
if (isConcurrencySafe) {
// 读操作(Read、Glob、Grep)→ 并行执行,上限 10 个
yield* runToolsConcurrently(blocks, ...)
} else {
// 写操作(Write、Edit、Bash)→ 串行执行,避免竞态
yield* runToolsSerially(blocks, ...)
}
}
默认策略是 fail-closed:不确定时假设不安全。
typescript
// Tool.ts 中的默认值 --- 安全优先
const TOOL_DEFAULTS = {
isConcurrencySafe: () => false, // 默认不可并发
isReadOnly: () => false, // 默认假设是写操作
isDestructive: () => false,
}
3.6 第六层:Bash 工具的特殊安全机制
Bash 工具是最危险的工具,拥有额外的安全层:
- AST 解析:通过 shell-quote 解析器将命令拆解为子命令,逐一检查
- AI Classifier :在
auto模式下使用独立的小模型判断命令安全性 - 沙盒隔离 :需要
dangerouslyDisableSandbox显式授权 - 内部字段保护:防止模型伪造内部状态(后文详述)
四、六个巧妙的设计
在校验和权限的基础之上,Claude Code 还有几个让人眼前一亮的设计。
4.1 投机式分类器(Speculative Classifier)--- 安全与速度兼得
AI 分类器需要几百毫秒才能判断命令安全性,但权限弹窗也需要时间。系统的做法是在 Hook 执行的同时就悄悄启动分类器:
typescript
// bashPermissions.ts:1482-1541
const speculativeChecks = new Map<string, Promise<ClassifierResult>>()
// 在工具校验阶段就提前启动分类
export function startSpeculativeClassifierCheck(command, ...): boolean {
const promise = classifyBashCommand(command, cwd, allowDescriptions, ...)
promise.catch(() => {}) // 防止 unhandled rejection
speculativeChecks.set(command, promise)
return true
}
// 在权限决策阶段消费结果
export function consumeSpeculativeClassifierCheck(command): Promise | undefined {
const promise = speculativeChecks.get(command)
if (promise) speculativeChecks.delete(command)
return promise
}
时间线对比:
没有投机: 有投机:
[Hook 运行 200ms] [Hook 运行 200ms]
[分类器 300ms] [分类器 300ms] ← 同时进行
[弹窗等待...] [弹窗等待...] ← 分类结果已就绪
总计:500ms + 弹窗 总计:300ms + 弹窗(节省200ms)
这是一个经典的**时间重叠(time-overlap)**设计,让安全检查几乎零额外延迟。
4.2 Sed 编辑模拟 --- 把 Bash 命令"翻译"成用户友好的 Diff
当模型用 sed -i 's/foo/bar/g' file.txt 修改文件时,系统不会直接弹出一个冷冰冰的 shell 命令让用户确认,而是:
- 解析 sed 命令,提取出
{pattern, replacement, flags, filePath} - 读取目标文件的当前内容
- 在内存中用 JavaScript 正则引擎模拟 sed 替换
- 显示和 Edit 工具一样的可视化 diff 预览
typescript
// sedEditParser.ts --- 解析 sed 命令
export type SedEditInfo = {
filePath: string // 文件路径
pattern: string // 搜索模式(正则)
replacement: string // 替换内容
flags: string // 标志位(g、i 等)
extendedRegex: boolean // 是否使用扩展正则
}
export function parseSedEditCommand(command: string): SedEditInfo | null {
// 解析 sed -i 's/pattern/replacement/flags' file 形式的命令
// 使用 AST 解析器确保安全性
const parseResult = tryParseShellCommand(withoutSed)
// ... 校验 flags 只允许安全字符
const validFlags = /^[gpimIM1-9]*$/
// ...
}
typescript
// sedEditParser.ts --- 在 JS 中模拟 sed 替换
export function applySedSubstitution(content: string, sedInfo: SedEditInfo): string {
let regexFlags = ''
if (sedInfo.flags.includes('g')) regexFlags += 'g'
if (sedInfo.flags.includes('i')) regexFlags += 'i'
// ... 将 sed 正则语法转换为 JavaScript 正则
}
这是一个语义提升的设计:把底层的 shell 操作翻译成用户可以直观审查的 diff 视图。
4.3 Denial Tracking --- 自适应降级
在 auto 模式下,AI 分类器自动决定是否允许命令执行。但如果分类器连续出错怎么办?系统设计了自适应降级:
typescript
// denialTracking.ts
export type DenialTrackingState = {
consecutiveDenials: number // 连续拒绝次数
totalDenials: number // 总拒绝次数
}
export const DENIAL_LIMITS = {
maxConsecutive: 3, // 连续被拒 3 次 → 降级
maxTotal: 20, // 总共被拒 20 次 → 降级
}
export function recordDenial(state: DenialTrackingState): DenialTrackingState {
return {
...state,
consecutiveDenials: state.consecutiveDenials + 1,
totalDenials: state.totalDenials + 1,
}
}
export function recordSuccess(state: DenialTrackingState): DenialTrackingState {
if (state.consecutiveDenials === 0) return state
return { ...state, consecutiveDenials: 0 }
}
export function shouldFallbackToPrompting(state: DenialTrackingState): boolean {
return (
state.consecutiveDenials >= DENIAL_LIMITS.maxConsecutive ||
state.totalDenials >= DENIAL_LIMITS.maxTotal
)
}
当分类器连续拒绝 3 次后,系统会自动降级到弹窗询问用户。这是一个自省机制------系统监控自己的决策质量,连续出错意味着"我的判断可能有误",主动把决策权交还给人类。
4.4 内部字段防护 --- 防止模型越权
Bash 工具的 sed 编辑预览功能依赖一个内部字段 _simulatedSedEdit。这个字段由权限系统在用户批准后注入,告诉 Bash 工具"这次 sed 已经预演过了,直接执行"。
问题来了:如果模型自己伪造这个字段呢?系统做了纵深防御:
typescript
// toolExecution.ts:756-773
// Defense-in-depth: strip _simulatedSedEdit from model-provided Bash input.
// This field is internal-only --- it must only be injected by the permission system.
if (
tool.name === BASH_TOOL_NAME &&
processedInput &&
typeof processedInput === 'object' &&
'_simulatedSedEdit' in processedInput
) {
const { _simulatedSedEdit: _, ...rest } = processedInput
processedInput = rest // 剥离掉!
}
即使 Schema 的 strictObject 将来有 bug,这层防线依然在。
4.5 backfillObservableInput --- 同一份数据,两条路径
Hook 和权限系统可能需要扩展后的字段 (如把相对路径展开为绝对路径),但 tool.call() 必须用原始字段(工具结果会嵌入路径,改了会破坏 transcript 一致性和 VCR 录制哈希)。
解决方案:一份浅拷贝,两条路径:
typescript
// toolExecution.ts:775-793
// Backfill legacy/derived fields on a shallow clone so hooks/canUseTool see
// them without affecting tool.call(). SendMessageTool adds fields; file
// tools overwrite file_path with expandPath --- that mutation must not reach
// call() because tool results embed the input path verbatim.
let callInput = processedInput
const backfilledClone =
tool.backfillObservableInput &&
typeof processedInput === 'object' &&
processedInput !== null
? ({ ...processedInput } as typeof processedInput) // 浅拷贝
: null
if (backfilledClone) {
tool.backfillObservableInput!(backfilledClone) // 扩展字段
processedInput = backfilledClone // 给 hooks 和权限系统用
}
// callInput 仍然是原始值,给 tool.call() 用
4.6 Schema 缺失时的自修复提示
当模型在未加载 Schema 的情况下硬调延迟工具,Zod 校验必然失败。系统不是简单地返回错误,而是在错误消息中附带修复指令:
typescript
// toolExecution.ts:577-597
export function buildSchemaNotSentHint(tool, messages, tools): string | null {
if (!isDeferredTool(tool)) return null
if (discovered.has(tool.name)) return null // 已发现 → 不是这个问题
return (
`This tool's schema was not sent to the API --- it was not in the ` +
`discovered-tool set derived from message history. Without the schema ` +
`in your prompt, typed parameters get emitted as strings and the ` +
`client-side parser rejects them. Load the tool first: call ` +
`ToolSearch with query "select:${tool.name}", then retry this call.`
)
}
错误消息本身就是修复指令------告诉模型"你需要先调用 ToolSearch 加载这个工具,然后再重试"。这让系统能从错误中自我恢复,而不是陷入死循环。
五、MCP 工具的渐进式加载
5.1 问题:上下文窗口的 Token 竞争
MCP(Model Context Protocol)工具由外部服务注册,数量可能很多。如果把所有 MCP 工具的完整 Schema(名称 + 描述 + 参数定义)都塞进 prompt,会占用大量上下文窗口:
50 个 MCP 工具 × 平均 500 token/schema = 25,000 token 始终占用
在 200K 上下文窗口下,这已经占了 12.5%。工具更多时问题更严重。
5.2 方案:defer_loading --- 注册空壳,按需注入
Claude Code 利用了 Anthropic API 的 defer_loading 特性:
typescript
// api.ts:119-226
export async function toolToAPISchema(tool, options) {
// ... 构建基础 schema
const schema = {
name: base.name,
description: base.description,
input_schema: base.input_schema,
}
// 关键:标记为延迟加载
if (options.deferLoading) {
schema.defer_loading = true // API 只知道名字,不注入完整 schema
}
return schema
}
效果对比:
普通工具: { name, description, input_schema: {完整参数定义} }
→ 模型完全可见,可直接调用
延迟工具: { name, defer_loading: true } → 模型只知道名字存在
→ 不知道参数格式,无法调用
5.3 ToolSearchTool --- 按需搜索和加载
模型通过 ToolSearchTool 搜索并加载需要的工具:
typescript
// ToolSearchTool.ts --- 搜索接口定义
export const inputSchema = lazySchema(() =>
z.object({
query: z.string().describe(
'Query to find deferred tools. ' +
'Use "select:<tool_name>" for direct selection, or keywords to search.'
),
max_results: z.number().optional().default(5),
}),
)
搜索支持三种查询方式:
① 精确选择: query="select:mcp__slack__send_message"
② 关键词搜索: query="slack send message"
③ 必须包含: query="+slack send" (+前缀表示必须包含该词)
关键词搜索的实现:
typescript
// ToolSearchTool.ts:186-230
async function searchToolsWithKeywords(query, deferredTools, tools, maxResults) {
// 快速路径:精确名称匹配
const exactMatch = deferredTools.find(t => t.name.toLowerCase() === queryLower)
if (exactMatch) return [exactMatch.name]
// MCP 前缀匹配:query 以 mcp__ 开头
if (queryLower.startsWith('mcp__')) {
return deferredTools
.filter(t => t.name.toLowerCase().startsWith(queryLower))
.slice(0, maxResults).map(t => t.name)
}
// 分词搜索:required(+前缀)和 optional
const requiredTerms = []
const optionalTerms = []
for (const term of queryTerms) {
if (term.startsWith('+')) requiredTerms.push(term.slice(1))
else optionalTerms.push(term)
}
// ... 评分排序
}
5.4 发现集的持久化 --- 跨轮次记忆
工具一旦被发现,就不需要重复加载。extractDiscoveredToolNames 扫描整个消息历史:
typescript
// toolSearch.ts:545-592
export function extractDiscoveredToolNames(messages: Message[]): Set<string> {
const discoveredTools = new Set<string>()
for (const msg of messages) {
// 从上下文压缩的 boundary 恢复(压缩后不丢失发现记录)
if (msg.type === 'system' && msg.subtype === 'compact_boundary') {
const carried = msg.compactMetadata?.preCompactDiscoveredTools
if (carried) {
for (const name of carried) discoveredTools.add(name)
}
continue
}
// 从 tool_result 中的 tool_reference 块提取
if (msg.type !== 'user') continue
const content = msg.message?.content
for (const block of content) {
if (isToolResultBlockWithContent(block)) {
for (const item of block.content) {
if (isToolReferenceWithName(item)) {
discoveredTools.add(item.tool_name)
}
}
}
}
}
return discoveredTools
}
每次构建 API 请求时,根据已发现的工具决定注册哪些:
typescript
// claude.ts:1154-1167
if (useToolSearch) {
const discoveredToolNames = extractDiscoveredToolNames(messages)
filteredTools = tools.filter(tool => {
if (!deferredToolNames.has(tool.name)) return true // 内置工具:始终包含
if (tool.name === 'ToolSearch') return true // 搜索工具:始终包含
return discoveredToolNames.has(tool.name) // MCP 工具:仅包含已发现的
})
}
5.5 Token 预算自适应
系统会根据 MCP 工具的 token 占比自动决定是否启用延迟加载:
typescript
// toolSearch.ts:96-117
const DEFAULT_AUTO_TOOL_SEARCH_PERCENTAGE = 10 // 上下文窗口的 10%
function getAutoToolSearchTokenThreshold(model: string): number {
const contextWindow = getContextWindowForModel(model, betas)
const percentage = getAutoToolSearchPercentage() / 100
return Math.floor(contextWindow * percentage)
}
三种模式:
| 模式 | 行为 |
|---|---|
tst(默认) |
所有 MCP 工具始终延迟加载 |
tst-auto |
MCP 工具总量超过 10% 上下文窗口时才启用 |
standard |
全部内联,不做延迟加载 |
5.6 完整生命周期图
┌──────────────────────────────────────────────────────┐
│ MCP Server 连接 │
│ 工具注册到 tools[] 数组,标记 isMcp=true │
│ → isDeferredTool() 返回 true │
└───────────────────┬──────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────┐
│ 构建 API 请求 │
│ MCP 工具以 defer_loading=true 注册(只有名字) │
│ ToolSearchTool 始终包含 │
└───────────────────┬──────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────┐
│ 模型看到工具名字列表,决定需要用某个 MCP 工具 │
│ → 调用 ToolSearch(query="select:mcp__slack__send") │
└───────────────────┬──────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────┐
│ ToolSearchTool 返回 tool_reference 块 │
│ API 自动将该工具的完整 Schema 注入模型上下文 │
└───────────────────┬──────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────┐
│ 下一轮 API 请求 │
│ extractDiscoveredToolNames() 扫描消息历史 │
│ 发现已引用的工具 → 完整 Schema 包含在后续请求中 │
│ 模型此刻能正确构造参数并调用 │
└──────────────────────────────────────────────────────┘
六、如何自己实现类似机制
Claude Code 的 defer_loading + tool_reference 依赖于 Anthropic API 的原生特性。如果你使用其他 API(OpenAI、Gemini 等),需要自己实现替代方案。
6.1 核心限制
所有主流 LLM API 的硬规则:
模型只能调用 tools[] 数组里声明过的工具
模型输出的 tool_use 的 name 必须匹配已注册的工具名
否则 API 直接返回错误
模型不是"看到描述就能调用",它受限于 API 的 tools 数组。你在工具描述里写再详细的说明,对 API 来说只是普通文本。
6.2 路线 A:动态注册(多轮注入)
最接近 Claude Code 原理的方案:
python
# 伪代码
discovered = set()
def build_tools_list(all_tools, messages):
"""每次 API 调用前,扫描消息历史,动态决定注册哪些工具"""
for msg in messages:
if "tool_discovered:" in msg.content:
discovered.add(extract_tool_name(msg))
return [
tool for tool in all_tools
if tool.builtin
or tool.name == "ToolSearch"
or tool.name in discovered
]
# 第 1 轮:只有内置工具 + ToolSearch
response = api.chat(tools=build_tools_list(all_tools, messages))
# 第 2 轮:模型调了 ToolSearch 后,被搜索的工具加入 tools
response = api.chat(tools=build_tools_list(all_tools, messages))
优缺点:
- Token 效率最好,无工具数量上限
- 但需要多一轮交互,实现复杂度高
6.3 路线 B:万能调度工具
不注册 10 个独立工具,注册一个通用执行器:
json
{
"tools": [
{
"name": "execute_tool",
"description": "执行指定工具。先调用 list_tools 查看可用工具和参数说明。",
"parameters": {
"type": "object",
"properties": {
"tool_name": { "type": "string" },
"arguments": { "type": "object" }
},
"required": ["tool_name", "arguments"]
}
},
{
"name": "list_tools",
"description": "列出所有可用的 MCP 工具及其参数说明",
"parameters": { "type": "object", "properties": {} }
}
]
}
python
async def handle_execute_tool(tool_name, arguments):
# 在客户端路由到实际的 MCP 调用
actual_tool = mcp_tools[tool_name]
result = await actual_tool.call(arguments)
return result
优缺点:
- 实现最简单,一轮就能工作
- 但参数没有类型校验,准确率较低
6.4 路线 C:全部注册 + 精简描述
不做延迟加载,压缩每个工具的描述长度:
json
{
"name": "mcp__slack__send_message",
"description": "发送 Slack 消息(调用 tool_help 获取详细参数说明)",
"parameters": {
"type": "object",
"properties": {
"channel": { "type": "string" },
"text": { "type": "string" }
}
}
}
10 个精简工具约 1500 token,可以接受。需要详细信息时通过 help 工具获取。
优缺点:
- 工具始终可调用,不需要多轮
- 适合工具数量不超过 20-50 个的场景
6.5 三种路线对比
| 维度 | 路线 A: 动态注册 | 路线 B: 万能调度 | 路线 C: 精简注册 |
|---|---|---|---|
| Token 效率 | 最好 | 好 | 一般 |
| 参数类型校验 | 有 | 无 | 有 |
| 调用延迟 | 多一轮 | 无 | 可能多一轮 |
| 实现复杂度 | 高 | 低 | 低 |
| 工具数量上限 | 无上限 | 无上限 | ~50 个 |
| 需要 API 特殊支持 | 不需要 | 不需要 | 不需要 |
建议:工具不超过 20 个选路线 C,工具很多或频繁增减选路线 A,快速原型选路线 B。
七、总结
Claude Code 的工具调用架构体现了三个核心设计原则:
-
不信任模型输出,但也不阻塞模型工作。每层校验都是非阻塞的------能自动处理的自动处理,不能处理的才交给用户。
-
纵深防御(Defense in Depth)。任何单一防线都可能出 bug,但六层防线同时失效的概率极低。
-
按需加载(Lazy Loading)。不是预先把所有信息塞给模型,而是在需要时才提供。这既节省了 token,也降低了模型出错的可能性。
这些设计思路不仅适用于 Claude Code,对任何需要 LLM 与外部工具交互的系统都有参考价值。