Claude Code 工具调用机制详解

背景

本文档研究 Claude Code 里模型调用工具的完整机制:工具怎么被分批调度、单次调用要经过哪些环节(权限检查、执行、结果处理)、同步/后台两种执行模式的区别、执行中途怎么取消、以及工具数量过多时的规模化处理(deferred tools)。不涉及模型推理本身,也不展开 queryLoop 里跟工具调用无关的其他机制(比如上下文压缩、错误恢复)。

代码仓库:github.com/claude-code...

0. queryLoop 是什么

query()src/query.ts:276)是对外暴露的入口,内部通过 yield* queryLoop(params, consumedCommandUuids) 把执行权完全委托给 queryLoopsrc/query.ts:393)------一个 async function*,本质是一个不断产出(yield)消息的生成器。

queryLoopwhile (true)query.ts:460)驱动一次 agentic turn:每次迭代做"调用模型 → 判断要不要执行工具 → (执行工具)→ 决定继续循环还是结束"。核心判断点只有一个变量:needsFollowUpquery.ts:756, 1092)------本轮 assistant 消息里是否出现了 tool_use 内容块。这个布尔值直接决定走哪条分支:

flowchart TD A([调用模型]) --> B{本轮是否产生 tool_use} B -->|否| C[收尾判断, 含各类异常恢复, 略] C --> D([结束本轮或 continue 重试]) B -->|是| E[执行工具] E --> F([continue 进入下一轮]) F --> A

1. 工具调用总览

needsFollowUp = truequeryLoop 把本轮解析出的 toolUseBlocks(一个或多个 tool_use 块)交给工具执行层。核心链路:

flowchart TD Start(需要执行工具) --> Partition[按并发安全性分批] Partition --> PreHook[PreToolUse hooks] PreHook --> Permission[权限决策 allow deny ask] Permission -->|allow| Call[执行工具] Permission -->|deny 或 ask 拒绝| Done[本次调用结束] Call --> ResultMap[结果映射为工具结果, 含大小限制与持久化] ResultMap --> PostHook[PostToolUse hooks] PostHook --> Finalize{PreToolUse 是否曾标记阻止继续} Finalize -->|是| EmitStop[生成阻止继续的信号消息] Finalize -->|否| Done EmitStop --> Done Done --> Back(返回结果给主循环)

这条链路的最底层实现是 runToolUsesrc/services/tools/toolExecution.ts:366)------每一个 tool_use 块,无论走哪种调度方式,最终都会落到这个函数上执行完整生命周期。后面几节按"分批调度 → 单次调用生命周期 → 特殊执行模式 → 取消机制 → 规模化设计"的顺序展开。

2. 并发调度(src/services/tools/toolOrchestration.ts

runToolstoolOrchestration.ts:20)不是把 tool_use 列表直接顺序丢给执行器,而是先分批、再按批次类型选择并发或串行执行。

2.1 分批算法:partitionToolCallstoolOrchestration.ts:106

ts 复制代码
return toolUseMessages.reduce((acc: Batch[], toolUse) => {
  const tool = findToolByName(toolUseContext.options.tools, toolUse.name)
  const parsedInput = tool?.inputSchema.safeParse(toolUse.input)
  const isConcurrencySafe = parsedInput?.success
    ? (() => {
        try {
          return Boolean(tool?.isConcurrencySafe(parsedInput.data))
        } catch {
          return false   // 抛错时保守当作不可并发
        }
      })()
    : false
  if (isConcurrencySafe && acc[acc.length - 1]?.isConcurrencySafe) {
    acc[acc.length - 1]!.blocks.push(toolUse)   // 连续可并发的合并进同一批
  } else {
    acc.push({ isConcurrencySafe, blocks: [toolUse] })   // 否则单独成批
  }
  return acc
}, [])

