學習 / 實作

KV Cache 拆解:多輪對話的歷史,為什麼還要再傳一遍?

llm · 機制拆解

從因果注意力推導 K/V 復用,用一個可計算的記憶體例子區分推理快取、前綴快取與聊天記錄。

進階 · 10 分鐘 · 更新於 2026-09-22

瀏覽統計暫不可用

本文目錄 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 多輪對話,現在可以分別檢查兩件事:程序有沒有提供正確歷史,服務有沒有高效處理這段歷史。二者相關,但要分別驗證。

寫給未來的自己合上文章前,留下一點自己的理解。

你的私人學習便箋,只存在目前瀏覽器,不會上傳或公開。清理瀏覽器資料會遺失,請匯出留存。三種語言共用這篇文章的便箋。

繼續探索

繼續探索

  1. 實作教程

    用 Java 8 呼叫大型語言模型:從第一次請求到多輪對話

    透過三個實際執行的 Java 範例,理解模型請求、JSON 解析、讀取逾時與對話歷史。

    15 分鐘已發布
  2. 機制拆解

    OpenVLA 程式碼導讀:圖像和一句指令,怎樣變成機器人動作?

    沿著 processor、視覺投影、動作 Token 與反正規化追蹤一次推理,重點檢查動作單位、邊界索引和部署介面。

    12 分鐘已發布

接下來,走哪條路?

依據文章關聯與同主題已發布內容整理,不使用隨機推薦。

全部文章