跳到主要内容

Prompt Caching 的隐形代价:当缓存命中提供错误的用户上下文时

· 阅读需 13 分钟
Tian Pan
Software Engineer

Prompt 缓存被宣传为一种稳赚不赔的方案。缓存长期的共享前缀——你的系统提示词、工具定义、检索到的上下文——只需为变化的短尾部分支付全额费用,然后看着账单下降。数字是真实的:缓存读取的成本大约是新鲜输入 Token 的十分之一,因此具有大量稳定前缀的工作负载,其输入成本可以降低 80% 或更多。团队因此采用它,因此调整它,并用单一指标来汇报:缓存命中率,且趋势向好。

这种表述掩盖了一个事实:你刚刚划定的边界——缓存前缀与非缓存尾部之间的界线——并不是一个计费旋钮。它是一个正确性边界。缓存断点之上的所有内容都是系统认为可以在请求之间互换的内容。如果你为了最大化命中率而划定这条线,你就是在让财务指标来决定你的 Prompt 中哪些事实可以在用户之间、租户之间以及跨时间共享。这是一个隔离决策,理应有目的地做出。

这种失效模式是隐蔽的,因为它永远不会报错。如果缓存命中提供了一个由另一个用户概况塑造的上下文,它会返回一个格式完全正确的响应。如果缓存命中提供了一个在缓存预热时为真、但在重用时已失效的个性化信息,它会返回一个自信、连贯但错误的答案。你的延迟图表或错误率不会有任何波动。唯一的信号是看起来 非常棒 的命中率——因为 Key 太粗颗粒度了。

缓存 Key 是正确性边界,而非计费旋钮

服务商端的 Prompt 缓存通过对标记断点之前的提示词前缀进行哈希处理来工作。在后续请求中,如果前缀哈希与现有条目匹配,模型将重用缓存状态而不是重新计算。匹配必须完全一致——直到并包括断点的每个 Token,必须按顺序排列。修改线上方的一个字符,你就会得到一个不同的哈希值并导致缓存未命中。

正是这种精确性让这种机制显得很安全。“只有在前缀字节完全一致时才会命中,怎么会提供错误的内容呢?”答案是, 决定了什么内容放在线上方。服务商并不知道 User: Jane Doe, plan: Enterprise, region: EU 这一段是针对特定用户的。它只看到 Token。如果这些 Token 位于你的断点之上,它们就是共享前缀的一部分,任何在该点之前前缀字节一致的其他请求都将命中同一个条目——这正是你把它们放在那里时所要求的。

所以真正的问题从来不是“哈希是否匹配”。而是“我决定了哪些内容可以被视为一致”。你放在断点之上的每一个事实,都是你宣告为可以互换的事实。把系统提示词放在那里:没问题,它确实是共享的。把工具 Schema 放在那里:没问题。为了填充前缀并提高命中率而把当前用户的权限放在那里:现在你已经宣告,只要周围的 Token 对齐,一个用户的权限就可以代表另一个用户的权限。缓存并没有泄露任何东西。是你把边界划错了地方。

粗颗粒度 Key 出错的两种方式

为了成本而非正确性而划定的边界会从两个不同的方向失效,它们需要不同的修复方法。

跨租户数据渗漏。 这是一个空间维度的失效:缓存片段包含属于特定用户或租户的事实,而来自 不同 主体的请求重用了它。在应用层缓存中,当你通过连接共享模板与每用户数据来构建前缀,并使用单个断点缓存整个内容时,就会发生这种情况——两个具有相似概况的用户可能会在前缀上发生碰撞,导致其中一个继承了另一个的上下文。在基础设施层,这比逻辑漏洞更严重:NDSS 2025 上发表的研究展示了 PROMPTPEEK,这是多租户 LLM 服务中的一种侧信道攻击,共享的键值(KV)缓存允许攻击者通过计时哪些前缀已预热来重建其他用户的提示词。这个教训具有普适性:跨信任边界共享的缓存就是一个跨越这些边界的信息通道,无论你是否是有意为之。