规则:

  • 每个 tool_use 先用工具自己的 inputSchema.safeParse 解析 input,再传给 tool.isConcurrencySafe(parsedInput.data) 判断这具体一次调用能否并发;
  • 只有连续出现的、都可并发的调用 才会被合并进同一批(Batch.blocks);一旦遇到不可并发的调用,无论前面攒了多少个可并发的,都会另起一批;
  • input 解析失败,或 isConcurrencySafe() 本身抛异常(比如 Bash 的 shell-quote 解析失败),都保守地当作不可并发处理------并发是工具需要显式声明的能力,不是默认假设。

2.2 批次内执行:并发批次 vs 串行批次(toolOrchestration.ts

ts 复制代码
let currentContext = toolUseContext
for (const { isConcurrencySafe, blocks } of partitionToolCalls(toolUseMessages, currentContext)) {
  if (isConcurrencySafe) {
    // 并发批次:runToolsConcurrently
    for await (const update of runToolsConcurrently(blocks, assistantMessages, canUseTool, currentContext)) {
      yield { message: update.message, newContext: currentContext }
    }
  } else {
    // 串行批次:runToolsSerially
    for await (const update of runToolsSerially(blocks, assistantMessages, canUseTool, currentContext)) {
      yield { message: update.message, newContext: currentContext }
    }
  }
}

批次之间严格串行 ------外层是一个普通 for...of 循环,必须等上一批(不管并发还是串行)完全跑完,才会开始下一批。

2.3 并发上限(toolOrchestration.ts:9-13

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

runToolsConcurrently 把这个数字传给通用工具 all()src/utils/generators.ts:34)。all() 不是简单的 Promise.all,而是维护一个大小为 concurrencyCap 的滑动窗口:任意时刻最多同时有这么多个生成器在跑,一个完成就立刻从等待队列里补一个上来(Promise.race 驱动),不是"一次性全发出去再等"。默认上限 10,可通过环境变量调整。

3. 单个工具调用的生命周期(src/services/tools/toolExecution.tsrunToolUse

不管走并发批次还是串行批次,每个 tool_use 最终都落到 runToolUsetoolExecution.ts:366)这一个函数上(内部经 streamedCheckPermissionsAndCallToolcheckPermissionsAndCallTool)。它是一个很长的 async function*,按顺序做以下几件事。

3.1 PreToolUse hooks(toolExecution.ts:842-903

ts 复制代码
let shouldPreventContinuation = false
let stopReason: string | undefined
let hookPermissionResult: PermissionResult | undefined
for await (const result of runPreToolUseHooks(...)) {
  switch (result.type) {
    case 'hookPermissionResult': hookPermissionResult = result.hookPermissionResult; break
    case 'hookUpdatedInput': processedInput = result.updatedInput; break
    case 'preventContinuation': shouldPreventContinuation = result.shouldPreventContinuation; break
    case 'stopReason': stopReason = result.stopReason; break
    case 'stop':
      // 立即短路:跳过权限检查和工具执行,直接返回错误 tool_result
      resultingMessages.push({ message: createUserMessage({ ... }) })
      return resultingMessages
  }
}

hook 可以做四件不同的事:给出权限决策(hookPermissionResult)、透传修改后的 input(hookUpdatedInput)、标记 (不是立即生效)preventContinuation/stopReason、或者直接 stop(立即结束这次调用,权限检查和工具本体都不会跑)。注意 preventContinuation 在这一步只是设置了一个局部变量,真正生效要等到 3.6 节。

3.2 权限决策(toolExecution.ts:963src/services/tools/toolHooks.ts:340

先由 resolveHookPermissionDecisiontoolHooks.ts:340)把 PreToolUse hook 给出的决策和外部 canUseTool(也就是 hasPermissionsToUseTool)拼在一起:

  • Hook 返回 deny → 直接采用,不再调用 canUseTooltoolHooks.ts:416-419);
  • Hook 返回 allow不是直接放行 ,还会再跑一次 checkRuleBasedPermissions(deny/ask 规则 + 工具自身 checkPermissions)------如果规则检查出 deny 或"内容级 ask",会覆盖 hook 的 allow;只有规则检查也放行,才真正跳过用户确认(toolHooks.ts:380-413,函数注释明确写"Hook allow skips the interactive prompt, but deny/ask rules still apply");
  • Hook 返回 ask 或没有给出决策 → 走正常的 canUseTool 全流程(toolHooks.ts:421-440,下面单独展开)。

hasPermissionsToUseToolInnersrc/utils/permissions/permissions.ts:1179)是权限判定的核心,顺序固定、不可绕过:

