工具系统 — Tool 接口与执行管线
工具是 agent 的手和眼,决定了 agent 能做什么。Claude Code 用精心设计的 Tool 接口抽象了 60+ 内置工具 + 无限 MCP 工具,统一走一条带并发分批、流式执行、失败自愈的管线。
01引言:工具是 agent 的手和眼
没有工具的 LLM 只能对话;有工具的 LLM 能做事。工具系统设计得好坏,直接决定 agent 能力的上限。
1.1 工具设计的 5 个核心问题
| 问题 | Claude Code 答案 |
|---|---|
| 如何定义一个工具? | buildTool({ name, prompt, schema, call, ...flags }) |
| 如何验证输入? | Zod schema + lazy evaluation |
| 如何并发执行? | isConcurrencySafe 判定 → partitionToolCalls |
| 如何处理大结果? | maxResultSizeChars 预算 → 落盘 + 预览 |
| 如何处理失败? | 翻译为 tool_result(is_error) → 模型自我修复 |
1.2 工具系统 = agent 的 syscall 接口
read / write / fork / exec 等系统调用;
agent 运行时对 LLM 暴露 Read / Edit / Bash / Agent 等工具。
设计规则也类似:接口窄、语义明、失败可恢复、权限可控。
02Tool 接口完整解构
src/Tool.ts 定义了 ToolDef<Input, Output>,是整个 agent 系统最重要的接口之一。
2.1 核心字段
type ToolDef<Input, Output> = {
// ====== 身份 ======
name: string // 唯一名,模型看到的 tool name
aliases?: string[] // 向后兼容的旧名
searchHint: string // ToolSearch 检索用的短描述
mcpInfo?: { serverName, toolName } // MCP 工具元信息
// ====== 给模型看的 prompt ======
description(): Promise<string> // 1 句短描述
prompt(context): Promise<string> // 完整使用说明
// ====== 输入输出 schema ======
inputSchema: Input // Zod schema
inputJSONSchema?: ToolInputJSONSchema // MCP 工具直接 JSON Schema
outputSchema?: ZodType // 可选,帮 SDK 类型推断
// ====== 执行逻辑 ======
call(input, context, canUseTool, assistantMessage, onProgress):
AsyncGenerator<Progress, { data: Output }>
checkPermissions?(input, context): PermissionResult
validateInput?(input, context): ValidationResult
// ====== 标志位 ======
isEnabled(): boolean // 当前环境是否可用
isConcurrencySafe(input): boolean // 与同类并发安全
isReadOnly(input): boolean // 只读
isDestructive?(input): boolean // 破坏性(删除/覆盖)
isOpenWorld?(input): boolean // 操作外部世界(网络)
shouldDefer?: boolean // 工具懒加载
alwaysLoad?: boolean // 永远在 turn 1 可见
strict?: boolean // 严格模式(tengu_tool_pear)
// ====== 行为策略 ======
interruptBehavior?(): 'cancel' | 'block' // 中断时怎么办
maxResultSizeChars: number // 结果过大自动落盘
isSearchOrReadCommand?(input): { isSearch, isRead, isList }
// ====== UI / 显示 ======
userFacingName(): string
renderToolUseMessage?(input): ReactNode
renderToolResultMessage?(output, ...): ReactNode
// ====== 转换 ======
mapToolResultToToolResultBlockParam?(output, toolUseID): ToolResultBlockParam
toAutoClassifierInput?(input): string // classifier 的 summary
backfillObservableInput?(input): void // 展开派生字段
}
2.2 buildTool 辅助函数
// 实际写工具时用 buildTool,自动补齐默认值
export const MyTool = buildTool({
name: 'MyTool',
searchHint: 'do something useful',
maxResultSizeChars: 100_000,
isEnabled() { return true },
isConcurrencySafe() { return false },
isReadOnly() { return false },
inputSchema: lazySchema(() => z.object({ ... })),
async* call(input, context) { yield progress; return { data } },
userFacingName() { return 'MyTool' },
}) satisfies ToolDef<...>
03Zod 输入校验
为什么用 Zod 而不是 JSON Schema 直接写?Zod 同时给运行时校验和 TypeScript 类型推断,是"双用"武器。
3.1 lazy schema 设计
// 为什么 lazy?
// 1. 避免模块加载时循环依赖
// 2. 某些 schema 依赖 feature flag(运行时才知道)
const inputSchema = lazySchema(() =>
z.strictObject({
path: z.string().describe('Absolute file path'),
offset: z.number().int().optional(),
limit: z.number().int().positive().optional(),
})
)
3.2 strictObject 禁止额外字段
用 strictObject 而不是 object —— 输入里多一个字段就报错。防止模型"自作主张"加未记录的字段。
3.3 describe 的作用
每个字段的 .describe('...') 会序列化进 JSON Schema,作为工具描述的一部分发给模型。这是模型理解每个参数含义的主要来源。
3.4 formatZodValidationError
// toolExecution.ts — 当校验失败时
const parsedInput = tool.inputSchema.safeParse(toolUse.input)
if (!parsedInput.success) {
let errorContent = formatZodValidationError(tool.name, parsedInput.error)
// "expected array, got string" 这种 Zod 原生错误不够清楚
// 补充 schemaHint 帮模型理解
errorContent += schemaHint
// 作为 tool_result(is_error: true) 返回
}
04并发 / 只读 / 破坏性标志
三个布尔标志把"这个工具能不能跟别的一起跑"、"能不能跳过权限检查"、"需不需要用户确认"统一到了类型系统里。
4.1 三个标志的含义
| 标志 | 输入依赖? | 用途 |
|---|---|---|
isConcurrencySafe(input) | ✓ | 能否与其他并发安全工具并发执行 |
isReadOnly(input) | ✓ | 是否不会改变世界状态 → 自动放行读权限 |
isDestructive?(input) | ✓ | 是否不可逆(删除/覆盖/send)→ 强制确认 |
isOpenWorld?(input) | ✓ | 是否操作外部世界(网络请求)→ 安全评估 |
4.2 为什么是 input 相关
// Bash 工具:同是 Bash,但 input 不同含义截然不同
isConcurrencySafe({ command: 'ls -la' }) == true // ✓ 只读
isConcurrencySafe({ command: 'rm -rf' }) == false // ✗ 可能与别的冲突
isConcurrencySafe({ command: 'cd foo && ls' }) == false // ✗ 改了 cwd
// 判定基于 shell-quote 解析命令 + 黑白名单
4.3 isConcurrencySafe 的异常容错
// toolOrchestration.ts:111
const isConcurrencySafe = parsedInput?.success
? (() => {
try { return Boolean(tool?.isConcurrencySafe(parsedInput.data)) }
catch {
// 比如 shell-quote 对奇怪命令解析抛错时,保守判定为 false
return false
}
})()
: false
设计哲学:不确定就保守。宁可错过并发机会,不冒并发错误风险。
05maxResultSizeChars 预算
每个工具声明自己单个结果的字符数上限,超过就自动落盘 + 给模型一个预览。
5.1 典型值
| 工具 | maxResultSizeChars | 原因 |
|---|---|---|
| Read | Infinity | Read 本身是"看内容",结果就是文件内容,落盘只会形成 Read→file→Read 循环 |
| Bash | 30_000 | 长命令输出应该持久化 |
| Grep | 30_000 | 匹配结果可能极长 |
| TodoWrite | 100_000 | 短数组,不太可能触发 |
| WebFetch | 100_000 | 网页 HTML 通常 |
5.2 触发时落盘
// 在 tool 执行完,结果大于阈值 → persistToolResult
// 文件路径://tool-results/.txt
// 返回给模型的是:
`<persisted-output>
Output too large (85 KB). Full output saved to:
/Users/me/.claude/projects/.../tool-results/toolu_X.txt
Preview (first 2 KB):
<前 2KB 内容>
...
</persisted-output>`
模型看到 preview + 文件路径,如需完整内容可用 Read 工具读该文件。
5.3 Infinity 的意义
三个工具用 Infinity:Read / MemoryTool / 某些 MCP。它们的输出是"内容本身",持久化一份等于复制没意义。applyToolResultBudget 会跳过它们。
06shouldDefer 工具懒加载
工具数量越多,tool schema 越长,占的 prompt 越多。shouldDefer 让不常用的工具延迟加载。
6.1 两阶段加载
-
初始 prompt
带
shouldDefer: true的工具只发名字 + searchHint,不发完整 schema。 -
模型发现需要用时
模型先调
ToolSearch({ query: 'task list' })→ 拿回匹配的工具完整 schema。 -
工具解锁
之后模型能正常调用该工具,直到 session 结束。
6.2 哪些工具 defer
- 所有 Task 工具(TaskCreate/Update/List/Get)
- TodoWrite
- CronCreate/Delete/List
- 大部分 MCP 工具(按需)
6.3 alwaysLoad 反面标志
// 关键工具永不 defer,即使 ToolSearch 启用
alwaysLoad: true
// 比如 Read、Edit、Bash、Agent、AskUserQuestion
// 这些模型必须在 turn 1 就能直接调用,不能等 ToolSearch
6.4 收益估算
07assembleToolPool 组装
Tool pool 不是静态数组,而是每次 permissionMode / MCP 变化时动态重组的。
7.1 组装流程
// tools.ts — assembleToolPool 简化版
export function assembleToolPool(permissionContext, mcpTools): Tool[] {
return [
// 1. 永远在的核心
BashTool, FileReadTool, FileEditTool, FileWriteTool,
GlobTool, GrepTool, WebFetchTool, WebSearchTool,
// 2. 条件工具
...(isTodoV2Enabled() ? [TaskCreate, TaskUpdate, TaskList, TaskGet] : [TodoWriteTool]),
...(isAgentSwarmsEnabled() ? [TeamCreateTool, TeamDeleteTool, SendMessageTool] : []),
// 3. permissionMode 依赖
...(permissionContext.mode === 'plan' ? [ExitPlanModeV2Tool] : [EnterPlanModeTool]),
// 4. MCP 工具
...mcpTools,
].filter(t => t.isEnabled())
}
7.2 重组时机
- permissionMode 变化(/plan / /accept-edits)
- 新的 MCP server 连上
- feature flag 翻转
- agent 切换(每个 agent 可能有不同的 tool 列表)
7.3 refreshTools 回调
// toolUseContext.options.refreshTools 在每回合间被调用
if (updatedToolUseContext.options.refreshTools) {
const refreshedTools = updatedToolUseContext.options.refreshTools()
if (refreshedTools !== updatedToolUseContext.options.tools) {
// 工具集变了 → 下一回合用新集合
updatedToolUseContext.options.tools = refreshedTools
}
}
08并发 / 串行分批算法
partitionToolCalls 把一轮返回的所有 tool_use 块按"能否并发"分成若干 batch,这是性能和正确性的平衡点。
8.1 核心算法(toolOrchestration.ts:105)
function partitionToolCalls(toolUseMessages, toolUseContext): Batch[] {
return toolUseMessages.reduce((acc, toolUse) => {
const tool = findToolByName(tools, toolUse.name)
const parsedInput = tool?.inputSchema.safeParse(toolUse.input)
const isConcurrencySafe = parsedInput?.success
? Boolean(tool?.isConcurrencySafe(parsedInput.data))
: false
// 合并策略:
// 连续的 concurrency-safe 合并成一个并发 batch
// 其他单独成一个串行 batch
if (isConcurrencySafe && acc.at(-1)?.isConcurrencySafe) {
acc.at(-1)!.blocks.push(toolUse)
} else {
acc.push({ isConcurrencySafe, blocks: [toolUse] })
}
return acc
}, [])
}
8.2 示例
8.3 运行时执行
// toolOrchestration.ts:20
for (const { isConcurrencySafe, blocks } of partitionToolCalls(...)) {
if (isConcurrencySafe) {
yield* runToolsConcurrently(blocks, ...) // 并发 Promise.all
} else {
yield* runToolsSerially(blocks, ...) // for-await 逐个
}
}
8.4 并发上限
function getMaxToolUseConcurrency() {
return parseInt(process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY || '', 10) || 10
}
// 默认 10 并发。超过时用 bounded all() 限流
09StreamingToolExecutor 流式执行
经典路径是"模型一轮输出完 → 开始执行工具"。StreamingToolExecutor 把它改成"模型输出 tool_use 的那一刻就开始执行"。
9.1 对比
| 经典 runTools | StreamingToolExecutor | |
|---|---|---|
| 开始时机 | 模型输出完后 | 流式阶段就开始 |
| 时间线 | 串行:流式 → 执行 | 并行:流式 + 执行 |
| 延迟改善 | — | 典型 30-50% |
| 复杂度 | 简单 | 需要处理中断、fallback |
| 启用条件 | 默认 | gates.streamingToolExecution |
9.2 执行器核心 API
class StreamingToolExecutor {
addTool(toolBlock, assistantMessage): void // 流式阶段喂入新 tool_use
*getCompletedResults(): Iterable<Update> // 当前已完成的
async* getRemainingResults(): AsyncIterable<Update> // 流结束后等剩下的
discard(): void // fallback 时丢弃
}
9.3 流式阶段的使用
// query.ts:848 — 流里每收到 tool_use 就塞进执行器
for (const toolBlock of msgToolUseBlocks) {
streamingToolExecutor.addTool(toolBlock, assistantMessage)
}
// 流未结束就可能有工具跑完了 — 马上 yield tool_result
for (const result of streamingToolExecutor.getCompletedResults()) {
if (result.message) yield result.message
}
9.4 中断语义
// 流式 abort 发生时
if (toolUseContext.abortController.signal.aborted) {
if (streamingToolExecutor) {
// getRemainingResults 内部检查 abort,给每个未完成 tool 合成中断型 tool_result
for await (const update of streamingToolExecutor.getRemainingResults()) {
if (update.message) yield update.message
}
}
}
10单工具生命周期 9 步
runToolUse 是单个工具从调用到返回的完整流程,每一步都可能分叉。
10.1 关键步骤解析
| 步骤 | 失败返回 |
|---|---|
| ① | <tool_use_error>Error: No such tool available: {name}</tool_use_error> |
| ② | createToolResultStopMessage + CANCEL_MESSAGE |
| ③ | <tool_use_error>InputValidationError: ...</tool_use_error> |
| ④ | <tool_use_error>{validationMessage}</tool_use_error> |
| ⑤ | permission 结果:allow/deny/prompt |
| ⑥ | exit 2 → 阻断;stdout JSON → 改输入 |
| ⑦ | try/catch 捕获所有异常 → formatError |
11tool_result 协议三种形态
工具的最终产出是一个 tool_result 块,三种可能形态。
11.1 文本结果
{
type: 'tool_result',
tool_use_id: 'toolu_X',
content: '文件读取成功,内容:\n...',
is_error: false
}
11.2 错误结果
{
type: 'tool_result',
tool_use_id: 'toolu_X',
content: '<tool_use_error>ENOENT: no such file</tool_use_error>',
is_error: true // ← 让模型知道失败
}
11.3 图像/多媒体结果
{
type: 'tool_result',
tool_use_id: 'toolu_X',
content: [
{ type: 'text', text: 'Screenshot captured' },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: '...' } }
]
}
11.4 mcpMeta 特殊字段
// MCP 工具可以附带协议层元数据(非模型可见)
{
type: 'tool_result',
tool_use_id: 'toolu_X',
content: ...,
mcpMeta: {
_meta: { timestamp: '2026-05-01', server: 'github' },
structuredContent: { issueNumber: 42 }
}
}
// 这些被 SDK 消费者读取,但不发给模型
12三种失败形态
工具失败被精心分成三类,每类的翻译不同。
| 失败类型 | 例子 | 翻译 |
|---|---|---|
| 校验失败 | input 参数错 | <tool_use_error>InputValidationError: expected "path" to be string</tool_use_error> |
| 权限拒绝 | PreToolUse hook 阻断 | Execution stopped by PreToolUse hook: {reason} |
| 运行时异常 | ENOENT / 网络错误 | <tool_use_error>Error calling tool (Read): ENOENT: no such file</tool_use_error> |
12.1 失败的异常安全保证
// toolExecution.ts:1664 — 顶层 try/catch 兜底
try {
...
return resultingMessages
} catch (error) {
// 任何未捕获异常都翻译成 tool_result(is_error)
// 保证协议配对 — 不会让 tool_use 变成孤儿
return [{
message: createUserMessage({
content: [{ type: 'tool_result', content: formatError(error), is_error: true, tool_use_id }],
...
})
}]
}
13失败→自我修复模式
Claude Code 最优雅的设计之一:把工具失败作为给模型的反馈,而不是给用户的报错。
13.1 范式对比
传统 RPC 模式
tool error throws
↓
caller handles with try/catch
↓
caller shows error to user
Agent 模式
tool error → tool_result(is_error)
↓
下一回合模型读到错误
↓
模型自己改参数再试
13.2 具体例子
// 第 1 轮
assistant: Read({ path: "foo.ts" })
tool_result(is_error): "ENOENT: no such file foo.ts"
// 第 2 轮 — 模型自己修复
assistant: "让我先 ls 看看目录"
→ Bash({ command: "ls" })
tool_result: "Foo.ts Bar.ts README.md"
// 第 3 轮
assistant: "原来是 Foo.ts(大写)"
→ Read({ path: "Foo.ts" })
tool_result: "文件内容..."
13.3 错误文案的艺术
// 好的错误文案
"ENOENT: no such file '/tmp/foo.ts'. Did you mean /tmp/Foo.ts?"
"InputValidationError: field 'limit' must be positive, got -1"
"PermissionDenied: File write to /etc/passwd blocked by policy rule 'managed-settings.json:3'"
// 坏的错误文案
"Error" // 毫无信息
"TypeError: Cannot read properties of undefined" // 内部细节
"something went wrong" // 模糊
14PreTool/PostTool hook 切入
工具执行前后,用户可以注入任意 shell 脚本。这是 Claude Code 扩展性的基石(详见 hook 篇)。
14.1 切入点
- PreToolUse:工具执行前。能阻断、能改 input
- PostToolUseFailure:工具失败后。只能提示、不能改变
- PostToolUse:工具成功后(部分场景)
14.2 切入方式
// settings.json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write", // 只对这些工具触发
"command": "./scripts/check-typecheck.sh"
}
]
}
}
14.3 阻断协议
// exit 0 → 允许
// exit 2 → 阻断,stderr 作为 reason 返回给模型
// stdout JSON { "updatedInput": {...} } → 修改输入
// stdout JSON { "additionalContexts": [...] } → 附加上下文
exit 2 >&2 "TypeScript check failed before Edit"
15MCP 工具的 wrapping
MCP 工具天然就是 ToolDef — 但要在客户端侧做一层转换。
15.1 包装过程
// 伪码:client.ts:getMcpToolsCommandsAndResources
function wrapMcpTool(mcpTool, serverName, client) {
return buildTool({
name: `mcp__${serverName}__${mcpTool.name}`, // 加前缀
searchHint: mcpTool.description.slice(0, 80),
inputJSONSchema: mcpTool.inputSchema, // 直接用 JSON Schema
isMcp: true,
mcpInfo: { serverName, toolName: mcpTool.name },
maxResultSizeChars: 100_000,
isConcurrencySafe() { return mcpTool._meta?.'anthropic/concurrencySafe' ?? false },
isReadOnly() { return mcpTool._meta?.'anthropic/readOnly' ?? false },
async* call(args, context) {
const result = await callMCPToolWithUrlElicitationRetry({
client, tool: mcpTool.name, args,
signal: context.abortController.signal,
})
return { data: result }
},
...
})
}
15.2 统一的 execute 路径
包装后,MCP 工具走完全相同的 tool execution pipeline:partition → batch → execute → format。
isMcp: true 只是让某些 telemetry 能识别。
16三路 telemetry 上报
每个工具调用都同时上报到三个系统,用于不同用途。
| 系统 | 用途 | 格式 |
|---|---|---|
Statsig(logEvent) | 产品分析、A/B test | tengu_tool_use_error 等 |
Langfuse(recordToolObservation) | LLM trace 可视化 | Observation tree |
OTLP(logOTelEvent) | 企业 observability | OpenTelemetry 标准 |
16.1 classifyToolError — 反混淆
// toolExecution.ts:179 — 解决压缩构建里错误分类丢失的问题
export function classifyToolError(error: unknown): string {
// 1. 开发者显式声明的 telemetry-safe 字符串
if (error instanceof TelemetrySafeError) return error.telemetryMessage.slice(0, 200)
// 2. Node.js fs 错误:用 errno(ENOENT, EACCES)
if (error instanceof Error) {
const errnoCode = getErrnoCode(error)
if (errnoCode) return errnoCode
}
// 3. 未压缩:用 error.name(ShellError, AbortError...)
if (error.name && error.name !== 'Error' && error.name.length > 3) {
return error.name.slice(0, 60)
}
return 'unknown'
}
class ShellError extends Error mangled 成 class a extends Error。
直接用 error.constructor.name 在线上会看到 'a'、'b' 这种无意义分类。
反混淆链路保证生产环境埋点仍可读。
17可迁移设计原则
8 条可用到任何 agent 工具系统的原则。
-
Tool 接口要涵盖"元数据 + 执行"两面
不仅要定义 call 函数,还要声明 concurrent、readonly、destructive 等元数据。元数据让编排层能自动优化。
-
schema 驱动,不要手写 JSON Schema
Zod 给双重好处:运行时校验 + TypeScript 类型。写一份,用两处。
-
并发是性能的金矿
partitionToolCalls 让 3 个独立 Read 从 3×串行降到 1 次并发时间。对调研任务提速 2-3×。
-
流式 > 批式
StreamingToolExecutor 让工具执行 overlap 模型输出,总延迟下降 30-50%。虽然实现复杂度翻倍,但用户体验翻三倍。
-
失败即上下文
错误不是 throw 给用户,是 tool_result 给模型。模型从错误中学习的能力远超硬编码重试。
-
预算机制保护上下文窗口
单工具 maxResultSizeChars + 跨工具 applyToolResultBudget,双层防护不让任何单次工具调用破坏上下文。
-
Hook 是扩展点,不是实现手段
把工具 hook 做成产品级扩展点,让用户不需要改源码就能加自定义逻辑 —— 这让工具系统的"上限"取决于用户创造力而非你的产品规划。
-
懒加载拯救 prompt 预算
shouldDefer + ToolSearch 让 90% 用不到的工具不占 prompt,每 query 节省 10k+ token。在工具数量增长时线性收益。