Agent 語義快取實作課:別再為同一個問題付兩次錢
前言:使用者很少用同一句話問兩次
Agent 上線後最常見的浪費,不是模型選太貴,而是同一個問題被重複付費。客服 Agent 每天會收到幾十種問法的同一個問題:「退貨要幾天?」「退貨流程要多久?」「商品退回需要幾個工作天?」字面全不一樣,意思完全一樣。傳統快取用字串當鍵,只認得一模一樣的輸入,面對這種流量幾乎無用武之地——實務經驗指出,精確比對的命中率常常只有個位數。
語義快取(Semantic Cache)就是為了解決這個落差:不再比對字面,而是比對意思。把問題轉成向量,找過去回答過的最相近問題,夠接近就直接回傳舊答案,跳過模型呼叫。Zilliz 的 GPTCache 自 2023 年起就是這條路線的代表作;2024 年的相關論文報告,在不同查詢類別下命中率落在六成以上。
本篇談語義快取怎麼做才安全:三層快取怎麼分工、相似度門檻怎麼調、哪些東西絕對不能快取,以及一段可以直接落地的骨架。
一、快取的三層:先分清楚你在快取什麼
如圖一,語義快取的標準流程只有四步:收到問題、向量化、相似度搜尋、命中回傳或未命中呼叫模型。但實作時要先分清楚三層不同的快取,混在一起最容易出錯。
第一層是精確快取:鍵是問題原文或雜湊值,命中條件是完全相同。成本最低、零誤判風險,但只對固定提示詞有用,例如 Agent 每次呼叫前都會貼上的同一段系統指令、同一份工具描述。LangChain 的 InMemoryCache 與 RedisCache 都屬於這一層。
第二層是語義快取:鍵是問題的向量,命中條件是餘弦相似度高於門檻。這是本篇的主角,適合使用者問句、客服問答、重複出現的子任務。代價是每次查詢要多一次向量化,以及誤命中的風險。
第三層是提示快取(Prompt Cache):供應商端提供的機制,快取的是提示詞前綴的鍵值(KV)計算結果,適合長上下文重複使用的場景,例如同一份文件被反覆提問。它省的是計算,不是模型呼叫本身,與語義快取互補而非互斥。
三層的選擇原則很直接:固定文字走精確快取,換句話說的同義問題走語義快取,長上下文重複走提示快取。
二、門檻是唯一的開關,也是唯一的風險
語義快取沒有什麼神祕參數,真正決定成敗的只有一個數字:相似度門檻。如圖二,門檻是一條光譜,兩端都是坑。
門檻設太低,命中率好看,但誤命中跟著來。最經典的反例是否定詞:「如何開啟雙重驗證」與「如何關閉雙重驗證」在多數向量模型下的相似度高達 0.95 以上,因為主詞與名詞片語完全主導了向量。快取若不加防護,會把開啟的步驟回給想關閉的使用者,這不是省錢,是製造事故。
門檻設太高,誤命中消失了,命中率也跟著消失。快取變成裝飾品,團隊很快就會認定語義快取沒用,把它拆掉。
實務上的起點可以參考已公開的經驗值:問答與客服場景從 0.93 左右開始,事實型或檢索型內容取 0.92 以上;GPT 語義快取論文的操作區間落在 0.91 至 0.95;Redis 的 LangCache 教學則以 0.65 作為其預設刻度的起點——注意,不同函式庫的刻度方向與定義不同,抄數字之前先確認你用的是相似度還是距離。起點只是起點,真正的做法是分群調校:短查詢提高門檻,長查詢可以放寬;高風險主題(帳號、付款、權限)提高門檻,低風險主題(一般說明、操作指引)放寬。
除了門檻,還有三道防護值得一起上線。第一是否定詞防護:查詢與命中候選若在否定詞、數字、方向詞上有差異,直接判為未命中。第二是語言過濾:向量不分語言,英文問題可能命中中文答案,查詢時必須以語言作為過濾條件。第三是評審覆核:對高風險主題,快取命中後先讓一個輕量模型或規則判斷候選答案是否真的適用,成本遠低於重跑一次大型模型。
三、不能快取的東西,比能快取的更重要
語義快取最危險的誤用,是把「看起來可以重用」的輸出全部塞進去。以下四類,預設一律不進快取。
第一類是帶副作用的工具呼叫結果。查訂單、扣款、寄信、寫入資料庫,這些呼叫的結果取決於當下狀態,快取等於把舊世界回給新問題。語義快取只快取純粹的語言生成,不快取行動。
第二類是時效性內容。價格、庫存、匯率、政策截止日期,答案會隨時間改變。真的要快取,必須配上存活時間(TTL),事實型內容常見的做法是 24 至 72 小時,並且在資料來源更新時主動失效。
第三類是個人化內容。帶有使用者姓名、帳號、訂單編號的回答,絕對不能被另一個語義相近的使用者命中。快取鍵必須綁定使用者或租戶範圍,查詢時以範圍過濾,否則就是資料外洩。
第四類是模型版本與提示詞版本不同的輸出。換了模型、改了系統提示詞、換了向量模型,舊快取的語義空間已經改變,必須整批失效或重新建立。向量模型一換,相似度數字就不再可比,這是最容易被忽略的失效條件。
四、實作架構:查詢前檢查、寫入時標記、命中後記錄
把語義快取接進 Agent,架構可以分成三段。
第一段是查詢前檢查。Agent 每次要呼叫模型之前,先把使用者問題(不是整段含檢索上下文的提示詞)向量化,查一次快取。命中就回傳,並標記這次回應來自快取;未命中才呼叫模型。只對原始問題做向量化是關鍵:若把檢索到的上下文也包進向量,幾乎每個查詢都會變成唯一值,命中率立刻歸零。LangChain 社群也有人提出同樣的呼籲:應該在問題層級做快取,而不是在渲染後的完整提示詞層級。
第二段是寫入時標記。每次模型回應寫入快取時,同時寫下四個欄位:原始問題、回應、模型與提示詞版本、寫入時間。TTL 由內容類型決定,寫入時就定好,不要事後補。向量模型的名稱也要寫進中繼資料,換模型時才能整批辨認。
第三段是命中後記錄。每一筆查詢都要留下日誌:查詢文字、命中候選文字、相似度分數、命中或未命中。儀表板上至少要有四個數字:命中率、平均命中相似度、快取大小、淘汰率。平均命中相似度下滑,通常是第一個警訊:可能是使用者問法分布變了,也可能是向量模型被換掉了。
五、最小可跑骨架:先讓門檻與防護看得見
不需要先導入整套函式庫,先把規則寫成程式,讓門檻與防護成為顯式邏輯:
# 語義快取骨架:門檻、否定詞防護、TTL 與命中日誌都要顯式寫出來
SIM_THRESHOLD = 0.93 # 問答場景起點,短查詢再提高
SHORT_QUERY_THRESHOLD = 0.95 # 短查詢更容易誤撞,提高門檻
TTL_SECONDS = 24 * 3600 # 事實型內容一天,依內容類型調整
def answer_with_cache(question, user_scope, call_model):
if has_side_effect(question) or is_personal(question):
return call_model(question) # 副作用與個人化內容不進快取
vec = embed(question)
hit = cache.search(vec, scope=user_scope, top_k=1)
threshold = SHORT_QUERY_THRESHOLD if len(question) < 20 else SIM_THRESHOLD
if hit and hit.similarity >= threshold and not negation_differs(question, hit.question):
log_lookup(question, hit.question, hit.similarity, "hit")
return hit.answer # 命中:標記為快取回應後回傳
answer = call_model(question)
cache.store(vec, question, answer, scope=user_scope, ttl=TTL_SECONDS)
log_lookup(question, None, hit.similarity if hit else 0.0, "miss")
return answer
這段骨架的重點不在向量搜尋本身,而在它把三個最容易被省略的決定寫成了程式碼:什麼不進快取、門檻隨查詢長度變化、每一次查詢都有日誌。先有這三個決定,再談要選 GPTCache、Redis 語義快取還是自建向量庫,選型才有依據。
結論:快取省的是重複,不是思考
語義快取的價值主張很樸素:使用者用不同句話問同一個問題時,系統應該認得出來。它的成本也很樸素:一次向量化、一個門檻、四個日誌欄位。真正的風險不在技術,而在治理——門檻沒人調、失效沒人管、副作用與個人化內容混進了快取。
上線順序建議是:先從低風險的問答場景開始,門檻取 0.93,跑兩週看命中率與誤命中日誌,再逐步放寬到其他主題。
參考來源
Redis LangCache 語義快取教學:https://redis.io/tutorials/semantic-caching-with-redis-langcache/
Redis 語義快取 API 文件:https://redis.io/docs/latest/develop/ai/redisvl/0.15.0/api/cache/
GPTCache 專案說明(Zilliz):https://zilliz.com/what-is-gptcache
GPTCache:為何要快取向量而非整段回應:https://dev.to/gabrielanhaia/stop-caching-the-whole-llm-response-cache-the-embedding-1928
IC-Cache:以情境內快取提升大型語言模型服務效率(SOSP 2025):https://arxiv.org/pdf/2501.12689
LangChain 社群:多語言語義快取的語言過濾討論:https://forum.langchain.com/t/semantic-caching-strategy-for-multilingual-chatbot-how-to-handle-language-specific-cache-entries/2903.rss
沒有留言:
張貼留言