ts 复制代码
// 1. 硬边界检查:命中即 return,即便 bypass 模式也拦截
const denyRule = getDenyRuleForTool(ctx, tool)
if (denyRule) return { behavior: 'deny', ... }

const askRule = getAskRuleForTool(ctx, tool)
if (askRule && !canSandboxAutoAllow) return { behavior: 'ask', ... }

const toolPermissionResult = await tool.checkPermissions(input, context)
if (toolPermissionResult.behavior === 'deny') return toolPermissionResult
if (tool.requiresUserInteraction?.() && toolPermissionResult.behavior === 'ask') return toolPermissionResult
if (toolPermissionResult.behavior === 'ask' && toolPermissionResult.decisionReason?.rule?.ruleBehavior === 'ask') return toolPermissionResult
if (toolPermissionResult.behavior === 'ask' && toolPermissionResult.decisionReason?.type === 'safetyCheck') return toolPermissionResult

// 2. 宽松放行:只有上面全部没命中,才会执行到这里
if (shouldBypassPermissions) return { behavior: 'allow', decisionReason: { type: 'mode' } }
if (toolAlwaysAllowedRule(ctx, tool)) return { behavior: 'allow', decisionReason: { type: 'rule' } }

// 3. 兜底
return { ...toolPermissionResult, behavior: 'ask' }

若最终判定不是 allowrunToolUse 直接把拒绝原因包装成 is_error: truetool_resultreturn resultingMessages------不会执行到 3.3-3.6 步,工具本体完全不会跑。

3.3 工具执行(toolExecution.ts:1257

ts 复制代码
const result = await tool.call(
  callInput,
  { ...toolUseContext, toolUseId: toolUseID, userModified: ... },
  canUseTool,
  assistantMessage,
  progress => onToolProgress({ toolUseID: progress.toolUseID, data: progress.data }),
)

真正跑工具逻辑的地方,onProgress 回调用于持续上报流式进度(比如 Bash 命令还没跑完时的中间输出)。

3.4 结果映射(toolExecution.ts:1367src/utils/toolResultStorage.ts

tool.call() 返回的 ToolResult<T>src/Tool.ts:331-346)一共 4 个字段:

ts 复制代码
export type ToolResult<T> = {
  data: T
  newMessages?: (UserMessage | AssistantMessage | AttachmentMessage | SystemMessage)[]
  contextModifier?: (context: ToolUseContext) => ToolUseContext
  mcpMeta?: { _meta?: Record<string, unknown>; structuredContent?: Record<string, unknown> }
}
  • data :工具执行的主结果,唯一必填字段。tool.mapToolResultToToolResultBlockParam(result.data, toolUseID) 把它映射成 API 要的 ToolResultBlockParam,这是这次工具调用真正要回答的内容。非 MCP 工具在这一步就调用 addToolResult 把消息推进 resultingMessagestoolExecution.ts:1552-1554;MCP 工具要等 3.5 节 PostToolUse 跑完才做这一步,见下)。
  • newMessages :可选,工具想额外插入的独立消息(零到多条),跟 data 语义不同、不适合塞进同一个 tool_result 时使用。例如 FileReadTool 读图片时,data 是图片本身,newMessages 里再补一条 isMeta 文本消息说明图片尺寸;SkillTool 展开一个 skill 时,newMessages 可能装好几条消息(skill 内容、附件、系统提示等)。这些消息在 3.6 节收尾阶段被追加进 resultingMessages
  • contextModifier :可选,工具执行完之后修改后续工具调用共享的 ToolUseContext(权限规则等)。只对不可并发的工具生效,影响范围仅限于触发它的这一次 queryLoop
  • mcpMeta :可选,MCP 协议自带的元数据(structuredContent/_meta),原样透传给外部 SDK 消费者使用,跟对话内容本身无关。子 agent 执行时会被丢弃(toolExecution.ts:1539mcpMeta: toolUseContext.agentId ? undefined : mcpMeta,错误路径同样处理,见 toolExecution.ts:1813-1818),只有主线程/顶层调用才保留。

