Back to Blog

Claude Code Harness 第12章:Token 预算策略

2026-04-05
Claude Code Token Budget Context Management Optimization

前言:从满载到有序的资源管理

在之前的章节中,我们探讨了 Claude Code 如何应对"上下文窗口满了"的情况:

  • 第8章:自动压缩机制——当上下文接近窗口上限时,调用 LLM 生成摘要
  • 第9章:文件状态保留——压缩后如何恢复关键文件的上下文
  • 第10章:微压缩——精准移除过时的工具结果
  • 第11章:上下文压缩策略——九段式压缩模板

但这些机制都是"事后诸葛亮"——它们只在问题出现后才采取行动。Token 预算策略则是"事前预防",它在内容进入上下文窗口之前就控制大小,是上下文管理的"入口闸门"。

想象这样的场景:你运行 grep -r "function" src/ 搜索整个代码库,返回 80KB 的搜索结果;紧接着又执行 cat logs/debug.log 读取 200KB 的日志文件;同时还发起了 5 个并行的文件搜索命令,每个返回 50KB。如果不加控制,单个工具结果就可能吃掉上下文窗口的四分之一,而一组并行工具调用则可能直接将上下文推到需要压缩的临界点。

Token 预算策略在三个层级运作:

  1. 单工具结果级别:超过 50K 字符的结果持久化到磁盘,只向模型展示 2KB 预览
  2. 单消息级别:一轮并行工具调用的结果总量不超过 200K 字符
  3. Token 计数级别:通过精确 API usage 或粗略估算追踪上下文窗口使用量

本章将深入这三个层级的实现,揭示其中的工程权衡——特别是并行工具调用场景下的 token 计数陷阱,以及如何设计一个既不会过早压缩、也不会溢出窗口的预算体系。


12.1 第一道防线:单工具结果持久化

50K 字符的硬性门槛

当你在 Claude Code 中执行 grepcatbash 命令时,如果返回的内容超过 50,000 个字符,完整结果会被写入磁盘文件,而模型只会看到一个包含文件路径和前 2KB 预览的替代消息。这个机制定义在 constants/toolLimits.ts 中:

// constants/toolLimits.ts
export const DEFAULT_MAX_RESULT_SIZE_CHARS = 50_000

为什么是 50K?这个数字的选择基于多方面考量:

  • 上下文窗口比例:在 200K token 的上下文窗口中,50K 字符约占 25%(按 4 字符/token 估算)
  • 实用性平衡:大多数代码文件、配置文件、日志片段都在这个大小以下
  • 并行工具安全边际:4 个工具同时达到 50K 上限,总量仍在 200K 消息级预算内

持久化阈值的优先级链

单工具的持久化阈值并非简单等于 50K——它是一个多层决策系统,体现了精细化的工程考量:

