跳到主要内容

悄无声息击穿提示缓存的那次模型迁移

· 阅读需 11 分钟
Tian Pan
Software Engineer

迁移看上去很干净。评估已经针对新模型版本重新校准。Judge 提示词重新调校过。两周的影子流量显示行为对齐在容差范围内。p50 和 p99 延迟都在预算之内。周四下午的上线评审签字通过,团队各回各家。

到了周五早上,推理账单是平时的 3 倍。评估分数依旧没问题。延迟依旧没问题。上线评审上没有人想到要对缓存命中率做埋点,因为前缀根本没变 —— 系统提示词逐字节相同,工具定义逐字节相同,对话框架逐字节相同。变的是请求体里的模型版本,而供应商的前缀缓存键是 (前缀字节 + 模型版本)。切换之后的每一个请求都打到了一个冷缓存上。预热曲线靠自然流量花了六周才恢复,在此期间团队为每个请求的每一个 token 都支付了完整的未命中价格。

这就是横亘在模型团队上线仪表盘与 FinOps 仪表盘之间的缓存失效盲区。模型团队负责行为对齐、延迟和评估分数。FinOps 负责月度账单。缓存命中率夹在中间,无人主管,而模型迁移恰恰是你能对它做的最昂贵的一次操作。

供应商并不把你的缓存当作合约的一部分

Anthropic、OpenAI、AWS Bedrock 和其他厂商都把前缀缓存当作尽力而为的加速层,而非有保障的服务。他们的文档明明白白写着:缓存键里包含模型标识符。从一个 Sonnet 版本换到下一个,或者从一个 Opus 版本换到下一个,都会让你积累起来的所有缓存前缀失效 —— 不是因为提示词变了,而是因为缓存是按模型权重做键的,而权重变了。

这并不是 bug。KV 缓存里存的是模型注意力在提示静态部分上算出来的 key 和 value 向量。这些向量是权重的函数。新权重意味着新向量,意味着新的缓存条目。版本切换后没有共享表示能存活下来,供应商也没办法用新模型去"重新水化"你的旧缓存 —— 数学上不成立。

操作层面的含义是:切换那一天,你每个请求的有效输入 token 成本会回到未命中的价格。如果你之前跑的是 50,000 token 的系统提示词、90% 命中率,那一个请求大约只为 5,000 token 付费。切换后那一天,你为完整的 50,000 token 付费。这就是 10 倍的成本飙升,藏在一次每个其他维度都成功的"行为对齐"上线背后。

而且恢复不是即时的。前缀缓存有 TTL —— 大多数供应商通常是五分钟,签了固定吞吐合约的客户可以拿到更长档位。一个缓存条目只有在 TTL 过期前有后续请求命中同一前缀才能存活。预热曲线是你自然流量分布的函数:高频前缀几分钟就能预热,长尾前缀要几天甚至几周,这段恢复窗口里累积起来的成本差额才是事故的真实体量。

模型团队仪表盘和 FinOps 仪表盘互相看不到对方

上线评审里,每一个对行为重要的人都到场了:模型负责人、评估负责人、延迟负责人、值班人员。但没有任何人的职责描述里包含"输入 token 账单"。那张账单是另一支团队按月、按另一种节奏、在另一块仪表盘上看的。等有人读到那波尖峰时,缓存已经又预热了一半,这次事故看起来像一次性的异常,而不是迁移可预见的后果。

解法不是把财务请到每次上线评审上,解法是把缓存命中率放到延迟和评估分数同一块仪表盘上,并把它列为发布门禁指标。Anthropic 和 OpenAI 的响应里都会返回缓存元数据:cache_read_input_tokenscache_creation_input_tokenscached_tokens。你可以按每请求算命中率 cache_read / (cache_read + cache_creation + uncached),也可以按每 token 算 cache_read / total_input。哪种都行,挑一种,把它画在你已经在看的那些指标旁边。

如果你的仪表盘当时显示了缓存命中率,周五早上那波尖峰会在上线评审本身就出现 —— 影子流量刚开始重路由的那一刻。而实际上,它六天之后才作为账单电子表格里的一列冒出来,然后有人不得不从日志里反推出因果链。

你跳过的那些上线前纪律

任何缓存花费不轻的模型迁移都不该冷启动上线。这些纪律不算什么新东西:

切换前的合成预填流量。 在切换用户流量前两三个小时,以保持在缓存 TTL 内的频率,用最常见的前 N 个前缀去打新模型版本。这正是对延迟敏感的团队在部署后用来对冷启动做的预热模式;唯一的区别是,你现在预热的是成本,而不是首 token 时间。合成预填的成本是每个不同前缀一次缓存写入。不做的代价,是在整个预热窗口里每个请求一次缓存写入。