结果过大时走持久化,不是截断src/utils/toolResultStorage.ts,阈值常量见 src/constants/toolLimits.ts):

  • 单个结果超过阈值(min(工具声明的 maxResultSizeChars, 50,000 字符),即 DEFAULT_MAX_RESULT_SIZE_CHARS)就整个写入 projectDir/sessionId/tool-results/{id}.{json|txt},返回给模型的是 <persisted-output> 包裹的预览片段 + 文件路径;绝对上限对应 MAX_TOOL_RESULT_TOKENS = 100_000 × BYTES_PER_TOKEN = 4 ≈ 400,000 字节;
  • 单条消息里所有工具结果加起来超过 MAX_TOOL_RESULTS_PER_MESSAGE_CHARS = 200,000 字符(防止多个并发工具各自都不超限、合计却撑爆请求)时,也会挑最大的新鲜结果做同样替换;
  • 模型要看完整内容,没有专用接口 ,就是拿到的文本里写明的路径去调用普通的 Read 工具。

勘误 :原始文章称"FileReadTool 自己的 maxResultSizeChars 设为 Infinity,避免死循环"。经核实,FileReadToolmaxResultSizeChars 实际是 100_000packages/builtin-tools/src/tools/FileReadTool/FileReadTool.ts:342),并非 Infinity。全仓库搜索未发现任何工具真的声明 InfinitygetPersistenceThreshold 虽然有 Number.isFinite 判断分支,但目前没有工具触发它。

3.5 PostToolUse hooks(toolExecution.ts:1552-1617

ts 复制代码
if (!isMcpTool(tool)) {
  await addToolResult(toolOutput, mappedToolResultBlock)   // 非 MCP:结果已在 3.4 加入
}
for await (const hookResult of runPostToolUseHooks(...)) {
  if ('updatedMCPToolOutput' in hookResult && isMcpTool(tool)) {
    toolOutput = hookResult.updatedMCPToolOutput   // 可以改写 MCP 工具的输出
  } else {
    resultingMessages.push(hookResult)   // 追加附件消息,排在 tool_result 之后
  }
}
if (isMcpTool(tool)) {
  await addToolResult(toolOutput)   // MCP:此时才加入,因为可能已被 PostToolUse 改写
}

MCP 工具的结果之所以要推迟到 PostToolUse 跑完才组装成 tool_result,是因为 PostToolUse hook 有能力改写它的输出内容(updatedMCPToolOutput),非 MCP 工具没有这个改写口子,可以提前处理。

3.6 收尾:hook_stopped_continuation 的生成条件(toolExecution.ts:1647-1657

hook_stopped_continuation 是什么 :一条附件消息,用来告诉 queryLoop"这次工具虽然正常执行完了,但有 hook 要求不要再继续往下跑了"。它不是错误、也不是工具结果本身------工具的 tool_result 已经正常记录,这条消息是额外追加 的一个"刹车信号":本轮工具全部跑完后,queryLoop 一旦看到它就直接结束当前轮次,不再把结果喂给模型继续下一轮迭代。典型场景是某个 PreToolUse hook 允许这次操作执行,但认为执行完之后不应该让 agent 继续自主往下跑,必须等用户下一次手动介入。

ts 复制代码
if (result.newMessages?.length) {
  for (const message of result.newMessages) resultingMessages.push({ message })
}
if (shouldPreventContinuation) {
  resultingMessages.push({
    message: createAttachmentMessage({
      type: 'hook_stopped_continuation',
      message: stopReason || 'Execution stopped by hook',
      hookName: `PreToolUse:${tool.name}`,
      toolUseID,
      hookEvent: 'PreToolUse',
    }),
  })
}
return resultingMessages

这条消息随结果逐层 yield 上去,由 queryLoop 识别;等本轮全部 tool_use 跑完,才检查是否出现过这个类型,决定 return { reason: 'hook_stopped' } 还是继续往下走。

4. 工具执行的特殊模式

4.1 同步 vs 后台异步(BashTool、PowerShellTool、AgentTool)

Tool.call() 在类型层面统一是 Promise<ToolResult<Output>>,但少数工具在业务语义上区分"等命令跑完才返回"和"提交后立刻返回、命令继续在后台跑"------目前全代码库支持这个语义的只有这三个工具,其余工具(Read、Grep、FileEdit 等)都是纯同步执行:

BashToolpackages/builtin-tools/src/tools/BashTool/BashTool.tsx):

ts 复制代码
if (run_in_background === true && !isBackgroundTasksDisabled) {
  const shellId = await spawnBackgroundTask()
  return { stdout: '', stderr: '', code: 0, interrupted: false, backgroundTaskId: shellId }
}

模型显式传 run_in_background: true 时,call() 几乎立即 resolve,只带一个 backgroundTaskId,命令本身在独立的 shell task 里继续跑(spawnBackgroundTaskBashTool.tsx:1056)。

此外还有两种自动 转后台的情况(不是模型主动要求):命令跑太久超过阈值又遇到超时/用户按 Ctrl+B,触发 startBackgrounding('tengu_bash_command_timeout_backgrounded', backgroundFn)BashTool.tsx:1134);assistant 模式(内部代号 KAIROS,可通过 --assistant 启动,主 agent 承担多任务协调角色而非单纯对话执行)下阻塞命令跑超过 ASSISTANT_BLOCKING_BUDGET_MS,为了不卡住主 agent 而自动挪到后台。

