语义缓存的命中边界
精确匹配缓存对大模型几乎没用——用户不会两次输入一模一样的话。同一个人问"怎么退款"和"退款流程是什么",字节完全不同,意思完全一样。
语义缓存就是把匹配从"文本相同"换成"意思相近":把 query 嵌入成向量,在缓存里找最相似的条目,超过阈值就直接返回上次的结果,一次模型调用都不发。
听起来很划算。它确实能省钱,但它是唯一一种会让你给出错误答案的缓存。
1. 先分清两种缓存
名字里都带"cache",但它们在完全不同的层上,解决的问题也不同:
| Prompt 缓存 | 语义缓存 | |
|---|---|---|
| 在哪一层 | 供应商侧 | 你的应用侧 |
| 缓存什么 | 相同前缀的 KV 状态 | 完整的回答 |
| 命中后 | 仍然生成,省的是输入处理 | 完全不调用模型 |
| 最低长度 | 有门槛(Sonnet 1024 token 起) | 无门槛 |
| 质量风险 | 无(输出完全一致) | 有(可能拿错答案) |
| 命中率量级 | 稳定前缀场景很高 | 取决于查询重复度 |
关键区别是最后两行。Prompt 缓存在机制上不可能改变输出——它复用的是计算,模型照常生成。语义缓存则是复用上一次的答案,如果匹配错了,给出的就是一个自信的错误答案。
两者可以叠加用,而且通常应该都用(Prompt 缓存的细节见《Token 成本的三个杠杆》)。如果只做一个,先做 Prompt 缓存——零风险,且实现只是加一个字段。
2. 命中率的现实数字
不同场景差得非常多,且都对语料和阈值敏感:
- 客服 FAQ 类:命中率较高(常见区间 40–70%)
- 代码辅助:明显更低(20–30% 量级)
- 创意生成:基本没有(个位数以下),不要做
整体生产流量的量级通常在 20–45%,不是厂商材料里偶尔出现的那些漂亮数字。判断要不要做,用这个数去乘你的调用成本就够了——但要注意一个反例:命中率很低时语义缓存是净亏损。每次未命中都要多付一次 embedding 计算和一次向量检索,如果命中率只有 2%,你给 98% 的请求增加了延迟。
所以它是个需要先测量再决定的东西,不是默认开启的优化。
3. 阈值:唯一的 knob,也是最危险的一个
3.1 它不是全局的
同一个阈值在稠密向量空间和稀疏空间里效果完全不同:
- 稠密空间(代码、术语密集):需要更高(0.88–0.90 量级),否则
sort_ascending会匹配上sort_descending - 稀疏空间(闲聊、客服):可以更低(0.75–0.78 量级),否则抓不住合法的同义改写
- 混合负载的起点:0.90–0.92 左右,然后按类别调
按类别分别设阈值、TTL、淘汰配额,是这类系统最常见的一个改进点。
3.2 误命中比未命中糟糕得多
这是语义缓存的核心风险,值得单独说:
未命中的代价是多花一次调用,用户拿到的是新鲜且正确的答案。
误命中的代价是系统自信地给出一个为另一个问题生成的答案,而用户无从判断——它看起来和正常回答没有任何区别。
所以监控不能只看命中率,要看误命中率。做法上通常是对一部分流量做抽样人工检查,或者对命中结果做一次轻量的一致性校验。
3.3 调不动的时候换 embedding
一个常见的排查顺序错误:发现误命中多,就去调阈值。
如果 embedding 模型本身把语义不同的 query 映射到了相近的向量,调阈值是治不好的——你把阈值调高,只是把误命中换成了未命中。
顺序应该是:先怀疑 embedding 质量,再调阈值。可以用一个小的标注集测:哪些 query 对应该命中、哪些不该,看模型给出的相似度分布能不能分开这两组。
Redis 的研究里给通用场景推荐过 all-mpnet-base-v2(768 维)作为综合最优,领域场景上微调模型通常优于通用模型。选型方法见《Embedding 的三个代价》。
4. 什么不能缓存
比"怎么缓存"更重要的是"什么不该进缓存"。四类:
实时数据。 股价、库存、物流状态。缓存就是在造假。
个性化内容。 同一个问题对不同用户答案不同(账户余额、订单状态、权限范围)。要缓存就必须把用户或权限范围放进 key——而一旦 key 变得很细,命中率就下来了,这时候不该缓存。
创意生成。 用户本来每次都期待不一样的结果,命中缓存就是 bug。
错误响应。 这条最容易被漏:一次超时或限流的错误结果被缓存下来,之后所有相似的 query 都会拿到这个错误。只缓存成功响应。
还有一条隐蔽的:涉及时效承诺和法律结论的内容不要长期缓存。政策和条款会变,缓存里的旧答案会成为新的风险来源。
5. 缓存 key 里必须有的东西
一个语义缓存的条目不能只有 query 向量。至少还要有:
- 模型版本——换了模型还返回旧模型的答案是错的
- 提示词版本——同上
- 知识库版本——文档更新后旧答案可能已过时
- 权限范围——租户或权限组的标识
前三点是版本问题:任何影响输出的组件变了,缓存就应该整体或部分失效。这也是为什么 TTL 不能设太长,以及为什么"模型升级了但成本没变"值得怀疑——可能是缓存还在返回旧答案。
第四点是安全问题,不是性能问题:如果缓存 key 不含权限范围,A 用户的答案可能被 B 用户命中。这和《权限过滤要在检索前》里说的是同一类事故——跨权限边界的缓存等于越权读取。
6. 多轮对话怎么办
单条 query 的语义缓存忽略上下文。"它多少钱"这句话脱离上一轮完全没有意义。
三个可选做法,按可靠性递增:
- 只缓存单轮可自足的问题——最简单,用分类器判断这条 query 是否依赖上下文,依赖的不进缓存
- 把上下文摘要并入 key——把前几轮的摘要拼进待嵌入的文本
- 上下文感知 embedding——把完整上下文编码进向量
第一个最实用。第二个要小心:摘要本身可能不稳定,导致同样的对话产生不同的 key。
7. 攻击面
语义缓存有一个常被忽略的安全属性:它可能被探测。
攻击者构造大量 query,观察哪些命中了缓存、返回了什么,就能反推出其他用户问过什么。如果缓存里存了带个人信息的内容,这构成一条数据泄漏路径。
缓解方式和权限过滤一样:key 带权限范围,高敏内容不跨用户缓存,命中日志里不记录完整内容。
这一条在《Agentic 的十类风险》里没有单独编号,但它属于同一类"系统自己引入的新攻击面"——你为了省钱加的组件,可能成为一个别人能查询的数据库。
8. 缓存污染:错误答案的复利
比"缓存了错误响应"更麻烦的是看起来成功的错误答案:模型答得不完整、引用错了文档、把旧政策当新政策,HTTP 200、格式合规、校验全过。它一旦进缓存,就会被后续所有相似 query 反复命中并放大,而且没有任何信号暴露它——因为你已经不再调用模型了。
四道防线,按成本从低到高:
- 点踩即失效:用户负反馈直接删条目,这是最便宜也最有效的信号
- 抽样审计:每天抽固定比例(比如 1%)的命中样本人工或模型复核,算"命中准确率"
- 准入 + 命中上限:不是所有答案都值得缓存。出现过 N 次以上、或单次调用成本高于阈值才允许写入;单条命中次数设上限,避免一条坏答案长期服役
- 影子命中:新条目先进"影子"状态,命中时仍然调用一次模型,只有两者一致才转正。成本高,只适合高风险类目
配套的淘汰策略也要有:容量上限 + LRU/LFU 是最低配,否则长尾的一次性 query 会把高频条目的空间挤掉。
9. 要不要做
按顺序问:
- 查询是否真的重复? 先统计真实流量里语义相似的占比,别猜
- 答案是否对同一类问题稳定? 政策类稳定,账户类不稳定
- 误命中的代价能否接受? 医疗、法律、金额相关的场景,代价通常不可接受
- 命中率估算下来是否超过盈亏线? 低于 15–20% 时,自己搭一套通常不划算
四个问题都过了再做。做的时候先按类别分阈值,同时把命中率和误命中率一起监控——只监控命中率的缓存系统,是在不知道自己有没有出错的情况下省钱。
开源实现里 GPTCache 是最常被拿来参考的一个,它的架构把 embedding、向量检索、相似度评估、淘汰策略都拆成了模块。即便不直接用,读它的结构也能更快想清楚这套东西的全貌。