一位用户报告称,我们的翻译插件对每个请求都返回相同的缓存结果,无论输入是什么。我们调查后发现情况更糟:平台上 95% 的语义缓存命中都是误报(false positives)。199 个不同的翻译请求,198 个唯一的请求体,却向所有请求提供了一个相同的缓存响应。
如果你关注长期运行的 Agent 状态和生产环境的请求处理,这篇文章可以与 《为什么你的 AI Agent 总是丢失记忆》、《一键式聊天机器人指南》以及 《AI API 速率限制指南》配合阅读。
Bug 报告
报告内容很简单:“我禁用了语义缓存,但每次翻译返回的结果都一样。”
三个请求 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 对象,其中 targetLanguage、title、description 和 tone 是固定的。只有 segments[].text 会发生变化。
当我们的语义缓存提取文本进行 Embedding(嵌入)时,它会拼接系统提示词和用户消息。固定模板约占文本的 80%。Embedding 模型(all-mpnet-base-v2,768 维)将其压缩成一个向量,其中模板结构占据了主导地位。实际的翻译内容几乎无法引起向量的变化。
结果:“翻译 ‘Hello world’” 与 “翻译 ‘季度财务报告显示收入增长了 15%’” 之间的余弦相似度超过了 0.95。而我们的阈值正是 0.95。因此,每个翻译请求都匹配到了第一个缓存条目。
通过分析日志,我们发现了这种失效的三种模式:
翻译插件是最严重的。固定的 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_ascending 与 sort_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)并仅提取有意义的可变内容。
逻辑如下:
- 检查消息内容是否以
{或[开头 - 如果能解析为 JSON,则递归收集所有字符串叶子节点的值
- 过滤掉短值(20 个字符或更少),因为它们通常是配置字段,如
"zh"、"formal"或"Product Page" - 如果提取的文本太短或为空,则回退到原始文本
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-bit) 进行哈希。它速度快、具有确定性,且约 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 的结构化输入的保险措施。
启示
如果你在生产环境中运行语义缓存:
监控响应的多样性。 如果一个模型的缓存命中率为 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。结构化输入会毁掉朴素的 Embedding。 任何带有固定模板的请求(JSON API、系统提示词包装器、表单填写任务)都会产生人为的高相似度分数。在 Embedding 之前进行预处理。
验证层不是可选的。 研究文献中提到的每个生产级语义缓存都有验证层。问题在于你是使用轻量级的哈希检查、交叉编码器(cross-encoder)重排序,还是完整的 LLM 验证调用。根据你的延迟预算进行选择。
全局阈值是一种折中,而不是解决方案。不同的查询类型需要不同的阈值。如果你无法做到按类别或按条目设置阈值,至少要添加输入预处理,以标准化不同类别的 Embedding 质量。
语义缓存可以削减 30-70% 的 LLM API 成本。但如果没有输入预处理和命中验证,你只是在提供陈旧的答案,并将其称为性能提升。
LemonData 提供对 300 多个 AI 模型的统一访问,并内置缓存、路由和成本优化功能。免费试用,注册即送 $1 额度。
