Claude Code 的 Token 预算
第 9 章讲的是上下文快满时怎么压缩。这一章往前看一步:内容还没进入上下文之前,Claude Code 怎么决定它能不能原样进去?
这不是单纯省钱。Token 预算在源码里更像一组入口闸门:
- 单个工具结果太大,先落盘,只给模型路径和预览。
- 一轮并行工具结果加起来太大,再做消息级预算。
- 预算替换不能破坏 prompt cache,所以结果一旦被模型看过,后续命运要冻结。
- 上下文大小不能只看最后一次 API usage,还要把之后新增的 tool_result 估进去。
核心直觉
Token 预算不是尾部清理,而是前置调度。它决定模型下一轮看到的是全文、预览、路径、占位符,还是被触发压缩后的摘要。
先看源码入口
这一章的关键代码集中在工具结果存储、token 估算和 query loop 预算判断几条线上。
| 源码定位 | 关键符号 | 负责什么 |
|---|---|---|
constants/toolLimits.ts | DEFAULT_MAX_RESULT_SIZE_CHARS、MAX_TOOL_RESULTS_PER_MESSAGE_CHARS | 定义单工具 50K 字符和单消息 200K 字符预算 |
utils/toolResultStorage.ts | getPersistenceThreshold() | 计算每个工具的持久化阈值,处理 Infinity、GrowthBook 覆盖和默认上限 |
utils/toolResultStorage.ts | maybePersistLargeToolResult()、persistToolResult() | 超大工具结果落盘,并构造模型可见的预览消息 |
utils/toolResultStorage.ts | collectCandidatesByMessage()、enforceToolResultBudget() | 按 API 视角的消息分组执行 200K 聚合预算 |
utils/toolResultStorage.ts | ContentReplacementState、ContentReplacementRecord | 跨轮保存哪些结果已见过、哪些替换必须重放 |
utils/tokens.ts | getTokenCountFromUsage()、tokenCountWithEstimation() | 用精确 usage 加新增消息估算得到当前上下文大小 |
services/tokenEstimation.ts | roughTokenCountEstimation()、bytesPerTokenForFileType() | 对文本、JSON、图片、PDF 做粗略 token 估算 |
queryLoop() | blocking_limit、token_budget_continuation | 根据预算决定继续、注入 nudge、恢复或终止 |
这几处源码共同说明:Token 预算不是一个全局数字,而是工具输出、消息结构、缓存稳定性、上下文追踪共同组成的运行时规则。
总流程图
这条链路要注意一个顺序:先控制工具结果,再控制消息聚合,最后才用 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 | 不走通用持久化 |
| 2 | GrowthBook tengu_satin_quoll 覆盖某工具阈值 | 使用远程覆盖值 |
| 3 | 工具声明自定义上限 | Math.min(声明值, 50_000) |
| 4 | 没有特殊声明 | 50_000 |
Infinity 是关键例外。原文指出 Read 工具使用这条路径,因为如果 Read 的输出又被持久化成另一个文件,模型就会陷入“Read -> 持久化文件 -> 再 Read”的循环。Read 用自己的 maxTokens 控制输出,不靠通用持久化路径。
持久化后模型看到什么
当工具结果太大时,系统不把全文塞进上下文,而是写入磁盘并生成替代消息。
空结果处理不是装饰。源码注释提到,空 tool_result 可能让某些模型误判轮次边界,所以要注入 (toolName completed with no output) 这种占位文本。
持久化写文件也有幂等保护:persistToolResult() 使用 flag: 'wx',文件已存在时捕获并忽略 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 要求前缀稳定。
如果模型上一轮已经看过某个结果的全文,下一轮突然把同一位置换成预览,缓存前缀就变了。于是源码把工具结果分成三类:
| 状态 | 判定 | 行为 |
|---|---|---|
mustReapply | seenIds 中存在,且 replacements 有替代内容 | 原样重放上次的预览文本 |
frozen | seenIds 中存在,但没有替代内容 | 已经给模型看过全文,不能再替换 |
fresh | 不在 seenIds 中 | 新结果,可被预算系统选择替换 |
这说明 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 的 {、}、:、,、" 很密集,如果仍用 4 字节/token,100KB JSON 可能被估成 25K token,但实际接近 50K。
图片和 PDF 又是反方向的特殊情况。源码对 image 和 document block 返回固定估算值,避免把 base64 字符串按文本长度估成几十万 token。这里的原则不是绝对精确,而是避免系统性离谱。
| 内容类型 | 估算规则 | 设计原因 |
|---|---|---|
| 普通文本、代码 | 默认 4 字节/token | 快速、便宜,误差可接受 |
| JSON / JSONL / JSONC | 2 字节/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_1 和 tr_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_continuation | TOKEN_BUDGET 开启且预算仍有空间 | 注入 nudge,鼓励模型继续工作 |
blocking_limit | token 数触达硬限制 | 直接返回终止原因 |
这说明预算不只是“太大就截断”。在 query loop 尾部,它还决定:
- 这轮回答是否应该续写。
- 模型是否提前停得太早,需要继续推进。
- 上下文是否已经到硬边界,不能再冒险调用。
TOKEN_BUDGET 还有一个提示词层面的细节:原文第 5 章提到,token_budget 段落曾经是 DANGEROUS_uncachedSystemPromptSection,因为会根据 getCurrentTurnTokenBudget() 动态切换。但这会在每次 budget 变化时破坏约 20K token 的缓存。后来通过改写提示词,让无预算时自然成为 no-op,降级为普通缓存段落。
这个改动说明:预算信息本身也会影响 prompt cache,动态内容要非常克制。
状态和数据结构
| 结构 | 字段/常量 | 影响 |
|---|---|---|
DEFAULT_MAX_RESULT_SIZE_CHARS | 50_000 | 单工具结果默认持久化阈值 |
MAX_TOOL_RESULTS_PER_MESSAGE_CHARS | 200_000 | 一条 API user message 中 tool_result 聚合上限 |
ContentReplacementState | seenIds、replacements | 记录哪些结果已经被模型看过,以及哪些替代文本必须重放 |
ContentReplacementRecord | transcript 持久化记录 | 让替换状态跨 resume 存活 |
Usage | input_tokens、cache_creation_input_tokens、cache_read_input_tokens、output_tokens | 形成上下文大小的精确基线 |
bytesPerTokenForFileType() | JSON 使用 2,默认 4 | 避免高密度格式被低估 |
maxOutputTokensRecoveryCount | 上限 3 | 控制输出截断恢复次数 |
maxOutputTokensOverride | 可覆盖默认输出上限 | 输出被截断时升级重试 |
设计取舍
| 取舍 | 源码证据 | 工程判断 |
|---|---|---|
| 大结果落盘而不是硬塞 | maybePersistLargeToolResult()、persistToolResult() | 给模型路径和预览,保留回读能力 |
Read 使用 Infinity 退出通用持久化 | getPersistenceThreshold() 的非有限值分支 | 避免 Read 结果被存成另一个要 Read 的文件 |
| 单消息 200K 聚合预算 | MAX_TOOL_RESULTS_PER_MESSAGE_CHARS | 防止多个合法小结果合成一个危险大消息 |
| 已见过的结果冻结 | seenIds、replacements 三态分区 | 保护 prompt cache 前缀稳定 |
| 超预算优先替换最大 fresh 结果 | selectFreshToReplace() | 用最少替换释放最多空间 |
| JSON 使用 2 字节/token | bytesPerTokenForFileType() | 宁可保守估算,也不要让密集结构漏进上下文 |
| 图片和 PDF 固定估算 | block 类型提前返回固定值 | 避免 base64 长度造成灾难性高估 |
| 并行工具同 ID 回溯 | tokenCountWithEstimation() | 避免漏算交错 tool_result,导致压缩过晚 |
token_budget 从动态非缓存段降级 | systemPromptSection no-op 改写 | 减少预算提示词变化对 20K 级缓存前缀的破坏 |
读源码抓手
- 先看
constants/toolLimits.ts,把 50K 和 200K 两个边界记住。 - 再看
getPersistenceThreshold(),确认Infinity、GrowthBook 覆盖和默认阈值的优先级。 - 接着读
maybePersistLargeToolResult(),关注空结果、图片、落盘、预览消息四个分支。 - 然后读
collectCandidatesByMessage(),重点理解同一个 assistant id 的并行结果为什么要归到同组。 - 再追
enforceToolResultBudget()和ContentReplacementState,看fresh、frozen、mustReapply如何保护 prompt cache。 - 最后读
tokenCountWithEstimation(),用并行工具消息交错例子验证同 ID 回溯。 - 如果想看预算如何影响 agent loop,回到第 2 章的
queryLoop(),找blocking_limit、max_output_tokens_recovery和token_budget_continuation。
小结
小结
Token 预算真正控制的是“哪些证据以什么形态进入模型视野”。
- 单工具 50K 和单消息 200K 是两层不同闸门,前者管单次输出,后者管并行聚合。
- 工具结果落盘不是静默丢弃,而是把全文路径和预览交给模型,让后续可以回读。
ContentReplacementState把预算执行变成有状态机制,因为 prompt cache 要求模型已经看过的前缀保持稳定。tokenCountWithEstimation()用精确 usage 加新增消息估算,并通过同 ID 回溯修正并行工具调用的漏算问题。- 预算系统宁可在关键位置保守估算,也不愿系统性低估,因为低估的代价是压缩过晚和 API 调用失败。