cache 4m23s,到底准不准?它怎么算出来的,会不会骗你。
我给 Claude Code 写的状态栏上有一行 cache 倒计时。绿色,每秒往下走,走到头变成红色的 cache COLD。
绿 = 还在窗口内 · 黄 = 不足 1 分钟 · 红 cache COLD = 已过期
它有用,是因为缓存命中的那段上下文几乎不占你的 5h / 7d 额度。缓存一凉,下一条 prompt 就得按全价把整个上下文重喂一遍。所以这行字在回答一个很具体的问题:现在还能不能趁热再发一条。
那它准不准?准。但前提是它做对了一件容易做错的事——分清你的缓存窗口是 5 分钟还是 1 小时。下面拆开看。
每次渲染,它读当前会话的 transcript(Claude Code 把每轮对话写成一个 JSONL 文件),从文件尾部往前找最近一条 assistant 记录。这条记录给它两个数:
timestamp ——缓存有多旧:age = 现在 − timestamp。message.usage.cache_creation ——缓存能活多久,也就是 TTL。剩下就是减法:remaining = TTL − age。大于零,格式化成倒计时;小于等于零,COLD。
从尾部倒读、而不是整个文件读,是因为 transcript 动辄几 MB,状态栏每秒都要重画一次。读取限制在末尾 320KB(每块 32KB,最多十块)。这段逻辑在 get_cache_age_text。
因为 Anthropic 的缓存是滑动窗口。官方文档 Prompt caching 的原话:
The cache is refreshed for no additional cost each time the cached content is used.
每命中一次,TTL 重新计时。所以"还能活多久"要从最后一次用到缓存算起,也就是最近一轮回答。每多答一轮,age 归零、倒计时自动续满,跟服务端的行为对上了。
这一步最容易错。窗口长度不是固定的,取决于你怎么登录 Claude Code(见 官方文档):
ENABLE_PROMPT_CACHING_1H。所以"5 分钟缓存"这个说法没错——那是 API key 的默认值;订阅用户跑的是 1 小时。同一台机器还会随用量在两者之间切。写死一个数,迟早是错的。
它没写死,也没靠猜。Anthropic 在每一轮的 usage 里把写进缓存的 token 按 TTL 分桶报告,落在哪个桶就是用了哪个 TTL:
{
"ephemeral_1h_input_tokens": 1421,
"ephemeral_5m_input_tokens": 0
}
全在 1h 桶。这台机器上 160 个 transcript、Claude Code 从 2.1.107 到 2.1.156、一亿多个 cache-creation token,无一例外落 1h 桶,5m 桶始终是零——和文档说的"订阅自动 1 小时"对得上。读这块的是 _entry_cache_ttl,它和取年龄那步在同一次倒读里完成(_last_assistant_info),拿到的是你这台机器此刻真正在用的 TTL。
顺带一提,这个窗口长度本身就变过,而且其中一次没写进 changelog——这也是"为什么不能写死"最好的注脚。
| 时间 | 发生了什么 | 来源 |
|---|---|---|
| 2026-02-01 | Claude Code 给订阅用户上 1 小时缓存。 | The Register |
| 2026-03-06 ~ 03-08 | 静默改回 5 分钟,没 changelog、没公告。逐日数据:3/5 还是纯 1h,3/6 冒出 5m,3/8 起 5m 占主导(The Register 记为 3/7)。 | issue #46829 · The Register |
| 2026-04-12 | 有人翻自己的 session 日志发现,开了 issue,附逐日 token 分桶证据。 | issue #46829 |
| 2026-04-13 | 媒体跟进;Anthropic 方面称改回 5 分钟反而更省,因为很多请求是一次性的、缓存只用一次。 | The Register |
| 2026-05-06 (v2.1.129) |
官方修复,恢复 1 小时——changelog 原文:"Fixed 1-hour prompt cache TTL being silently downgraded to 5 minutes"。 | Claude Code changelog |
所以"1h 还是 5m"从来不是定数。写死任何一个,下次调整就错;读 bucket 才能回退也跟得上、修复也跟得上。(这次回退是分批推送的,没覆盖所有账号——本机日志从 4 月 14 日起一直是 1h,上面那 160 个 transcript 因此全落 1h 桶。)
剩下一处偏差,是秒级的。assistant 的 timestamp 是这一轮写完的时间,而缓存是在请求发出那一刻被服务端刷新的,中间隔着一次回答的耗时:
assistant 2026-05-29T04:46:18.432Z
assistant 2026-05-29T04:46:19.653Z
assistant 2026-05-29T04:46:25.680Z
几秒到几十秒,相对 5 分钟、1 小时的窗口可以忽略,方向上略偏乐观。严格说,这个锚点是"精确到一轮"的代理,不是缓存刷新的精确时刻。当提示器够用,当秒表不行。(还有个更小的边角:末尾 320KB 里万一一条 assistant 都没有,会判成 COLD,纯性能取舍,日常碰不到。)
| 场景 | 判定 | 说明 |
|---|---|---|
| 5 分钟 / 1 小时缓存 | 准 | TTL 直接读这一刻真正在用的值;锚点对、滑动窗口对,时钟回拨、naive 时间戳、Z 后缀这些边界也都处理了。 |
| 想要秒级精度 | 别指望 | 锚点有一轮时延的代理误差。它是"还剩几分钟"量级的提示器,不是计时器。 |
它回答的是"要不要趁热再发一条",这个问题答得准。当秒表用,就是用错了。
这套逻辑跟语言、跟项目无关。想在自己的状态栏、插件或脚本里复刻一个缓存倒计时,把下面这段贴给 AI,让它用你的语言实现就行:
# prompt-cache 倒计时。输入:当前会话 transcript(JSONL)的路径。
# 三个要点:① 锚最近一条 assistant(不是用户消息、不是文件 mtime);
# ② TTL 读 cache_creation 桶,别写死;③ 倒读限长,别整文件读。
function cacheCountdown(transcriptPath):
age = null # 秒,来自最近一条 assistant 的 timestamp
ttl = null # 秒,来自最近一条「写了缓存」的 assistant
# 从文件尾部反向读,最多 320KB(32KB 一块);一遍同时拿 age 和 ttl
for entry in reversedJsonlEntries(transcriptPath, maxBytes = 320 * 1024):
if entry.type != "assistant": continue
if age is null and entry.timestamp:
age = now() - parseTimeUTC(entry.timestamp) # 朴素时间戳当 UTC
if ttl is null:
cc = entry.message.usage.cache_creation
if cc.ephemeral_1h_input_tokens > 0: ttl = 3600
elif cc.ephemeral_5m_input_tokens > 0: ttl = 300
if age is not null and ttl is not null: break # 两样都拿到,收工
if age is null: return "COLD" # 没有 assistant 记录
if ttl is null: ttl = 300 # 没有写缓存信号 → 保守兜底
if age < 0: age = 0 # 时钟回拨 / 未来时间戳,钳到 0
remaining = ttl - age
if remaining <= 0: return "COLD"
return formatCountdown(ceil(remaining))
# 始终带秒:数字每秒在走,一眼能看出是活的、不是卡住
function formatCountdown(s):
if s >= 3600: return "{h}h{mm}m{ss}s" # 1h59m03s
if s >= 60: return "{m}m{ss}s" # 58m23s / 4m07s
return "{s}s" # 47s(<1min)
分桶字段是 Anthropic 在 message.usage.cache_creation 里给的标准字段,任何走 Claude API 的项目都能读到,不限于 Claude Code。
仅管理员可见。点击后将物理删除该文章内容(含跨语种联删规则)。