Claude Code 源码解剖

Claude Code 的 Token 预算

从工具结果持久化、单消息聚合预算、ContentReplacementState 和 tokenCountWithEstimation 拆解 Claude Code 如何在内容进入上下文前控制大小,并避免并行工具调用导致系统性低估。

第 10 章 16 分钟

Claude Code 的 Token 预算

第 9 章讲的是上下文快满时怎么压缩。这一章往前看一步:内容还没进入上下文之前,Claude Code 怎么决定它能不能原样进去?

这不是单纯省钱。Token 预算在源码里更像一组入口闸门:

  • 单个工具结果太大,先落盘,只给模型路径和预览。
  • 一轮并行工具结果加起来太大,再做消息级预算。
  • 预算替换不能破坏 prompt cache,所以结果一旦被模型看过,后续命运要冻结。
  • 上下文大小不能只看最后一次 API usage,还要把之后新增的 tool_result 估进去。

核心直觉

Token 预算不是尾部清理,而是前置调度。它决定模型下一轮看到的是全文、预览、路径、占位符,还是被触发压缩后的摘要。

先看源码入口

这一章的关键代码集中在工具结果存储、token 估算和 query loop 预算判断几条线上。

源码定位关键符号负责什么
constants/toolLimits.tsDEFAULT_MAX_RESULT_SIZE_CHARSMAX_TOOL_RESULTS_PER_MESSAGE_CHARS定义单工具 50K 字符和单消息 200K 字符预算
utils/toolResultStorage.tsgetPersistenceThreshold()计算每个工具的持久化阈值,处理 Infinity、GrowthBook 覆盖和默认上限
utils/toolResultStorage.tsmaybePersistLargeToolResult()persistToolResult()超大工具结果落盘,并构造模型可见的预览消息
utils/toolResultStorage.tscollectCandidatesByMessage()enforceToolResultBudget()按 API 视角的消息分组执行 200K 聚合预算
utils/toolResultStorage.tsContentReplacementStateContentReplacementRecord跨轮保存哪些结果已见过、哪些替换必须重放
utils/tokens.tsgetTokenCountFromUsage()tokenCountWithEstimation()用精确 usage 加新增消息估算得到当前上下文大小
services/tokenEstimation.tsroughTokenCountEstimation()bytesPerTokenForFileType()对文本、JSON、图片、PDF 做粗略 token 估算
queryLoop()blocking_limittoken_budget_continuation根据预算决定继续、注入 nudge、恢复或终止

这几处源码共同说明:Token 预算不是一个全局数字,而是工具输出、消息结构、缓存稳定性、上下文追踪共同组成的运行时规则。

总流程图

flowchart TD A["工具执行完成"] --> B["maybePersistLargeToolResult()"] B --> C{"单工具结果 > getPersistenceThreshold()?"} C -->|否| D["原样进入 tool_result"] C -->|空结果| E["写入占位文本<br/>(tool completed with no output)"] C -->|图片 block| F["原样保留<br/>图片必须给模型"] C -->|是| G["persistToolResult()<br/>写入磁盘"] G --> H["buildLargeToolResultMessage()<br/>路径 + 2KB 预览"] D --> I["enforceToolResultBudget()"] E --> I F --> I H --> I I --> J["collectCandidatesByMessage()<br/>按 API 消息分组"] J --> K{"单消息 tool_result 总量 > 200K?"} K -->|否| L["标记 seen<br/>保持 cache 前缀稳定"] K -->|是| M["selectFreshToReplace()<br/>只替换 fresh 结果"] M --> N["ContentReplacementState<br/>记录 replacements / seenIds"] L --> O["tokenCountWithEstimation()"] N --> O O --> P{"接近压缩或 blocking 阈值?"} P -->|压缩阈值| Q["autoCompactIfNeeded()"] P -->|硬上限| R["return blocking_limit"] P -->|预算仍有空间| S["token_budget_continuation<br/>继续推进"]

