📘 這是「Prompt Caching 深入淺出」系列的 第二篇(共三篇)

  • Part 1(SP-31): 省錢指南 — 為什麼 prompt caching 重要 + 六個實戰 tips
  • Part 2(本篇): LLM 推理基礎 — Prefill vs Decode、KV Cache、記憶體的噩夢
  • Part 3(即將推出): Paged Attention + Prefix Caching — OS 的古老智慧搬上 GPU

原文作者:Sankalp Shubham(@dejavucoder),Nevara 的 Founding AI Engineer,專注 AI engineering、context engineering 和 coding agents。


🤔 為什麼要往下挖?

上一篇我們學了六個省錢 tips — 穩定 prefix、append-only、sort_keys=True 之類的。

但你有沒有想過:這些 tips 到底為什麼有效?

為什麼「prefix 一樣」就能省錢?為什麼改了中間的東西,後面就全部爆掉?KV tensors 到底是什麼東西?

Sankalp 在原文裡提到一個觀點,我覺得很值得引用:

當你想在應用層優化,往下看一層抽象永遠不會是浪費時間。

他舉了 Manus 團隊 的例子 — 那群人能把 prompt caching 玩得那麼好、context engineering 做得那麼細,不是因為他們有什麼神奇的秘訣,而是因為他們理解底層引擎怎麼運作的。

知道引擎怎麼跑,才知道油門要怎麼踩。

Clawd Clawd 偷偷說:

這個道理其實到處都適用 —

你不需要「成為」引擎工程師才能開好車,但如果你知道「渦輪增壓在高轉速才有效」,你就不會在 2000 rpm 的時候抱怨車沒力。

同理,你不需要自己寫 vLLM,但如果你知道 KV cache 是怎麼運作的,你就不會在 system prompt 裡塞用戶資料然後抱怨帳單太貴。

往下看一層,上面的一切都會變得清晰 (๑•̀ㅂ•́)و✧

好,那就讓我們往下挖吧。

這一篇可能會讓你有種在上作業系統課的感覺 — 別急著關掉,我保證講得比你教授有趣(大概啦)。


🍳 Prefill 和 Decode — LLM 推理的兩個階段

LLM 生成文字不是一個動作,而是兩個完全不同的階段

想像你去一家餐廳:

  • Prefill(備料): 廚師收到你的點單後,一次把所有食材備好 — 切菜、醃肉、調醬汁,一口氣全部處理完。這是一個「大量平行處理」的步驟。
  • Decode(上菜): 備料完成後,廚師開始一道一道上菜。每道菜都要等前一道上了才做下一道。慢,但每道菜的工作量不大。

用更技術的語言來說:

Prefill(處理 prompt):

  • 模型一次看完你整個 prompt 的所有 token
  • 每個 token 透過 causal self-attention 去看前面的所有 token
  • 計算出每一層 transformer 的 Query、Key、Value tensors
  • 最後吐出第一個 output token
  • 這步是 compute-bound(算力密集) — 大量矩陣乘法,GPU 的 FLOPs 被操到滿

Decode(生成 output):

  • 拿到第一個 output token 後,把它接回 input,再跑模型,拿下一個 token
  • 一次只處理一個 token
  • 但每次都要從 GPU 記憶體載入整個 KV cache 才能計算 attention
  • 這步是 memory-bound(記憶體頻寬密集) — 運算量不大,但搬資料搬到死
Clawd Clawd murmur:

我跟你說,這兩個階段的差異根本是「搬家日 vs 日常生活」。

Prefill 就是搬家那天 — 所有家具一次搬完,累死但就那一天的事,瓶頸是你有幾台搬家卡車(算力)。

Decode 是搬完之後的日常 — 每天只需要拿一件東西出來用,但你得從一整間塞滿的倉庫裡找到它(記憶體頻寬)。倉庫越大,翻找越久。

搞懂這個區別,你就知道為什麼 prefill 優化和 decode 優化是完全不同的策略 (◕‿◕)

這個區分為什麼重要?

因為推理引擎(像 vLLM)會根據這個特性來做 排程(scheduling)

想像你是一個餐廳經理。你有一堆新訂單等著備料(prefill queue),也有正在出菜的訂單等著上下一道(decode queue)。

你會優先排誰?

vLLM 的答案是:優先 decode。

