背景
本文档研究 Claude Code 里模型调用工具的完整机制:工具怎么被分批调度、单次调用要经过哪些环节(权限检查、执行、结果处理)、同步/后台两种执行模式的区别、执行中途怎么取消、以及工具数量过多时的规模化处理(deferred tools)。不涉及模型推理本身,也不展开 queryLoop 里跟工具调用无关的其他机制(比如上下文压缩、错误恢复)。
代码仓库:github.com/claude-code...
0. queryLoop 是什么
query()(src/query.ts:276)是对外暴露的入口,内部通过 yield* queryLoop(params, consumedCommandUuids) 把执行权完全委托给 queryLoop(src/query.ts:393)------一个 async function*,本质是一个不断产出(yield)消息的生成器。
queryLoop 用 while (true)(query.ts:460)驱动一次 agentic turn:每次迭代做"调用模型 → 判断要不要执行工具 → (执行工具)→ 决定继续循环还是结束"。核心判断点只有一个变量:needsFollowUp(query.ts:756, 1092)------本轮 assistant 消息里是否出现了 tool_use 内容块。这个布尔值直接决定走哪条分支:
1. 工具调用总览
当 needsFollowUp = true,queryLoop 把本轮解析出的 toolUseBlocks(一个或多个 tool_use 块)交给工具执行层。核心链路:
这条链路的最底层实现是 runToolUse(src/services/tools/toolExecution.ts:366)------每一个 tool_use 块,无论走哪种调度方式,最终都会落到这个函数上执行完整生命周期。后面几节按"分批调度 → 单次调用生命周期 → 特殊执行模式 → 取消机制 → 规模化设计"的顺序展开。
2. 并发调度(src/services/tools/toolOrchestration.ts)
runTools(toolOrchestration.ts:20)不是把 tool_use 列表直接顺序丢给执行器,而是先分批、再按批次类型选择并发或串行执行。
2.1 分批算法:partitionToolCalls(toolOrchestration.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.ts 的 runToolUse)
不管走并发批次还是串行批次,每个 tool_use 最终都落到 runToolUse(toolExecution.ts:366)这一个函数上(内部经 streamedCheckPermissionsAndCallTool → checkPermissionsAndCallTool)。它是一个很长的 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:963,src/services/tools/toolHooks.ts:340)
先由 resolveHookPermissionDecision(toolHooks.ts:340)把 PreToolUse hook 给出的决策和外部 canUseTool(也就是 hasPermissionsToUseTool)拼在一起:
- Hook 返回
deny→ 直接采用,不再调用canUseTool(toolHooks.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,下面单独展开)。
hasPermissionsToUseToolInner(src/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' }
若最终判定不是 allow,runToolUse 直接把拒绝原因包装成 is_error: true 的 tool_result,return 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:1367,src/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把消息推进resultingMessages(toolExecution.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:1539:mcpMeta: 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,避免死循环"。经核实,FileReadTool的maxResultSizeChars实际是100_000(packages/builtin-tools/src/tools/FileReadTool/FileReadTool.ts:342),并非Infinity。全仓库搜索未发现任何工具真的声明Infinity;getPersistenceThreshold虽然有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 等)都是纯同步执行:
BashTool (packages/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 里继续跑(spawnBackgroundTask,BashTool.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_turn 或 completed 了。真正的通知走一条独立的路径:
也就是说,"等待后台任务"不是靠某个循环挂起等出来的,而是"循环结束、进程空闲"之后,一个事件驱动的旁路机制检测到队列非空就主动重新拉起一轮对话。用户如果确实想让模型什么都不做、专门等某个后台任务,需要模型主动调用 Sleep 工具------它的 call() 内部按 500ms 间隔轮询这个共享队列,一旦发现有新东西就提前结束等待,而不是傻等到 duration_seconds 走完。
4.3 interruptBehavior:能否被用户中途打断(src/Tool.ts:426)
ts
interruptBehavior?(): 'cancel' | 'block'
用户在工具还在跑的时候提交新消息,系统只会真正取消声明了 'cancel' 的工具;没声明(默认 'block')的工具会继续跑完。目前全仓库唯一声明 'cancel' 的是 SleepTool (SleepTool.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)、函数名(getToolSearchMode、getAutoToolSearchTokenThreshold)、环境变量名(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 客户端整体流程
关键点:deferred 工具"该不该出现在这次请求里",是客户端本地过滤决定的,不是服务端决定的。 具体分三步:
- 本地判定 (
src/Tool.ts:452,459,packages/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构造请求处):每次构造新请求前,先用extractDiscoveredToolNames(src/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 = 10,searchExtraTools.ts:45,未设置 ENABLE_SEARCH_EXTRA_TOOLS 或只写 auto 时生效),也可以用 ENABLE_SEARCH_EXTRA_TOOLS=auto:N(N 为 1-99,parseAutoPercentage,searchExtraTools.ts:51-66)显式指定,此时用 N 替换默认的 10。数值越小,阈值越低,越容易触发延迟(省 prompt token,但模型要多花一轮检索工具);数值越大,阈值越高,越不容易触发(工具第一轮就完整可见,但初始 prompt 更大)。判断时优先用 token 计数 API 精确统计所有 deferred 工具定义的 token 量,API 不可用则按字符数估算(2.5 chars/token)兜底。