跳到主要内容

自信地返回错误答案的语义缓存

· 阅读需 11 分钟
Tian Pan
Software Engineer

两个支持用户在短短一分钟内向你的智能体提出了几乎相同的问题。第一个用户问:“我们针对 EU 订单的退款期限是多久?”第二个用户问:“我们针对 US 订单的退款期限是多久?”这两个句子的嵌入向量(embeddings)仅有一丝之差——长度相同,结构相同,只有一个两字母 token 的区别。你的语义缓存经过了相似度阈值的微调(在演示中看起来非常合理),将它们判定为匹配。于是,第二个用户得到了第一个用户的答案。EU 的 14 天冷静期被当作事实,以流利的文字呈现给了一位 US 客户,且没有任何补充说明。

没有人会因此收到报警。缓存返回了 200。延迟表现优异。成本看板显示了一次命中,这正是每个人想要的结果。唯一能说明出了问题的信号是客户根据一项不适用于他们的政策行事——而这个信号在几天后才会通过退款纠纷传来,而不是通过你的监控系统。

正是这种故障模式,让语义缓存与你之前构建过的任何缓存都截然不同。精确匹配缓存可能会过期,但它永远不会 错误——key 要么匹配,要么不匹配。语义缓存则是有意放弃了这种保证。它被设计为能针对从未见过的 key 返回答案,而这种延迟优势的代价是正确性风险,大多数团队从未对这一风险进行过量化。

相似度匹配不等同于等值匹配

语义缓存的全部魅力在于,“我该如何重置密码?”和“我忘记密码了,现在该怎么办?”尽管几乎没有共享任何 token,但理应命中同一个缓存答案。基于字面字符串的传统缓存完全无法实现这一点。因此,你对查询进行嵌入处理,搜索最近的存储向量,如果余弦相似度超过阈值,就提供缓存的响应。

这种设计中埋藏着一个心照不宣的假设:超过阈值的相似度意味着相同的正确答案。事实并非如此。相似度是针对整个句子的连续分值;而正确性往往取决于单个具有辨别力的 token。“EU”与“US”、“税前”与“税后”、“2024”与“2025”、“包括”与“排除”——正是这些词决定了哪个答案是正确的,而这些词恰恰被嵌入模型视为微小的扰动,因为句子周围 95% 的内容是完全相同的。

你在无意中颠倒了缓存的契约。普通的缓存是闭合失效(fail closed)的:一次未命中只会让你损失一次重新计算的开销。语义缓存则可能开启失效(fail open):一次错误的命中会让你向用户交付一个错误答案,且其表现出的可信度与正确答案无异。输出在字节层面上与正确响应完全一致,无从分辨。没有异常,没有低置信度标志,也没有日志记录说“我是猜的”。系统完全按照你告诉它的方式运行了。

误中率是一个数字,而非一个假设

询问一个运行语义缓存的团队他们的误中率(false-hit rate)是多少,你通常会得到一个命中率(hit rate)作为回答——40%、60%,“我们节省了大量成本”。这是不同的指标。命中率告诉你缓存提供答案的频率。误中率告诉你它提供 错误 答案的频率。一个缓存可以拥有漂亮的命中率和悄无声息的腐蚀性误中率,而显示前者的仪表盘永远不会揭示后者。

误中率必须被测量,因为无法仅凭阈值进行推断。针对生产环境缓存的公开发布分析显示,误报(false positives)约占已提供命中次数的 1%——关键在于,这些错误往往聚集在阈值边界,即相似度刚好擦过截止线,但意图已经分化的地方。更糟糕的是,“可以安全复用”和“不可安全复用”的候选对的相似度分布存在严重重叠。没有任何一个清晰的界限可以将它们分开。任何单一阈值要么会引入误中,要么会退化为精确匹配行为,从而让你放弃采用缓存时追求的节省效果。

因此,你需要建立测量机制,而不是想当然。从生产环境中提取实际的缓存命中样本,对于每一个样本,照样运行一遍实时模型,并将缓存的答案与新鲜的答案进行对比。如果在实质内容上存在分歧,那么这次命中就是错误的。这会让你损失一些原本想节省的 LLM 调用成本,但这仅仅是针对样本,它能将“缓存可能没问题”转化为一个你可以围绕其设定错误预算(error budget)的数字。如果你的误中率是 0.3%,且在这里给出一个错误答案只是轻微的困扰,那没问题。如果误中率是 2%,且领域涉及医疗剂量或退款政策,那么这个缓存就是一个隐患,你需要在客户发现之前意识到这一点。

否定词以及被嵌入模型忽略的 Token

退款期限的例子并不是边缘案例,它正是分布的核心。嵌入模型的训练目标是捕捉主题和结构的相似性,而事实证明,它们在处理那些会导致答案反转的语言特征方面非常乏力。

