AI 互動教室 ‹ 個人地端大語言模型實作
下載 .py 單獨開啟實驗場 ↗ 留言回報
SPEC DECODE · 06

投機解碼:
先猜後驗

上一課你把 KV cache 往 SSD 搬,換回來的是記憶體;這一課換的是時間—— 而且不用多買任何東西,因為你的 GPU 在吐字的時候其實一直在發呆。 做法聽起來很像作弊:找一個很小很快的模型先猜接下來 5 個字, 再讓大模型一次驗完。按按看,看大模型怎麼批改:

已確定的字 (還沒開始)
這一輪
草稿猜 5 個
85%
大模型每驗一次,就往前推進「猜對的字數 + 1」個字。

右邊的實驗場是真的 Python(在你的瀏覽器裡跑,不用安裝任何東西)。 首次載入約需 30–60 秒,正好夠你讀完第 1 節。上面這個小玩具是隨機抽的, 右邊會把同一件事跑三萬輪、跟公式對答案。改壞了重新整理就復原——盡量玩。

01 · 問題起點

GPU 其實一直在等

自回歸解碼每吐 1 個字,就要把整份模型權重從 HBM 搬進計算單元一次。 8B 模型的 BF16 權重是 16 GB——搬 16 GB,只為了算出一個字。 真正的計算量小到可以忽略,這叫 memory-bound: 卡住你的不是算力,是記憶體頻寬

三件事同時發生:

  • 搬得多、算得少:生成 1 個 token 要搬 16 GB 權重,計算量卻只有一個位置的份。
  • 算力閒置 70%+:解碼階段 GPU 計算單元的利用率通常不到 30%,其餘全在等資料。
  • 串行依賴:第 N+1 個字要等第 N 個字生出來,本質上無法平行。
搬權重實際計算總時間
生成 1 個 token16 GB1 個 token 的量T
驗證 5 個 token16 GB(還是一次)5 個 token 的量≈ T

看懂這張表,這堂課就結束一半了:反正權重都要搬一次,順便多算幾個字幾乎是免費的。 如果能事先「知道」接下來 5 個字是什麼,就能把它們排在一起送進去做一次前向—— 權重只搬一次,時間幾乎不變。問題只剩一個:怎麼事先知道?

02 · 解法

先猜後驗:把 decode 偽裝成 prefill

答案是「找個人先猜」。一輪長這樣:

  1. 草稿模型猜 K 個字——極輕量的模型(或一個預測頭、甚至只是查前文的 n-gram), 一口氣產出 5~16 個 token。
  2. 大模型一次驗證全部——把「已確定的前綴 + 草稿猜的 K 個字」當成一整段輸入, 做一次前向。成本 ≈ 生成 1 個字。
  3. 接受的直接留下——猜對的部分等於免費賺到。

關鍵在第 2 步為什麼便宜:平常 decode 之所以慢,是因為下一個字還不知道, 只能一個一個來。草稿把未來的字先「填進去」之後,輸入就從未知變成已知—— 於是 decode 可以偽裝成 prefill 來平行算。prefill 你已經知道它很快 (處理一整段 prompt 跟處理一個字的時間差不多),這就是投機解碼的全部魔法。

每輪能推進幾個字,只由兩個數字決定:草稿的接受率 α每輪猜幾個字 K。 期望值有閉式解——右邊直接算給你看,並且用三萬輪蒙地卡羅對答案。

03 · 機制

大模型怎麼知道哪個字猜錯了

這是最多人卡住的一點:驗證的那一次前向,每個位置都同時吐出「它自己認為的下一個字」。 Transformer 本來就是這樣算的——平常 decode 時我們只用最後一個位置的預測, 中間位置的預測全部丟掉。投機解碼只是把那些本來就算好、卻被丟掉的預測撿回來用

位置 12345
草稿猜的 DEFGH
大模型在同一次前向給的 DEX
判定 ✓ 接受✓ 接受✗ 拒絕 丟掉丟掉

比對規則依取樣設定分兩種,兩種都不需要額外呼叫模型:

  • Greedy(T=0):直接比字。大模型的 argmax 等於草稿猜的字就接受。
  • Sampling(T>0)rejection sampling—— 設草稿給這個字的機率是 q、大模型給的是 p,以 min(1, p/q) 的機率接受; 拒絕時不是隨便挑一個,而是從殘差分布重新採樣。這一步是「無損」的技術核心, 下一節說為什麼。

第 3 格一旦被拒,第 4、5 格就沒有意義了——草稿是接在自己猜的 F 後面往下猜的, 前提已經錯了。所以一輪的收穫是「第一個沒中之前的連續命中數」, 不是「總共猜對幾個」。這也是為什麼右邊的模擬只需要找每輪第一個沒中的位置。

04 · 無損

被拒之後,以及為什麼「無損」是數學保證

被拒絕不是災難,是一次乾淨的重來:

  1. 丟掉——被拒的 G、H 連同它們在 KV cache 裡的位置一起 rollback (草稿模型自己的 cache 也要清)。
  2. 新前綴——大模型在第 3 格順手給的 X 直接變成新前綴的一部分。 這個修正字是免費的:它是同一次前向的產物,沒有多花任何時間。
  3. 重新猜——草稿從 X 之後重新起草,不是接著自己猜錯的地方繼續。
  4. 再驗證——循環到 EOS。

所以每一輪的起點,都是一段經大模型認證過的乾淨前綴,錯誤不會累積。 最壞情況(第一格就被拒)仍然拿到 1 個正確 token,等同不加速; 全中的話末端還會多送一個 bonus token。這就是右邊直方圖最左邊那根紅柱的意義: 它不是零,也不是負的

至於「無損」——它不是「差不多一樣好」,是可以證明的相等: 接受與否由 rejection sampling 決定,最終序列的機率分布與大模型自己逐字生成 完全相同。不是近似、不是幾乎、不是統計上顯著沒差別,是同一個分布。

這件事的實務意義很大:JSON 格式、tool calling 的參數、推理鏈都不會因為加速而出錯。 你不需要「開了之後回頭驗一遍輸出品質」,也不需要為了保險把 K 調小。 要關掉它的理由永遠只跟吞吐有關,跟品質無關。

05 · 代價

它確實更耗算力:用閒置 FLOPs 換記憶體頻寬

天下沒有白吃的午餐,只有「本來就要倒掉的午餐」。投機解碼的交易是: 多花算力(驗 K+1 個位置、外加跑 K 次草稿),少花記憶體頻寬(權重只搬一次)。 省下的是瓶頸,多花的是本來就閒置的資源——所以它划不划算, 完全取決於你的算力還閒不閒。而算力閒不閒,由併發數決定。

併發算力狀態結果
1–4 人閒置 70%+幾乎免費,純賺速度
中等開始吃緊仍划算,但邊際遞減
32+ 人已經滿載可能反而拖垮總吞吐

高併發時發生的事很具體:每一個「大概率會被拒」的草稿 token, 都在擠佔大模型這一批的 batch 容量——那個名額本來可以拿去服務別的使用者。 你把別人的吞吐,換成了自己的體感速度。

右邊 4️⃣ 把這件事畫成兩張圖:左邊是每個人的速度(使用者的體感), 右邊是總產能(老闆看的數字)。兩條曲線會在某個併發數交叉—— 那是一個教學用的簡化模型,用來看趨勢;你自己那張卡的交叉點在哪, 要用第 8 節的方法實測。

06 · 演進

從 Medusa 到 DSpark:差別只在「草稿怎麼產生」

這幾年所有的投機解碼方法,改的都是同一件事:草稿從哪來—— 自回歸、純並行、還是半自回歸。

方法草稿怎麼產生突破卡在哪
Medusa
2024
大模型頭上長 K 個輕量預測頭 不用另訓 draft model 每個頭獨立,準確率僅 ~0.6
EAGLE 1/2/3 用 hidden state 當條件,輕量 Transformer 逐字猜 接受率最高,最貼近原生分布 仍是自回歸,猜 N 字跑 N 次 → 加速卡在 2~3×
DFlash
2026.02
Block Diffusion,一次前向並行吐 16 個 token 草稿成本 O(1) block 內互相不知道,尾部接受率崩塌
DSpark
2026.07
並行骨幹 + 輕量序列 head + 置信度調度驗證 補回依賴又不亂驗 目前最完整的生產解

把這張表濃縮成一條式子,你就有了一張選型地圖:

每 token 延遲 = (T_draft + T_verify) / τ τ = 每輪平均接受的 token 數(=右邊 1️⃣ 那條曲線)