為什麼?因為 decode 是延遲敏感的 — 用戶正在螢幕前等著 token 一個一個蹦出來。如果你讓一堆 prefill 的大運算把 GPU 佔滿了,正在 decode 的 request 就會卡住,用戶就會看到回覆「凍住了」。

這就是所謂的 Chunked Prefill — 把 prefill 的 token 數量設一個上限(cap),不讓 prefill 一次吃完所有資源,確保 decode 的 request 不會被餓死。

想像餐廳規定:「不管你有多少新訂單要備料,每次最多只能備 5 份,剩下的排隊。這樣正在出菜的訂單才不會被耽誤。」

Clawd Clawd OS:

Chunked Prefill 聽起來很花俏,但核心邏輯簡單到不行 — 就是排隊理論裡最基本的「不要讓大單擋住小單」。

你去過鼎泰豐吧?如果一桌 20 人的團體客進來,廚房不會把所有爐子都讓給那桌。因為後面還有一堆兩人桌在等他們的第三籠小籠包。

vLLM 做的事一模一樣:大 prefill 你乖乖排隊分批處理,不要把正在串流 decode 的用戶晾在那裡 ┐( ̄ヘ ̄)┌


📝 沒有 KV Cache 的慘狀

OK,你知道了 LLM 推理有兩個階段。現在我們來看看,如果什麼都不快取,decode 階段會發生什麼慘事。

假設 prompt 是 “The capital of France is”

Prefill 階段,沒啥好說的:

[The capital of France is] → 模型吐出 “Paris”

到目前為止還好。接下來 decode 開始,恐怖片正式上映。

Decode 階段(沒有 cache):

第 1 輪:[The Capital of France is Paris] → 吐出 “which”

OK,多算了 5 個已知 token 的 K/V。還能忍。

第 2 輪:[The Capital of France is Paris which] → 吐出 “has”

又全部重算了一遍。開始不對勁。

第 3 輪:[The Capital of France is Paris which has] → 吐出 “the”

再算。已經在浪費生命了。

第 4 輪:[The Capital of France is Paris which has the] → 吐出 “Eiffel”

好,到第 4 輪:

[The]→K₁V₁   [Capital]→K₂V₂   [of]→K₃V₃   [France]→K₄V₄   [is]→K₅V₅   [Paris]→K₆V₆   [which]→K₇V₇   [has]→K₈V₈ → [the]

前面 7 個全部是 浪費 — 它們跟上一輪算出來的一模一樣!唯一真正新的只有 [has] 這一個!

這就像你每天早上去上班,不是繼續昨天的進度,而是把過去三個月的工作重做一遍,然後才開始做今天的新事情。

瘋了吧?但沒有 cache 的 LLM 就是這樣跑的。

Clawd Clawd 忍不住說:

說個更慘的比喻 —

想像你在考數學聯考。每一題都需要用到前面題目的答案。

沒有 KV cache 的 LLM 就像一個考生,每做一題都要從第一題開始重新計算所有答案

做到第 50 題的時候,他已經把前 49 題重新算了 49 遍了。

而且不是他笨 — 是他的大腦真的沒有記憶功能。每算完一輪就全部忘光,下一題又要從頭來。

LLM 天生是 stateless 的。它本來就沒有「記住上次算了什麼」的能力。

但人類很聰明 — 我們給它做了小抄本 (⌐■_■)


📋 KV Cache — 給 LLM 一本小抄

解法其實很直觀:算過的東西就存起來,下次直接用。

把每個 token 在每一層 transformer 算出來的 Key 和 Value tensors 存在 GPU 記憶體裡。下次要用的時候,直接去讀,不用重算。

有了 KV cache 之後,世界完全不一樣了:

Prefill(有 cache):

模型看完 [The capital of France is],算出每個 token 的 K/V,全部存進 cache,然後吐出 “Paris”

Decode 第 1 輪:

模型只看 [Paris](一個 token!)→ 去 cache 讀取前面所有 K/V → 只為 [Paris] 計算新的 K/V → append 到 cache → 吐出 “which”

看到了嗎?從「把所有 token 重新算一遍」變成「只算新的那個」。接下來更快:

第 2 輪: [which] → cache 讀取 → append → “has” 第 3 輪: [has] → cache 讀取 → append → “the” 第 4 輪: [the] → cache 讀取 → append → “Eiffel”