这条链路要注意一个顺序:先控制工具结果,再控制消息聚合,最后才用 token 追踪判断是否压缩或终止。

单工具结果预算:50K 字符不是建议,是入口闸门

源码里的两个常量先把边界画出来。

export const DEFAULT_MAX_RESULT_SIZE_CHARS = 50_000

export const MAX_TOOL_RESULTS_PER_MESSAGE_CHARS = 200_000

这段代码证明:Claude Code 同时有单工具结果预算和单消息聚合预算。50K 管一个工具结果,200K 管一条 user message 里的所有工具结果。

阈值不是固定 50K

真正计算单工具阈值的是 getPersistenceThreshold()

export function getPersistenceThreshold(
  toolName: string,
  declaredMaxResultSizeChars: number,
): number {
  // Infinity = hard opt-out
  if (!Number.isFinite(declaredMaxResultSizeChars)) {
    return declaredMaxResultSizeChars
  }

  const overrides = getFeatureValue_CACHED_MAY_BE_STALE<Record<
    string, number
  > | null>(PERSIST_THRESHOLD_OVERRIDE_FLAG, {})
  const override = overrides?.[toolName]
  if (
    typeof override === 'number' &&
    Number.isFinite(override) &&
    override > 0
  ) {
    return override
  }

  return Math.min(declaredMaxResultSizeChars, DEFAULT_MAX_RESULT_SIZE_CHARS)
}

这段代码证明阈值有四层优先级:

优先级条件结果
1工具声明 maxResultSizeChars: Infinity不走通用持久化
2GrowthBook tengu_satin_quoll 覆盖某工具阈值使用远程覆盖值
3工具声明自定义上限Math.min(声明值, 50_000)
4没有特殊声明50_000

Infinity 是关键例外。原文指出 Read 工具使用这条路径,因为如果 Read 的输出又被持久化成另一个文件,模型就会陷入“Read -> 持久化文件 -> 再 Read”的循环。Read 用自己的 maxTokens 控制输出,不靠通用持久化路径。

持久化后模型看到什么

当工具结果太大时,系统不把全文塞进上下文,而是写入磁盘并生成替代消息。

flowchart TD A["tool_result content"] --> B{"content 为空?"} B -->|是| C["注入 (toolName completed with no output)"] B -->|否| D{"包含 image block?"} D -->|是| E["原样返回"] D -->|否| F{"字符数 <= threshold?"} F -->|是| G["原样返回"] F -->|否| H["persistToolResult()<br/>flag: wx 写文件"] H --> I["替代消息<br/>Full output saved to path<br/>Preview first 2KB"]

空结果处理不是装饰。源码注释提到,空 tool_result 可能让某些模型误判轮次边界,所以要注入 (toolName completed with no output) 这种占位文本。

持久化写文件也有幂等保护:persistToolResult() 使用 flag: &#39;wx&#39;,文件已存在时捕获并忽略 EEXIST。这说明系统预期同一个 tool_use_id 在 microcompact 或重放场景下可能被再次处理,重复写入不应该把会话打断。

模型最终看到的不是“结果被删了”,而是类似:

<persisted-output>
Output too large. Full output saved to:
  /path/to/session/tool-results/toolu_xxx.txt

Preview (first 2.0 KB):
...
</persisted-output>

这段格式的工程意义是:让模型知道自己看到的不是全文,并且知道如何重新定位全文。预算不是静默截断,而是把“信息缺口”显式暴露给模型。

单消息预算:并行工具调用不能绕过 50K

50K 只限制单个工具结果。并行调用时,10 个工具每个返回 40K,单独都合法,加起来却是 400K。MAX_TOOL_RESULTS_PER_MESSAGE_CHARS = 200_000 就是为这种组合风险准备的。

为什么分组逻辑复杂