分子分母各有旋鈕,總共只有三個:草稿更快(壓 T_draft,DFlash 走這條)、 猜得更準(拉高 τ,EAGLE 走這條)、驗得更聰明(DSpark 的置信度調度)。 你看到的每一篇新論文,都在轉這三顆之中的一顆。

幾個公開報導的數字,給你一點量級感:EAGLE 這類自回歸草稿大致停在 2~3×; DFlash 在 Baseten 的實測(Qwen3-8B on B200)是端到端 3×,同組對照下 EAGLE 是 2×; DSpark 已經跑在 DeepSeek-V4 線上,公開資料稱同等吞吐下 每用戶生成速度提升 60–85%,補回 token 依賴的額外延遲成本只有 0.2–1.3%。 這些是別人的環境跑出來的引用值,不是右邊 notebook 的輸出—— 右邊給你的是「為什麼會是這個量級」的模型。

07 · 實作

vLLM 怎麼開

好消息是它免訓練、一個參數開關,vLLM / SGLang / llama.cpp 都已內建。 vLLM 統一走 speculative_config(舊的 speculative_model 參數已棄用)。離線推論:

from vllm import LLM llm = LLM( model="Qwen/Qwen3-8B", speculative_config={ "method": "eagle3", "model": "<eagle3-head-repo>", "num_speculative_tokens": 5, }, gpu_memory_utilization=0.85, )

三個常用鍵:

  • methodngram / suffix / draft_model / mtp / eagle3 / dflash / dspark
  • model:草稿模型或 EAGLE head 的路徑 (ngramsuffixmtp 可省略)。
  • num_speculative_tokens:每步猜幾個—— 這就是右邊那根 K 拉桿,也是「算力 vs 速度」的旋鈕。

起服務時同一組鍵值用 CLI 以 JSON 傳入。零額外模型的最快上手法:

vllm serve Qwen/Qwen3-8B \ --speculative-config '{"method":"ngram", "num_speculative_tokens":4, "prompt_lookup_min":2, "prompt_lookup_max":5}'

用一個獨立的小模型當草稿:

vllm serve Qwen/Qwen3-8B \ --speculative-config '{"method":"draft_model", "model":"Qwen/Qwen3-0.6B", "num_speculative_tokens":3}'

建議路徑(順序有意義,別跳):

  1. 先用 ngram 零成本驗證有沒有效—— 不用另一個模型、不佔 VRAM,跑一輪就知道你的工作負載吃不吃這一套 (重複性高的任務,例如改寫、抽取、長文問答,n-gram 接受率意外地好)。
  2. 有效再換 eagle3 / dflash 拉高接受率。
  3. 高併發務必實測 on/off 的總吞吐,別憑感覺開著。

draft_model 的兩個坑

  • 跨模型家族 tokenizer 不同:必須加 "use_heterogeneous_vocab": true—— 但它會把草稿限制在兩邊共享的 token 上,接受率通常很差, 跑得起來卻幾乎沒加速。建議選同家族的小模型。
  • VRAM 要裝得下兩個模型:單張 24G 卡跑 Qwen3-8B BF16 光權重就約 16 GB, gpu_memory_utilization 別設太低, 否則權重 + KV cache + 草稿模型塞不下。

想拿逐請求的接受率,用官方腳本 examples/features/speculative_decoding/spec_decode_offline.py—— 那個數字就是右邊的 α,量出來之後就可以回到 3️⃣ 的曲線挑你的 K。

08 · 調參

順帶收尾:單人快 vs 多人穩

投機解碼是「單人快」這一側的最後一塊拼圖。既然講到這裡, 把整組取捨一次擺出來——同一張卡,兩種完全相反的調法:

參數快速模式(少人)高併發模式(多人)作用
--max-num-seqs1~464~256最主要的旋鈕,同時 decode 的序列上限
--max-num-batched-tokens2048~40968192~16384每次 iteration 的 token 預算,越大 prefill 越有效率
--gpu-memory-utilization0.850.92~0.95KV cache 空間,直接決定能塞幾個人
--max-model-len可放寬調到剛好夠用每個序列的 KV 佔用與它成正比
--kv-cache-dtypeautofp8KV 減半 → 併發數約翻倍
--enforce-eager不要加(要 CUDA graph)影響小小 batch 時 kernel launch overhead 佔比高
--speculative-config,可 1.5~2×高併發時 GPU 已飽和,猜測反而浪費算力
--enable-prefix-cachingV1 引擎預設已開,系統提示重複時省超多
--max-num-partial-prefills12~4控制長 prompt 的 prefill 不要卡住別人的 decode
--scheduling-policyfcfspriority多人時可以做優先級

兩份可以直接抄的指令(以 24G 卡為例):

A. 低延遲模式——自己用,追求 token 噴很快

vllm serve Qwen/Qwen3-8B \ --max-num-seqs 4 \ --max-num-batched-tokens 4096 \ --max-model-len 16384 \ --gpu-memory-utilization 0.85 \ --enable-prefix-caching \ --port 8000

B. 高吞吐模式——開給一群人打

vllm serve Qwen/Qwen3-8B \ --max-num-seqs 128 \ --max-num-batched-tokens 16384 \ --max-model-len 8192 \ --gpu-memory-utilization 0.94 \ --kv-cache-dtype fp8 \ --enable-prefix-caching \ --max-num-partial-prefills 2 \ --long-prefill-token-threshold 2048 \ --port 8000

怎麼確認你真的落在你以為的位置

先看啟動 logGPU KV cache sizeMaximum concurrency for N tokens per request 這兩行, 直接告訴你理論上能塞幾個人。然後實測,掃不同併發看每人速度怎麼掉:

for c in 1 4 16 64; do vllm bench serve \ --model Qwen/Qwen3-8B \ --base-url http://localhost:8000 \ --dataset-name random \ --random-input-len 1024 --random-output-len 512 \ --max-concurrency $c --num-prompts $((c*10)) done

盯三個數字:

  • Output token throughput——總產能(右邊 4️⃣ 的右圖)。
  • Mean TPOT——每人 token 間隔,也就是體感速度(4️⃣ 的左圖)。
  • Mean TTFT——首字延遲。

實務上曲線長這樣:--max-num-seqs 從 1 拉到 16~32, 總吞吐幾乎線性上升、每人速度只掉 20~30%——這段是免費午餐。 超過某個臨界點進入 compute-bound,每人速度開始等比例下滑,總吞吐卻不再成長。 所以:先跑上面那個 sweep,找到 TPOT 開始陡降的那個併發數, 把 --max-num-seqs 設在它附近,而不是直接設 256。 設太高只會讓所有人一起變慢,還可能觸發 preemption—— log 裡出現 Sequence group ... is preempted 就是塞爆了。

09 · 實戰

換你動手

右邊最下面有一格「你的實驗區」。三個挑戰,由易到難(notebook 裡都附折疊解答):

LEVEL 1

把 1️⃣ 的 α 從 0.40 一路拉到 0.95(K 固定 5),記下每輪期望產出。 體會一件事:接受率才是主旋鈕

LEVEL 2

α = 0.85、草稿成本 6% 時,最佳 K 是多少? 用 notebook 的 speedup() 掃 K = 1…20 找最大值。 找到之後想一想:你會選最高點那個 K,還是曲線開始變平的那個?

LEVEL 3

4️⃣ 的 T_FLOP(每個序列的計算單價)改成 0.01—— 換一張算力更強的卡之後,「投機解碼開始拖垮總吞吐」的交叉點會往左還是往右? 先猜,再改常數驗證。

10 · 驗收

情境測驗

離開前試試看:下面的情境都真的會遇到。每題選一個你認為的最佳做法,選了馬上看得到解釋。

Q1 情境題

你的服務原本只有自己在用,開了投機解碼快很多。現在要開放給一個 60 人的團隊共用同一張卡,你該怎麼處理這個設定?

投機解碼是「用閒置算力換記憶體頻寬」,划不划算完全取決於你那張卡在那個併發下算力還閒不閒——這件事沒有通則,只能實測。C 的 sweep 同時給你總吞吐(老闆看的)與 TPOT(使用者看的),交叉點在哪一翻兩瞪眼。A 正好反過來:高併發時每個大概率被拒的草稿 token 都在擠佔 batch 名額,K 越大擠得越兇。B 太武斷——很多中等併發的場景開著仍然賺,直接關掉是把免費午餐丟掉。D 搞錯了瓶頸:多的 KV cache 解決的是「塞得下幾個人」,解決不了「算力已經滿載」。

Q2 情境題

你想知道投機解碼對自己的工作負載(大量長文改寫與資料抽取)到底有沒有用,但不想先花時間訓草稿模型、也不想多佔 VRAM。第一步該做什麼?

