一位用戶回報我們的翻譯外掛程式對每個請求都傳回相同的快取結果,無論輸入內容為何。我們進行了調查,發現了更糟糕的情況:我們平台上 95% 的語義快取命中 (semantic cache hits) 都是誤報 (false positives)。199 個不同的翻譯請求中,有 198 個唯一的請求主體 (request bodies),但卻向所有請求提供了一個相同的快取回應。
錯誤報告
報告內容很簡單:「我停用了語義快取,但每次翻譯都傳回相同的結果。」
三個請求 ID,三個不同的翻譯段落,卻有完全相同的快取回應。請求主體的大小從 1,564 到 8,676 bytes 不等。所有請求的快取回應 ID 都是同一個:chatcmpl-DG6J03nhdvcF7Ek0C8rJkjh7lN9pF。
起初懷疑是用戶的快取設定沒有生效。結果發現這是一個獨立的資料源同步 Bug(管理後台寫入一個資料表,而 API gateway 從另一個資料表讀取)。但修復這個問題只解決了一半。即使啟用了快取且運作正常,語義快取仍在匹配那些根本不應該匹配的請求。
生產環境數據
我們從 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 dimensions) 將其壓縮成一個向量,而模板結構在其中佔據了主導地位。實際的翻譯內容幾乎無法影響向量的變化。
結果:翻譯「Hello world」與翻譯「The quarterly financial report shows a 15% increase in revenue」之間的餘弦相似度 (cosine similarity) 超過了 0.95。而我們的閾值 (threshold) 正好是 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 已經被共享的前綴所主導。
研究報告怎麼說
這並不是一個新問題。最近的論文已經對此進行了量化分析。
加州大學柏克萊分校 (UC Berkeley) 的 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 層:指紋驗證 (Fingerprint Verification)
在語義快取命中後,我們會將當前請求提取文本的雜湊值 (hash) 與快取條目中存儲的雜湊值進行比較。如果不匹配,則拒絕該命中。
// 寫入快取時
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 重排序 (reranker),還是完整的 LLM 驗證調用。根據你的延遲預算進行選擇。
全域閾值是一種妥協,而非解決方案。不同類型的查詢需要不同的閾值。如果你無法做到針對每個類別或每個條目設置閾值,至少要加入輸入預處理,以標準化不同類別間的 Embedding 品質。
語義快取可以節省 30-70% 的 LLM API 成本。但如果沒有輸入預處理和命中驗證,你只是在提供過時的答案,並將其稱為性能提升。
LemonData 提供對 300 多個 AI 模型的統一訪問,並內建快取、路由和成本優化功能。免費試用,即贈 1 美元額度。