设置

语言

为什么你的语义缓存会返回错误答案

L
LemonData
·2026年3月5日·201 次浏览
#语义缓存#嵌入#LLM基础设施#生产环境调试
为什么你的语义缓存会返回错误答案

一位用户报告称,我们的翻译插件对每个请求都返回相同的缓存结果,无论输入是什么。我们调查后发现了更严重的问题:我们平台上 95% 的语义缓存命中都是误报(false positives)。199 个不同的翻译请求,198 个唯一的请求体,却向所有请求提供了一个相同的缓存响应。

错误报告

报告内容很简单:“我禁用了语义缓存,但每次翻译返回的结果都一样。”

三个请求 ID,三个不同的翻译片段,缓存响应却完全相同。请求体的大小从 1,564 字节到 8,676 字节不等。所有请求的缓存响应 ID 都是同一个:chatcmpl-DG6J03nhdvcF7Ek0C8rJkjh7lN9pF

最初的怀疑是:用户的缓存设置没有生效。结果发现这是一个独立的数据源同步 Bug(管理面板写入一个表,而 API 网关从另一个表读取)。但修复这个 Bug 只解决了一半的问题。即使缓存已启用并正常工作,语义缓存仍在匹配那些本不该匹配的请求。

生产数据

我们从 ClickHouse 提取了 24 小时的缓存命中数据。结果非常糟糕。

模型 总请求数 缓存命中数 唯一请求数 唯一响应数 命中率
gpt-4.1-nano 200 199 198 1 99.5%
glm-4.6-thinking 100 38 13 1 38%
gpt-5-nano 31 29 28 2 93.5%
gpt-oss-120b 18 17 17 1 94.4%
qwen3-vl-flash 17 16 16 1 94.1%

198 个唯一的翻译请求,全部返回同一个缓存响应。这不叫缓存,这是一个返回常量的损坏函数。

每个受影响的模型都有两个共同特征:所有请求都来自同一个用户,并且都使用了一个固定的系统提示词(system prompt)模板,仅用户内容有所不同。

为什么 Embedding 在结构化输入上会失效

翻译插件发送的请求如下:

System: "Act as a translation API. Output a single raw JSON object only.
         Input: {"targetLanguage":"<lang>","title":"...","segments":[...]}"

User:   {"targetLanguage":"zh","title":"Product Page",
         "description":"Translate product descriptions",
         "tone":"formal",
         "segments":[{"text":"actual varying content here"}]}

系统提示词在所有请求中都是相同的。用户消息是一个 JSON 对象,其中 targetLanguagetitledescriptiontone 是固定的。只有 segments[].text 在变化。

当我们的语义缓存提取文本进行 embedding 时,它会将系统提示词和用户消息拼接在一起。固定模板大约占文本的 80%。embedding 模型(all-mpnet-base-v2,768 维)将其压缩为一个向量,其中模板结构占据了主导地位。实际的翻译内容几乎无法产生显著影响。

结果:“translate 'Hello world'” 和 “translate 'The quarterly financial report shows a 15% increase in revenue'” 之间的余弦相似度(cosine similarity)超过了 0.95。而我们的阈值正是 0.95。因此,每个翻译请求都匹配到了第一个缓存条目。

通过查阅日志,我们发现了导致这种失效的三种情况:

结构化 JSON:翻译插件是情况最严重的。固定的 JSON 键和值掩盖了实际的翻译片段。gpt-4.1-nano 和 gpt-5-nano 都遇到了这个问题。

漫长的系统提示词:一个上下文摘要助手也遇到了类似的问题。它的系统提示词非常长,以至于用户内容(从 5KB 到 47KB 不等)在 embedding 中几乎没有体现。这就是为什么 glm-4.6-thinking 会为每次对话返回相同的摘要。

相同的前缀:第三种模式更为隐蔽。对于 gpt-oss-120b 和 qwen3-vl-flash,每个请求的前 500 个字符在字节层面是完全相同的。变化的内容出现在后面,但 embedding 已经被共享的前缀主导了。

研究表明

这并不是一个新问题。最近的论文已经对其进行了量化。