过期的个性化。 这是一个时间维度的失效:缓存片段在最初写入前缀时是真实的,但在重用时已经变为了假。嵌入用户偏好、近期活动或租户当前策略的前缀是一个快照。如果 TTL 是 5 分钟,它就是一个可以提供 5 分钟的快照;如果 TTL 是 1 小时,则为 1 小时。如果在该窗口内底层事实发生了变化——用户更新了设置、策略被修订、长期运行的智能体(agent)压缩了自己的历史——缓存会继续提供过期版本,而模型无法区分过期上下文和当前上下文。这并非假设。最近一个广泛使用的编程智能体中的回归故障就发生了这种情况:一项更改提高了过期前缀条目的命中率,但没有为压缩事件添加失效逻辑,导致模型开始接收无法与实时状态区分的过时上下文。

这两种失效有一个共同的根源——在断点上方放置了可变的或特定于主体的事实——但它们的修复方法不同。跨租户渗漏通过 设置 Key (keying) 来修复:将主体作为匹配条件的一部分。过期的个性化通过 失效逻辑 (invalidation) 来修复:使事实的生命周期短于缓存条目,或对其进行版本化处理以使更改强制导致未命中。一个团队如果只注意到其中一个而忽视另一个,那么交付的系统就只是“一半正确”。

命中率是症状,而非计分板

缓存命中率是每个提示词缓存(prompt-caching)部署方案都会报告的指标,它确实很有用——但它是用来诊断“相反”问题的。接近零的命中率几乎总是意味着你的前缀(prefix)中存在某些动态变化的内容:时间戳、请求 ID 或非确定性的 JSON 键值顺序。这是一个真正的 Bug,而命中率能帮你抓住它。

然而,命中率的攀升并不自动等同于好消息,将其视为“计分板”往往会导致粗颗粒度的键(coarse keys)被推向生产环境。想想是什么推高了这个数字。你可以通过真正稳定你的前缀来提高它——即把易变的 token 提取到断点(breakpoint)之下,从而使稳定的部分能够被干净地缓存。这是你想要的胜利。但你也可以通过“扩大”共享片段来提高它:将更多内容移动到分界线之上,包括那些本应属于特定用户或特定请求的内容。这两种做法提升数字的效果是一样的。仪表盘无法区分二者,因为命中率衡量的是前缀匹配的频率,而不是匹配的内容是否“安全”。

这就是为什么命中率不应孤立存在,而应与第二个指标并列。请给它配一个正确性探测(correctness probe):一个评估切片(eval slice),专门发出针对不同租户和不同新鲜度状态的请求,并断言没有任何响应携带了其他主体(principal)的事实或过时的值。如果命中率上升且该评估切片保持绿色,说明你稳定了前缀。如果命中率上升而评估切片开始报错,说明你扩大了边界,引入了风险。同样的仪表盘走势,结果却截然相反——只有第二个指标能告诉你究竟发生了哪种情况。

设计键:决定缓存片段中包含什么内容

实践中的准则是进行审计。对于缓存断点之上的每一个块,请回答一个问题:对于每个可能命中此条目的主体,该事实是否完全一致,并且在整个 TTL 期间是否保持稳定? 如果两者的回答都是“是”,那么它属于缓存片段。如果其中任何一个回答为“否”,它就属于断点之下不被缓存的尾部——或者你需要移动断点。