否定词是最尖锐的例子。针对文本嵌入的研究不断发现,最先进的模型依然缺乏对否定词的感知——它们会将一个句子及其否定句评为高度相似,因为两者共享了每一个实词,仅相差一个“不(not)”。“这笔费用可以退还吗?”和“这笔费用不可以退还吗?”是完全相反的,但语义缓存会乐于将它们视为同一个 key。同样的盲点也覆盖了反义词交换、量词变化(“所有”与“某些”)以及限定词(“仅”、“除了”、“除非”)。这些并不是生僻的表达方式。它们正是人们提出精确问题的方式。

这就是为什么嵌入模型是正确性的依赖项,而非性能细节。将通用嵌入器更换为针对特定领域调优的嵌入器,对误中率的影响可能比任何阈值调整都要大,因为失败发生在阈值判断之前——两个真正不同的问题在进行比较之前就已经被映射到了相近的点上。重排序器(reranker)会阅读候选对的全文,而不是比较两个压缩后的向量,它能捕捉到一些嵌入器遗漏的信息,对于管控任何重要内容的缓存来说,增加这一跳的开销是值得的。普遍的教训是:当一个词决定了答案时,不要信任一个为了总结句子而构建的模型。

缓存检索,而非生成

如果你缓存了正确的层,部分风险就会消失。一个检索增强智能体(retrieval-augmented agent)运行一个链路 —— 对查询进行嵌入(embedding)、搜索向量数据库,然后调用 LLM 从检索到的分块中合成答案。最昂贵、最慢的步骤是检索和生成。诱人的做法是缓存最终生成的答案,因为那是节省开销最大的环节。但这也是风险最高的,因为生成的答案是链路中最具特定性的产物,也是错误命中(false hit)后破坏最彻底的环节。

相比之下,缓存“检索”是一个安全得多的折中方案。当一个语义接近的查询到来时,你重用检索到的文档集而不是最终答案,并且仍然针对新查询运行生成步骤。模型就有多一次机会注意到这个问题想要的是美国政策而非欧盟政策,因为它正在阅读实际的查询和实际的文档块。你放弃了一些延迟收益 —— 生成步骤仍然在运行 —— 但关于检索阶段缓存的已发表研究显示,延迟大幅下降而准确性损失微乎其微,正是因为不可逆的步骤保持了实时运行。经验法则:缓存那些昂贵且可重用的工作,而不是昂贵且最终的工作。

这也重新定义了阈值的归属。控制检索重用的宽松阈值只是轻微的效率低下 —— 最坏的情况下,你从稍微偏离主题的文档块中合成答案,而模型仍然可以忽略它们。但控制答案重用的宽松阈值则会导致错误的答案。同样的数值,取决于它控制哪一层,其爆炸半径(blast radius)完全不同。

阈值调优是安全决策,而溯源是调试器

将相似度阈值视为安全参数,而不是一个为了让命中率看起来漂亮而随意转动的旋钮。正确的值并不是全局通用的。为一个 FAQ 风格的查询提供服务的缓存,如果错误答案会损害信任,就应该针对精确率(precision)进行调优 —— 接受更少的命中,要求近乎确定的匹配。而为低风险查询提供服务的缓存,如果未命中只会浪费金钱,则可以倾向于召回率(recall)。有些团队这样拆分缓存后报告称,在同一个产品中,不同查询类别的阈值需求差异竟然高达 0.88 到 0.94。一个数字无法兼顾两者。

而且无论阈值如何,错误命中最终都会发生。问题在于当它发生时你是否能够调试它。这要求每个缓存条目都有溯源(provenance)信息:哪个原始查询生成了这个答案、它的嵌入向量是什么、什么样的相似度得分让当前查询进入、以及答案是根据哪些源文档构建的。当一个错误答案出现时,你希望打开缓存条目并看到“提供此结果是因为查询 X 对存储查询 Y 的得分为 0.91” —— 而不是盯着一个不透明的二进制大对象(blob)瞎猜。

溯源还为你提供了失效机制。如果你记录了每个缓存答案背后的源文档 ID,那么当底层文档发生变化时,你可以精确地让依赖于它的条目过期,而不是清理整个缓存,或者 —— 更糟的是 —— 让基于上季度政策构建的答案继续留存。一个无法告诉你答案来源的缓存条目不是缓存条目,它只是一个带有 TTL(生存时间)且无出处的断言。

延迟收益所要求的纪律

语义缓存确实是很好的基础设施。将 LLM 开销减半并将延迟降低一个数量级并不是边际改进,这种技术在严肃的 AI 系统中占有一席之地。但它是大多数团队部署的第一个可能会自信地、悄无声息地出错的缓存,在操作它时应该时刻牢记这一事实。

这意味着要养成四个习惯。将错误命中率作为真实数字,对照真实的错误预算进行衡量,而不是从命中率中推断安全性。深思熟虑地选择嵌入模型,因为它决定了哪些不同的问题会被映射到同一个键上。在链路允许的情况下,缓存检索而非生成,以便让不可逆的步骤保持实时。并且为每个条目附加溯源信息,这样你最终交付的那个错误命中就是你可以找到并解释的。

一个偶尔出错的缓存并不自动就是一个坏缓存。一个偶尔出错且你不知道出错频率的缓存 —— 那是在交付一个无人定价的数字。

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