加州大学伯克利分校的 vCache 项目发现,正确和错误的缓存命中具有“高度重叠的相似度分布”。不同缓存条目的最佳阈值在 0.71 到 1.0 之间波动。没有一个统一的数值是有效的。他们的解决方案是:为每个缓存条目学习一个独立的阈值,这使错误率降低了 6 倍,同时使命中率翻了一番。(vCache, 2025

当混合不同类型的查询时,情况会变得更糟。一项类别感知缓存研究表明,0.80 的阈值在代码查询(如 sort_ascendingsort_descending)中会产生 15% 的错误匹配,而同样的阈值在对话查询中却会错过有效的改写。一个阈值,两种失败模式。(Category-Aware Semantic Caching, 2025

银行也遇到了这个问题。InfoQ 的一个案例研究记录了一个 RAG 系统,其中“我这个月可以跳过贷款还款吗”与“如果我错过贷款还款会怎样”的相似度达到 88.7%。意图不同,缓存答案却相同。他们最初的误报率为 99%,经过四轮优化才降至 3.8%。(InfoQ Banking Case Study, 2025

更深层次的问题是:embedding 衡量的是两个提示词在语义上是否相似,而不是同一个响应是否能回答这两个问题。这种差距正是错误缓存命中的温床。(Efficient Prompt Caching via Embedding Similarity, 2024

我们找到的每一篇论文都达成了一个共识:仅靠 embedding 相似度是不够的。你需要一个验证层。

双层修复方案

我们构建了两道防线。第一层在 embedding 之前去除模板噪声。第二层在匹配后验证命中结果。

第 2 层:用于 Embedding 的内容提取

在生成 embedding 之前,我们现在会检测结构化输入(JSON)并仅提取有意义的可变内容。

逻辑如下:

  1. 检查消息内容是否以 {[ 开头
  2. 如果解析为 JSON,则递归收集所有字符串叶子值
  3. 过滤掉短值(20 个字符或更少),因为它们通常是配置字段,如 "zh""formal""Product Page"
  4. 如果提取的文本太短或为空,则回退到原始文本
function extractContentForEmbedding(text: string): string {
  const extracted = tryExtractJsonContent(text);
  return extracted && extracted.length > 20 ? extracted : text;
}

这同时适用于系统提示词和用户消息。对于翻译插件,embedding 现在代表的是 “Hello world”,而不是一个 2KB 的 JSON 数据块。对于摘要助手,它从模板包装器中提取出了实际的对话内容。

20 个字符的阈值是根据经验选择的:

  • "zh" (2 字符):被过滤。配置值。
  • "formal" (6 字符):被过滤。配置值。
  • "Product Page" (12 字符):被过滤。模板字段。
  • "Translate product descriptions" (31 字符):保留。有意义的内容。
  • "The quarterly financial report..." (40+ 字符):保留。实际翻译内容。

第 3 层:指纹验证

在语义缓存命中后,我们会将当前请求提取文本的哈希值与缓存条目中存储的哈希值进行比较。如果不匹配,则拒绝该命中。

// 在写入缓存时
entry.metadata.textHash = fnv1aHash(extractedText);

// 在读取缓存时,找到相似度匹配后
if (entry.metadata.textHash !== undefined) {
  if (entry.metadata.textHash !== fnv1aHash(currentExtractedText)) {
    // 误报:语义相似但内容不同
    metrics.recordFingerprintRejection();
    return null;
  }
}

哈希使用的是提取后的文本(第 2 层处理后),而不是原始输入。两个具有不同模板包装但实际内容相同的请求仍然可以匹配。内容不同,则哈希不同,从而被拒绝。

没有 textHash 的旧缓存条目将跳过验证(向后兼容)。它们将通过 TTL 自然过期。

我们使用 FNV-1a(32 位)进行哈希。它速度快、具有确定性,且约 40 亿分之一的碰撞率对于检查单个缓存命中来说已经足够。

为什么不直接提高阈值?

我们的阈值已经是 0.95 了。提高它并没有帮助。问题在于,结构相似的输入无论实际内容是什么,都会产生高于 0.95 的相似度分数。

vCache 的数据支持了这一点:正确和错误命中的相似度分布重叠非常严重,以至于没有一个单一的界限可以将它们分开。如果将阈值推到 0.99,你会扼杀对改写内容的合法缓存命中,却无法消除来自模板密集型请求的误报。

修复输入,验证输出。不要在阈值上纠结。

结果

部署这两层方案后:

指标 之前 之后
gpt-4.1-nano 误报 198/199 0
误报在所有缓存命中中的占比 ~95% <5%
合法缓存命中率 无变化 无变化
每个请求增加的延迟 0 <1ms (JSON 解析 + FNV 哈希)

仅靠第 2 层就能修复翻译插件的问题。第 3 层是针对 JSON 提取无法完全分离内容的情况,或针对非 JSON 的结构化输入的最后一道防线。

核心要点

如果你在生产环境中运行语义缓存:

  1. 监控响应的多样性。 如果一个模型的缓存命中率为 100% 且只有 1 个唯一的响应,那么你就遇到了误报问题。查询语句:SELECT model, uniqExact(substring(response_body, 1, 200)) as unique_responses, count() as total FROM request_logs WHERE cache_hit = true GROUP BY model

  2. 结构化输入会破坏朴素的 embedding。 任何带有固定模板的请求(JSON API、系统提示词包装器、表单填写任务)都会产生人为的高相似度分数。在 embedding 之前进行预处理。

  3. 验证层不是可选的。 研究文献中提到的每个生产级语义缓存都有验证层。问题在于你是使用轻量级的哈希检查、cross-encoder 重排序器,还是完整的 LLM 验证调用。根据你的延迟预算进行选择。

  4. 全局阈值是一种妥协,而不是解决方案。不同类型的查询需要不同的阈值。如果你无法做到按类别或按条目设置阈值,至少要添加输入预处理,以使不同类别的 embedding 质量标准化。

语义缓存可以降低 30-70% 的 LLM API 成本。但如果没有输入预处理和命中验证,你就是在提供过时的答案,并将其称为性能提升。


LemonData 提供对 300 多个 AI 模型的统一访问,并内置缓存、路由和成本优化功能。立即免费试用,获赠 1 美元额度。

分享: