Claude Code 账单里那 22%,不是装个工具能省掉的
一个开源项目的默认配置里写着 270 秒。
不是 300,不是 240,就是 270。换算过来 4 分 30 秒,而 Claude 的提示缓存活 5 分钟。这个数不是随手拍的,它在跟一个倒计时赛跑,赶在缓存断气前 30 秒把它捞回来。
项目叫 claude-thermos,昨天在 Hacker News 上挂着。MIT 协议,一百多个 star,作者叫 izeigerman。它干的事一句话说得清,在本地起一个反向代理挡在 Claude Code 和 Anthropic 之间,盯着你的会话,一旦发现主 agent 在干等子 agent、而且等超过 4 分半,就替你偷偷发一个请求过去,把缓存续上。
真正让这个项目上首页的,是 README 里的一个数字。作者在自己大约 185 次本地会话上量了一遍,缓存过期导致的重新编码,占账单总额约 22%。
两成多。不是模型在思考,不是工具在跑,是你的 agent 在等另一个 agent 的时候,缓存自己凉了,回来得重新付一遍钱。
好家伙。
我看到这个数第一反应是想装。第二反应是,等一下,这 22% 到底算个什么东西。它真是「浪费」吗,还是说它压根就是这套架构自带的成本,只是以前没人把它单独拎出来称过重。
想清楚这件事,比装不装那个代理重要。顺着它往下扒,你会发现 Anthropic 其实早给了原生开关,而且还有几个坑,那个代理压根堵不上。
要理解 22%,得先知道提示缓存的钱是怎么收的。Anthropic 的官方文档里三条规则,你记住这三条,后面全通。
第一条,读缓存大概是基础输入价的 0.1 倍。同样一段上下文,命中缓存只要一折。
第二条,写缓存要 1.25 倍(5 分钟 TTL),或者 2 倍(1 小时 TTL)。注意写是要溢价的,你不是白存的。
第三条最狠,缓存是前缀字节级匹配。缓存键由渲染后 prompt 的精确字节决定,一路算到你打的每一个 cache_control 断点。前缀里任何一个字节变了,它后面的所有断点全部失效。渲染顺序是固定的,tools 先,然后 system,最后 messages。
三条摆在一起,那个 22% 就说得通了。
一次长会话,几万 token 的历史挂在那儿。主 agent 派了几个子 agent 出去干活,自己闲着。子 agent 跑了六分钟回来,主 agent 一说话,缓存早没了。这几万 token 不是按 0.1 倍读进去的,是按 1.25 倍重新写一遍。中间差了 12.5 倍。
thermos 的做法就是在这 6 分钟里插一脚。它把主 agent 的最后一个请求原样重放,max_tokens 设成 1,输出直接扔掉。相同的可缓存前缀,走的是读路径,0.1 倍。用一次一折的读,换掉一次一倍二五的写。

回本点这块很多人算错。 5 分钟 TTL 的缓存,两次请求就回本,1.25 加 0.1 等于 1.35,对比不缓存的 2 倍,赚。但 1 小时 TTL 是 2 倍写,2 加 0.2 等于 2.2,得至少三次请求才追平 3 倍。你要是一个前缀只用两次就换了,开 1 小时 TTL 是往里亏钱的。
所以,这 22% 算浪费吗。
我一直觉得,把一笔钱叫做「浪费」,前提是它可以在不改变别的东西的情况下被拿掉。
这 22% 不是。
Anthropic 把默认 TTL 定成 5 分钟,是有它的道理的,缓存在服务端占着实打实的资源。而多 agent 架构的空闲窗口天生就比 5 分钟长,一个子 agent 去读十几个文件、跑一遍测试、再总结回来,六七分钟是常态。
一边是 5 分钟的保质期,一边是 6 分钟的等待。这俩数从设计的第一天就对不上。
所以更准确的说法是,那 22% 是结构性错配的账单,不是谁写代码写漏了。thermos 做的事,是在这个错配之上加一层缓冲垫,让你不用改架构也能少疼一点。这是个好东西,但它的存在本身在提醒你,你的工作流和这套计费模型没对齐。
而对齐这件事,官方其实给了两个原生开关。
开关一,把 TTL 拉到一小时。一行的事。
"cache_control": {"type": "ephemeral", "ttl": "1h"}
6 分钟的空闲窗口,在 1 小时 TTL 面前根本不算事,缓存稳稳活着,thermos 那套保温逻辑直接失业。
代价上面说了,写入从 1.25 倍涨到 2 倍,回本点从两次变成三次。所以它适合的场景很明确,前缀大、复用次数多、但请求之间有长空档。典型就是长跑的 agent 会话,几万 token 的系统提示加工具定义,一整天要用几十次,中间穿插各种等待。多付 0.75 倍的写,换掉一堆重建,划算得很。
反过来,如果你的前缀只复用两三次,或者流量本来就密集(请求间隔一直小于 5 分钟),老老实实用默认 TTL。密集流量下真实请求自己就把缓存热着了,你根本不需要额外操作。
开关二,官方的预热姿势是 max_tokens: 0。
这个比较有意思,因为 thermos 用的是 max_tokens: 1。
max_tokens: 1 是个老变通做法,让模型吐一个 token 然后你扔掉。而 Anthropic 现在支持的一等写法是 max_tokens: 0。API 会跑完 prefill,在你的 cache_control 断点处把缓存写好,然后立刻返回 content: []、stop_reason 是 max_tokens,以及一个填好的 usage。输出 token 计 0,cache_creation_input_tokens 上正常收写入费。
差别不大,但方向对了。没有单 token 回复要丢弃,不计输出 token,意图也不含糊。
这里有个坑,断点位置。cache_control 要打在和真实请求共享的最后一个 block 上,通常是 system prompt 或者 tool 定义。不能打在那个占位的 user message 上,也不要图省事用顶层自动缓存,那会把缓存键绑到占位消息上,等真实请求来了根本对不上。占位内容随便写个非空白字符串就行,prefill 会读它,但永远不会回答它。
还有几个组合是直接报 invalid_request_error 的,max_tokens: 0 不能配 stream: true、不能配 thinking.type 为 enabled、不能配 output_config.format、不能配 tool_choice 为 tool 或 any,也不能塞进 Batches 请求。
但预热不是免费午餐。 官方文档里有一段我觉得比预热本身更值钱,它明说了什么时候不该预热。
流量连续(请求间隔小于等于 TTL)的时候不该预热,第一个真实请求就把缓存热了,你单独发那次纯粹是多付一次写。前缀每个用户都不一样的时候不该预热,压根没有可共享的东西。投机式地预热一堆不同前缀更不该,每个都是 1.25 倍的写,很容易超过你省下的那点延迟。
至于定时重新预热,只有在流量间隔长于 TTL 的时候才需要。真实请求比每 5 分钟更频繁,它们自己就把缓存热着了,你加个定时器纯属自嗨。
聊到这儿,thermos 的定位其实清楚了,它解决的是「主 agent 空闲超时」这一种特定形态的缓存流失。
但多 agent 场景下还有几个更隐蔽的,它够不着。
第一个是 20 block 回看窗口。
每个断点向后最多回看 20 个 content block 去找已有的缓存条目。听着挺技术,落到实处很吓人,agentic loop 里 tool_use 和 tool_result 是成对出现的,一个 turn 里模型连着调十几次工具,四十个 block 轻轻松松就出去了。
一旦单个 turn 加的 block 超过 20 个,下一个请求的断点就找不到上一次的缓存。静默 miss,没有报错,没有警告,你只会在账单上看到一个不明所以的凸起。
修法是在长 turn 里每约 15 个 block 插一个中间断点,每个请求最多 4 个断点,得省着用。或者反过来,用上下文编辑把陈旧的 tool result 清掉,让 turn 本身就别那么长。
这个坑跟空闲无关,thermos 保温保得再好也白搭,因为缓存不是过期没的,是根本没被找到。
第二个是并发扇出的时序陷阱。
缓存条目只有在第一个响应开始流式返回之后才可读。你派 5 个前缀相同的子 agent 同时出去,这 5 个请求全部付全价,一个都读不到,因为它们在互相等对方写完。
而并行扇出恰恰是多 agent 架构最常见的姿势。
正确姿势是先发 1 个请求,等到第一个 streamed token(不是等完整响应),再把剩下的 N 减 1 个放出去。多等那么一两百毫秒,换回来的是 N 减 1 份的一折价。
第三个是静默失效因素,这个是你自己代码里的。
下面这几条,你今天就可以去 grep 一遍自己的项目,命中一条就是一次全量重建。
system prompt 里塞了 datetime.now() 或者 Date.now(),每次请求前缀都变。
早期内容里有 uuid4() 或者 request ID,同理,每个请求都是唯一的。
json.dumps(d) 没加 sort_keys=True,或者直接迭代一个 set,序列化不确定,前缀字节就不同。
f-string 把 session ID 或者 user ID 插进 system prompt,每个用户一份前缀,跨用户什么都不共享。
条件性 system 段落(if flag: system += ...),每种 flag 组合都是一份独立前缀。
tools=build_tools(user) 工具集跟着用户变,tools 渲染在位置 0,那就是彻底没得救了。
这些跟 TTL 一点关系都没有,你把 TTL 拉到一年也救不回来。
还有一件事跟直觉反着来。
改哪些东西会废掉缓存,我猜大部分人的默认假设是「改啥都得重建」。
不是的。API 有三层缓存,改动只废掉自己那层和下层。
改 tool_choice、传图片、开关 thinking,tools 和 system 的缓存都还在,只有 messages 那层没了。
改 system prompt 内容,tools 缓存还在。
真正会全量重建的只有两件事,改工具定义(增删换序),和切模型。
这个结论挺有用的。它意味着你可以放心地每次请求改 tool_choice,可以按需开关 thinking,不用担心把几万 token 的前缀全废掉。很多人为了保缓存把这些参数写死,其实是白白牺牲了灵活性。
顺着这个往下,会话中途要改 system prompt 怎么办?改顶层 system 会把整段历史前面的前缀全动了,所有缓存重来。正确姿势是往 messages[] 里追加一条 {"role": "system", ...} 消息(Opus 5、Opus 4.8、Fable 5、Mythos 5 支持,不用 beta header)。它坐在历史后面,缓存前缀纹丝不动,而且模型会把它当作 operator 权限的指令,不是普通用户文本。
工具集要中途变呢?别直接改 tools 数组。用 tool search 做动态发现,它是追加 schema 而不是替换,前缀保得住。或者上 Opus 5 起支持的 mid-conversation-tool-changes-2026-07-01,用 tool_addition 和 tool_removal block。
要切便宜模型省钱呢?缓存是按模型隔离的,主循环切一次模型,整个缓存作废。主循环保持单一模型,需要便宜模型时派生一个 subagent 去做子任务,这才是对的。
顺便说个容易踩的模型差异。最小可缓存长度,低于这个数静默不缓存,不报错。
而且这个数不是随代次单调下降的。

Opus 5 是 512,Opus 4.8 和 Sonnet 5 是 1024,Opus 4.7 是 2048,而 Opus 4.6 和 Haiku 4.5 是 4096。厉害了,越老的模型门槛反而越高。
一个 3K token 的 prompt,在 Opus 5 上缓存得好好的,切到 Opus 4.6 上直接不缓存了,你还看不到任何提示。模型总览页上这些参数都能查到。
好消息是 Opus 5 把门槛从 1024 砍到了 512,以前那些「太短所以缓存不了」的 prompt,现在不用改代码就能进缓存。
绕了这么大一圈,回到最开始那个问题,你该不该装 thermos。
我的答案是,先别急着装,先去看一眼你的 usage。
Anthropic 每个响应的 usage 里有三个字段。
cache_creation_input_tokens 是这次写进缓存的量,付了 1.25 倍溢价。
cache_read_input_tokens 是这次从缓存读的量,付 0.1 倍。
input_tokens 是全价处理的量。
这里有个特别容易搞错的地方,input_tokens 只是「未缓存的剩余部分」,不是 prompt 总长。prompt 总大小得三个加起来。一个 agent 跑了几小时,input_tokens 只显示 4K,别慌,剩下的都从缓存来了。
自查逻辑就一句话。把一段时间内的 cache_creation_input_tokens 和 cache_read_input_tokens 拉出来比一比。
读远大于写,恭喜,你的缓存工作得很好,thermos 装了也就是锦上添花,甚至它自己发的那些保温请求还要占你一点钱。
写的占比明显偏高,尤其是那种「一段长历史被反复重新写入」的形态,那就对上了,你确实在付那 22%。
写和读都很小、input_tokens 一枝独秀,那你的问题根本不在 TTL,回去看上面那个静默失效清单,多半是 system prompt 里插了个时间戳。
顺手提一句,别用 tiktoken 去估 Claude 的 token 数,那是 OpenAI 的分词器,在普通文本上就低估 15% 到 20%,代码和中文只会更离谱。用官方的 count_tokens 接口,client.messages.count_tokens(model=..., messages=[...]),几行的事。
坦率讲,我对这类「本地代理帮你省钱」的项目一向有点戒心。多一层代理就多一层出问题的地方,--upstream 配错指到 loopback 就是个自转发死循环(作者自己也在 README 里挡了这个),VSCode 扩展还得记得继承 ANTHROPIC_BASE_URL。这些都是要还的技术债。
但我觉得 izeigerman 这个项目最大的价值不在代码,在那个 22%。
在他把这个数量出来之前,绝大多数人不知道自己的账单里有这么一块。你看到的只是月底一个偏高的数字,然后归因给「最近用得多」。有人拿 185 次会话把它称出来了,这件事本身就有点子牛逼。
至于怎么处理,选择比工具多。TTL 拉到一小时是一种,max_tokens: 0 定时预热是一种,重新排一下并行扇出的发起顺序是一种,把长 turn 切成几段插断点也是一种。thermos 是其中一种,而且是最不用动自己代码的那一种。
但前提是,你得先知道自己在为什么付钱。
那个 270 秒的默认值挺好的,它诚实地承认了自己在跟一个 5 分钟的倒计时赛跑。工程里大部分让人头疼的账,都长这样,不是谁犯了错,是两个各自都合理的设计凑在一起,中间漏了条缝。
看得见缝,才谈得上补。