Agent X-Ray
RuntimeNotesAbout
Notes/源码拆解/Claude Code Harness/第8章

第8章:工具系统 —— 47 个成员的接口与延迟加载

12 分钟 · 更新于 2026-09-01

第8章:工具系统 —— 47 个成员的接口与延迟加载

Pi 的工具接口有 5 个字段,dsh 的工具经过五段流水线。Claude Code 的 Tool 类型有 47 个成员。本章回答两个问题:这 47 个成员分别在解决什么问题、以及工具多到喂不下时该怎么办


一、先看 Tool 类型的全貌

src/Tool.ts 有 792 行,Tool 类型本身占 300 多行。按用途分组是这样的 [源码 src/Tool.ts:362-680]:

组 1:身份(5 个)

ts
readonly name: string
aliases?: string[]              // 改名后的向后兼容
readonly inputSchema: Input     // Zod
readonly inputJSONSchema?: ToolInputJSONSchema   // MCP 工具直接给 JSON Schema
outputSchema?: z.ZodType<unknown>

aliases 的用途在执行链里 [源码 src/services/tools/toolExecution.ts:350]:

ts
// If not found, check if it's a deprecated tool being called by alias
// (e.g., old transcripts calling "KillShell" which is now an alias for "TaskStop")

旧 transcript 恢复出来时,里面的工具名可能已经改了。 别名让 /resume 一个几个月前的会话不至于全是 "No such tool available"。

组 2:能力声明(8 个)

ts
isEnabled(): boolean
isReadOnly(input): boolean
isConcurrencySafe(input): boolean
isDestructive?(input): boolean          // 「只在真正不可逆时设」
isOpenWorld?(input): boolean
isMcp?: boolean
isLsp?: boolean
requiresUserInteraction?(): boolean

注意 isReadOnlyisConcurrencySafe两个判断,而且都接受 input。同一个 Bash 工具,ls 是只读的、rm 不是——能力是按调用判定的,不是按工具判定的

isDestructive 的注释很克制:

ts
/** Defaults to false. Only set when the tool performs irreversible operations
 *  (delete, overwrite, send). */

组 3:执行(4 个)

ts
call(args, context, canUseTool, parentMessage, onProgress?): Promise<ToolResult<Output>>
validateInput?(input, context): Promise<ValidationResult>
checkPermissions(input, context): Promise<PermissionResult>
getPath?(input): string

call 拿到了 canUseTool 也就是说工具在执行过程中可以再次发起权限检查——Agent 工具就靠这个给子 agent 做权限(第 12 章)。

组 4:中断与并发(2 个)

ts
/**
 * What should happen when the user submits a new message while this tool is running.
 * - `'cancel'` — stop the tool and discard its result
 * - `'block'`  — keep running; the new message waits
 * Defaults to `'block'` when not implemented.
 */
interruptBehavior?(): 'cancel' | 'block'
inputsEquivalent?(a, b): boolean

interruptBehavior 是个很产品化的设计:用户打断时,一个跑了 30 秒的构建该不该被杀?默认是"不杀,让新消息等着"——因为杀掉的代价通常更高。

组 5:上下文预算(3 个)

ts
maxResultSizeChars: number          // 第 7 章讲过
readonly shouldDefer?: boolean      // 本章 §3
readonly alwaysLoad?: boolean       // 本章 §3
searchHint?: string                 // 本章 §3

组 6:可观测输入(1 个,但很重要)

ts
/**
 * Called on copies of tool_use input before observers see it (SDK stream,
 * transcript, canUseTool, PreToolUse/PostToolUse hooks). Mutate in place
 * to add legacy/derived fields. Must be idempotent. The original API-bound
 * input is never mutated (preserves prompt cache). Not re-applied when a
 * hook/permission returns a fresh updatedInput — those own their shape.
 */
backfillObservableInput?(input: Record<string, unknown>): void

"观察者看到的输入"和"发给 API 的输入"是两份。

为什么?因为观察者需要展开后的信息(相对路径展开成绝对路径、补上派生字段),而发给 API 的必须保持模型原样吐出来的字节——动一个字符就断缓存

执行链里为此写了一大段小心翼翼的逻辑 [源码 src/services/tools/toolExecution.ts:775-793, 1189-1205]:

ts
// 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 (e.g. "File
// created successfully at: {path}"), and changing it alters the serialized
// transcript and VCR fixture hashes.

工具结果里会原样嵌入输入路径,改了它就改了 transcript 的字节,就破坏了 VCR(录制回放测试)的 fixture 哈希。所以最后收敛时还要判断"如果 hook 没换过输入,就把模型原本的路径换回来":

ts
if (backfilledClone && processedInput !== callInput
    && 'file_path' in processedInput && 'file_path' in callInput
    && processedInput.file_path === backfilledClone.file_path) {
  callInput = { ...processedInput, file_path: callInput.file_path }   // 换回原值
} else if (processedInput !== backfilledClone) {
  callInput = processedInput
}

这 15 行代码同时服务三个目标:hook 要看到展开路径、call() 要拿到原始路径、hook 真的改了输入时要生效。

可迁移的判断 ⑮ 当"给观察者看的数据"和"给执行用的数据"有冲突时,做成两份,并且明确规定哪一份是权威的。

Claude Code 的规则很清晰:API 绑定的那份是权威的、永不修改(因为它决定缓存);观察者那份是克隆 + 幂等回填。冲突只在"观察者链条上有人真的改了输入"时出现,这时以修改者为准。把这三条写进注释,比任何抽象设计都管用。

组 7:提示词与渲染(15+ 个)

ts
description(input, options): Promise<string>
prompt(options): Promise<string>
userFacingName(input): string
userFacingNameBackgroundColor?(input): keyof Theme | undefined
renderToolUseMessage(...)
renderToolResultMessage?(...)
renderToolUseProgressMessage?(...)
renderToolUseQueuedMessage?(): React.ReactNode
renderToolUseRejectedMessage?(...)
renderToolUseErrorMessage?(...)
renderToolUseTag?(input): React.ReactNode
isSearchOrReadCommand?(input): { isSearch, isRead, isList? }
…

一个工具要为"排队中 / 执行中 / 成功 / 被拒 / 出错"五种状态各提供一个渲染函数。

isSearchOrReadCommand 是纯 UI 用途:

ts
/**
 * Returns information about whether this tool use is a search or read operation
 * that should be collapsed into a condensed display in the UI.
 */

搜索和读取类操作在 UI 里折叠成一行,因为它们通常很多、单条价值低。

组 8:钩子与权限匹配(2 个)

ts
/**
 * Prepare a matcher for hook `if` conditions (permission-rule patterns like
 * "git *" from "Bash(git *)"). Called once per hook-input pair; any
 * expensive parsing happens here. Returns a closure that is called per
 * hook pattern.
 */
preparePermissionMatcher?(input): Promise<(pattern: string) => boolean>
readonly strict?: boolean

两阶段匹配器:昂贵的解析(比如把 bash 命令拆成 token)做一次,然后返回一个闭包给每个 hook 模式调用。因为一个工具调用可能要匹配几十条规则。


二、这 47 个成员说明了什么

一个工具接口膨胀到 47 个成员,通常是设计失败的信号。但这里不是——每一组都对应一个真实的横切关注点

对应的系统章节
能力声明并发调度、权限、UI 折叠本章 §4、第 10 章
可观测输入缓存 + 钩子 + transcript 稳定性第 4、11 章
上下文预算延迟加载、结果落盘本章 §3、第 7 章
渲染五种状态的 TUI——
权限匹配器钩子的 if 条件第 11 章

换句话说:Tool 接口的宽度 = 这个 harness 横切关注点的数量。 对照第 3 章那句"400 行主循环里核心只有 20 行",是同一个现象的两面。

Pi 的 Tool 只有 5 个字段,因为 Pi 没有权限系统、没有钩子、没有并发分区、没有延迟加载、没有五态渲染。不是 Pi 的接口设计得更好,是 Pi 没有这些东西。


三、延迟加载:工具太多喂不下时

3.1 问题

一个装了七八个 MCP 服务器的用户,工具 schema 可能占几万 token。每一轮都要重发(虽然能缓存,但缓存创建也要钱),而且挤占上下文窗口。

第 6 章那张表里 deferred_tools_delta 出现了 130 次,就是这个机制在跑。

3.2 判定:哪些工具会被延迟

ts
export function isDeferredTool(tool: Tool): boolean {
  // Explicit opt-out via _meta['anthropic/alwaysLoad'] — tool appears in the
  // initial prompt with full schema. Checked first so MCP tools can opt out.
  if (tool.alwaysLoad === true) return false

  // MCP tools are always deferred (workflow-specific)
  if (tool.isMcp === true) return true

  // Never defer ToolSearch itself — the model needs it to load everything else
  if (tool.name === TOOL_SEARCH_TOOL_NAME) return false

  // Fork-first experiment: Agent must be available turn 1, not behind ToolSearch.
  if (feature('FORK_SUBAGENT') && tool.name === AGENT_TOOL_NAME) {
    if (isForkSubagentEnabled()) return false
  }
  …
}

[源码 src/tools/ToolSearchTool/prompt.ts:60]

四条规则,顺序有意义:

  1. 显式豁免优先(MCP 服务器可以通过 _meta['anthropic/alwaysLoad'] 声明"我这个工具必须第一轮就在")
  2. MCP 工具默认全延迟——理由是 "workflow-specific",即它们只在特定工作流里用得上
  3. ToolSearch 自己永不延迟——否则模型没法加载任何东西(自举问题)
  4. fork 模式下 Agent 工具不延迟——因为 fork 是默认动作,不能让它藏在一次 ToolSearch 往返后面

3.3 三种模式与自动阈值

ts
/**
 * Tool search mode:
 *   - 'tst': Tool Search Tool — deferred tools discovered via ToolSearchTool (always enabled)
 *   - 'tst-auto': auto — tools deferred only when they exceed threshold
 *   - 'standard': tool search disabled — all tools exposed inline
 *
 *   ENABLE_TOOL_SEARCH    Mode
 *   auto / auto:1-99      tst-auto
 *   true / auto:0         tst
 *   false / auto:100      standard
 *   (unset)               tst (default: always defer MCP and shouldDefer tools)
 */

[源码 src/utils/toolSearch.ts:155]

auto:N 里的 N 是上下文窗口的百分比阈值

ts
/**
 * Default percentage of context window at which to auto-enable tool search.
 * When MCP tool descriptions exceed this percentage (in tokens), tool search is enabled.
 */
const DEFAULT_AUTO_TOOL_SEARCH_PERCENTAGE = 10 // 10%

function getAutoToolSearchTokenThreshold(model: string): number {
  const contextWindow = getContextWindowForModel(model, getMergedBetas(model))
  return Math.floor(contextWindow * (getAutoToolSearchPercentage() / 100))
}

MCP 工具定义超过上下文窗口的 10% 就自动转延迟加载。 又是百分比而非绝对值——和第 6 章技能清单的 1% 是同一个思路。

Token 计数走 API,失败了退到字符估算:

ts
/** Approximate chars per token for MCP tool definitions (name + description + input schema). */
const CHARS_PER_TOKEN = 2.5

注意这个 2.5 和 SkillTool 里的 CHARS_PER_TOKEN = 4 不一样。 因为 JSON Schema 里符号密度高,同样的字符数对应更多 token。两个常量各自标了自己的适用范围,没有硬凑成一个"全局常量"。

而且 token 计数是 memoize 的,缓存键是延迟工具名的拼接:

ts
memoize(async (…) => {…}, (tools: Tools) =>
  tools.filter(t => isDeferredTool(t)).map(t => t.name).join(','))

MCP 服务器连上/断开时工具名集合变了,缓存自然失效。 不需要显式失效逻辑。

3.4 searchHint:为了被搜到

ts
/**
 * One-line capability phrase used by ToolSearch for keyword matching.
 * Helps the model find this tool via keyword search when it's deferred.
 * 3–10 words, no trailing period.
 * Prefer terms not already in the tool name (e.g. 'jupyter' for NotebookEdit).
 */
searchHint?: string

[源码 src/Tool.ts:369]

"优先写工具名里没有的词" —— 因为名字本身已经能被匹配到了,hint 的价值在于覆盖模型可能用的其它说法NotebookEditjupyter 就是这个道理。

3.5 模型侧的说明书

ToolSearch 工具自己的描述 [源码 src/tools/ToolSearchTool/prompt.ts:28-53]:

Fetches full schema definitions for deferred tools so they can be called.

Deferred tools appear by name in <system-reminder> messages. Until fetched, only the name is known — there is no parameter schema, so the tool cannot be invoked. This tool takes a query, matches it against the deferred tool list, and returns the matched tools' complete JSONSchema definitions inside a <functions> block. Once a tool's schema appears in that result, it is callable exactly like any tool defined at the top of the prompt.

Result format: each matched tool appears as one <function>{"description": "...", "name": "...", "parameters": {...}}</function> line inside the <functions> block — the same encoding as the tool list at the top of this prompt.

Query forms:

  • "select:Read,Edit,Grep" — fetch these exact tools by name
  • "notebook jupyter" — keyword search, up to max_results best matches
  • "+slack send" — require "slack" in the name, rank by remaining terms

注意它花了两段解释"结果长什么样"和"和顶部工具列表是同一种编码"。 这不是给人看的文档,是在给模型建立"我可以把这个结果当成新工具来用"的心智模型。

3.6 参数没对上时的补救

延迟加载有个隐蔽的失败模式:模型只看到名字、没看到 schema,就凭猜测调用——于是数组参数写成字符串、数字写成字符串,客户端 Zod 校验直接拒。

ts
/**
 * Appended to Zod errors when a deferred tool wasn't in the discovered-tool
 * set — re-runs the claude.ts schema-filter scan dispatch-time to detect the
 * mismatch. The raw Zod error ("expected array, got string") doesn't tell the
 * model to re-load the tool; this hint does.
 */
export function buildSchemaNotSentHint(tool, messages, tools): string | null {
  if (!isToolSearchEnabledOptimistic()) return null
  if (!isToolSearchToolAvailable(tools)) return null
  if (!isDeferredTool(tool)) return null
  const discovered = extractDiscoveredToolNames(messages)
  if (discovered.has(tool.name)) return null
  return (
    `\n\nThis 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 (arrays, numbers, booleans) get emitted as strings and the client-side parser rejects them. ` +
    `Load the tool first: call ${TOOL_SEARCH_TOOL_NAME} with query "select:${tool.name}", then retry this call.`
  )
}

[源码 src/services/tools/toolExecution.ts:578]

原始的 Zod 错误("期望数组,得到字符串")不会让模型意识到要先加载工具。 所以在错误里补一段诊断,把"是什么错"翻译成"该怎么办"。

而且它有四道守卫(gate 关着不提示、ToolSearch 不可用不提示、非延迟工具不提示、已加载过的不提示),注释解释了为什么用"乐观"判断:

Optimistic gating — reconstructing claude.ts's full useToolSearch computation is fragile. These two gates prevent pointing at a ToolSearch that isn't callable; occasional misfires (Haiku, tst-auto below threshold) cost one extra round-trip on an already-failing path.

"偶尔误报的代价是在一条本来就失败的路径上多一次往返" —— 明确算过代价才选的近似。

可迁移的判断 ⑯ 错误消息应该回答"该怎么办",不只是"哪里错了"。

尤其当消费者是 LLM 时更关键——它不会去查文档,它只会重试。Claude Code 这段 hint 直接给出了下一个动作和确切的参数(select:{toolName})。判断标准:如果错误消息不足以让一个没有上下文的读者决定下一步做什么,它就还没写完。


四、并发:只读的批量并行,写操作串行

runTools() 的调度逻辑 [源码 src/services/tools/toolOrchestration.ts:20]:

ts
for (const { isConcurrencySafe, blocks } of partitionToolCalls(toolUseMessages, currentContext)) {
  if (isConcurrencySafe) {
    // 并发跑这一批
    yield* runToolsConcurrently(blocks, …)
    // 批跑完后,统一应用所有 contextModifier
  } else {
    // 串行跑
    yield* runToolsSerially(blocks, …)
  }
}

分区规则 [源码 src/services/tools/toolOrchestration.ts:88]:

ts
/**
 * Partition tool calls into batches where each batch is either:
 * 1. A single non-read-only tool, or
 * 2. Multiple consecutive read-only tools
 */

连续的并发安全工具合成一批并行;一遇到不安全的就单独成批。 顺序被严格保持——不会把后面的只读工具提前到前面的写操作之前。

并发上限:

ts
function getMaxToolUseConcurrency(): number {
  return parseInt(process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY || '', 10) || 10
}

默认 10。

4.1 判定失败时保守处理

ts
const isConcurrencySafe = parsedInput?.success
  ? (() => {
      try {
        return Boolean(tool?.isConcurrencySafe(parsedInput.data))
      } catch {
        // If isConcurrencySafe throws (e.g., due to shell-quote parse failure),
        // treat as not concurrency-safe to be conservative
        return false
      }
    })()
  : false

isConcurrencySafe 自己抛异常(比如 bash 命令引号没配对,shell-quote 解析炸了)→ 当作不安全。 schema 都没通过 → 也当作不安全。

fail-closed。 这是安全相关判断的正确默认值,第 10 章还会看到同一个模式。

4.2 contextModifier 的时序

并发那一支有个细节:工具可以返回一个"修改上下文"的函数(比如 EnterPlanMode 会改权限模式),但并发批里的修改不能立刻生效

ts
const queuedContextModifiers: Record<string, ((context) => ToolUseContext)[]> = {}
for await (const update of runToolsConcurrently(…)) {
  if (update.contextModifier) {
    queuedContextModifiers[toolUseID] ??= []
    queuedContextModifiers[toolUseID].push(modifyContext)
  }
  yield { message: update.message, newContext: currentContext }   // ← 还是旧的
}
// 批全部跑完之后,按 block 顺序统一应用
for (const block of blocks) {
  for (const modifier of queuedContextModifiers[block.id] ?? []) {
    currentContext = modifier(currentContext)
  }
}

并发批里所有工具看到的是同一个上下文快照,修改攒着,批结束后按原始顺序应用。

如果不这么做,就会出现"工具 A 和 B 并发跑,A 改了上下文,B 看到改前还是改后取决于调度"——不可复现的竞态

而串行那一支就可以立刻生效:

ts
if (update.contextModifier) {
  currentContext = update.contextModifier.modifyContext(currentContext)
}

可迁移的判断 ⑰ 并发批次内的副作用要"攒起来、批后按确定顺序统一应用",而不是即时生效。

这是把并发执行的不确定性限制在"批内互不可见"这一个明确语义里。代价是批内的工具看不到彼此的修改——但这本来就是"并发安全"这个前提该有的含义。


五、工具的两个提示词入口

Tool 上有两个都返回字符串的方法,容易混:

ts
description(input, options): Promise<string>    // 针对这一次调用的描述(UI / 权限对话框)
prompt(options): Promise<string>                // 工具的 schema description(发给模型)

prompt() 的参数很说明问题:

ts
prompt(options: {
  getToolPermissionContext: () => Promise<ToolPermissionContext>
  tools: Tools
  agents: AgentDefinition[]
  allowedAgentTypes?: string[]
}): Promise<string>

一个工具的描述可以依赖当前权限上下文、当前工具集、当前 agent 列表。 这就是第 4 章那个"agent 列表内嵌在工具描述里导致 10.2% 缓存创建"问题的结构性来源——接口允许了动态描述,于是有人用了

修法不是收回这个能力(Skill 工具仍然需要动态列出技能),而是把最不稳定的那部分挪到附件通道。


六、动手复核

bash
cd claude-code-deep-dive/extracted-source

# 1. Tool 类型全文(300 行,建议整读)
sed -n '362,680p' src/Tool.ts

# 2. 并发分区与上限
cat src/services/tools/toolOrchestration.ts

# 3. 延迟加载判定
sed -n '55,120p' src/tools/ToolSearchTool/prompt.ts

# 4. 三种模式与 10% 阈值
sed -n '43,175p' src/utils/toolSearch.ts

# 5. 「schema 没发」诊断提示
grep -n -B12 -A20 'buildSchemaNotSentHint' src/services/tools/toolExecution.ts

# 6. 两份输入(可观测 vs API 绑定)
grep -n -B8 -A20 'backfillObservableInput' src/Tool.ts src/services/tools/toolExecution.ts

# 7. 42 个工具目录
ls -d src/tools/*/

本机侧:

bash
# 本会话里的 ToolSearch 提示(在 Claude Code 里翻上下文即可看到)
# 形如:The following deferred tools are now available via ToolSearch…

# 关掉延迟加载对比
ENABLE_TOOL_SEARCH=false claude

七、总结

  1. Tool 有 47 个成员,分八组:身份、能力声明、执行、中断与并发、上下文预算、可观测输入、提示词与渲染、钩子匹配。接口宽度 = 横切关注点数量
  2. 能力是按调用判定的isReadOnly / isConcurrencySafe / isDestructive 全都接受 input——lsrm 是同一个工具的两种不同调用
  3. "观察者看到的输入"和"发给 API 的输入"是两份,API 那份永不修改(保缓存 + 保 transcript 字节 + 保 VCR fixture 哈希)
  4. 延迟加载四条判定规则:显式豁免优先 > MCP 全延迟 > ToolSearch 自己不延迟(自举)> fork 模式下 Agent 不延迟
  5. 自动阈值是上下文窗口的 10%,token 计数走 API、失败退字符估算;CHARS_PER_TOKEN 在工具(2.5)和技能(4)两处取了不同的值,各自标了适用范围
  6. searchHint 要写工具名里没有的词——名字本身已经能匹配了
  7. schema 没加载就瞎调的错误,会被追加一段"该怎么办"的诊断,明确给出 select:{toolName} 这个下一步动作
  8. 并发调度:连续的并发安全工具合成一批并行(默认上限 10),一遇到不安全的就单独成批;判定抛异常或 schema 不过 → fail-closed 当作不安全
  9. 并发批里的上下文修改攒到批后按原始顺序统一应用,把不确定性限制在"批内互不可见"这一个语义里
  10. prompt() 允许工具描述依赖运行时状态——这是能力,也是第 4 章那个缓存问题的结构性来源

下一章沿着一次工具调用往下走:从模型吐出 tool_usetool.call() 真正执行,中间隔着多少道关。


  • 第7章-上下文压缩-五层防线
  • 第9章-工具执行链-一次调用要过多少道关
  • 第4章-模型调用与缓存经济学 —— 两份输入、动态描述与缓存的关系
  • 第6章-附件与system-reminder-第二条注入通道 —— deferred_tools_delta 的载体
  • Codex 教程第 8 章 —— 同一个问题的另一种解法
  • Pi 教程第 5 章 —— 5 字段接口的对照
  • dsh 教程第 7 章 —— 五段流水线的对照

本章目录
一、先看 Tool 类型的全貌二、这 47 个成员说明了什么三、延迟加载:工具太多喂不下时四、并发:只读的批量并行,写操作串行五、工具的两个提示词入口六、动手复核七、总结Related Documents
苏ICP备2025204887号-2