site logo

Marico's space

Claude Code 生产成本控制:Token 预算、缓存策略与计费面板隐藏的秘密

前端技术 2026-07-27 14:52:56 7

最近在生产环境里跑 Claude Code,发现这玩意儿的成本控制比我想象中复杂得多。大部分费用超支都来自看不见的上下文堆积和缓存失效,而计费面板压根不显示这些。等发现账单的时候,往往已经多烧了好几万 tokens。 这篇文章说说怎么在请求级别硬性限制 token 预算,怎么让 prompt 缓存真正省到钱,以及生产环境中那些计费面板不会告诉你的隐藏成本。实测下来,做好这几点,账单能降一大截。

核心思路很简单:在 API 调用前就卡死预算,而不是等账单来了再救火。实现上分三块:硬性 token 预算、前缀缓存策略、以及支持压缩的上下文管理器。下面逐个说。

核心要点

  • Token 预算必须在请求级别硬性执行,在 API 调用前就卡住,事后监控只会让成本在会话间累积。
  • Prompt 缓存只有在缓存命中率超过失效开销时才能省钱,TTL 设置不合理的缓存策略反而比冷读更贵。
  • 计费面板只显示"输入 tokens"和"缓存 tokens",但不会告诉你会话级别的上下文增长曲线和缓存失效级联。
  • 生产环境需要预处理钩子在到达阈值前截断上下文,模型选择门控阻止高价调用,以及提前触发的告警阈值。
  • 上下文管理器在固定时间间隔对会话历史做摘要压缩,能防止 token 膨胀同时保持 agent 连续性,代价是长会话的准确度下降。

理解 Token 预算:硬性限制又不打断 agent 工作流

Token 预算本质上是个断路器,防止单次请求把额度烧光。大多数 Claude Code 成本爆炸都发生在多轮对话中——每轮交互都会往会话历史里追加消息、工具返回和思考 tokens,没有上限的话输入 token 数会指数级飙升。

软预算和硬预算的区别很关键。软预算只在超限时记录警告,但仍然放行请求;硬预算则直接拒绝调用或在发送前截断上下文。生产环境必须用硬预算,因为警告攒着攒着就超支了——开发者无视了五条"高 token 使用"警告,月底账单可能就是 50 次调用,每次烧了 10 万 tokens 全价。

实现方式是在 API 边界前计算 token 数。Claude Code 的 SDK 没内置 tokenizer,所以生产系统一般用字符长度估算(英文文本约 4 字符 ≈ 1 token),或者调用轻量级 tokenizer 库。权衡很明显——估算精度差(代码密集型上下文容易低估),tokenizer 则增加延迟。但两者都比无上限烧钱强。

硬预算实现超限时直接抛错或截断最早的消息。截断保留最近上下文、丢弃历史记录,这样 agent 还能继续跑,代价是丢失之前的对话线索。另一个方案是摘要压缩,把旧消息压成精简 prompt,但这本身也是一次额外调用、同样消耗 tokens。对于成本敏感场景,截断更划算。

TypeScript 实现 Token 预算守卫

生产级的 token 预算守卫包装 Claude API 客户端,在调用前检查 token 使用量,强制执行单次请求上限和会话累计上限,确保单次调用不超限、多轮对话不会漂移到无上限区域。

下面的实现用了简单的字符数估算,超过预算时截断消息数组:

interface TokenBudgetConfig { maxTokensPerRequest: number; maxTokensPerSession: number; estimateRatio: number; // characters per token, default 4
} class TokenBudgetGuard { private sessionTokens = 0; constructor(private config: TokenBudgetConfig) {} estimateTokens(text: string): number { return Math.ceil(text.length / this.config.estimateRatio); } enforceRequestBudget(messages: Array<{ role: string; content: string }>): Array<{ role: string; content: string }> { let totalTokens = 0; const estimatedMessages = messages.map(msg => ({ ...msg, estimatedTokens: this.estimateTokens(msg.content) })); totalTokens = estimatedMessages.reduce((sum, msg) => sum + msg.estimatedTokens, 0); if (totalTokens > this.config.maxTokensPerRequest) { // Truncate oldest messages until under budget const truncated = [...estimatedMessages]; while (totalTokens > this.config.maxTokensPerRequest && truncated.length > 1) { const removed = truncated.shift()!; totalTokens -= removed.estimatedTokens; } console.warn(`Token budget exceeded, truncated ${estimatedMessages.length - truncated.length} messages`); return truncated.map(({ estimatedTokens, ...msg }) => msg); } return messages; } enforceSessionBudget(requestTokens: number): void { this.sessionTokens += requestTokens; if (this.sessionTokens > this.config.maxTokensPerSession) { throw new Error( `Session token budget exhausted: ${this.sessionTokens}/${this.config.maxTokensPerSession}` ); } } resetSession(): void { this.sessionTokens = 0; }
} // Usage in a Claude Code workflow
const budgetGuard = new TokenBudgetGuard({ maxTokensPerRequest: 50000, maxTokensPerSession: 200000, estimateRatio: 4
}); async function sendClaudeRequest(messages: Array<{ role: string; content: string }>) { const truncatedMessages = budgetGuard.enforceRequestBudget(messages); const requestTokens = truncatedMessages.reduce( (sum, msg) => sum + budgetGuard.estimateTokens(msg.content), 0 ); budgetGuard.enforceSessionBudget(requestTokens); // Proceed with API call using truncatedMessages // const response = await claudeClient.messages.create({ messages: truncatedMessages, ... });
}

这个模式同时约束单次请求和会话累计限制。enforceRequestBudget 从最早的消息开始截断,保留最近上下文。enforceSessionBudget 在会话总计超限时直接抛错,强制调用方重置或终止对话。生产系统可以替换成 js-tiktoken 这类真实 tokenizer,或者等 Anthropic 官方 tokenizer API 出来后接入。

一个隐蔽的坑:如果估算方法低估了 token 数,API 调用实际消耗的比预算多,成本静默累积。保险做法是把系数设保守一点(比如用 3 字符/token 而不是 4),然后在拿到真实账单数据后对比修正。

Prompt 缓存策略:实际多轮会话中的缓存命中 vs 冷读

Prompt 缓存通过跨 API 调用复用已处理的上下文来降低成本。Claude Code 对缓存的输入 tokens 收取低价——截至 2026 年,缓存 tokens 成本约为冷读输入 tokens 的 10%。这意味着:5 万 tokens 命中一次缓存就能省下 90% 的输入成本,但一旦缓存失效变成冷读,这笔节省就清零了。

缓存机制基于前缀匹配。Claude 会缓存消息数组的最长公共前缀,所以如果调用 A 发送了 [system, user1, assistant1],调用 B 发送了 [system, user1, assistant1, user2],那么前三条消息命中缓存,只有 user2 按冷读计费。缓存默认有效期 5 分钟,所以一次多轮对话如果在这个时间窗口内完成,缓存命中最多。

失效模式在于缓存失效会在会话间级联。如果系统 prompt 在对话中途改了,整个前缀失效,后续每次调用都变成冷读。同理,如果预处理钩子打乱了工具返回的顺序,也会导致缓存未命中。成本差异很显著:10 次调用的会话如果缓存稳定,后 9 次的输入成本只有第一次的 10%;但如果 10 次全是冷读,成本就是 10 倍。

生产环境的缓存策略要遵循这些规则:

  1. 系统 prompt 必须稳定:会话期间禁止修改 system 消息。跨会话更新 prompt 版本没问题,但同一次会话内编辑就会破坏缓存。
  2. 消息数组只追加不修改:新消息必须 append 到末尾,禁止 reorder 或编辑已有消息。
  3. 感知 TTL:追踪缓存过期时间,超过 5 分钟窗口的会话直接终止重置,强制从新缓存开始。
  4. 工具结果批量返回:如果工作流要调用多个工具,把结果打包成一条消息返回,而不是逐条追加,否则会把缓存切碎。

开发和生产环境对缓存的影响差异很大。开发阶段往往频繁改 prompt 迭代,缓存命中率本来就低,但因为消息量小所以成本也低。生产环境 prompt 稳定、调用频率高,缓存能省很多钱——但前提是架构上得保证前缀稳定性。

给 Claude Code 构建一个感知成本的上下文管理器

感知成本的上下文管理器封装会话历史,管理 token 追踪、缓存规则,并在预算接近上限时压缩或截断上下文。这个管理器是会话状态的单一数据源,防止随意修改消息数组导致缓存失效或超出预算。

核心职责:

  • Token 追踪:估算或精确计算每条消息的 token 数并维护累计计数。
  • 缓存稳定性:强制只追加语义,检测会触发缓存失效的修改操作。
  • 压缩触发器:token 数超阈值时执行摘要或截断。
  • 预算执行:拒绝会导致单次请求或会话超限的消息添加。

下面是 TypeScript 实现:

interface Message { role: 'system' | 'user' | 'assistant'; content: string;
} interface ContextManagerConfig { maxTokensPerSession: number; compressionThreshold: number; // trigger compression at this token count estimateRatio: number;
} class CostAwareContextManager { private messages: Message[] = []; private totalTokens = 0; constructor(private config: ContextManagerConfig) {} private estimateTokens(text: string): number { return Math.ceil(text.length / this.config.estimateRatio); } addMessage(message: Message): void { const tokens = this.estimateTokens(message.content); if (this.totalTokens + tokens > this.config.maxTokensPerSession) { throw new Error( `Adding message would exceed session budget: ${this.totalTokens + tokens}/${this.config.maxTokensPerSession}` ); } this.messages.push(message); this.totalTokens += tokens; if (this.totalTokens >= this.config.compressionThreshold) { this.compress(); } } private compress(): void { // Summarize older messages to reduce token count // This example truncates, but production systems use an LLM call to summarize const keepRecent = 3; // keep last 3 messages for continuity const toCompress = this.messages.slice(0, -keepRecent); if (toCompress.length === 0) return; const summary = `[Summarized ${toCompress.length} earlier messages: conversation history compressed to preserve context within token budget]`; const summaryTokens = this.estimateTokens(summary