PowerShellTool 是同一套设计的 Windows 版本。

AgentTool (子 agent/Task,packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx:709-713):

同样有 run_in_background 参数,但判断更复杂------shouldRunAsync 由下面这串条件 OR 出来,不止用户显式声明这一种触发方式:

ts 复制代码
run_in_background === true || selectedAgent.background === true || isCoordinator || forceAsync

4.2 后台任务如何回到对话:task-notification 队列

后台命令/子任务完成时,不会让当时那一轮 queryLoop 收到任何回调------那一轮早就已经因为 call() 提前 resolve 而正常走完 next_turncompleted 了。真正的通知走一条独立的路径:

flowchart TD A[后台任务完成] --> B[塞进进程级命令队列] B --> C{此刻 queryLoop 还在跑吗} C -->|是| D[本轮注入 AttachmentMessage 时顺带取走这条通知] C -->|否, 已 return completed| E[useQueueProcessor, 常驻在 REPL 组件, 独立于 queryLoop] E --> F[持续监视两个条件, 没有查询在跑且队列非空] F -.同时成立时触发.-> G[发起全新 query] D --> H[模型在本轮看到通知] G --> H

也就是说,"等待后台任务"不是靠某个循环挂起等出来的,而是"循环结束、进程空闲"之后,一个事件驱动的旁路机制检测到队列非空就主动重新拉起一轮对话。用户如果确实想让模型什么都不做、专门等某个后台任务,需要模型主动调用 Sleep 工具------它的 call() 内部按 500ms 间隔轮询这个共享队列,一旦发现有新东西就提前结束等待,而不是傻等到 duration_seconds 走完。

4.3 interruptBehavior:能否被用户中途打断(src/Tool.ts:426

ts 复制代码
interruptBehavior?(): 'cancel' | 'block'

用户在工具还在跑的时候提交新消息,系统只会真正取消声明了 'cancel' 的工具;没声明(默认 'block')的工具会继续跑完。目前全仓库唯一声明 'cancel' 的是 SleepToolSleepTool.ts:78-80)。