并行工具调用在内部消息数组里并不一定表现为“一条 assistant + 一条 user”。原文描述的结构类似:

..., assistant(id=A), user(result_1), assistant(id=A), user(result_2), ...

多个 assistant 片段共享同一个 message.id,但 normalizeMessagesForAPI 发送前会把连续 user 结果合并。预算如果按内部数组的每条 user message 单独算,就会低估 API 实际看到的一条大消息。

collectCandidatesByMessage() 用 assistant 边界分组,而且只在第一次遇到新的 assistant id 时 flush。

const seenAsstIds = new Set<string>()
for (const message of messages) {
  if (message.type === 'user') {
    current.push(...collectCandidatesFromMessage(message))
  } else if (message.type === 'assistant') {
    if (!seenAsstIds.has(message.message.id)) {
      flush()
      seenAsstIds.add(message.message.id)
    }
  }
}

这段代码证明:消息级预算按 API 视角近似分组,不按本地数组表象分组。同一个 assistant id 的多段结果仍然属于同一轮并行调用的聚合风险。

容易误解

“每个工具结果都没超过 50K”不代表这一轮安全。真正发给 API 时,多个 tool_result 可能被合并成一条 user message,触发 200K 聚合预算。

ContentReplacementState:预算执行必须保护 prompt cache

消息级预算最容易写成一个无状态函数:每轮都看大小,超了就替换。但 Claude Code 不能这么做,因为 prompt cache 要求前缀稳定。

如果模型上一轮已经看过某个结果的全文,下一轮突然把同一位置换成预览,缓存前缀就变了。于是源码把工具结果分成三类:

状态判定行为
mustReapplyseenIds 中存在,且 replacements 有替代内容原样重放上次的预览文本
frozenseenIds 中存在,但没有替代内容已经给模型看过全文,不能再替换
fresh不在 seenIds新结果,可被预算系统选择替换
flowchart LR A["tool_result candidate"] --> B{"seenIds.has(id)?"} B -->|否| C["fresh<br/>可按大小选择替换"] B -->|是| D{"replacements.has(id)?"} D -->|是| E["mustReapply<br/>重放同一替代文本"] D -->|否| F["frozen<br/>保持全文不变"]

这说明 ContentReplacementState 的本质不是“省 token 状态”,而是“模型已经看过什么”的一致性账本。

超预算时只替换 fresh 结果

enforceToolResultBudget() 超预算时会调用 selectFreshToReplace(),按大小降序选择 fresh 结果落盘,直到剩余总量回到 200K 内。

const selected = fresh
  .slice()
  .sort((a, b) => b.size - a.size)

for (const candidate of selected) {
  if (remaining <= limit) {
    break
  }
  remaining -= candidate.size
  toReplace.push(candidate)
}

这段伪源码对应原文的实现逻辑,证明了两个判断:

  • 优先替换最大的 fresh 结果,因为它们释放空间最多。
  • frozen 结果即使导致当前消息超额,也不能随便改,最终交给 microcompact 或后续压缩清理。

这里的工程取舍很硬:prompt cache 稳定性优先于单轮预算完美达标

计数基线:精确 usage 加新增消息估算

预算执行完之后,系统还要判断当前上下文整体有多大。这不是简单 messages.length,也不是只看最后一次输出长度。

精确基线:API usage 不是只看输出

getTokenCountFromUsage() 组合 API usage 里的四类 token。

export function getTokenCountFromUsage(usage: Usage): number {
  return (
    usage.input_tokens +
    (usage.cache_creation_input_tokens ?? 0) +
    (usage.cache_read_input_tokens ?? 0) +
    usage.output_tokens
  )
}

这段代码证明:缓存读取的 token 仍然属于上下文窗口占用。cache_read_input_tokens 可能更便宜,但不是“免费上下文”。判断是否快满时必须把它算进去。

粗略估算补足 API 调用之间的空白

两次 API 调用之间会新增工具结果、附件、用户消息。源码用 roughTokenCountEstimation() 估算这些新增内容。

export function roughTokenCountEstimation(
  content: string,
  bytesPerToken: number = 4,
): number {
  return Math.round(content.length / bytesPerToken)
}

JSON 会用更保守的 2 字节/token。

export function bytesPerTokenForFileType(fileExtension: string): number {
  switch (fileExtension) {
    case 'json':
    case 'jsonl':
    case 'jsonc':
      return 2
    default:
      return 4
  }
}

这段代码证明:估算不是一把尺子量所有内容。JSON 的 {}:,&quot; 很密集,如果仍用 4 字节/token,100KB JSON 可能被估成 25K token,但实际接近 50K。

图片和 PDF 又是反方向的特殊情况。源码对 imagedocument block 返回固定估算值,避免把 base64 字符串按文本长度估成几十万 token。这里的原则不是绝对精确,而是避免系统性离谱。

内容类型估算规则设计原因
普通文本、代码默认 4 字节/token快速、便宜,误差可接受
JSON / JSONL / JSONC2 字节/token避免低估高密度结构
image block固定约 2,000 token避免 base64 长度造成灾难性高估
document block固定约 2,000 token同上

并行工具调用的回溯修正

tokenCountWithEstimation() 是阈值判断的规范函数。它解决了一个并行工具调用特有的问题:最后一次 API usage 可能出现在同一个 assistant id 的后半段。

内部消息可能是:

asst(A with usage) -> user(tr_1) -> asst(A with same usage) -> user(tr_2)

如果只从最后一个有 usage 的 assistant 往后估算,就只会算 tr_2,漏掉 tr_1。下一次 API 请求却会同时包含 tr_1tr_2

源码用同 ID 回溯修正锚点。

const responseId = getAssistantMessageId(message)
if (responseId) {
  let j = i - 1
  while (j >= 0) {
    const prior = messages[j]
    const priorId = prior ? getAssistantMessageId(prior) : undefined
    if (priorId === responseId) {
      i = j
    } else if (priorId !== undefined) {
      break
    }
    j--
  }
}

然后用精确 usage 加锚点之后所有消息的估算值:

return (
  getTokenCountFromUsage(usage) +
  roughTokenCountEstimationForMessages(messages.slice(i + 1))
)

这段代码证明:并行工具调用会改变 token 计数的正确锚点。系统必须回溯到同一次 API 响应的第一个 assistant 片段,否则会稳定漏算交错在中间的 tool_result。

读源码抓手

看 token 计数时,先分清三个函数:tokenCountWithEstimation() 用于阈值比较;tokenCountFromLastAPIResponse() 只看最后一次 API usage;messageTokenCountFromLastAPIResponse() 只看单次输出。后两者不能替代上下文窗口判断。

输出预算和硬终止:预算也影响循环是否继续

Token 预算不只影响输入内容,也影响 query loop 是否继续推进。

原文第 3 章的 query loop 状态表里有几个相关 transition:

transition触发条件行为
max_output_tokens_escalate模型输出被截断,且还没升级过提高 maxOutputTokensOverride,原样重试
max_output_tokens_recovery输出截断且升级已用完,恢复次数 < 3注入 meta 消息让模型继续
token_budget_continuationTOKEN_BUDGET 开启且预算仍有空间注入 nudge,鼓励模型继续工作
blocking_limittoken 数触达硬限制直接返回终止原因

这说明预算不只是“太大就截断”。在 query loop 尾部,它还决定:

  • 这轮回答是否应该续写。
  • 模型是否提前停得太早,需要继续推进。
  • 上下文是否已经到硬边界,不能再冒险调用。

TOKEN_BUDGET 还有一个提示词层面的细节:原文第 5 章提到,token_budget 段落曾经是 DANGEROUS_uncachedSystemPromptSection,因为会根据 getCurrentTurnTokenBudget() 动态切换。但这会在每次 budget 变化时破坏约 20K token 的缓存。后来通过改写提示词,让无预算时自然成为 no-op,降级为普通缓存段落。

这个改动说明:预算信息本身也会影响 prompt cache,动态内容要非常克制

状态和数据结构

结构字段/常量影响
DEFAULT_MAX_RESULT_SIZE_CHARS50_000单工具结果默认持久化阈值
MAX_TOOL_RESULTS_PER_MESSAGE_CHARS200_000一条 API user message 中 tool_result 聚合上限
ContentReplacementStateseenIdsreplacements记录哪些结果已经被模型看过,以及哪些替代文本必须重放
ContentReplacementRecordtranscript 持久化记录让替换状态跨 resume 存活
Usageinput_tokenscache_creation_input_tokenscache_read_input_tokensoutput_tokens形成上下文大小的精确基线
bytesPerTokenForFileType()JSON 使用 2,默认 4避免高密度格式被低估
maxOutputTokensRecoveryCount上限 3控制输出截断恢复次数
maxOutputTokensOverride可覆盖默认输出上限输出被截断时升级重试

设计取舍

取舍源码证据工程判断
大结果落盘而不是硬塞maybePersistLargeToolResult()persistToolResult()给模型路径和预览,保留回读能力
Read 使用 Infinity 退出通用持久化getPersistenceThreshold() 的非有限值分支避免 Read 结果被存成另一个要 Read 的文件
单消息 200K 聚合预算MAX_TOOL_RESULTS_PER_MESSAGE_CHARS防止多个合法小结果合成一个危险大消息
已见过的结果冻结seenIdsreplacements 三态分区保护 prompt cache 前缀稳定
超预算优先替换最大 fresh 结果selectFreshToReplace()用最少替换释放最多空间
JSON 使用 2 字节/tokenbytesPerTokenForFileType()宁可保守估算,也不要让密集结构漏进上下文
图片和 PDF 固定估算block 类型提前返回固定值避免 base64 长度造成灾难性高估
并行工具同 ID 回溯tokenCountWithEstimation()避免漏算交错 tool_result,导致压缩过晚
token_budget 从动态非缓存段降级systemPromptSection no-op 改写减少预算提示词变化对 20K 级缓存前缀的破坏

读源码抓手

  1. 先看 constants/toolLimits.ts,把 50K 和 200K 两个边界记住。
  2. 再看 getPersistenceThreshold(),确认 Infinity、GrowthBook 覆盖和默认阈值的优先级。
  3. 接着读 maybePersistLargeToolResult(),关注空结果、图片、落盘、预览消息四个分支。
  4. 然后读 collectCandidatesByMessage(),重点理解同一个 assistant id 的并行结果为什么要归到同组。
  5. 再追 enforceToolResultBudget()ContentReplacementState,看 freshfrozenmustReapply 如何保护 prompt cache。
  6. 最后读 tokenCountWithEstimation(),用并行工具消息交错例子验证同 ID 回溯。
  7. 如果想看预算如何影响 agent loop,回到第 2 章的 queryLoop(),找 blocking_limitmax_output_tokens_recoverytoken_budget_continuation

小结

小结

Token 预算真正控制的是“哪些证据以什么形态进入模型视野”。

  • 单工具 50K 和单消息 200K 是两层不同闸门,前者管单次输出,后者管并行聚合。
  • 工具结果落盘不是静默丢弃,而是把全文路径和预览交给模型,让后续可以回读。
  • ContentReplacementState 把预算执行变成有状态机制,因为 prompt cache 要求模型已经看过的前缀保持稳定。
  • tokenCountWithEstimation() 用精确 usage 加新增消息估算,并通过同 ID 回溯修正并行工具调用的漏算问题。
  • 预算系统宁可在关键位置保守估算,也不愿系统性低估,因为低估的代价是压缩过晚和 API 调用失败。