外观
Token 账单里那些看不见的钱
约 1016 字大约 3 分钟
2026-09-07
月底财务发消息问我,这个模型接口这个月花了 $1,847,是不是哪里配错了。我打开控制台看了一眼调用量,心里咯噔一下——按我当初的估算,这个量级应该在 $300 上下。
差出来的一千五百刀,全在缓存上。
先看那个最该看的字段
排查只用了一个指标。Anthropic 的每次响应里都有三个 token 计数:
Usage usage = response.usage();
log.info("input={} cacheWrite={} cacheRead={}",
usage.inputTokens(),
usage.cacheCreationInputTokens(),
usage.cacheReadInputTokens());我们的日志里,cacheReadInputTokens 从上线到那天,一次都没有大于 0。cacheCreationInputTokens 倒是每次都满额——也就是说,每一次请求都在老老实实地写缓存,然后一次都没读到过。
写缓存不是免费的,是 1.25 倍的输入价。Sonnet 4.6 的输入是 $3/MTok,写缓存就是 $3.75/MTok。我们不但没省钱,还比不开缓存多付了 25%。
罪魁祸首是一行「很合理」的代码
缓存是前缀匹配:渲染顺序是 tools → system → messages,从头开始逐字节比对,任何一个字节不一样,从那个位置往后全部作废。
而我们的 system prompt 第一行是这么拼的:
String system = "当前时间:" + LocalDateTime.now() + "\n\n" + KNOWLEDGE_BASE;当时加这行是为了让模型回答「今天是几号」。代价是:整个 system prompt 的第一个字节之后全变了,后面挂着的 6k token 知识库,每一次都是全新的前缀,永远命中不了。
改法没什么技术含量——把时间挪到最后,让稳定内容排在前面:
MessageCreateParams params = MessageCreateParams.builder()
.model("claude-sonnet-4-6")
.maxTokens(2048L)
.systemOfTextBlockParams(List.of(
TextBlockParam.builder()
.text(KNOWLEDGE_BASE) // 固定不变,放最前
.cacheControl(CacheControlEphemeral.builder().build())
.build()))
.addUserMessage("当前时间:" + LocalDateTime.now() + "\n" + question)
.build();上线第二天,cacheReadInputTokens 稳定在 6k 左右,日均成本从 $61 掉到 $9。
几个没人提前告诉我的数字
- 缓存读是 0.1 倍输入价,写是 1.25 倍(5 分钟 TTL)、2 倍(1 小时 TTL)。
- 5 分钟 TTL 下,同一前缀命中两次就回本(1.25 + 0.1 = 1.35,比两次全价的 2.0 划算);1 小时 TTL 要三次才回本。
- 有最小可缓存前缀门槛,而且不单调:Sonnet 4.6 是 1024 token,Haiku 4.5 反而要 4096。低于门槛不报错,只是
cacheCreationInputTokens永远是 0——我在 Haiku 上白试了半天。 - 缓存是按模型隔离的。主流程和摘要子任务用不同模型,两边各写各的缓存,谁也读不到谁。
输出 token 才是大头
第二笔冤枉钱在输出侧。Sonnet 4.6 输入 $3/MTok、输出 $15/MTok,输出是输入的 5 倍价。我们花了两周精简 prompt,把 system 从 8k 压到 6k,省下来的还没有一个「请详细说明」的按钮费钱。
后来加了两条硬约束:maxTokens 一律显式设死;prompt 里明确写清输出长度上限。摘要接口从平均 1400 输出 token 降到 420,这一项省的钱比缓存还多。
请求发出去之前想知道要花多少,有个免费的接口可以用,只算输入不算输出:
long inputTokens = anthropic.messages().countTokens(
MessageCountTokensParams.builder()
.model("claude-sonnet-4-6")
.addUserMessage(prompt)
.build()
).inputTokens();我把它挂在了批量任务的入口,超过阈值直接拒绝,免得有人一次性丢进来一个 300 页的 PDF。
现在的做法
- 每次响应把三个 usage 字段打进日志,按天聚合出命中率;
- 加了条集成测试:连发两次相同前缀的请求,断言第二次
cacheReadInputTokens > 0。缓存失效是静默的,不报错、不告警,只是账单变贵,不写测试守着,下次有人往 system prompt 里加个 feature flag 又会重来一遍; - 凡是拼进 system prompt 的东西,先问一句「它每次请求都一样吗」。
那次事故之后我才真正理解一句话:prompt 缓存不是一个开关,是一种代码结构约束——稳定的在前,变化的在后。顺序错了,加多少 cacheControl 都没用。