缓存贵的、重算便宜的:LLM输出缓存策略
给出LLM缓存是否值得的精确公式:命中率高但答案可能过期时,缓存风险可能超过收益;过期答案无害时则可激进缓存。
给出LLM缓存是否值得的精确公式:命中率高但答案可能过期时,缓存风险可能超过收益;过期答案无害时则可激进缓存。
模型输出的缓存格外诱人,因为调用本身就格外昂贵;它又格外危险,因为同样的输入在明天可能理应得到不同的答案。这两个事实本应同时出现在同一个决策中,但大多数实现只考虑了前者。
对于某个特定的调用点,当预期的节省超过提供过期内容的预期成本时,就应该使用缓存。拆解成具体数字:
h 命中率 找到有效条目的调用占比
C 调用成本 金钱成本,以及用户等待时的延迟代价
s 过期率 命中中存储答案已非当前应返回答案的占比
L 一次过期答案的成本 从"无人察觉"到"展示了错误价格"
k 缓存本身的成本 存储、查询,以及保持失效逻辑正确的工程成本
使用缓存的条件: h * C > h * s * L + k
即 C > s * L + k/h
两种解读:
· h 缩放了收益但不影响每次命中的过期惩罚——低命中率不会让缓存变得安全,
只是让它变得毫无意义。
· s * L 才是决定大多数实际情况的项。如果过期答案无害,可以激进地缓存。
如果过期答案是给客户展示了一个错误的数字,那 C 必须非常大才能证明
缓存的合理性。
这条规则的价值在于它强制要求两个通常被跳过的估算。h 在你动手实现之前就可以测量——取上周输入的哈希并统计重复次数,这只需一个小时,而且常常能直接得出结论。s 是一个关于业务领域的问题,有人知道答案,只要你去问。
在得出结论之前,有两点改进值得考虑。首先,C 应该包含用户等待时的延迟,不仅仅是金钱成本:对于一个交互式功能,消除数秒等待的价值常常超过 token 成本,仅从价格看可能微不足道的缓存在这个基础上就完全值得了。其次,h 不是一个常数。它是流量构成的属性,会在你进入新市场、改变生成输入的界面、或者获得一个使用模式与众不同的客户时发生变化。在设计时测量一次就再也不管,是缓存在其不再划算之后仍然留在代码库中的根本原因。
所有人都忽略的那个项
过期不是缓存的属性。它是输入与答案之间关系的属性,在代码中看起来相同的调用点之间差异巨大。
第四行是导致生产环境意外的那种情况。团队改进了 prompt,部署了它,但质量没有变化,因为大多数流量正由旧 prompt 填充的缓存来服务。这个 bug 在所有仪表盘上都不可见:成本降了,延迟也很好,但改进就是没有发生。
关键是正确性问题
几乎所有不正确的缓存都是一个遗漏了答案所依赖因素的 key。纪律要求是:枚举答案所依赖的所有输入并全部包含进来,然后再决定故意遗漏什么。
key = hash(
normalised_input, // 规范化输入:trim、合并空白、case-fold,前提是答案
// 确实不依赖于大小写
prompt_id + version, // 遗漏这个,prompt 修复就不会生效
model_id + version, // 遗漏这个,模型升级会被静默回滚
params, // temperature、max_tokens、schema 版本
tenant_or_scope, // 遗漏这个,一个客户会看到另一个客户的答案
locale, // 遗漏这个,缓存就替所有人决定了语言
data_version, // 检索场景:索引或文档版本
)
其中两条是安全属性而非正确性细节。没有 tenant scope 的缓存 key 是一个看起来合理的跨客户数据泄露,而且是这个模式中最具破坏力的 bug。如果条目在设计上就是跨用户共享的——比如公开的 FAQ 答案、翻译——那种共享应该是调用点上明确记录的决定,而不是某个人记得了哪些字段的后果。
规范化值得花点心思而不是条件反射。激进的规范化提高了命中率但悄悄合并了本应得到不同答案的输入;保守的规范化是正确的但可能使命中率低到毫无意义。根据被丢弃的区分是否能改变答案,在每个调用点单独决定。
失效策略,按「什么变了」来选择
基于时间的过期是默认选项,也是最弱的选项,因为 TTL 是对过期时间的猜测而非确知。按优先级排序:
内容寻址,永不失效。如果 key 包含了答案所依赖一切的哈希,那么变更产生不同的 key,旧条目自然就不再被使用。没有失效逻辑可供出错。优先考虑这个方案;它比人们预期的更常可用。
基于依赖。记录一个条目依赖了哪些实体,当其中任何一个变化时就丢弃它。工作量更大,但对于任何派生自自有数据的场景都是正确答案,因为你已经有了失效逻辑应该所在的写入路径。
版本前缀。在 key 中放一个全局版本号,通过递增来一次性丢弃所有内容。最粗暴的工具,但对于 prompt 或模型变更确实有用,因为失效的实际范围就是"全部"。
基于时间。最后的选择,用于答案依赖于外部世界、且无法知道世界何时变化的场景。TTL 的选择依据是错误答案能容忍多久,而不是你希望节省效果持续多久。
还有一个与失效相关、应该放在一起而不是之后才考虑的决定:当一个条目刚被丢弃后发生 miss 时怎么办。如果一个热门条目过期了,在重新计算完成前有一百个请求到达,除非有什么阻止它们,否则这一百个都会调用模型。单飞锁——第一个调用者执行计算,其余等待其结果——把这变成一次调用,而且它在这里比在普通缓存中重要得多,因为这一百个重复调用中的每一个都是一笔真金白银,而不是一次浪费的数据库读取。
在后台刷新时提供过期内容也值得了解,因为它将延迟收益与新鲜度成本解耦。缓存层的机制——每层放在哪里、以什么为 key、如何组合——是另一个话题;本文只讨论是否缓存这个决策。
不适合缓存的场景
任何多样性本身就是重点的场景。创意建议、脑力激荡、多种表达方式。用户问了两次并两次得到完全相同的答案,会认为这个功能坏了,而且他们没有说错。
长尾输入。自由格式的问题很少完全重复。建之前先测量 h;命中率很低的缓存纯粹是 k 而没有任何收益,而用语义匹配来提高命中率会引入新的正确性风险——两个嵌入相似的提问不一定是两个答案相同的提问。
任何没有正确作用域的个人化内容。如果无法自信地枚举个人化输入并将其放入 key,就不要缓存。这里的不完整 key 就是上面描述的那种泄露。
提供商已经缓存了的内容。很长的稳定前缀可能在上一层被折扣了,这是另一种机制、不同经济账——prompt 缓存降低了你仍然要做的调用的成本,而本文讨论的模式是关于根本不做调用。两者可以组合,但混淆它们会导致在更简单的方案可用时去实现更复杂的那个。
规则中的 C 是一个每次调用的数字,必须来自真实数据。每 token 价格在价目表上,而你需要用来计算 h 的字段——请求的稳定哈希以及它是否重复——必须自己在调用点记录,因为没有任何账单汇总事后能重建它们。
相关模式
Pattern: Precompute Overnight, Serve Instantly
Pattern: The Cheap Filter in Front of the Expensive Model
Anti-Pattern: One Model for Everything