flowchart TD
    A["工具声明 maxResultSizeChars"] --> B{"值为 Infinity?"}
    B -->|是| C["优先级1: 永不持久化
Read工具使用此机制
避免循环调用"] B -->|否| D{"GrowthBook有覆盖值?"} D -->|是| E["优先级2: 使用远程配置值
运行时动态调整
无需重新部署"] D -->|否| F{"工具声明了自定义值?"} F -->|是| G["优先级3: min声明值, 50K
工具可降低阈值
但不能超过默认值"] F -->|否| H["优先级4: 50K字符
默认行为"] style C fill:#ffcccc style E fill:#ccffcc style G fill:#ccccff style H fill:#ffffcc

这个优先级链的设计体现了几个关键原则:

Read 工具的特殊地位(优先级1):Read 工具将自己的 maxResultSizeChars 设为 Infinity,意味着它永远不会被持久化。源码注释解释了原因——Read 工具的输出如果被持久化到文件,模型就需要再次调用 Read 去读取那个文件,形成死循环。Read 工具通过自己的 maxTokens 参数控制输出大小,不依赖通用的持久化机制。

远程配置的灵活性(优先级2):通过 GrowthBook feature flag tengu_satin_quoll,可以针对特定工具设置持久化阈值覆盖。例如,可以将某个返回大量结构化数据的工具阈值降低到 10K,而将另一个只返回关键信息的工具阈值提升到 100K——无需发布新版本,只需在远程控制台调整配置。

工具的自定义能力(优先级3):每个工具可以在定义时声明自己的 maxResultSizeChars,但最终阈值是 Math.min(声明值, 50_000)。这个设计允许工具制定更严格的标准(比如某个知道会返回巨大输出的工具主动降低阈值),但不能绕过全局上限。

持久化执行流程

当工具结果超过阈值时,maybePersistLargeToolResult 函数执行以下决策流程:

flowchart TD
    A["工具执行完成
产生结果内容"] --> B{"内容为空?"} B -->|是| C["注入占位符
'toolName completed with no output'
避免模型误判为对话边界"] B -->|否| D{"包含图片 block?"} D -->|是| E["原样返回
图片必须发送给模型
无法仅通过预览理解"] D -->|否| F{"size ≤ 阈值?"} F -->|是| G["原样返回完整内容"] F -->|否| H["persistToolResult()
1. 生成唯一文件名
2. 写入磁盘文件
3. 生成 2KB 预览"] H --> I["buildLargeToolResultMessage()
构建替代消息:
- 文件路径
- 预览内容
- 大小信息"] style C fill:#ffcccc style E fill:#ccffcc style G fill:#ccccff style I fill:#ffffcc

这个流程中有几个值得关注的实现细节:

空结果的特殊处理:空的 tool_result 内容会导致某些模型误判为对话轮次边界,从而错误地结束输出。这是因为服务端渲染器在 tool_result 之后不插入 \n\nAssistant: 标记,空内容会匹配到 \n\nHuman: 的停止序列模式。解决方案是注入一个简短的占位字符串 (toolName completed with no output),确保模型理解这是一个有效的工具结果。

图片的强制保留:如果工具结果包含图片 block(例如截图、照片),即使超过阈值也必须原样发送给模型。这是因为图片的语义无法通过 2KB 文本预览传达——模型需要看到实际的图像内容才能理解。图片的 token 计费按像素计算(约 2000 token/张),远低于同等大小的文本内容。

文件写入的幂等性:持久化函数使用 flag: 'wx' 写入文件,这意味着如果文件已存在则抛出 EEXIST 错误——函数捕获并忽略这个错误。这个设计是为了应对微压缩重放原始消息时的重复持久化问题:tool_use_id 在每次调用中是唯一的,相同 ID 的内容是确定性的,所以跳过已存在的文件是安全的。

持久化后的消息格式

当结果被持久化后,模型实际看到的消息如下:


Output too large (82.3 KB). Full output saved to:
  /path/to/session/tool-results/toolu_01XYZABC123.txt

Preview (first 2.0 KB):
[前 2000 字节的内容,在换行符处截断]
...

预览的生成逻辑会尽量在换行符处截断,避免切断一行的中间位置。如果最后一个换行符的位置在限制值的 50% 之前(意味着要么只有一行,要么行非常长),则回退到精确的字节限制。

这个设计确保了模型能够:

  1. 知道完整内容的位置:通过文件路径,模型可以使用 Read 工具主动读取完整内容
  2. 获得内容类型的线索:通过预览,模型可以判断这是否是需要深入查看的内容
  3. 理解大小的量级:通过 "82.3 KB" 这样的信息,模型可以评估是否有必要读取完整内容

12.2 第二道防线:单消息聚合预算

为什么需要消息级别的预算

单工具的 50K 上限不足以应对并行工具调用的场景。考虑这种情况:模型同时发起 10 个 Grep 调用搜索不同的关键词,每个返回 40K 字符——单独看都在 50K 阈值以下,但合计 400K 字符将在一条 user 消息中发送给 API。这会立即消耗大量上下文窗口,可能触发不必要的压缩。

MAX_TOOL_RESULTS_PER_MESSAGE_CHARS = 200_000 就是为这个场景设计的聚合预算。注释中明确说明了核心设计原则:

消息之间独立评估——一轮对话中 150K 的结果和另一轮中 150K 的结果各自都在预算内,不会互相影响。

这个设计的原因在于并行工具调用的内部表示。当模型发起多个并行工具调用时,流式处理代码为每个 content_block_stop 事件产生一个独立的 AssistantMessage 记录,然后每个 tool_result 作为独立的 user 消息紧随其后:

[
  ...,
  assistant(id=A), user(result_1), 
  assistant(id=A), user(result_2), 
  assistant(id=A), user(result_3)
]

注意多个 assistant 记录共享同一个 message.id。但在发送给 API 之前,normalizeMessagesForAPI 会将连续的 user 消息合并为一条。消息级别的预算必须按照 API 看到的分组方式工作,而不是内部的分散表示。

消息分组的边界识别

collectCandidatesByMessage 函数实现了按"assistant 消息边界"分组的逻辑。它只将未曾见过的 assistant message.id 作为分组边界:

flowchart LR
    subgraph Messages["内部消息数组"]
        M1["asst(id=A)"]
        M2["user(res_1)"]
        M3["asst(id=A)"]
        M4["user(res_2)"]
        M5["asst(id=A)"]
        M6["user(res_3)"]
        M7["asst(id=B)"]
        M8["user(res_4)"]
    end
    
    subgraph Groups["按API消息分组"]
        G1["Group 1:
asst(id=A)
user(res_1)
asst(id=A)
user(res_2)
asst(id=A)
user(res_3)"] G2["Group 2:
asst(id=B)
user(res_4)"] end Messages --> Groups style M1 fill:#ffcccc style M3 fill:#ffcccc style M5 fill:#ffcccc style M7 fill:#ccffcc

这里有一个微妙的边界情况:当并行工具执行中发生中止(abort),agent_progress 消息可能插入到 tool_result 消息之间。如果在 progress 消息处创建分组边界,那些 tool_result 就会被拆分到不同的低于预算的小组中,绕过了聚合预算检查——但 normalizeMessagesForAPI 会在线路上把它们合并为一条超预算的大消息。

代码通过只在 assistant 消息处分组(忽略 progress、attachment 等类型)来避免这个问题。这体现了"防御性编程"的原则:预算检查必须匹配实际发送给 API 的消息结构,而不是内部表示

三态分区:Prompt Cache 稳定性机制

消息级别预算的核心机制是 enforceToolResultBudget 函数。它的设计围绕一个关键约束:prompt cache 稳定性

一旦模型看到了某个工具结果(无论是完整内容还是替代预览),这个决策在后续所有 API 调用中必须保持一致。否则,前缀变化会导致 prompt cache 失效,大幅增加延迟和成本。

这引出了"三态分区"机制:

stateDiagram-v2
    [*] --> Fresh: 新工具结果
未被模型见过 Fresh --> MustReapply: 被选中持久化
生成替代预览
replacements[id] = 替代内容 Fresh --> Frozen: 未被选中持久化
但已发送给模型
加入 seenIds MustReapply --> MustReapply: 后续调用
复用缓存的替换
字节级一致 Frozen --> Frozen: 不可变更
保持完整内容
避免破坏前缀 note right of MustReapply seenIds: ✓ replacements: ✓ → 重新应用缓存 end note note right of Frozen seenIds: ✓ replacements: ✗ → 不可变更 end note note right of Fresh seenIds: ✗ replacements: ✗ → 可被选中替换 end note

每轮 API 调用前的执行流程:

  1. MustReapply(必须重新应用):从 Map 中取出之前缓存的替代字符串,原样重新应用——零 I/O,字节级一致,确保 prompt cache 前缀不变
  2. Frozen(冻结):之前看过但没有被替换的结果——不可再替换,否则会破坏 prompt cache 前缀
  3. Fresh(新鲜):本轮新增的结果——检查聚合预算,超预算时按大小降序选择最大的结果进行持久化

选择哪些 fresh 结果进行替换的逻辑很简单:按大小降序排列,逐一选中直到剩余总量降到预算限制以下。如果仅 frozen 结果就超过了预算,则接受超额——微压缩最终会清理它们。

状态标记的时序约束

代码中有一个精心设计的时序约束。未被选中持久化的候选者立即同步标记为 seen(加入 seenIds),而被选中持久化的候选者则在 await persistToolResult() 完成后才标记。

这保证了 seenIds.has(id)replacements.has(id) 的一致性。注释解释了原因:如果一个 ID 出现在 seenIds 中但不在 replacements 中,它会被分类为 frozen(不可替换),导致完整内容被发送;而同时主线程可能发送的是预览——两边不一致会导致 prompt cache 失效。

sequenceDiagram
    participant Main as 主线程
    participant Disk as 磁盘I/O
    participant State as 状态管理
    
    Main->>State: 检查工具结果大小
    State->>State: 识别fresh候选者
    State->>State: 按大小降序排序
    State->>State: 选择需持久化的结果
    
    par 未被选中的结果
        Main->>State: 立即标记为seen
        Main->>Main: 原样发送完整内容
    and 被选中的结果
        Main->>Disk: await persistToolResult()
        Disk-->>Main: 写入完成
        Main->>State: 标记为seen
同时加入replacements Main->>Main: 发送替代预览 end State->>State: 保存状态到transcript Note over Main,State: 时序保证:
seenIds + replacements同步更新
避免"seen但无替换"的中间状态

这个时序设计体现了"状态机的一致性优于性能"的原则。虽然理论上可以先标记所有 seen,然后并行执行持久化,但这会引入短暂的不一致窗口,可能导致 prompt cache 失效。同步等待磁盘 I/O 虽然稍慢,但保证了状态的确定性。


12.3 Token 计数:精确 vs 估算

两种计数机制

Claude Code 维护两套 token 计数机制,适用于不同场景:

特性 精确计数(API usage) 粗略估算
数据来源 API 响应中的 usage 字段 字符长度 ÷ 字节-per-token 系数
精确度 精确 偏差可达 ±50%
可用时机 API 调用完成后 任何时刻
性能开销 无(已有数据) 可忽略(简单除法)
主要用途 阈值判断、预算计算、计费 填补 API 调用间的空白

精确计数:从 API Usage 到上下文大小

API 响应的 usage 对象包含多个字段,getTokenCountFromUsage 函数将它们组合为完整的上下文窗口大小:

// utils/tokens.ts
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
  )
}

这个计算包含了四个组成部分:

  • input_tokens:本次请求的非缓存输入 token
  • cache_creation_input_tokens:本次新写入缓存的前缀 token
  • cache_read_input_tokens:从缓存读取的 token(大幅降低成本)
  • output_tokens:模型生成的输出 token

注意缓存相关的字段是可选的(?? 0),因为不是所有 API 提供方都返回这些字段(例如某些 Bedrock 配置)。

粗略估算:4 字节/token 规则

当 API usage 不可用时——例如在两次 API 调用之间新增了消息——Claude Code 使用字符长度除以经验系数来估算 token 数:

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

默认的 4 字节/token 是一个保守估算。Claude 的 tokenizer 对英文文本的实际比率约在 3.5-4.5 之间,取 4 作为经验中位数。但不同内容类型的实际比率差异很大:

内容类型 字节/Token 系数 原因
普通文本(英文、代码) 4 默认值,经验中位数
JSON / JSONL / JSONC 2 密集的单字符 token({}:,"
图片(image block) 固定 2,000 token API 按 (width × height) / 750 计费,约 2K
PDF 文档(document block) 固定 2,000 token 同图片

JSON 的特殊系数特别值得注意。密集的 JSON 包含大量单字符 token,这使得每个 token 平均只对应约 2 个字节。如果仍然用 4 来估算,一个 100KB 的 JSON 文件会被估算为 25K token,而实际可能接近 50K——这个低估可能导致超大的工具结果未被持久化,悄悄进入上下文。

图片和文档的固定估算

图片和 PDF 文档是特殊情况。API 对图片的实际 token 计费是 (width × height) / 750,图片会被缩放到最大 2000×2000 像素(约 5,333 token)。但在粗略估算中,Claude Code 统一使用 2,000 token 的固定值。

这里有一个重要的工程考量:如果图片或 PDF 的 source.data(base64 编码)被送入通用的 JSON 序列化路径,一个 1MB 的 PDF 会产生约 1.33M 的 base64 字符,按 4 字节/token 估算就是约 325K token——远高于 API 实际收费的 ~2,000 token。

因此代码在通用估算之前显式检查 block.type === 'image' || block.type === 'document' 并提前返回固定值,避免灾难性的高估。这体现了"特殊情况的显式处理优于通用逻辑的隐式假设"的工程原则。


12.4 并行工具调用的 Token 计数陷阱

消息交错问题

并行工具调用引入了一个微妙但严重的 token 计数问题。tokenCountWithEstimation——Claude Code 中用于阈值判断的规范函数——的实现包含了对这个问题的详细分析。

问题的根源在于消息数组的交错结构。当模型发起两个并行工具调用时,内部消息数组呈现如下形式:

索引:  ... i-3       i-2         i-1          i
消息:  ... asst(A)   user(tr_1)  asst(A)      user(tr_2)
            ↑ usage              ↑ 相同 usage

两个 assistant 记录共享同一个 message.id 和相同的 usage(因为它们来自同一个 API 响应的不同 content block)。

如果简单地从后往前找到第一个有 usage 的 assistant 消息(索引 i-1),然后估算它之后的消息(只有索引 i 处的 user(tr_2)),就会遗漏索引 i-2 处的 user(tr_1)

但在下一次 API 请求中,user(tr_1)user(tr_2) 都会出现在输入中。这意味着 tokenCountWithEstimation 会系统性地低估上下文大小。

flowchart TB
    subgraph Incorrect["错误估算(遗漏)"]
        direction TB
        A1["asst(A)"]
        A2["user(tr_1)"]
        A3["asst(A) ← 锚点"]
        A4["user(tr_2) ← 只估算这个"]
        A1 -.-> A2
        A2 -.-> A3
        A3 --> A4
    end
    
    subgraph Correct["正确估算(完整)"]
        direction TB
        B1["asst(A) ← 回溯锚点"]
        B2["user(tr_1) ← 包含"]
        B3["asst(A)"]
        B4["user(tr_2) ← 包含"]
        B1 --> B2
        B2 --> B3
        B3 --> B4
    end
    
    Incorrect -->|"系统性低估
导致溢出"| Error["API 调用失败"] Correct -->|"精确估算
触发压缩"| Success["正常处理"] style A2 fill:#ffcccc,stroke:#f00,stroke-width:2px style A4 fill:#ffffcc style B2 fill:#ccffcc,stroke:#0f0,stroke-width:2px style B4 fill:#ccffcc,stroke:#0f0,stroke-width:2px

同 ID 回溯修正

tokenCountWithEstimation 的解决方案是在找到最后一个有 usage 的 assistant 记录后,向前回溯到共享同一 message.id 的第一个 assistant 记录:

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  // 锚定到更早的同 ID 记录
    } else if (priorId !== undefined) {
      break  // 遇到不同 API 响应,停止回溯
    }
    j--
  }
}

回溯逻辑中的三种情况:

  1. priorId === responseId:同一 API 响应的更早分片——将锚点移到这里
  2. priorId !== undefined(且不同 ID):遇到了另一个 API 响应——停止回溯
  3. priorId === undefined:这是 user/tool_result/attachment 消息——可能是分片之间交错的工具结果,继续回溯

回溯完成后,从锚点之后的所有消息(包括所有交错的 tool_result)都会被纳入粗略估算:

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

最终的上下文大小 = 最后一次 API 响应的精确 usage + 此后所有新增消息的粗略估算。这种"精确基线 + 增量估算"的混合方法平衡了精度和性能。

函数选择的陷阱

源码中的注释反复强调了函数选择的重要性:

  • tokenCountWithEstimation规范函数,用于所有阈值比较(自动压缩触发、会话记忆初始化等)
  • tokenCountFromLastAPIResponse:只返回最后一次 API 调用的精确 token 总量,不包含新增消息的估算——不适合阈值判断
  • messageTokenCountFromLastAPIResponse:只返回 output_tokens——仅用于衡量模型单次生成了多少 token,不反映上下文窗口的使用量

误用这些函数的后果是实际的:如果用 messageTokenCountFromLastAPIResponse 来判断是否需要压缩,返回值可能只有几千(一次助手回复的输出),而实际上下文已经超过 180K——压缩永远不会触发,最终导致 API 调用因超过窗口限制而失败。