每一輪就是三步舞:讀 cache、算新的、存回去。

每一輪 decode 只需要處理 1 個 token。 之前所有 token 的 K/V 都從 cache 直接拿,不用重新計算。Append 到 cache 是 O(1) 操作 — 常數時間,不管你之前有多少 token。

Clawd Clawd 補個刀:

回到考試的比喻:

有了 KV cache,就像你考試的時候有一本不斷增長的小抄

做第 1 題 → 算出答案 → 抄進小抄本 做第 2 題 → 翻小抄找到第 1 題的答案 → 用它算第 2 題 → 抄進去 做第 50 題 → 翻小抄找到前 49 題的答案 → 用它算第 50 題 → 抄進去

從「每題都從頭算」變成「每題只算一步」。

這就是為什麼 KV cache 被稱為 LLM 推理最重要的一個優化 — 沒有之一 ╰(°▽°)⁠╯

為什麼 KV Cache 的效益這麼大?

因為在大多數真實場景裡 — 不管是看長文件、寫程式、還是多輪對話 — input 的 token 數遠遠多於 output 的 token 數。

你丟一整個 codebase 進去(input: 50,000 tokens),模型只回你一段修改建議(output: 500 tokens)。

Prefill 處理那 50,000 個 token 一次就好(而且是平行運算,很快)。Decode 只需要跑 500 步,每步只處理 1 個新 token。

沒有 KV cache: 500 步 x 每步重算 50,000+ tokens 的 K/V = 災難 有 KV cache: 500 步 x 每步只算 1 個 token 的 K/V = 快到飛起

input 跟 output 的比例越大,KV cache 的效益越明顯。

Clawd Clawd 補個刀:

來,我們做個直覺測試。

你用 Claude 寫程式的時候,通常會貼一整個檔案(幾千 token)然後問「這裡有 bug 嗎?」,Claude 回你三行修改建議(幾十 token)。

Input:output 的比例大概是 100:1。

沒有 KV cache 的話,那幾十步 decode 每一步都要重算幾千個 token 的 K/V。有了 KV cache,每步只算一個。

差距不是「快一點」的等級 — 是「從等三分鐘到等零點幾秒」的等級 (。◕‿◕。)


💀 記憶體的噩夢

OK 到這裡你應該覺得 KV cache 很棒,完美解決了重複計算的問題。

但等等。天下沒有白吃的午餐。

KV cache 要存在哪裡? GPU 記憶體(VRAM)。

它佔多少空間? 呃… 我們來算算看,你可能會想坐穩一點。

KV Cache 大小 = 線性成長

KV cache 的大小公式是:

kv_size = 2(K 和 V)x 層數 x KV heads 數 x head 維度 x sequence 長度 x 精度

用一個 7B 的模型來算(32 層,32 個 KV heads,head_dim 128,float16 = 2 bytes):

  • 每個 token: 2 x 32 x 32 x 128 x 2 bytes 大約是 0.5 MB

半個 MB。一個 token。讓這個數字沈澱一下。

  • 1K context(1024 tokens): 大概 512 MB per request
  • 100 個同時在線的 request: 大概 50 GB 光是 KV cache 就要吃掉

一個 7B 的小模型。100 個 request。50 GB。

要知道,一張 A100 GPU 也就 80 GB VRAM。50 GB 給了 KV cache,剩下 30 GB 要放模型權重、activation、gradient… 已經在爆炸的邊緣了。

如果是 70B 的模型呢?如果 context 是 128K 呢?如果同時有 1000 個 request 呢?

數學不會騙人。這東西 scale 不了。

Clawd Clawd 插嘴:

讓我把這個數字翻譯成人話:

每個 token 佔 0.5 MB。

你知道一張 1080p 的 JPEG 照片大概多大嗎?大概 0.5 MB。

也就是說,KV cache 裡面每個 token 佔的空間,跟一張照片差不多。

你的 prompt 有 1000 個 token?恭喜,那就是 1000 張照片佔的空間。

你的 GPU 基本上在開一個攝影展 (╯°□°)⁠╯

Clawd Clawd 忍不住說:

順帶一提,上面那個公式裡有個細節很多人沒注意到 — sequence length 是乘在裡面的。

也就是說 KV cache 的大小跟 context 長度是線性關係。Context 從 4K 變 128K,KV cache 直接膨脹 32 倍。

這就是為什麼那些標榜「支援 1M context window」的模型,你真的丟 1M token 進去,inference 成本會讓你的財務部門打電話來關心你 ( ̄▽ ̄)⁠/

經典的 OS 記憶體問題 — 搬到 GPU 上了

如果你上過作業系統的課,接下來的內容會讓你有種「似曾相識」的感覺。

最簡單的 KV cache 管理方式是:每個 request 預先分配一大塊連續的 GPU 記憶體。 但這帶來了兩個經典問題:

Internal Fragmentation(內部碎片化):

你幫每個 request 預先分配 max sequence length 的記憶體。

假設 max 是 1024 tokens,但某個 request 只用了 100 tokens。

剩下 924 tokens 的空間就這樣白白浪費在那裡,沒人能用。

想像你去停車場,每個車位都是大貨車規格的。你開一台小 Honda 去停,一個人佔了一整個大車位,旁邊的空間全部浪費。

External Fragmentation(外部碎片化):

不同 request 在不同時間結束,歸還記憶體。

結果 GPU 記憶體裡到處都是東一塊西一塊的小空隙。

新來一個 request 需要 512 tokens 的連續空間,你加起來有 600 tokens 的空閒 — 但它們散落在不同地方,湊不成一塊連續的。

分配失敗。

想像停車場裡大車陸續開走了,留下一堆分散的小空位。現在來了一台遊覽車,需要連續三個空位,但空位東一個西一個,根本停不進去。你的停車場明明還有很多空位,但就是停不下這台車。

Clawd Clawd 忍不住說:

我知道這聽起來真的很像在上作業系統課。

但重點是:GPU 記憶體遇到的問題,跟幾十年前 CPU + RAM 遇到的問題一模一樣。

Internal fragmentation — 分太多,用不完。 External fragmentation — 碎片化,湊不齊。

作業系統在 1960 年代就解決了這個問題。 GPU 推理引擎到 2023 年才開始認真處理。

歷史不會重演,但它總是押韻 ( ̄▽ ̄)⁠/

還有一個更痛的問題 — 重複儲存

假設你有 100 個用戶同時發 request,而且他們都用同一個 system prompt。

在 naive 的 KV cache 實作裡:

100 個 request = 100 份一模一樣的 system prompt KV cache。

一模一樣的資料,複製了 100 份,佔了 100 倍的記憶體。

這就像公司有 100 個員工,每個人的電腦裡都存了一份完全相同的員工手冊 PDF。一人一份,100 GB。但其實你只需要一份放在共用硬碟,讓大家讀同一份就好了。

但 naive 的 KV cache 沒有「共享」的概念。每個 request 都活在自己的世界裡,不知道隔壁的 request 算了一模一樣的東西。

Clawd Clawd 偷偷說:

讓我們算一下這有多痛:

System prompt 假設 500 tokens x 每 token 0.5 MB = 250 MB per request

100 個 request = 25 GB 光是存重複的 system prompt KV cache

如果這 25 GB 可以壓縮成一份 250 MB… 你就多出了將近 25 GB 的 VRAM 來做其他事。

這不是優化,這是基本的衛生。

而且你知道最氣的是什麼嗎?這些 KV cache 還是 per-request 的 — 生成完就丟掉了。下一個有相同 system prompt 的 request 來了?再算一次,再存一份。

如果只有某種方法可以用 block 和 pointer… 就像作業系統幾十年前解決的那樣… ヽ(°〇°)ノ


🔮 下一篇預告

所以問題很清楚了:

記憶體碎片化 — 內部浪費加上外部湊不齊。重複儲存 — 100 份相同的 KV cache 吃掉寶貴的 VRAM。沒有共享機制 — 每個 request 都是孤島。

聽起來像是一個無解的困局?

不。

作業系統在幾十年前就解決了一模一樣的問題 — 用 paging。

固定大小的 page、virtual-to-physical 的映射、reference counting、page sharing…

如果我們把這套搬到 GPU 上呢?

下一篇(Part 3),我們來看 vLLM 怎麼用 Paged Attention 把這個古老的智慧搬到 GPU 記憶體管理上,以及 Prefix Caching 如何讓不同 request 共享 KV cache blocks。

這就是 prompt caching 真正的魔法所在。

下一篇見 ╰(°▽°)⁠╯


延伸閱讀: