學習 / 實作
KV Cache 拆解:多輪對話的歷史,為什麼還要再傳一遍?
llm · 機制拆解
從因果注意力推導 K/V 復用,用一個可計算的記憶體例子區分推理快取、前綴快取與聊天記錄。
瀏覽統計暫不可用
本文目錄 6
在 Java 多輪對話 Demo 中,程序每次都把以前的問答放進 messages。看到介面返回 cached_tokens 後,很容易產生一個疑問:既然模型已經有快取,下一次能不能只發新問題?
對於需要客戶端攜帶歷史的 Chat Completions 請求,答案是不能據此省略歷史。介面需要哪些輸入,由會話協議決定;服務器怎樣加速這些輸入,由推理實現決定。 KV Cache 討論的是後一個問題。
閱讀範圍
這是一篇基於一手資料的 AI 輔助機制拆解,不是 GPU 實測。以下算例由明確假設推導,不代表 Agnes 或其他具體服務的配置、價格和延遲。無需下載模型即可核對算術。
1. 先把三種“記憶”拆開
| 層次 | 保存什麼 | 誰負責 | 丟失後會怎樣 |
|---|---|---|---|
| 應用對話歷史 | 使用者與助手消息 | Java 程序或會話服務 | 請求可能缺少完成任務需要的事實 |
| 一次生成的 KV Cache | 每層過去位置的 K/V 張量 | 推理引擎 | 可以重新計算,代價是計算時間 |
| 跨請求前綴快取 | 可復用前綴對應的快取塊 | 支持此能力的服務 | 快取未命中,仍應按完整請求計算 |
例如 vLLM 的前綴快取設計用前綴上下文和 Token 等資訊區分快取塊,目標是復用計算,不是替使用者補寫未提供的消息。cached_tokens 的具體語義還要看服務商協議,單憑這個數字不能推出“已記住上次對話”。參見 vLLM 前綴快取設計。
可以做一個不需要模型的檢查:把要發出的 JSON 印出出來。如果關於“我正在學習 Java”的資訊既不在請求中,也沒有通過會話標識交給有狀態服務,那么客戶端並未提供這條事實。模型偶然答對也不能證明它持久保存了歷史。
2. 為什麼保存 K/V,而不是把舊 Q 也存下來?
在一層注意力中,可以把 Q 看作當前位置用來查詢的向量,把 K/V 看作可被讀取的位置表示。核心運算是:
scores = Q × transpose(K) / sqrt(head_dim) + causal_mask
weights = softmax(scores)
output = weights × V因果遮罩讓一個位置無法讀取未來位置。於是,在模型參數、輸入前綴和位置處理保持一致的推理中,追加新 Token 不要求重算舊位置的表示。新位置仍要計算自己的 Q/K/V;舊位置的 K/V 可以復用。新查詢不需要再使用舊查詢向量,所以標準 KV Cache 不保存舊 Q。機制依據:Transformer 原論文 與 Hugging Face 快取說明。
假設提示詞處理後有 3 個位置:
已快取:K[0..2]、V[0..2]
處理新位置 3:計算 q3、k3、v3
讀取:q3 與 K[0..3] 計算權重,再對 V[0..3] 加權
保存:追加 k3、v3
輸出:用該位置的隱藏狀態預測下一個 Token這裡快取的是每一層的張量,不是把輸入文本復制進一個字典。修改前綴中的一個 Token 後,其後位置的表示可能改變,不能把原來的整段快取不加判斷地繼續接上。
3. Prefill 與 Decode 的工作不同
Prefill 處理提示詞,建立快取,並可從最後位置得到首個輸出 Token 的分布。Decode 隨後逐步處理新生成的位置,繼續擴展快取。一次生成並不是每次都重新跑完整提示詞。
但“有快取”不意味著後續每步都是固定成本。對標準全注意力,當前查詢仍需訪問越來越長的歷史 K/V。長上下文節省了重復投影和舊位置計算,卻增加了快取占用與讀取量。不能把“單步只新增一個位置”理解成“只看最後一個 Token”。
這也解釋了兩個延遲指標為何應分開觀察:首個 Token 的等待與整個回答生成耗時。網絡、排隊、提示長度、輸出長度都可能參與;僅憑一次 API 超時,無法判斷是不是 KV Cache 配置導致。
4. 一張紙算出快取大小
對每層都使用全注意力、統一 KV 頭數與精度的簡化模型,單個 K 或 V 的元素數是 B × Hkv × T × D。因此:
KV 位元組數 = 2 × L × B × Hkv × T × D × S
2 K 與 V 兩份
L 層數
B 同時保留的序列數
Hkv KV 頭數(不是查詢頭數)
T 每條序列快取的位置數
D 每個頭的維度
S 每個元素的位元組數GQA 允許多組查詢共享更少的 KV 頭,所以計算時不能直接把查詢頭數代入。參見 GQA 原論文。
教學假設:32 層、8 個 KV 頭、頭維度 128、每元素 2 位元組;每條序列長度相同,不做前綴共享,不計額外開銷。
| 序列數 B | 長度 T | 理論 KV 占用 |
|---|---|---|
| 1 | 4,096 | 512 MiB |
| 1 | 8,192 | 1 GiB |
| 4 | 8,192 | 4 GiB |
| 4 | 32,768 | 16 GiB |
可用 Node.js 或瀏覽器控制臺核對:
function kvGiB({ layers, sequences, kvHeads, tokens, headDim, bytes }) {
return (2 * layers * sequences * kvHeads * tokens * headDim * bytes) / 2 ** 30;
}
const config = { layers: 32, sequences: 1, kvHeads: 8,
tokens: 8192, headDim: 128, bytes: 2 };
console.log(kvGiB(config)); // 1 GiB
console.log(kvGiB({ ...config, sequences: 4, tokens: 32768 })); // 16 GiB如果把 KV 頭數從 8 改成 32,其他假設不變,表內各值變為四倍。這是公式敏感性分析,不是更換真實模型後的測量結果。
總顯示記憶體還包含模型權重、激活、執行時工作空間和分配開銷。滑動窗口、混合層、不同序列長度、快取量化與共享會改變估算方式。不能用“權重能放進顯卡”直接推導“任意長對話都能跑”。
5. 三種優化,各自付出什麼?
Hugging Face 提供動態、靜態、卸載和量化等快取策略。動態快取隨長度增長;靜態快取預留容量,便於某些編譯優化;卸載降低 GPU 駐留需求但引入傳輸;量化降低存儲精度,也可能增加處理成本。選擇與模型和執行方式有關,不存在統一最快選項。參見 官方快取策略文檔。
將這些機制映射回應用問題:
- 長文問答經常顯示記憶體不足:先確認長度與並發對應的 KV 容量,再決定是否裁剪、共享或更換快取策略。
- 短請求用了量化反而更慢:節省的空間未必能抵消額外處理;需要測量,而不是只比較位寬。
- 同一系統提示重復很多次:有前綴復用機會,但命中還受 Token 前綴、位置、模型與服務策略約束。
對於調用托管 API 的 Java 客戶端,上述服務端策略未必可配置。客戶端更直接的職責是明確上下文預算、保留必要歷史、區分輸入與輸出用量,並記錄失敗。不要把底層名詞當成不存在的介面開關。
6. 用三個反例檢查理解
“有 KV Cache,所以可以不傳歷史。” 混淆了請求語義與計算復用。先確認協議如何提供上下文。
“快取減少顯示記憶體。” 相對不保留舊張量的實現,KV Cache 用記憶體換計算。量化或卸載是在這個快取之上繼續做權衡。
“上下文翻倍,延遲一定翻倍。” 本文公式只推導統一假設下的快取位元組數,不推導端到端延遲。硬件、批處理、注意力內核和排隊都會影響結果。
回到 Java 多輪對話,現在可以分別檢查兩件事:程序有沒有提供正確歷史,服務有沒有高效處理這段歷史。二者相關,但要分別驗證。
寫給未來的自己合上文章前,留下一點自己的理解。
你的私人學習便箋,只存在目前瀏覽器,不會上傳或公開。清理瀏覽器資料會遺失,請匯出留存。三種語言共用這篇文章的便箋。
繼續探索
繼續探索
接下來,走哪條路?
- 用 Java 8 呼叫大型語言模型:從第一次請求到多輪對話 →
透過三個實際執行的 Java 範例,理解模型請求、JSON 解析、讀取逾時與對話歷史。
- OpenVLA 程式碼導讀:圖像和一句指令,怎樣變成機器人動作? →
沿著 processor、視覺投影、動作 Token 與反正規化追蹤一次推理,重點檢查動作單位、邊界索引和部署介面。
依據文章關聯與同主題已發布內容整理,不使用隨機推薦。