为什么默认不可打断:大多数工具(Bash、文件写入)一旦开始就有真实副作用,中途砍掉会留下"写了一半"的状态。Sleep 没有副作用,取消它只是提前结束等待,是良性的。

这也决定了用户打断能不能立刻生效:只有当前这批工具全部'cancel' 类型(也就是全是 Sleep),用户提交新消息才会立刻打断当前轮次;只要混了一个别的工具,新消息就只能排队,等这批工具跑完------不存在"正在写文件的工具被腰斩"的情况。

'cancel' 的唯一实现见 packages/builtin-tools/src/tools/SleepTool/SleepTool.ts:78-80

5. 取消与中断机制:Abort Controller 的层级与冒泡

5.1 三层结构

scss 复制代码
toolUseContext.abortController      (query 级根 controller,贯穿整个 queryLoop)
  └─ siblingAbortController         (本批并发工具共享,见 StreamingToolExecutor)
       └─ toolAbortController       (每个工具专属,runToolUse 实际拿到的就是它)

5.2 正向传导:父 abort 会级联到子(src/utils/abortController.ts:68

ts 复制代码
export function createChildAbortController(parent, maxListeners?) {
  const child = createAbortController(maxListeners)
  if (parent.signal.aborted) {
    child.abort(parent.signal.reason)   // 快路径:父已中止,直接同步中止子
    return child
  }
  const weakChild = new WeakRef(child)
  const weakParent = new WeakRef(parent)
  const handler = propagateAbort.bind(weakParent, weakChild)
  parent.signal.addEventListener('abort', handler, { once: true })   // 父一旦中止,级联把子也中止
  child.signal.addEventListener('abort', removeAbortHandler.bind(weakParent, new WeakRef(handler)), { once: true })   // 子一旦中止(不管谁触发的),把上面这个监听器从父身上摘掉,避免残留
  return child
}

父子之间全部用 WeakRef 包裹,而不是强引用------如果 handler 强引用 child,只要根 controller 活着(往往贯穿整个会话),每次工具调用创建的子 controller 就永远不会被 GC,长会话下会造成内存泄漏。子 controller 用完即弃(没被 abort)时能正常回收,父 signal 上残留的只是指向已死对象的弱引用(deref() 返回 undefined,安全 no-op);子 controller 一旦被 abort,还会主动移除父 signal 上对应的 listener,防止长会话里 handler 无限堆积。

5.3 反向冒泡:子 abort 主动让根也 abort(src/services/tools/StreamingToolExecutor.ts:328-345

createChildAbortController 只解决父→子的传导,不解决子→根的反向影响。StreamingToolExecutor 手写了这一段:

ts 复制代码
const toolAbortController = createChildAbortController(this.siblingAbortController)
toolAbortController.signal.addEventListener('abort', () => {
  if (
    toolAbortController.signal.reason !== 'sibling_error' &&
    !this.toolUseContext.abortController.signal.aborted &&
    !this.discarded
  ) {
    this.toolUseContext.abortController.abort(toolAbortController.signal.reason)
  }
}, { once: true })

为什么需要这段(代码注释标注的 #21056 回归)

ExitPlanMode 弹出权限确认对话框,用户点拒绝,拒绝逻辑会中止 controller------但这里能拿到的只是当前工具专属的 toolAbortController ,不是根 controller。而 queryLoop 判断"这一轮是否该终止"看的是根 controller。如果没有这段反向冒泡,根 controller 感知不到子级已经中止,就会把这次拒绝当成普通 tool_result 继续往下跑,而不是真正停下来。

加上这段监听后:toolAbortController 一旦被中止,会主动把根 controller 也一起中止,queryLoop 才能正确感知并终止这一轮。

为什么要排除 sibling_error :这是同批并发工具之间的横向级联(一个 Bash 命令报错时,this.siblingAbortController.abort('sibling_error')StreamingToolExecutor.ts:386-390,取消同批其它还在跑的命令),本质上这一轮工具调用仍然正常结束,只是部分子任务被标记失败,不代表用户想终止整个会话。冒泡逻辑显式排除这个 reason,避免"一个 Bash 命令出错"被误放大成"整个 agent 会话中止"------被 sibling_error 取消的工具只需要拿到一条合成的错误消息,正常走完这一轮即可。

6. 规模化设计:Deferred tools / SearchExtraToolsTool

勘误说明 :原始文章此节使用的路径(src/services/searchExtraTools/toolSearch.ts)、函数名(getToolSearchModegetAutoToolSearchTokenThreshold)、环境变量名(ENABLE_TOOL_SEARCH)、常量名(DEFAULT_AUTO_TOOL_SEARCH_PERCENTAGE)、工具类名(ToolSearchTool)均与本仓库实际实现不符。下文已全部改为核实后的真实命名。只有三种模式的字符串值(tst/tst-auto/standard)、默认 10% 阈值、auto:N 语法这些数值/字符串常量层面的描述是对的。

6.1 要解决的问题

MCP server 一多,每个工具的 name + description + inputSchema 全部塞进系统提示,会占用大量上下文 token。Deferred tools 机制让这些工具先只以"名字"轻量出现,模型需要时再主动检索、展开完整 schema。

6.2 客户端整体流程

flowchart LR A[本地判定 isDeferredTool] --> B[扫描历史, 找出已发现的工具] B --> C[本地过滤工具列表, 未发现的 deferred 工具不放进这次请求] C --> D[发给服务端] D --> E[模型只看到已发现或不延迟的工具, 调用 SearchExtraToolsTool 搜索新的] E --> F[搜到的工具名写入 tool_result 文本, 引导模型用 ExecuteExtraTool] F --> B

关键点:deferred 工具"该不该出现在这次请求里",是客户端本地过滤决定的,不是服务端决定的。 具体分三步:

  • 本地判定src/Tool.ts:452,459packages/builtin-tools/src/tools/SearchExtraToolsTool/prompt.ts:64,69,71):工具通过 shouldDefer?: boolean 字段声明自己"倾向于"被延迟;alwaysLoad?: boolean 是显式 opt-out,优先级最高(MCP 工具通过 server 端元数据 _meta['anthropic/alwaysLoad'] 声明)。但真正驱动 isDeferredTool 判断的主因是"是否在 CORE_TOOLS 白名单常量列表中"------不在白名单里、且没有 alwaysLoad: true 的工具(MCP 工具默认全部符合)才被判定为 deferred;SearchExtraToolsTool 自己永不算(否则无法自举)。
  • 本地过滤src/services/api/claude.ts 构造请求处):每次构造新请求前,先用 extractDiscoveredToolNamessrc/utils/searchExtraTools.ts:489)扫描对话历史,找出所有已发现的工具名,得到"已发现集合"。然后过滤全部工具:不需要延迟的工具、SearchExtraToolsTool 自己、已经被发现过的 deferred 工具------这三类才会进入这次请求的 tools 数组;还没被发现的 deferred 工具,直接被剔除,完全不会出现在这次请求里(不是带着精简信息发过去,是整个都不发)。
  • 发现 :模型调用 SearchExtraToolsTool(支持直接选或关键词搜索)。这次调用本身不直接改工具列表 ------真正生效的是结果的编码方式,把搜到的工具名写进 tool_result 引导模型下一步用 ExecuteExtraTool 调用。下一次构造请求时,extractDiscoveredToolNames 会重新扫到这条记录,这个工具才会被上面的过滤逻辑放行、带着完整定义进入下一次请求。会话压缩时这个"已发现集合"会被快照保留到压缩边界消息上,避免压缩之后又要重新搜索一遍。

SearchExtraToolsTool 的结果编码(packages/builtin-tools/src/tools/SearchExtraToolsTool/SearchExtraToolsTool.ts:542):

代码注释明确写着"No longer uses tool_reference blocks --- unified self-built tool search for all providers"------也就是说结果不是 通过 API 原生的 tool_reference 内容块类型传递的,而是纯文本格式(Found N deferred tool(s): ...),由 extractDiscoveredToolNames 用正则从文本里解析出工具名(同时兼容一种历史上的 legacy 格式)。

6.3 服务端做了什么

"这个工具这次发不发"由客户端决定(见 6.2)。服务端只需要认识 defer_loading: true 这个工具定义上的标记字段------看到它就不把这个工具的完整描述塞进模型的 system prompt 区域,只让模型看到工具名,省 token。服务端具体怎么解析、怎么处理是它自己内部的逻辑,客户端代码里看不到,本文档也不展开。

这一整套机制依赖一个专门的 beta 请求头。此格式在 1P(Anthropic 官方)/Foundry 上有效,Bedrock/Vertex 可能还不支持

6.4 三种模式与触发阈值(src/utils/searchExtraTools.ts

getSearchExtraToolsMode()searchExtraTools.ts:170)由 ENABLE_SEARCH_EXTRA_TOOLS 环境变量决定:

