技术备忘 · 缓存与保活

别让缓存睡着

如果你在用 Claude Code(不是裸调 API),缓存有效期不是你选的,是它替你选的——而且它会在你毫无察觉的情况下,把档位从一小时悄悄换成五分钟。这篇讲清楚它怎么选、什么时候会偷偷换挡、以及怎么一条命令看穿它现在在哪一档。

自动档
订阅 + 套餐内用量 = 主对话自动 1 小时缓存
5分钟
一旦超额进入"额外用量",静默降档
1条命令
自查现在到底在哪一档

它不是你以为的"开关"

Anthropic 的 API 本身确实是两档:默认 5 分钟,想要 1 小时得自己在请求里加 cache_control: {ttl: "1h"}——这部分官方文档写得很清楚,不是秘密。但如果你用的是 Claude Code 这个客户端,情况不一样:它替你做了这个选择,而且这个选择会随你的账单状态自动变化。

请求类型Claude 订阅・套餐内额外用量 / API key / 云厂商
主对话1 小时5 分钟
子代理 / 工作流 / 压缩摘要等5 分钟(少数服务端固定请求例外)5 分钟

来源:Claude Code 官方文档「Cache lifetime」一节。子代理天生就在 5 分钟档,跟主对话是两件独立的事。

档位对了,内容也得长一样 背景知识

缓存匹配的是请求最前面那一段的精确内容,不是"大致相似"。哪怕字数、语义完全没变,顺序变一点,整段就被判成新的前缀,从这里往后全部重算。Claude Code 自己内部按这个顺序组织每次请求:

层内容什么时候会变
系统提示核心指令、工具定义、输出风格加载的工具集变了,或 Claude Code 升级了
项目上下文CLAUDE.md、自动记忆会话开始时,或 /clear、/compact 之后
对话历史你的消息、回复、工具结果每一轮

越稳定的东西排得越靠前,只在最后面往后追加——这是设计上刻意为之的,不是碰运气对齐的。真正会把这个顺序打乱、导致中途失效的,官方文档点名的是这几件事:切换模型、改变推理强度、开关 fast mode、连接或断开一个把工具定义直接放进最前面那层的 MCP server、把某个工具整个拒绝掉、以及升级 Claude Code 版本。

这跟下面排查清单里"经过中转站被重新排序"不是一回事——这里说的是直连也存在的敏感点,那边说的是第三方网关把本来排好的顺序又打乱了一次。

真正该记住的那句话 这才是坑

静默降档

一旦你的订阅用超了套餐额度、开始扣"额外用量"的钱,Claude Code 会把主对话的缓存从 1 小时悄悄降回 5 分钟——没有提示,没有警告,界面上不会告诉你"你现在换挡了"。你只会感觉到:同样的使用节奏,突然变贵了、变慢了、"感觉冷了"。

这解释了一类常见但很难查的现象:保活脚本明明按 1 小时的节奏写的,却时灵时不灵。如果账号已经进入额外用量状态,地基已经从 1 小时变成 5 分钟了——不管保活代码写得多精确,都是在拿 1 小时的尺子量一个已经变成 5 分钟的东西,天然对不上。排查保活问题时,这应该是第一件要确认的事,而不是最后一件。

怎么自查现在在哪一档

不用猜,官方给了直接读数的办法。

claude -p "hello" --output-format json

看返回结果里 usage.cache_creation 这个字段:

字段含义
ephemeral_1h_input_tokens这次写入用的是 1 小时 档
ephemeral_5m_input_tokens这次写入用的是 5 分钟 档

日常用不想每次都手动查的话,交互模式里跑 /usage,Session 里会有一行 Prompt cache (main),直接告诉你命中率、失手次数、缓存现在是不是热的(需要 Claude Code v2.1.251 及以上)。

想强制锁在 1 小时怎么办

如果你不想被账单状态牵着走,可以自己钉死:设置 promptCacheTtl(或环境变量 CLAUDE_CODE_PROMPT_CACHE_TTL),值填 "1h"。子代理/工作流那一类请求同理,对应的是 subagentPromptCacheTtl。这两个都要求 Claude Code v2.1.242 及以上版本。

保活在防什么 你可能还是要写它

就算稳稳待在 1 小时档,缓存也会在没人碰它的情况下过期。如果对话安静超过一小时,下一句话就要把系统提示、工具定义、历史全部重新算一遍钱——又贵又慢,还会明显感觉出"换了个人在说话"。保活就是在这扇窗关上之前,替你悄悄敲一下门。

0 分上一次真实互动
50 分保活探测触发
60 分缓存过期线(仅 1 小时档适用)

留 10 分钟余量,是为了吸收网络延迟和进程调度的抖动——掐着 59 分钟发,任何一点延迟都会错过窗口。如果你其实在 5 分钟档,这条时间线要整体按比例缩小,不是套用同一组数字。

探测本身长什么样

关键的一步:这一条必须真的作为一轮消息,发进那个正在续着的同一个会话,而不是发去一个独立的健康检查接口。缓存续命的本质是"这个会话又被处理了一次请求"——不是"我 ping 通了服务器"。

{
  "role": "user",
  "content": [{
    "type": "text",
    // 明确告诉模型这不是真人在说话,别当真回应
    "text": "【系统】这不是用户发的话,是保活探测,只是为了不让缓存过期。不用理解内容、不用做任何事,回一个句号「。」就行,别的都不要写。"
  }]
}

它要真的走一轮完整的请求-响应,模型也要真的回一个字——这才算"这个会话被处理过一次"。只是把请求丢过去、不等它处理完,或者进程当时其实已经挂了,保活等于发了个寂寞。回复内容压到最短(一个句号),是因为读缓存本身很便宜,真正的成本在输出——别让保活这一步自己变成一笔浪费。

如果保活"发了但没用" 排查清单

按最容易漏、影响最大排的序: