Claude Code 源码深度讲解 · 必修篇 2/5

工具系统 — 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 接口

操作系统类比
OS 对用户进程暴露 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原因
ReadInfinityRead 本身是"看内容",结果就是文件内容,落盘只会形成 Read→file→Read 循环
Bash30_000长命令输出应该持久化
Grep30_000匹配结果可能极长
TodoWrite100_000短数组,不太可能触发
WebFetch100_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 两阶段加载

  1. 初始 prompt

    shouldDefer: true 的工具只发名字 + searchHint,不发完整 schema。

  2. 模型发现需要用时

    模型先调 ToolSearch({ query: 'task list' }) → 拿回匹配的工具完整 schema。

  3. 工具解锁

    之后模型能正常调用该工具,直到 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 收益估算

5-10% token 节省
Task 系列 5 个工具 ~6k token,Cron 3 个 ~2k token,MCP 10 个 ~5k token。 90% 的 query 用不到它们,defer 能为每个 query 节省 10k+ token。 对"小任务高并发"场景收益最大。

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 示例

模型一轮输出 7 个 tool_use(按顺序) Read a.ts Read b.ts Grep "x" Edit a.ts Bash cd X Read c.ts Glob *.ts Batch 1:并发(3 个 ReadOnly) Batch 2 Batch 3 Batch 4:并发 串行跑这 4 个 batch;batch 内并发执行
连续的 concurrency-safe 合并成并发 batch,不 safe 的单独成串行 batch

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 对比

经典 runToolsStreamingToolExecutor
开始时机模型输出完后流式阶段就开始
时间线串行:流式 → 执行并行:流式 + 执行
延迟改善典型 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 是单个工具从调用到返回的完整流程,每一步都可能分叉。

tool_use 到达 ① findToolByName — 工具不存在则报错 ② abort 预检 — 已中断则返回 CANCEL_MESSAGE ③ inputSchema.safeParse — Zod 校验 ④ validateInput — 工具自定义校验 ⑤ canUseTool — permission 决策 ⑥ PreToolUse hook — 可阻断 / 改输入 ⑦ tool.call() — 实际执行 ⑧ PostToolUse* hook → ⑨ format tool_result
9 步生命周期:每步都可能因错误/中断提前返回,但都返回一个配对的 tool_result

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: "文件内容..."
为什么这是 killer feature
传统 API:错了就停。Agent 模式:错了就学习。 把错误信息完整暴露给模型,让 LLM 发挥它最强的能力 —— 从上下文推理。 这个设计让 agent 处理"未知错误"的能力显著超过硬编码的 try/retry 逻辑。

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 testtengu_tool_use_error
Langfuse(recordToolObservation)LLM trace 可视化Observation tree
OTLP(logOTelEvent)企业 observabilityOpenTelemetry 标准

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 工具系统的原则。

  1. Tool 接口要涵盖"元数据 + 执行"两面

    不仅要定义 call 函数,还要声明 concurrent、readonly、destructive 等元数据。元数据让编排层能自动优化。

  2. schema 驱动,不要手写 JSON Schema

    Zod 给双重好处:运行时校验 + TypeScript 类型。写一份,用两处。

  3. 并发是性能的金矿

    partitionToolCalls 让 3 个独立 Read 从 3×串行降到 1 次并发时间。对调研任务提速 2-3×。

  4. 流式 > 批式

    StreamingToolExecutor 让工具执行 overlap 模型输出,总延迟下降 30-50%。虽然实现复杂度翻倍,但用户体验翻三倍。

  5. 失败即上下文

    错误不是 throw 给用户,是 tool_result 给模型。模型从错误中学习的能力远超硬编码重试。

  6. 预算机制保护上下文窗口

    单工具 maxResultSizeChars + 跨工具 applyToolResultBudget,双层防护不让任何单次工具调用破坏上下文。

  7. Hook 是扩展点,不是实现手段

    把工具 hook 做成产品级扩展点,让用户不需要改源码就能加自定义逻辑 —— 这让工具系统的"上限"取决于用户创造力而非你的产品规划。

  8. 懒加载拯救 prompt 预算

    shouldDefer + ToolSearch 让 90% 用不到的工具不占 prompt,每 query 节省 10k+ token。在工具数量增长时线性收益。