这种审计通常会产生一个层级结构,而不是一个巨大的缓存块。真实的提示词包含以不同速率变化的内容:

  • 真正静态、真正共享 —— 系统提示词、工具定义、少样本(few-shot)示例。这是高价值的缓存目标。它对每个用户都是相同的,并且仅在你部署时发生变化。
  • 租户级别、低频变动 —— 租户的策略、品牌定制或配置。可以在租户内部共享,但绝不能跨租户共享。这需要将租户身份作为缓存键的一部分,大多数供应商会隐式地为你提供这一点:只有当租户块在字节上完全一致时,前缀才会匹配。因此,只要租户块位于断点之上且每个租户各不相同,两个租户就不会发生碰撞。常见的错误是为了“提高”匹配度而通过模板化去除了这些差异。
  • 用户级别、可变 —— 偏好设置、近期历史、权益。这是危险层。由于其体积大且“感觉像是在做准备工作”,开发者往往倾向于缓存它,但它既不共享也不稳定。它应当放在断点之下,或者放在拥有独立短 TTL 且在用户修改内容时会被你显式作废的断点之后。
  • 请求级别、易变 —— 实际的查询、时间戳、请求 ID。始终位于断点之下。它们的存在就是为了让每次请求都各不相同。

大多数供应商允许你设置多个断点——通常多达四个——这正是为了让你能在这些自然缝隙处切分提示词。利用好它们。在静态块之后设置一个断点,在租户级别块之后再设置一个,而将用户级别和请求级别的内容保持在未缓存状态。这样你既能获得大部分成本节省,又不会面临跨租户泄露的风险。缓存用户层与不缓存它之间的成本差异很小;但正确性上的差异却是成败的关键。

TTL 是真相衰减预算,而非成本调节盘

在 5 分钟和 1 小时的 TTL 之间做选择,通常被框定为经济问题:较长的 TTL 写入成本更高(写入成本是短 TTL 的 2 倍,而短 TTL 仅为 1.25 倍),但它能在请求节奏较慢时保持缓存“温热”,从而减少冷启动写入。这种框定是正确的,但并不完整。

TTL 也是缓存片段中任何事实的最大有效期。如果你缓存了任何可变的内容,TTL 就是一个承诺:你愿意在事实发生变化后的这段时间内,继续提供旧的事实。对于静态系统提示词,这个承诺是无代价的——反正它在一小时内也不会改变。对于租户级别的策略,一小时也许可以接受,也许不行,这取决于策略变更需要多快生效。对于用户状态,这几乎总是不可接受的:如果一个用户切换了设置,却因为温热的前缀比他们的更改活得更久而立即看到了旧的行为,他们会认为这是一个纯粹的 Bug。

因此,请按层级而不是按请求来选择 TTL。对于年龄不重要的静态共享内容,使用长 TTL。对于真相有效期短于窗口期的任何内容,使用短 TTL 或不缓存。当一个事实可以在特定事件发生时改变(而不仅仅是随时间老化)时,不要依赖 TTL:请对缓存块进行版本化管理。在块中附加内容哈希或版本计数器,当底层事实改变时,版本改变,前缀哈希随之改变,下一次请求就会干净利落地不命中并重新写入。基于时间的过期机制是处理“内容变陈旧”的粗糙工具;而版本键则是处理“内容发生了特定改变”的精确仪器。

边界由你划定

Prompt 缓存不仅仅是带有一项正确性备注的成本优化。它是一个关于正确性的决策 —— 哪些事实可以在不同主体(principals)之间以及不同时间维度上共享 —— 只是恰好带来了成本收益。服务商为你提供了机制和完全匹配保证;它不会也无法决定你的哪些 Token 可以安全地被视为可互换的。那是你的设计。

手动划定边界。审计断点(breakpoint)上方的每一个区块,确保它们“对所有主体都相同”且“在整个 TTL 内稳定”。对 Prompt 进行分层,使静态内容、每个租户的内容、每个用户的内容以及每个请求的内容分别处于各自的断点,并拥有各自的生命周期。在命中率旁边报告一份跨租户和陈旧度的评估切片,这样不断上升的数字就不会悄无声息地意味着边界的扩大。做到这一点,Prompt 缓存就是它所宣传的那种名副其实的纯粹获益。如果跳过这一步,你就相当于把隔离边界外包给了一个计费仪表盘 —— 而这个仪表盘会一直告诉你一切正常,直到某位用户读到了别人的上下文。

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