源码解读 · Claude Code

状态栏那行 cache 4m23s,到底准不准?

它怎么算出来的,会不会骗你。

2026-05-29

我给 Claude Code 写的状态栏上有一行 cache 倒计时。绿色,每秒往下走,走到头变成红色的 cache COLD

[██████░░░░] 58% · 🕐2h14m · opus | cache 51m07s

绿 = 还在窗口内 · 黄 = 不足 1 分钟 · 红 cache COLD = 已过期

它有用,是因为缓存命中的那段上下文几乎不占你的 5h / 7d 额度。缓存一凉,下一条 prompt 就得按全价把整个上下文重喂一遍。所以这行字在回答一个很具体的问题:现在还能不能趁热再发一条。

那它准不准?准。但前提是它做对了一件容易做错的事——分清你的缓存窗口是 5 分钟还是 1 小时。下面拆开看。

怎么算的

每次渲染,它读当前会话的 transcript(Claude Code 把每轮对话写成一个 JSONL 文件),从文件尾部往前找最近一条 assistant 记录。这条记录给它两个数:

剩下就是减法:remaining = TTL − age。大于零,格式化成倒计时;小于等于零,COLD

从尾部倒读、而不是整个文件读,是因为 transcript 动辄几 MB,状态栏每秒都要重画一次。读取限制在末尾 320KB(每块 32KB,最多十块)。这段逻辑在 get_cache_age_text

为什么锚最近一条 assistant

因为 Anthropic 的缓存是滑动窗口。官方文档 Prompt caching 的原话:

The cache is refreshed for no additional cost each time the cached content is used.

每命中一次,TTL 重新计时。所以"还能活多久"要从最后一次用到缓存算起,也就是最近一轮回答。每多答一轮,age 归零、倒计时自动续满,跟服务端的行为对上了。

5 分钟还是 1 小时

这一步最容易错。窗口长度不是固定的,取决于你怎么登录 Claude Code(见 官方文档):

所以"5 分钟缓存"这个说法没错——那是 API key 的默认值;订阅用户跑的是 1 小时。同一台机器还会随用量在两者之间切。写死一个数,迟早是错的。

它没写死,也没靠猜。Anthropic 在每一轮的 usage 里把写进缓存的 token 按 TTL 分桶报告,落在哪个桶就是用了哪个 TTL:

真实 transcript · message.usage.cache_creation
{
  "ephemeral_1h_input_tokens": 1421,
  "ephemeral_5m_input_tokens": 0
}

全在 1h 桶。这台机器上 160 个 transcript、Claude Code 从 2.1.1072.1.156、一亿多个 cache-creation token,无一例外落 1h 桶,5m 桶始终是零——和文档说的"订阅自动 1 小时"对得上。读这块的是 _entry_cache_ttl,它和取年龄那步在同一次倒读里完成(_last_assistant_info),拿到的是你这台机器此刻真正在用的 TTL。

插曲:1 小时 → 5 分钟 → 1 小时

顺带一提,这个窗口长度本身就变过,而且其中一次没写进 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 桶。)

唯一够不上秒级的地方

剩下一处偏差,是秒级的。assistanttimestamp 是这一轮写完的时间,而缓存是在请求发出那一刻被服务端刷新的,中间隔着一次回答的耗时:

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,纯性能取舍,日常碰不到。)

一张图看懂

transcript.jsonl 尾部反向读 ≤320KB 最近一条 assistant 记录 timestamp age = 现在 − 时间戳 usage.cache_creation 1h 桶>0 → TTL 3600 5m 桶>0 → 300 · 都没有 → 300 remaining = TTL − age > 0 ≤ 0 cache 51m07s 绿;不足 1 分钟转黄 cache COLD
一次渲染里发生的事:倒读 transcript → 从最近一条 assistant 记录同时取年龄和真实 TTL → 相减 → 出倒计时或 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。