arduino 复制代码
ENABLE_SEARCH_EXTRA_TOOLS    模式
auto / auto:1-99             tst-auto
true / auto:0                tst
false / auto:100             standard
(未设置)                      tst   ← 默认值
  • tst (默认):MCP 工具和被判定为 deferred 的工具,每次构造请求时都会被判定为需要延迟,请求里对应的 schema 固定带上 defer_loading: true
  • tst-auto:只有延迟工具定义的 token 总量超过阈值才真正启用延迟,否则退化为全部内联;
  • standard :完全关闭,所有工具(含 MCP)内联在初始工具列表,SearchExtraToolsTool 本身也不启用。此外 CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS 会强制降级为 standard,作为不支持 beta 特性的网关代理的兜底开关。

阈值算法(getAutoSearchExtraToolsTokenThreshold(model)searchExtraTools.ts:100):触发阈值 = 当前模型 context window × 百分比。这个百分比默认是 10%DEFAULT_AUTO_SEARCH_EXTRA_TOOLS_PERCENTAGE = 10searchExtraTools.ts:45,未设置 ENABLE_SEARCH_EXTRA_TOOLS 或只写 auto 时生效),也可以用 ENABLE_SEARCH_EXTRA_TOOLS=auto:N(N 为 1-99,parseAutoPercentagesearchExtraTools.ts:51-66)显式指定,此时用 N 替换默认的 10。数值越小,阈值越低,越容易触发延迟(省 prompt token,但模型要多花一轮检索工具);数值越大,阈值越高,越不容易触发(工具第一轮就完整可见,但初始 prompt 更大)。判断时优先用 token 计数 API 精确统计所有 deferred 工具定义的 token 量,API 不可用则按字符数估算(2.5 chars/token)兜底。

相关推荐
ksueh1 小时前
AI写小说接入文心一言教程:千帆API+向量记忆系统实现百万字长篇智能创作
人工智能·ai助手
不焦躁的程序员2 小时前
程序员该补获客能力了
人工智能·程序员
AI科技星2 小时前
基于32维Cayley_Dickson超复数的全域拓扑统一场论——反重力、真空自持供能、维度瞬移与星际宇宙脑秩序体系
人工智能·学习·算法·机器学习·数据挖掘
星马梦缘2 小时前
机器学习与模式识别 第十四章 神经网络中的反向传播 模拟卷及答案
人工智能·神经网络·机器学习·微分·反向传播
吴bug2 小时前
认识 Open-ACE — AI 编程智能体的工作空间
人工智能·ai·ai编程
ksueh2 小时前
AI写小说工具哪个好用?9款AI工具使用体验(2026年横评)
人工智能·ai写作
Bode_20022 小时前
Codex 的安装与使用指南
人工智能
“码”力全开2 小时前
ONVIF摄像头接入项目实战记录
人工智能·算法·边缘计算
AI的探索之旅2 小时前
AI Agent替我做原理图:立创EDA + CubeMX + 知识库的三合一工作流
人工智能