12.5 辅助计数:API Token 计数与 Haiku 回退

countTokens API

除了粗略估算,Claude Code 还可以通过 API 获取精确的 token 计数。countMessagesTokensWithAPI 调用 anthropic.beta.messages.countTokens 端点,传入完整的消息列表和工具定义,获取精确的 input_tokens 值。

这个 API 用于需要精确计数的场景(如工具定义的 token 开销评估),但有延迟开销——它需要一次额外的 HTTP 往返。因此日常的阈值判断使用 tokenCountWithEstimation 的混合方法,只在特定场景下使用 API 计数。

Haiku 回退方案

countTokens API 不可用(例如某些 Bedrock 配置)时,countTokensViaHaikuFallback 使用一种巧妙的替代方案:向 Haiku(小模型)发送一个 max_tokens: 1 的请求,利用返回的 usage 获取精确的输入 token 数。

flowchart TD
    A["需要精确计数"] --> B{"countTokens API可用?"}
    B -->|是| C["调用countTokens API
获取精确input_tokens"] B -->|否| D{"Haiku可用?"} D -->|是| E["Haiku回退
max_tokens: 1请求
从usage获取token数"] D -->|否| F{"Sonnet可用?"} F -->|是| G["Sonnet回退
成本更高
但更可靠"] F -->|否| H["粗略估算
最后手段
精度最低"] C --> I["返回精确token数"] E --> I G --> I H --> J["返回估算值
标记为不精确"] style C fill:#ccffcc style E fill:#ffffcc style G fill:#ffcccc style H fill:#ff9999

函数在选择回退模型时需要考虑多个平台约束:

  • Vertex 全局区域:Haiku 不可用,回退到 Sonnet
  • Bedrock + thinking blocks:Haiku 3.5 不支持 thinking,回退到 Sonnet
  • 其他情况:使用 Haiku(成本最低)

这个回退链体现了"优雅降级"的原则:优先使用最佳方案(countTokens API),其次使用低成本方案(Haiku),再次使用高成本但可靠方案(Sonnet),最后使用不精确的本地估算。


12.6 端到端的 Token 预算体系

将上述所有机制组合起来,Claude Code 的 token 预算形成一个多层防御体系:

flowchart TB
    subgraph L1["第1层:单工具结果持久化"]
        direction TB
        L1A["工具执行完成"]
        L1B{"结果 > 阈值?"}
        L1C["持久化到磁盘"]
        L1D["生成2KB预览"]
        L1E["返回替代消息"]
        
        L1A --> L1B
        L1B -->|是| L1C --> L1D --> L1E
        L1B -->|否| L1F["原样返回"]
        
        L1N["阈值计算
min(工具声明, 50K)
或 GrowthBook 覆盖
特例: Read ∞"] end subgraph L2["第2层:单消息聚合预算"] direction TB L2A["API调用前准备"] L2B{"tool_result总量 > 200K?"} L2C["按大小降序排序"] L2D["选择最大的fresh结果"] L2E["持久化直到 ≤ 200K"] L2F["frozen结果不可替换"] L2A --> L2B L2B -->|是| L2C --> L2D --> L2E L2B -->|否| L2G["原样发送"] L2N["状态冻结
seen结果命运不变
确保prompt cache稳定"] end subgraph L3["第3层:上下文窗口追踪"] direction TB L3A["tokenCountWithEstimation()"] L3B["精确usage + 增量估算"] L3C{"超过阈值?"} L3D["触发自动压缩"] L3E["触发微压缩"] L3A --> L3B --> L3C L3C -->|是| L3D L3C -->|接近| L3E L3N["并行工具修正
同ID回溯
避免系统性低估"] end subgraph L4["第4层:事后清理"] direction TB L4A["自动压缩
生成摘要"] L4B["微压缩
移除旧工具结果"] L4C["API原生微压缩
cache_edits"] end L1 -->|"未拦截"| L2 L2 -->|"未拦截"| L3 L3 -->|"超出阈值"| L4 style L1N fill:#ffffcc,stroke:#333,stroke-width:1px style L2N fill:#ccffcc,stroke:#333,stroke-width:1px style L3N fill:#ccccff,stroke:#333,stroke-width:1px

每一层都有明确的职责边界和失败后的降级路径:

  • 第1层失败(持久化磁盘出错)→ 原样返回完整结果,第2层和第4层兜底
  • 第2层的 frozen 结果无法替换 → 接受超额,由第4层的微压缩最终清理
  • 第3层的粗略估算不准确 → 可能导致压缩触发过早或过晚,但不会导致数据丢失

GrowthBook 动态调参

两个核心阈值都可以通过 GrowthBook feature flag 在运行时调整,无需发布新版本:

  • tengu_satin_quoll:单工具持久化阈值的 per-tool 覆盖 map
  • tengu_hawthorn_window:单消息聚合预算的全局覆盖值

这种设计允许团队在不重新部署的情况下:

  1. 快速响应问题:如果发现某个工具的阈值设置不当,可以立即调整
  2. A/B 测试:对不同用户群体设置不同的阈值,比较效果
  3. 渐进式优化:基于实际使用数据逐步调整到最优值

代码对 GrowthBook 返回的值进行了三重检查(typeofisFinite> 0),因为缓存层可能泄漏 nullNaN 或字符串类型的值。这体现了"信任但验证"的原则——即使是不受控的外部数据源,也要进行防御性检查。


12.7 用户能做什么

12.7.1 控制工具输出大小

当你的 grepbash 命令返回大量输出时(超过 50K 字符),结果会被持久化到磁盘,模型只能看到前 2KB 的预览。为了避免这种信息损失,尽量使用更精确的搜索条件:

不好的做法

grep -r "function" src/  # 可能返回80KB结果

好的做法

grep -l "function" src/  # 只列文件名
grep -r "function" src/ | head -n 100  # 限制输出行数

这样模型能看到完整结果,而不是被截断的预览。

12.7.2 注意并行工具调用的累积效应

当模型同时发起多个搜索时,所有结果的聚合大小受 200K 字符限制。如果你要求模型"同时搜索这 10 个关键词",部分结果可能因超出预算而被持久化。

策略1:分批搜索

用户:先搜索这5个关键词,完成后再搜索另外5个

策略2:渐进式搜索

用户:搜索"authentication",分析结果后再决定下一步搜索

这样可以保持每轮结果在预算内,避免部分结果被持久化。

12.7.3 JSON 文件的特殊考量

JSON 文件的 token 密度是普通代码的 2 倍(每 token 约 2 字节 vs 4 字节)。这意味着一个 100KB 的 JSON 文件实际消耗约 50K token,而同等大小的 TypeScript 文件只消耗约 25K token。

实用建议

  • 大型 JSON 配置文件考虑拆分为多个小文件
  • 让模型使用 jq 等工具提取需要的字段,而不是读取整个文件
  • 优先使用 JSON5 或 YAML(注释可以减少 token 密度)

12.7.4 利用 Read 工具的特殊地位

Read 工具的输出永远不会被持久化到磁盘——它通过自己的 maxTokens 参数控制大小。这意味着通过 Read 读取的文件内容始终直接呈现给模型,不会被截断为 2KB 预览。

对比

# 使用 cat:可能被持久化
cat large-file.json

# 使用 Read:始终完整显示(通过 maxTokens 控制)
Read large-file.json

如果你需要模型完整看到某个文件的内容,使用 Read 比 cat 命令更可靠。

12.7.5 关注 Token 计数的粗略估算偏差

Claude Code 在两次 API 调用之间使用粗略估算(字符数 / 4)来追踪上下文大小,偏差可达 ±50%。这意味着自动压缩的触发时机可能早于或晚于预期。

观察到的现象

  • 压缩在 160K token 时触发(估算偏差导致)
  • 压缩在 190K token 时才触发(JSON 文件被低估)

这些都是正常的系统行为,不是 bug。如果你发现压缩时机异常,可以考虑:

  • 清理不必要的工具结果(手动触发微压缩)
  • 重启会话(重置上下文)
  • 调整工作流程,减少大文件的操作

12.8 设计洞察

保守估算 vs 激进估算

Token 预算体系中反复出现的设计取舍是:宁可高估 token 数量,也不要低估

  • JSON 使用 2 字节/token 而非 4,是因为低估会导致超大结果未被持久化
  • 图片使用固定 2,000 token 而非 base64 长度估算,是因为后者会导致灾难性高估(上下文看起来"满"了但其实不满)
  • 并行工具调用的回溯修正,是因为遗漏 tool_result 会导致系统性低估

这些选择体现了一个原则:token 预算是一个安全机制而非优化机制

高估的代价是:

  • 提前触发压缩 → 轻微的性能损失
  • 部分工具结果被持久化 → 可通过 Read 重新获取

低估的代价是:

  • 上下文窗口溢出 → API 调用失败
  • 无法恢复的上下文丢失 → 需要重启会话

显然,后者比前者严重得多。

Prompt Cache 对预算设计的深层影响

消息级别预算的大部分复杂性——三态分区、状态冻结、字节级一致的重新应用——都源于一个外部约束:prompt cache 要求前缀稳定

如果没有 prompt cache,每轮 API 调用前可以自由地重新评估所有工具结果是否需要持久化。但 prompt cache 的存在意味着一旦模型"看到了"某个工具结果的完整内容,后续调用必须继续发送完整内容(否则前缀变化导致缓存失效)。

这个约束将一个本可以是无状态的函数("检查大小,超标则替换")变成了一个有状态的状态机ContentReplacementState),而且状态必须跨越会话恢复(resume)存活——这就是 ContentReplacementRecord 被持久化到 transcript 的原因。

这是一个典型的例子:在 AI Agent 系统中,性能优化(prompt cache)会反向约束功能设计(预算执行),形成意想不到的架构耦合

类似的耦合还有:

  • 流式输出 → 要求增量 token 计数 → 引入估算偏差
  • 并行工具调用 → 消息交错结构 → 要求回溯修正
  • 会话恢复 → 状态持久化 → 增加复杂度

理解这些耦合关系,有助于在设计 AI 系统时做出更好的权衡。

最便宜的 Token 是你从未发送的

整个预算体系的核心哲学是:The cheapest token is the one you never send

与其让大文件进入上下文后再压缩(消耗 LLM 调用),不如在进入前就拦截(持久化到磁盘)。与其让多个工具结果累积后再清理,不如在单轮内就控制总量。

这个哲学的体现:

机制 避免的成本 替代的成本
单工具持久化 避免发送 50K+ 字符 2KB 预览 + 磁盘 I/O
消息级预算 避免累积超过 200K 部分结果持久化
精确计数 避免低估导致溢出 额外的 HTTP 调用(countTokens API)

磁盘 I/O 和额外的 HTTP 调用成本远低于发送大量 token 到 LLM——这不仅是经济成本(API 费用),还包括延迟成本(更大的输入 = 更慢的响应)。


12.9 总结与展望

Token 预算策略是 Claude Code 上下文管理的"入口闸门",它在内容进入上下文窗口之前就控制大小,与"事后诸葛亮"的压缩机制形成互补。

我们探讨了三个层级的防御:

  1. 单工具结果持久化:50K 字符的硬性门槛,支持工具自定义和远程配置
  2. 单消息聚合预算:200K 字符的聚合上限,通过三态分区确保 prompt cache 稳定
  3. Token 计数机制:精确 usage + 粗略估算的混合方法,处理并行工具调用的计数陷阱

这些机制共同构成了一个多层防御体系,既不会过早压缩(保留上下文完整性),也不会溢出窗口(确保 API 调用成功)。

在接下来的章节中,我们将探讨 Prompt Cache——这个已经多次提及的性能优化机制。它不仅是缓存策略,更是整个 Claude Code 架构的设计约束之一。理解了它,你就能真正理解为什么 token 预算要如此复杂,为什么微压缩要区分缓存感知和非感知版本,以及为什么"前缀稳定"成为如此核心的设计原则。


扩展阅读

  • 第8章:自动压缩机制——上下文接近满时的"最后一道防线"
  • 第10章:微压缩技术——精准移除过时工具结果
  • 第13章(预告):Prompt Cache——理解前缀稳定性的深层原因
  • 原始仓库anthropics/anthropic-quickstarts - Claude Code 的源代码
  • 相关规范specs/part3-context-management.spec.md - 上下文管理的完整设计规范

系列目录

  1. 第1章:架构概览
  2. 第2章:Agent 状态机
  3. 第3章:消息流与轮次控制
  4. 第4章:工具系统
  5. 第5章:提示词工程
  6. 第6章:安全与权限
  7. 第7章:系统提示词
  8. 第8章:自动压缩
  9. 第9章:文件状态保留
  10. 第10章:微压缩
  11. 第11章:上下文压缩策略
  12. 第12章:Token 预算策略(本章)
  13. 第13章:Prompt Cache(待发布)

关于作者

本文是《Claude Code Harness》系列的第12章,深入解析 AI 编程助手的工程实践。作者通过逆向工程 Claude Code 的源代码,揭示其设计原则和实现细节。

本系列基于开源项目 harness-engineering-from-cc-to-ai-coding 的研究成果,欢迎贡献和反馈。

Enjoyed this article? Share it with others!