一位用戶回報我們的翻譯外掛程式對每個請求都傳回相同的快取結果,不論輸入內容為何。我們調查後發現了更糟糕的情況:在我們平台上,95% 的語義快取命中 (semantic cache hits) 都是誤報 (false positives)。199 個不同的翻譯請求,198 個唯一的請求主體,卻全都共用了同一個快取回應。
如果你關注長效的 agent 狀態和生產環境的請求處理,這篇文章很適合搭配 《為什麼你的 AI Agent 總是遺忘記憶》、《單一 API Key 聊天機器人指南》以及 《AI API 速率限制指南》一起閱讀。
Bug 報告
報告內容很簡單:「我停用了語義快取,但每次翻譯都傳回相同的結果。」
三個請求 ID,三個不同的翻譯片段,卻有完全相同的快取回應。請求主體的大小從 1,564 到 8,676 bytes 不等。所有請求的快取回應 ID 都是相同的:chatcmpl-DG6J03nhdvcF7Ek0C8rJkjh7lN9pF。
第一個懷疑是:用戶的快取設定沒有生效。結果發現這是一個獨立的資料源同步 bug(管理後台寫入一個資料表,而 API gateway 讀取另一個)。但修復這個問題只解決了一半。即使快取已啟用且運作正常,語義快取仍在匹配那些根本不該匹配的請求。
生產環境數據
我們從 ClickHouse 提取了 24 小時的快取命中數據。數據非常糟糕。
| Model | 總請求數 | 快取命中 | 唯一請求 | 唯一回應 | 命中率 |
|---|---|---|---|---|---|
| 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 模板,僅 user 內容有所不同。
為什麼 Embeddings 在結構化輸入上會失效
翻譯外掛程式發送的請求如下:
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"}]}
System prompt 在所有請求中都是相同的。User message 是一個 JSON 物件,其中 targetLanguage、title、description 和 tone 是固定的。只有 segments[].text 會變動。
當我們的語義快取提取文本進行 embedding 時,它會將 system prompt 和 user message 串接起來。固定的模板約佔文本的 80%。embedding 模型 (all-mpnet-base-v2, 768 維度) 將其壓縮成一個向量,而模板結構在其中佔據主導地位。實際的翻譯內容幾乎無法產生影響。
結果:「翻譯『Hello world』」與「翻譯『季度財務報告顯示營收增長 15%』」之間的餘弦相似度 (cosine similarity) 超過了 0.95。我們的閾值是 0.95。因此,每個翻譯請求都匹配到了第一個快取項目。
透過分析日誌,我們發現了三種導致失效的情況:
翻譯外掛程式是最大的元兇。固定的 JSON 鍵值對掩蓋了實際的翻譯片段。gpt-4.1-nano 和 gpt-5-nano 都遇到了這個問題。
一個上下文摘要助手也遇到了類似問題。它的 system prompt 太長,導致 user 內容(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 vs sort_descending)中會產生 15% 的錯誤匹配,而同樣的閾值在對話查詢中卻會錯過有效的改寫。一個閾值,兩種失敗模式。( Category-Aware Semantic Caching, 2025 )
銀行也遇到了這個問題。InfoQ 的一個案例研究記錄了一個 RAG 系統,其中「我這個月可以跳過貸款還款嗎」與「如果我錯過貸款還款會怎樣」的相似度達 88.7%。意圖不同,快取答案卻相同。他們最初的誤報率高達 99%,需要經過四輪優化才能降至 3.8%。( InfoQ Banking Case Study, 2025 )
更深層的問題是:embeddings 衡量的是兩個 prompt 是否在語義上相似,而不是同一個回應是否能回答這兩個問題。這兩者之間的差距就是快取誤報存在的地方。( Efficient Prompt Caching via Embedding Similarity, 2024 )
我們找到的每一篇論文都同意一件事:單靠 embedding 相似度是不夠的。你需要一個驗證層。
兩層修復方案
我們建立了兩道防線。第一道是在 embedding 之前去除模板噪音。第二道是在匹配後驗證命中結果。
第二層:用於 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;
}
這同時適用於 system prompt 和 user message。對於翻譯外掛程式,embedding 現在代表的是「Hello world」,而不是一個 2KB 的 JSON 區塊。對於摘要助手,它會從模板包裝中提取出實際的對話內容。
20 個字元的閾值是根據經驗選擇的:
"zh"(2 字元):過濾。配置值。"formal"(6 字元):過濾。配置值。"Product Page"(12 字元):過濾。模板欄位。"Translate product descriptions"(31 字元):保留。有意義的內容。"The quarterly financial report..."(40+ 字元):保留。實際翻譯內容。
第三層:指紋驗證 (Fingerprint Verification)
在語義快取命中後,我們將當前請求提取文本的 hash 與快取項目中存儲的 hash 進行比較。如果不匹配,則拒絕該命中。
// 在寫入快取時
entry.metadata.textHash = fnv1aHash(extractedText);
// 在讀取快取時,找到相似度匹配後
if (entry.metadata.textHash !== undefined) {
if (entry.metadata.textHash !== fnv1aHash(currentExtractedText)) {
// 誤報:語義相似但內容不同
metrics.recordFingerprintRejection();
return null;
}
}
Hash 使用的是提取後的文本(經過第二層處理),而不是原始輸入。兩個具有不同模板包裝但實際內容相同的請求仍然可以匹配。內容不同,則 hash 不同,會被拒絕。
沒有 textHash 的舊快取項目會跳過驗證(向下相容)。它們會透過 TTL 自然過期。
我們使用 FNV-1a (32-bit) 進行 hash。它速度快、具確定性,且約 40 億分之一的碰撞率對於檢查單個快取命中來說已經足夠。
為什麼不直接提高閾值?
我們的閾值已經是 0.95 了。提高它沒有幫助。問題在於結構相似的輸入無論實際內容為何,產生的相似度分數都會高於 0.95。
vCache 的數據支持了這一點:正確和錯誤命中的相似度分佈重疊非常嚴重,以至於沒有任何單一的切分點可以將它們分開。將閾值推到 0.99 會殺死合法的改寫快取命中,卻無法消除來自模板密集型請求的誤報。
修正輸入,驗證輸出。不要糾結於閾值。
結果
部署這兩層防線後:
| 指標 | 之前 | 之後 |
|---|---|---|
| gpt-4.1-nano 誤報數 | 198/199 | 0 |
| 誤報佔所有快取命中的比例 | ~95% | <5% |
| 合法快取命中率 | 無變化 | 無變化 |
| 每個請求增加的延遲 | 0 | <1ms (JSON 解析 + FNV hash) |
單靠第二層就能修復翻譯外掛程式。第三層則是安全網,用於處理 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、system prompt 包裝、表單填寫任務)都會產生人為的高相似度分數。在 embedding 之前進行預處理。
驗證層是必不可少的。 研究文獻中的每個生產環境語義快取都有一個驗證層。問題在於你是使用輕量級的 hash 檢查、cross-encoder 重排序,還是完整的 LLM 驗證調用。根據你的延遲預算進行選擇。
全域閾值是一種妥協,而不是解決方案。不同類型的查詢需要不同的閾值。如果你無法做到針對每個類別或每個項目設定閾值,至少要加入輸入預處理,以標準化跨類別的 embedding 品質。
語義快取可以減少 30-70% 的 LLM API 成本。但如果沒有輸入預處理和命中驗證,你只是在提供過時的答案並稱之為效能提升。
LemonData 提供對 300 多個 AI 模型的統一存取,並內建快取、路由和成本優化功能。免費試用,即贈 $1 額度。
