为什么静态前缀打了 cache 标记,还是每轮重新付费

cache 是前缀逐字节匹配,差一字节后面全废;打标记不等于缓存,先数 token 过没过模型阈值。

Module E · 第 5 讲

立靶:你以为冻住了,账单说它每轮在重写

一个多轮对话的 Agent,人设、工具说明、全阶段指令——这一坨上千 token 的静态前缀,每一轮模型都得重新读一遍。按理它逐字不变,凭什么每轮重新付费?于是你给它打上 cache 标记,心想这下省了。

上线后翻账单,省下来的钱并没有出现。打印每轮用量一看,cache_read 从头到尾是 0——缓存压根没命中,那段静态前缀每一轮都在原价重写。更隐蔽的是它不报错:你以为冻住了,框架默默地每轮重新计费,没人提醒你。

这就是 Cache 工程要解决的问题,它是上下文工程里的一条横切层——L1 系统提示、L4 结构化 IO、L5 工具集成这些静态层,全都要靠它才能不重复付费。但 cache 有它自己的脾气:它不是「打个标记就生效」的开关,而是一套有严格前置条件的机制。你得先搞清楚它按什么匹配、什么时候被悄悄解冻,否则就会出现「标记打了、钱照付」这种最让人困惑的状态。

框架:一条不变式,长出整套打法

整个 Cache 工程,只从一句话推出来:

Prompt caching 是前缀匹配(prefix match)。前缀里任何一个字节变化,都会让它之后的一切 cache 失效。

cache key 是渲染后的 prompt、从头到每个 cache_control 断点为止的精确字节。第 N 位有一个字节不同,N 之后所有断点的缓存全部作废。渲染顺序是铁律:toolssystemmessages。所以工具定义排在最前(位置 0),动一个工具,整条 cache 从头塌掉;把 cache_control 打在最后一个 system block,等于把 tools + system 一起缓存;而时间戳、当前状态这类每轮在变的内容,必须排在最后一个断点之后

拿到一段 prompt,第一个动作永远是问:「从头到尾,哪一段每轮一模一样?」那一段、且只有那一段,才有资格进 cache 前缀。

双 block:把会变的和不变的拆开

多轮对话最常见的 cache 反模式,是把人设 + 工具 + 阶段指令,和当前状态 + 已填槽位,混在同一个 block 里。后果是只要任一运行时字段一变(比如阶段从 S4 跳 S5),整个 block 前缀就变了,缓存全失效,每轮重写相同人设,cache_read 永远是 0。

修法是拆成两个独立 block:

Block装什么打 cache_control行为
Stable 块人设 + 工具说明 + 全阶段指令 + 输出格式约束通话期不变,首轮写入,2..N 轮读取
Volatile 块当前状态 / 已填槽位 / 下一步提示不打每轮变但很短,不缓存,成本可控

要点是顺序:Stable 块在前、打标记,Volatile 块排在断点之后、不打标记。会变的东西短小,不缓存也不心疼;不变的东西厚重,缓存下来省的就是大头。这一拆,往往就能把一段多轮对话的 input 成本省掉大半。

模型最小阈值:打了标记也可能静默不缓存

这是最反直觉的一条。短于阈值的前缀,会被静默地不缓存——不报错,只是 cache_creation_input_tokens 始终为 0。而这个阈值因模型而异,是可以查到的官方事实:

模型系列最小可缓存 token
Claude Opus 4.x / Haiku 4.54096
Claude Sonnet 4.6 / 早期 Haiku2048
早期 Sonnet(4.5 / 4 / 3.7 等)1024

致命推论:假设你的静态块约 3k token,标记也打对了,但你跑在 Opus 或 Haiku 4.5 端点上——3k 小于 4096,它根本不会缓存。换到 Sonnet 4.6(阈值 2048)才缓存得上。所以「打了 cache_control」和「真的省钱」之间,隔着一道你必须先迈过的门槛:先数 token,再谈 cache。

Cache 经济学:省多少不靠感觉

写缓存比原价贵,读缓存比原价便宜,所以 cache 是一笔有回本周期的账:

价格(相对未缓存 input)
cache write(短 TTL)约 1.25×
cache write(长 TTL)约 2×
cache read约 0.1×
未命中(uncached)

break-even 很好算:短 TTL 下,第二次请求就回本(1.25 + 0.1 = 1.35 < 2);长 TTL 写贵一倍,至少三次请求回本,但好处是能扛住流量空档,间隔超过短 TTL 也不掉。

算账模板(假设 30 轮、静态块 5000 token、短 TTL):

  • 无 cache:30 × 5000 × 1 = 150,000 单位
  • 有 cache:首轮 5000 × 1.25(write)+ 29 × 5000 × 0.1(read)+ 少量 volatile ≈ 6250 + 14500 ≈ 20,750 单位
  • 省约 (150000 − 20750) / 150000 ≈ 86%

这个推导本身就是 PM 和工程对齐时最好的语言:不说「cache 能省点钱」,而说「这条链路按 1.25× 写、0.1× 读算,两次请求回本,30 轮省八成」。

失效层级与回看窗:不是所有改动都全废

cache 分 tools / system / messages 三层,一次改动只作废自己这层及之后:

改动tools cachesystem cachemessages cache
增删工具 / 换工具顺序 / 换模型
system 内容变
tool_choice / 图片 / thinking 开关
message 内容变

推论很实用:tool_choicethinking 开关可以每轮换,不丢 tools + system 缓存;只有动工具定义或换模型才强制全重建。

还有一个容易被忽略的回看窗:每个断点向前最多走约 20 个 content block 找旧 cache。在 agentic loop 里,如果单轮塞进超过 20 个 tool_use / tool_result 对,下一轮的断点就找不到上一轮的 cache,静默 miss。修法是长轮里每隔约 15 个 block 补一个中间断点。

数据 / case:缓存怎么在真实场景里咬人

  • 混 block 导致 cache_read 恒为 0(某客服 Agent):system 里塞了人设 + 工具 + 当前订单状态,全混在一个 block 打了标记。上线后 cache_read 一直是 0。根因是当前订单状态每轮在变,把整个前缀污染了,每轮重写人设。拆成 stable(人设 + 工具)+ volatile(订单状态)两块后,从第二轮起 cache_read 才转正。
  • 端点 + 阈值双坑(某语音 Agent 审计):给一个实时语音 Agent 画上下文账本时发现两个隐患叠在一起——一是它直连的端点可能根本不支持 cache_control,二是它的静态块约 3k token、短于 Opus / Haiku 4.5 的 4096 阈值。两个坑任意一个都会让「打了标记照样不省」,必须先各自验证,不能假设。后来在一个静态块做厚到 5000 多 token 的另一端点上,某团队实测从第二轮起 cache_read 稳定大于 0,30 轮通话省了约八成 input 成本——印证了「过阈值 + 端点支持」这两个前提缺一不可。
  • 动态拼工具集,跨用户一条都命不中(常见误区):有人「按用户偏好动态拼工具集,还打了 cache_control 想省钱」。问题是工具在位置 0,工具集随用户变 = 每个用户都是不同前缀,跨用户一条 cache 都命不中,反而每次都付写费。正解是把工具集冻成稳定全集(按名排序、确定性序列化),用 tool_choice 或 message 内容控制「这轮用哪个」,而不是改 tools 定义。

三个案例指向同一句话:cache_read 连续为 0,就是前缀里藏了一个静默杀手——可能是混进来的运行时状态,可能是没过阈值,可能是随用户变动的工具集。

可操作做法:怎么把这套用起来

PM 视角——用它做判断和对账:

  1. 谈任何「cache 优化」之前,先要两个数字:静态块真实 token 数、目标端点用的是哪个模型。前者决定够不够阈值,后者决定阈值是多少——少一个都没法判断能不能省。
  2. 用回本周期说话,而不是「能省点」:「这条链路两次请求回本、N 轮省八成」是可被工程验证的承诺。
  3. 把「上线后 cache_read 从第二轮起必须大于 0」写进验收条件。归零不是「没生效」一句带过,而是要 diff 两次请求的渲染字节,抓出那个静默杀手。
  4. 警惕「冻」这个动作的诱惑:cache 省钱会诱使你把本该变的东西也塞进 stable 块。一旦混进日期、用户名、状态,不但不省,连写费都打水漂。

工程视角——用它做实现决策:

  1. 上线前先数 token、再查模型阈值表。静态块短于阈值就别打标记,那是白付不了的写费、也没省到。够不上就把 stable 块做厚,或换阈值更低的端点。
  2. 把静态层拆成 stable / volatile 两个 block:人设 + 工具 + 全阶段指令进 stable 并打 cache_control,当前状态 + 填槽位进 volatile、排在断点之后、不打。
  3. 把前缀里的静默杀手清干净:system 里的 datetime.now()、早段的 uuid4() / request_id、未排序的 json.dumps()、按用户 f-string 拼接的 id、条件拼接的 if flag: system += ...、随用户变的工具集——这些每一个都会让 cache_read 归零。修法通则是:动态片段挪到最后断点之后 / 改成确定性 / 不必需就删。
  4. agentic loop 里单轮 block 数逼近 20 时,每约 15 个补一个中间断点,避免超出回看窗导致下轮静默 miss。
  5. 加观测:每轮打印 input / cache_creation / cache_read 三个数。真实总量 = input + cache_creation + cache_read,只看 input 小就以为没花钱是错觉;cache_read 从第二轮起转正,才是缓存真的生效了。

收口

打了 cache_control 不等于缓存生效。先数 token 过没过模型阈值,再把静态前缀冻成逐字节不变的 stable 块,最后上线必看 cache_read 从第二轮起大于 0——给模型一个省钱的能力,它反过来要求你证明这段前缀真的没变。