与缓存 TTL 匹配的分批上线窗口。 1% → 10% → 50% → 100%、每隔 5 分钟的灰度,对行为验证来说没问题,但对缓存经济学来说反而是反向操作 —— 因为每一档低流量份额都不足以把前缀维持在 TTL 内的热状态。要么放量放得足够快、让新版本流量本身就能撑住缓存,要么放得足够慢、同时跑合成预填。错误的组合 —— 慢放量又不预填 —— 给你的是最糟的两端:整个窗口都是冷缓存,且暴露时间被拉长。

新旧版本并行保温的窗口。 在迁移窗口内,有意把一小撮流量打给老版本,让它的缓存保持热度,这样万一回滚也不会再吃一次冷启动成本。不这么做的话,回滚会变成第二次成本事故,团队就会更不愿意回滚 —— 即使该回滚。

这些都不是什么新鲜玩意儿。工程团队对数据库迁移、CDN 切换、搜索索引更替用的就是同一套模式。新意只在于:把提示缓存当作一项资产,赋予它和那些系统同样的运维属性,而不是把它当作一个在背后默默跑的、不透明的厂商优化。

别名很方便,且以最糟糕的方式承重

很多团队指向的是 claude-sonnet-latest 或类似别名,而不是钉死一个带日期的具体版本号,例如 claude-sonnet-4-6-20260315。别名很方便:你免费拿到新模型,不用改代码,不用部署。但它们同时也是供应商让你整个缓存失效却不需要你团队任何人触发一次评审的机制。

任何足以让 5–10 倍临时倍数构成事件的模型花费,都应该用不可变的、带日期的版本锁。模型标识符成为你可部署合约的一部分。升级需要改代码,改代码需要 PR,PR 需要 review,review 需要有人问一句"我们的缓存预热计划是什么?"摩擦本身就是这个机制的特性。

还有一个二阶好处:回滚变得简单且对缓存安全。如果你在 claude-sonnet-4-5-20260101 上跑了六个月,你的缓存在那个版本上完全是热的。如果迁移到 4-6-20260315 翻车了 —— 行为退化、judge 被钻空子、随便什么 —— 你把字符串改回去,缓存已经在那儿。用别名的话,老版本可能已经被悄悄下线、回滚路径不复存在;或者回滚意味着要去冷启动一个长时间没流量、已经丢失热状态的版本的缓存。

对超大客户来说,合同层面也有讲究。固定吞吐合约通常带有缓存预热选项或能跨版本过渡的专属算力。如果你的缓存花费是每月六位数,那一行账单条目存在的意义就是用来谈判的;如果你从没问过供应商"针对我们的缓存,你们的迁移方案是什么?",那你买到的就是默认行为,不管你喜不喜欢。

把缓存命中率作为发布门禁指标

上线后的纪律是最容易加上去的一条,也是回报复利最高的一条。让缓存命中率成为上线评审签字时认证的指标,带上明确的阈值:

  • 上线前基线。 在迁移前一周,测量在位模型的命中率。这就是你的目标。
  • 上线窗口阈值。 定义预热窗口内的可接受范围 —— 例如"切换后四小时内不低于基线的一半" —— 并像对延迟回归那样对它分派告警。
  • 恢复 SLO。 定义新版本何时应回到基线 —— 例如"完成切换 72 小时内"。如果没回到,那就是一个真实的信号:要么预填计划不充分,要么前缀分布有你没估到的长尾,要么新版本在某些影响缓存复用的行为上确实跟之前不一样了。

这不是表演式监控。把缓存命中率作为一等指标的团队报告过单个任务从 3% 命中率经过一次定向干预升到 90% 以上 —— 同样流量下大约 30 倍的成本差。这个指标足够敏感,回归会清晰显现;它能被改动的手段也足够具体,出现回归时跟进项就一目了然。

你没意识到已经发生过的架构转变

更深一层的认识是:"模型升级对你的代码是透明的"这句话,在 2023 年是真的,现在不是了。供应商侧的缓存悄然变成了一根承重的成本杠杆,有时候就是盈利功能与亏损功能之间的差别。一旦一个系统的毛利依赖于缓存命中率才能闭合,缓存就成了系统接口的一部分,接口契约也就包含了"模型版本变化时会发生什么"。

旧的心智模型是:模型是一段对文本的不透明函数,API 边界上唯一重要的就是输入 token 和输出 token,供应商的内部优化是他们自己的事。当缓存只是边角优化时,这个模型是成立的。当缓存意味着每百万 token 0.30 美元(命中)对 3.00 美元(未命中)的差别,同样的前缀、同样的供应商、相邻两天,这个心智模型就不成立了。

把提示缓存当成你对任何其他生产缓存的态度对待:一项有预热成本、有命中率指标、有 TTL、有失效面、有上线计划的资产。供应商会持续优化你脚下的那一层,有时候会以悄悄让你积累的一切失效的方式优化。版本锁和缓存命中率仪表盘,就是你掌控成本叙事、而不是在某个周一早上从财务复盘里被动发现它的方式。

那支跑完这次细致迁移的团队,针对他们以为和供应商签的合约,所有该做的事都做了。合约里有一条他们没读过的条款。

References:Let's stay in touch and Follow me for more thoughts and updates