n-gram 是唯一「零成本」的選項:不用另一個模型、不佔 VRAM、一行設定就能開關,而且改寫/抽取這類重複性高的任務接受率意外地好——它回答的是「我的流量吃不吃這一套」,這正是你現在要知道的事。有效之後再換 EAGLE3 / DFlash 拉高接受率,順序不能反:A 和 D 都是先付出訓練或整合成本,才發現自己的工作負載根本沒得賺。C 是本課明講的坑:跨家族 tokenizer 開了 heterogeneous vocab 雖然跑得起來,但草稿被限制在共享 token 上,接受率通常很差,量到的結論會低估投機解碼的潛力。

Q3 錯誤診斷

同事按下面的設定跑起了服務,沒有任何錯誤訊息,但實測速度跟關掉投機解碼幾乎一樣。最可能的原因與修法是?

vllm serve Qwen/Qwen3-8B \ --speculative-config '{"method":"draft_model", "model":"HuggingFaceTB/SmolLM2-360M", "use_heterogeneous_vocab": true, "num_speculative_tokens":5}'

Qwen3 和 SmolLM2 是不同家族、不同 tokenizer,use_heterogeneous_vocab 讓它「跑得起來」,代價是草稿只能在兩邊共享的 token 上提議——接受率 α 被壓得很低,而每輪產出是 α 的連乘(1 + α + α² + …),α 一低整條曲線就貼著 1.0,等於沒加速。修法是選同家族的小模型(Qwen3-0.6B),tokenizer 一致就不需要這個開關。A 反而更糟:α 很低時 K 越大越虧,草稿成本線性增加、產出卻早就收斂(右邊 3️⃣ 的紅線就是這個形狀)。B 無關:prefix caching 省的是 prefill,跟接受率沒關係。C 方向相反——草稿模型越大越貴,端到端加速比的分母 K × 草稿成本會直接吃掉收益。

Q4 錯誤診斷

有人量到自己的接受率只有 0.6、草稿模型單步成本約大模型的 30%。他想「那我多猜一點就好」,把 K 從 5 調到 12,結果更慢了。右邊 notebook 的 speedup() 算出來是這樣——為什麼?

>>> round(speedup(alpha=0.60, k=5, draft_cost=0.30), 3) 0.953 >>> round(speedup(alpha=0.60, k=12, draft_cost=0.30), 3) 0.543

加速比 = 每輪產出 ÷ (1 + K × 草稿成本)。分子是 1 + α + α² + … + α^K,α=0.6 時第 5 項只剩 0.078、第 12 項只剩 0.002,早就收斂在 2.5 附近;分母卻從 2.5 一路線性長到 4.6。多猜的那幾個字幾乎不可能用到,成本卻照付——這就是「最佳 K 隨草稿成本往左移」的來源,草稿越貴,最佳 K 越小(本例是 1)。真正該修的是 α 或草稿成本,不是 K。A 是虛構的行為。C 症狀相似但原因不同:preemption 是 KV cache 塞爆的症狀(log 會出現 Sequence group ... is preempted),跟這裡的算術無關。D 搞錯因果:greedy 比對是驗證規則,不會讓草稿變準;而且改採樣設定會改變你的輸出行為,不是加速手段。

Q5 情境題

你的服務要輸出嚴格的 JSON 給下游解析,還會做 tool calling。團隊擔心「加速會不會讓模型偶爾吐壞掉的 JSON」,想加一輪輸出品質回歸測試才敢開。你該怎麼建議?

「無損」不是「差不多一樣好」,是可以證明的相等:rejection sampling 的接受/重採樣規則讓最終序列的機率分布與大模型自己逐字生成的分布完全相同,所以 JSON、tool calling 參數、推理鏈都不會因為加速而出錯。這正是投機解碼在生產環境被大量採用的原因——它是少數「純加速、零品質取捨」的手段。B 和 C 都是「保險起見」的直覺,但 K 與溫度都不影響正確性:K 只影響速度與算力,sampling 模式下的 rejection sampling 同樣是無損的(greedy 只是它的特例)。D 誤解了 rollback:被拒的 token 連同 KV cache 一起丟掉,每輪的起點都是大模型認證過的乾淨前綴,永遠不會有「半個被污染的結構」留在序列裡。

Python 環境載入中(首次約 30–60 秒)…讀完第 1 節它就好了