KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

03 · 工具运行时 — keel 龙骨

工具是 Agent 与外部世界的唯一接口。Claude Code 2.1.88 把「工具」定义得比「一个模型能调用的函数」宽得多:它同时携带 zod schema、权限检查、执行逻辑、并发语义、乃至 UI 渲染方法。

工具是 Agent 与外部世界的唯一接口。Claude Code 2.1.88 把「工具」定义得比「一个模型能调用的函数」宽得多:它同时携带 zod schema、权限检查、执行逻辑、并发语义、乃至 UI 渲染方法。

这一章讲清楚三件事:

  1. Tool 契约上到底有哪些字段,以及默认值为什么是「最保守」的那一组;
  2. 一批工具请求进来后,怎么被切成「连续可并行批」与「单个非安全」,以及为什么写工具必须严格串行;
  3. 工具结果为什么需要三级预算,超限之后又去了哪里。

一、Tool 契约

Tool.ts: Tool(第 372 行)的注释把职责列全了:面向模型的 schema 与 prompt 文案、输入校验与权限检查钩子、执行逻辑、UI 渲染辅助、以及 query / 编排层要用的并发与只读语义。

按类别拆开:

面向模型的部分

执行与校验

语义与安全检查

结果与展示

默认值集中在 Tool.ts: TOOL_DEFAULTS(第 777 行),全部按「最保守」原则写死:isEnabled → true、isConcurrencySafe → false(assume not safe)、isReadOnly → false(assume writes)、isDestructive → false、checkPermissions → { behavior: 'allow', updatedInput: input }、toAutoClassifierInput → ''(跳过分类器,安全相关工具必须自己覆盖)、userFacingName → ''。

buildTool(def) 负责把这份默认值和工具定义合并,类型层面用 BuiltTool<D> 镜像运行时行为。也就是说「忘记声明」的结果永远是更安全、更慢,而不是更快更危险。

maxResultSizeChars 有一个刻意的例外:Tool.ts 的注释说明,对「输出绝不能落盘」的工具应设为 Infinity,并点名了 Read——因为让 Read 落盘会制造 Read → 文件 → Read 的循环,而 Read 本身已经自带边界。

二、工具池装配

tools.ts: getAllBaseTools()(第 193 行)返回内置工具数组。其中无条件注册的有:AgentTool、TaskOutputTool、BashTool、ExitPlanModeV2Tool、FileReadTool、FileEditTool、FileWriteTool、NotebookEditTool、WebFetchTool、TodoWriteTool、WebSearchTool、TaskStopTool、AskUserQuestionTool、SkillTool、EnterPlanModeTool、getSendMessageTool()、BriefTool、ListMcpResourcesTool、ReadMcpResourceTool。

条件注册的包括(这是「约 40 个工具」这个说法的来源):

真正决定「模型看见什么」的是 tools.ts: getTools(permissionContext):先应用全局模式(如 CLAUDE_CODE_SIMPLE 只留 Bash/Read/Edit),再用 filterToolsByDenyRules 剔掉被 blanket deny 的工具。后者的注释特意说明:它用的是与运行时权限检查同一个匹配器,所以 mcp__server 这样的前缀规则会在模型看到工具之前就把整个 server 的工具摘掉,而不是等到调用时才拒绝。

最终合并发生在 tools.ts: assembleToolPool(permissionContext, mcpTools):

const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name)
return uniqBy(
  [...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
  'name',
)

关键不是排序,而是保序去重:内置工具在前、MCP 在后,各自按名字排序,uniqBy 保留插入顺序,于是同名冲突时内置胜出。理由写在注释里,值得完整引用其要点:服务端的 claude_code_system_cache_policy 会在最后一个前缀匹配的内置工具之后放一个全局 cache breakpoint;如果做扁平排序,MCP 工具会插进内置工具之间,一旦有 MCP 工具排到现有内置工具中间,下游所有 cache key 全部失效。

三、并发分批

services/tools/toolOrchestration.ts: partitionToolCalls 用一次 reduce 把工具调用切成批次:

const isConcurrencySafe = parsedInput?.success
  ? (() => { try { return Boolean(tool?.isConcurrencySafe(parsedInput.data)) } catch { return false } })()
  : false   // safeParse 失败或 isConcurrencySafe 抛异常 → 一律视为不安全

两个「保守默认」值得注意:inputSchema.safeParse 失败直接判为不安全;isConcurrencySafe 抛异常(注释举例:shell-quote 解析失败)也判为不安全。

分组的规则很短:如果本个工具安全且上一个批次也安全,就并进上一批;否则开一个新批次。于是结果必然是「若干个连续可并行批」与「若干个只含单个非安全工具」交替。

runTools 遍历批次分派:

并发上限来自 toolOrchestration.ts: getMaxToolUseConcurrency():

parseInt(process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY || '', 10) || 10

即默认 10,且非数字 / 0 都会退回 10。

写工具严格串行是安全语义,不是性能取舍。注释写得很清楚:「只读 / 可并发的工具可以一起跑来提速;会修改状态或文件的工具则必须串行,这样后一个工具才能看到前一个工具刚刚产生的上下文变化。」

并行批里最大的坑是 contextModifier。工具可以通过它修改 ToolUseContext,但在并行批里「谁先改」是不确定的。解决方案是入队延后:

if (update.contextModifier) {
  const { toolUseID, modifyContext } = update.contextModifier
  ;(queuedContextModifiers[toolUseID] ??= []).push(modifyContext)
}
// 批次结束后,按 blocks 的原始顺序依次 apply
for (const block of blocks) {
  for (const modifier of queuedContextModifiers[block.id] ?? []) {
    currentContext = modifier(currentContext)
  }
}

顺序由模型发出 tool_use 的顺序决定,因此可复现。串行批里则相反——currentContext = update.contextModifier.modifyContext(currentContext) 立即生效。

四、三级结果预算

constants/toolLimits.ts 定义了三级上限,注释解释了每一级在防什么:

常量 值 作用域
DEFAULT_MAX_RESULT_SIZE_CHARS 50_000 单个工具结果;系统级上限,工具自报的 maxResultSizeChars 只能更低
MAX_TOOL_RESULT_TOKENS 100_000 以 token 计的硬上限
MAX_TOOL_RESULTS_PER_MESSAGE_CHARS 200_000 单条 user message 内 tool_result 块的聚合上限

第三级的动机注释写得很具体:「This prevents N parallel tools from each hitting the per-tool max and collectively producing e.g. 10 × 40K = 400K in one turn's user message.」并且明确「Messages are evaluated independently」——上一轮的 150K 与这一轮的 150K 互不影响。

派生常量和辅助量:

超限之后去哪:utils/toolResultStorage.ts: persistToolResult。它把完整内容写入文件,然后 generatePreview(contentStr, PREVIEW_SIZE_BYTES) 生成回喂给模型的预览,PREVIEW_SIZE_BYTES = 2000。消息里用 <persisted-output> / </persisted-output> 标签包裹(PERSISTED_OUTPUT_TAG / PERSISTED_OUTPUT_CLOSING_TAG),目录是 tool-results(TOOL_RESULTS_SUBDIR)。

预算执行有两个入口:

getPerMessageBudgetLimit() 可被 GrowthBook flag tengu_hawthorn_window 覆盖(有限正数才生效),单工具的落盘阈值也可被 tengu_satin_quoll 覆盖。另一个重要细节在 query.ts:传给 applyToolResultBudget 的排除集合是

new Set(toolUseContext.options.tools
  .filter(t => !Number.isFinite(t.maxResultSizeChars))
  .map(t => t.name))

即 maxResultSizeChars = Infinity 的工具(Read)永远不会被替换。持久化还有配套的 ContentReplacementState(createContentReplacementState / cloneContentReplacementState / reconstructContentReplacementState),用于在恢复会话或子 Agent 续跑时重建「哪些块已被替换」的账本。

五、工具摘要与进度消息

工具摘要不是写死的,而是用模型生成的:services/toolUseSummary/toolUseSummaryGenerator.ts: generateToolUseSummary(第 45 行)内部走 queryHaiku,并把 querySource 标成 'tool_use_summary_generation'。

系统提示(TOOL_USE_SUMMARY_SYSTEM_PROMPT)明确要求「think git-commit-subject, not sentence」,并说明它会出现在移动端单行、约 30 字符处被截断。

调度方式同样有讲究——query.ts: 1476 不 await:

nextPendingToolUseSummary = generateToolUseSummary({
  tools: toolInfoForSummary, signal: toolUseContext.abortController.signal, ...,
}).then(summary => summary ? createToolUseSummaryMessage(summary, toolUseIds) : null)
  .catch(() => null)

注释是「Fire off summary generation without blocking the next API call」——这个 promise 被塞进 State.pendingToolUseSummary,跟着下一轮循环走;失败被吞成 null,因为摘要只是展示层。

进度消息走另一条路。StreamingToolExecutor 的 TrackedTool 把 results 与 pendingProgress 分开存放,注释说明「Progress messages are stored separately and yielded immediately」——进度是实时的,结果是按到达顺序 buffer 的。

代码地图

机制 位置 要点
工具契约 Tool.ts: Tool 第 372 行;schema + 校验 + 权限 + 执行 + 渲染 + 并发语义
工具默认值 Tool.ts: TOOL_DEFAULTS 第 777 行;三类语义方法默认 false;由 buildTool(第 803 行)合并
结果大小例外 Tool.ts: maxResultSizeChars 第 486 行;Read 用 Infinity 避免 Read→文件→Read 循环
内置工具清单 tools.ts: getAllBaseTools 第 193 行;19 个无条件 + 若干条件启用
工具池装配 tools.ts: assembleToolPool 第 351 行;内置在前 + 保序 uniqBy(内置胜出);先经 filterToolsByDenyRules 按同一匹配器预剔除
并发分批 services/tools/toolOrchestration.ts: partitionToolCalls 第 97 行;safeParse 失败或抛异常一律视为不安全;上限见 getMaxToolUseConcurrency(默认 10)
并行批上下文延后 services/tools/toolOrchestration.ts: runTools 第 37 行;queuedContextModifiers 按 toolUseID 入队,批次末按序 apply
结果上限常量 constants/toolLimits.ts: DEFAULT_MAX_RESULT_SIZE_CHARS 50_000 / 100_000 / 200_000 三级;BYTES_PER_TOKEN = 4
超限落盘 utils/toolResultStorage.ts: persistToolResult 第 137 行;PREVIEW_SIZE_BYTES = 2000 预览回喂
单消息预算执行 utils/toolResultStorage.ts: enforceToolResultBudget 第 769 行;把最大块落盘直到落进预算;上限可被 tengu_hawthorn_window 覆盖
工具摘要生成 services/toolUseSummary/toolUseSummaryGenerator.ts: generateToolUseSummary 第 45 行;走 queryHaiku;在 query.ts 第 1476 行不 await,随 State 带入下一轮
流式执行器 services/tools/StreamingToolExecutor.ts: addTool 第 76 行;每收到一个 tool_use 立即入队并按并发规则启动

关键取舍

Tool 契约把执行与展示混在一起,代价是接口很宽。
好处是核心层零 React 依赖(见第 01 章);代价是任何新工具都要面对二十余个可选方法,且 buildTool 默认值一旦设错,影响面覆盖所有工具。这也是为什么默认值刻意全部选「保守」而不是「方便」。

isConcurrencySafe 默认 false,代价是顺手写的新工具天然慢。
工具作者必须显式声明并发安全,否则它每次都独占执行。配合「safeParse 失败/抛异常也算不安全」,并发风险被压到最小;代价是并发收益只存在于被认真标注过的只读工具上。

并行批的 contextModifier 延后 apply,代价是「工具已返回但上下文还没变」。
批次结束前 currentContext 看到的是批次开始时的快照。这要求工具不能依赖「我改的上下文在同一批的下一个工具里立刻可见」——这类工具必须声明为非并发安全。

三级预算而不是单级,代价是行为更难预测。
单工具 50K、token 100K、单消息聚合 200K,外加两个 GrowthBook 覆盖开关与 Read 的 Infinity 例外,模型看到的结果有时是全文、有时是 2000 字节预览加一个路径。换来的是任何一轮的工具输出总量有上界,不会因为模型一口气发 10 个并行 grep 而把上下文打爆。

工具摘要交给 haiku 生成,代价是额外的模型调用与非确定性。
它给紧凑视图提供了可读标签,但每次都要发一次 API 请求(与主流程并行),失败被静默吞掉。TOOL_SUMMARY_MAX_LENGTH = 50 与提示词里的「~30 字符」并不一致——提示词约束生成,常量兜底截断。

自测题

  1. TOOL_DEFAULTS 里 checkPermissions 默认返回 { behavior: 'allow' },而 isConcurrencySafe 默认 false。这两个默认值的「保守方向」为什么不一致?它们各自把风险推给了谁?
  2. assembleToolPool 用「内置在前 + 保序去重」而不是简单 uniqBy([...mcp, ...builtin], 'name')。请构造一个具体场景,说明后者为什么会破坏 prompt cache。
  3. 并行批里 contextModifier 被延后到批次结束才 apply。如果一个工具在 call() 内部就依赖「自己刚写进上下文的字段」,会发生什么?请给出一个应该把它标成非并发安全的例子。
  4. MAX_TOOL_RESULTS_PER_MESSAGE_CHARS 是「单消息」而不是「单轮」。这两者在什么情况下会不同?请结合 query 循环里 applyToolResultBudget 的调用位置说明它为什么选择「消息」这个粒度。
  5. Read 用 maxResultSizeChars = Infinity 把落盘的皮球踢回给自己。请说明 Read 必须自己实现哪些边界,才能保证这个例外不会变成新的上下文炸弹。

进入 keel 阅读