一. 前言:多跑一個模型,為什麼反而可能更快? #
本地 LLM 的 decode 很像一位資深編輯逐字審稿。 每產生一個 token,大模型都要做一次昂貴的 forward pass。 Speculative decoding 的想法是:
- 先讓較小的 draft model 快速猜幾個 token;
- 再請原本的 target model 一次驗證整段猜測;
- 接受吻合的前綴,從第一個不吻合處改由 target model 決定。
如果 draft 猜得準,target 一次 forward 就能推進多個 token。 如果猜得差,額外跑 draft model 的成本可能比省下來的還多。 所以它不是打開
--draft-model就免費加速的按鈕。 真正要回答的是:
- draft 和 target 是否相容;
- 平均可以接受多少 draft tokens;
num_draft_tokens設多少才划算;- 長 prompt、短回答和長回答的收益是否一致;
- 開啟後的文字品質是否仍符合預期。 今天拍拍君會做一套可重跑的 benchmark,而不是只截一張 tokens/s 當結論。 如果你還沒跑過 MLX-LM,先看本機模型推論入門。
二. Speculative Decoding 的驗證流程 #
先用簡化例子建立直覺。 假設 draft model 猜出:
拍拍君 / 今天 / 要 / 測試
target model 驗證後認為前三個 token 可接受,但第四個應該是「量測」。 這一輪會輸出:
拍拍君 / 今天 / 要 / 量測
接著兩個模型從新的位置繼續。 MLX-LM 的 speculative generation 會維護 target 與 draft 各自的 KV cache。 猜錯時還要把未採用部分回退,所以 cache 必須支援 trim。 這也解釋兩個限制:
- draft model 不能只是「任何比較小的模型」;
- 某些自訂或 hybrid cache 組合可能不能使用這條路徑。 理想情況下,draft 很便宜、分布又接近 target。 最糟情況則是 draft 每輪都猜錯,還多佔記憶體與時間。
三. 建立乾淨的測試環境 #
建立獨立專案:
uv init mlx-spec-decode-lab
cd mlx-spec-decode-lab
uv add mlx-lm
mkdir -p prompts results
先記錄版本與 CLI 介面:
uv run python -c "import importlib.metadata as m; print(m.version('mlx-lm'))"
uv run mlx_lm.generate --help | tee results/generate-help.txt
MLX-LM 更新很快。 文章寫作時,CLI 使用這兩個參數:
--draft-model MODEL_ID
--num-draft-tokens N
如果你安裝的版本沒有它們,先不要硬抄後面的指令。 把環境資訊一起保存:
system_profiler SPHardwareDataType > results/hardware.txt
sw_vers > results/macos.txt
uv pip freeze > results/requirements-lock.txt
Benchmark 沒有版本與硬體資訊,就很難重現。
四. Draft Model 的第一條規則:Tokenizer 必須相容 #
MLX-LM 的 stream_generate() 文件明確要求 draft model 與 target model 使用相同 tokenizer。 只有 vocab size 一樣仍不夠保險。 還要檢查相同文字是否得到相同 token IDs。
建立 check_tokenizers.py:
from mlx_lm import load
TARGET = "mlx-community/your-target-model"
DRAFT = "mlx-community/your-draft-model"
_, target_tokenizer = load(TARGET)
_, draft_tokenizer = load(DRAFT)
samples = [
"拍拍君今天要測試 speculative decoding。",
"def hello(name: str) -> str:",
"JSON: {\"ok\": true, \"count\": 3}",
"emoji 🔭 and whitespace\n\nnext line",
]
for text in samples:
target_ids = target_tokenizer.encode(text)
draft_ids = draft_tokenizer.encode(text)
if target_ids != draft_ids:
raise SystemExit(f"tokenizer mismatch: {text!r}")
print("tokenizer samples match")
這不是形式檢查。 Speculative decoding 比較的是 token,不是解碼後看起來差不多的字串。 優先選同一模型家族、同一代 tokenizer、較小參數量的 draft。 不要因為兩個模型都叫 instruct,就假設它們能配對。
五. Draft Model 不是越小越好 #
挑選 draft model 時要同時看兩件事:
| 指標 | 太差會發生什麼事 |
|---|---|
| draft 每 token 成本 | draft 本身吃掉加速收益 |
| draft 與 target 的吻合度 | acceptance 太低,target 無法一次推進 |
| 極小模型雖然很快,但若語言分布差太多,可能每一兩個 token 就被拒絕。 較大的 draft 猜得準,卻可能已經貴到失去意義。 實務上先準備兩個候選: |
- 同家族的最小可用模型;
- 同家族中稍大、但仍明顯小於 target 的模型。 用同一組 prompts 測試後再決定。 不要只拿一句「你好」做選型。 程式碼、繁體中文、JSON 與長篇解釋的 acceptance pattern 可能不同。
六. 先跑 Plain Baseline #
先建立不使用 draft model 的基準:
export TARGET_MODEL="mlx-community/your-target-model"
uv run mlx_lm.generate \
--model "$TARGET_MODEL" \
--prompt "請用繁體中文解釋 Python generator,附一個短例子。" \
--max-tokens 256 \
--temp 0 \
--seed 20260928 \
2>&1 | tee results/plain.txt
保留完整輸出與 verbose 統計。 至少記錄:
- prompt tokens;
- prompt tokens/s;
- generation tokens;
- generation tokens/s;
- peak memory;
- 從啟動到完整答案的 wall time。 第一次執行可能包含模型下載、檔案 cache 與 Metal 初始化。 因此先 warm up,再跑正式回合。 不要把冷啟動和熱執行混成同一個平均值。
七. 開啟第一組 Speculative Decoding #
指定 draft model:
export DRAFT_MODEL="mlx-community/your-draft-model"
uv run mlx_lm.generate \
--model "$TARGET_MODEL" \
--draft-model "$DRAFT_MODEL" \
--num-draft-tokens 3 \
--prompt "請用繁體中文解釋 Python generator,附一個短例子。" \
--max-tokens 256 \
--temp 0 \
--seed 20260928 \
2>&1 | tee results/spec-k3.txt
3 是合理的起點,不是宇宙常數。 如果每輪常常全數接受,可以再試較大的值。 如果第一、第二個 token 就經常失敗,增加 draft 長度只會多做白工。
同一回合裡不要順便改模型量化、prompt 與輸出長度。 一次只變一個因素,結果才可解釋。
八. Acceptance Rate 要怎麼理解? #
最直觀的定義是:
accepted draft tokens / proposed draft tokens
但單一百分比還不完整。 假設兩組測試都是 70%:
-
A 經常連續接受 3 個 token;
-
B 常在第一個失敗,偶爾整段全收。 它們對 target forward 次數的影響可能不同。 最好同時保存:
-
proposed tokens;
-
accepted tokens;
-
acceptance rate;
-
每輪連續接受長度的分布;
-
最後的 end-to-end tokens/s。 若目前 CLI 沒有直接印出 acceptance 統計,別從最終文字硬猜。 可以用 Python API 讀取每個 token 的
from_draft標記。
九. 用 Python API 收集 Draft 接受資訊 #
建立 acceptance_probe.py:
from collections import Counter
from mlx_lm import load, stream_generate
TARGET = "mlx-community/your-target-model"
DRAFT = "mlx-community/your-draft-model"
PROMPT = "請列出五個讓 Python CLI 更容易維護的原則。"
model, tokenizer = load(TARGET)
draft_model, draft_tokenizer = load(DRAFT)
if tokenizer.encode(PROMPT) != draft_tokenizer.encode(PROMPT):
raise SystemExit("tokenizer mismatch")
counts = Counter()
parts: list[str] = []
for response in stream_generate(
model,
tokenizer,
prompt=PROMPT,
draft_model=draft_model,
num_draft_tokens=3,
max_tokens=256,
):
parts.append(response.text)
counts["all_output_tokens"] += 1
counts["accepted_draft_tokens"] += int(response.from_draft)
print("".join(parts))
print(dict(counts))
from_draft=True 表示該輸出 token 來自已被 target 接受的 draft 預測。 它可以幫你比較候選模型與 num_draft_tokens。 但要注意:
accepted draft tokens / all output tokens
不等於嚴格的 proposed-token acceptance rate。 因為被拒絕的提案不會成為輸出 token。 如果需要完整分母與每輪分布,應在固定版本的 speculative_generate_step() 周圍加 instrumentation,並把 patch 一起保存。
十. Sweep Draft Length,不要只試一個值 #
先比較 1、2、3、5、8:
for k in 1 2 3 5 8; do
/usr/bin/time -p uv run mlx_lm.generate \
--model "$TARGET_MODEL" \
--draft-model "$DRAFT_MODEL" \
--num-draft-tokens "$k" \
--prompt "$(< prompts/explain-generator.txt)" \
--max-tokens 512 \
--temp 0 \
--seed 20260928 \
> "results/spec-k${k}.txt" 2>&1
done
常見曲線不是「越大越快」,而是先升、到甜蜜點、再下降。 原因很直接:
k太小:target 每輪推進幅度有限;k適中:draft 成本和接受長度取得平衡;k太大:尾端更容易猜錯,浪費 draft 計算。 最好的k也可能依 prompt 類型改變。 若線上服務混合多種工作,選穩定的中間值通常比追單一案例最高分可靠。
十一. Benchmark Dataset 要包含哪些題目? #
至少準備四類 prompts:
- 繁體中文知識解釋;
- Python 程式碼生成;
- 固定 schema 的 JSON;
- 長篇摘要或改寫。 每類準備 5 到 10 題,避免單題運氣。 JSONL 可以長這樣:
{"id":"zh-01","kind":"explain","prompt":"解釋 context manager,附短例子。","max_tokens":256}
{"id":"code-01","kind":"python","prompt":"寫一個有 timeout 的 async retry helper。","max_tokens":384}
{"id":"json-01","kind":"json","prompt":"輸出三筆測試案例的 JSON array,不要加說明。","max_tokens":256}
{"id":"sum-01","kind":"summary","prompt":"把下列規格整理成六點:...","max_tokens":512}
固定每題的:
- chat template;
- temperature 與 seed;
- max tokens;
- stop conditions;
- target 與 draft artifacts。 否則你比較到的是多個變因的混合物。
十二. 分開看 TTFT 與 Decode Throughput #
Speculative decoding 主要改善生成階段。 它不會讓超長 prompt 的 target prefill 消失。 所以要分開觀察:
| 指標 | 回答什麼問題 |
|---|---|
| TTFT | 使用者多久看到第一個 token |
| decode tokens/s | 持續生成有多快 |
| end-to-end latency | 整份答案多久完成 |
| peak memory | 兩個模型與兩份 cache 是否值得 |
| 若 prompt 很長、答案只有 20 tokens,總時間可能幾乎都在 prefill。 這時 decode 就算快 30%,使用者也不一定感覺得到。 相反地,長篇程式碼或文章生成通常更容易看到差異。 | |
| 不要用 decode tokens/s 代替整體延遲。 |
十三. 算 Speedup,也要算 Break-even #
最基本的 speedup:
plain elapsed / speculative elapsed
大於 1 才是真正變快。 建立簡單比較:
def speedup(plain_seconds: float, spec_seconds: float) -> float:
if plain_seconds <= 0 or spec_seconds <= 0:
raise ValueError("times must be positive")
return plain_seconds / spec_seconds
cases = [
("short", 3.8, 3.6),
("medium", 12.4, 9.1),
("long", 31.0, 20.5),
]
for name, plain, spec in cases:
print(name, f"{speedup(plain, spec):.2f}x")
也要把額外記憶體列入決策。 若只快 3%,卻讓系統開始 swap,實際服務反而可能更不穩。 一個實用的上線條件可以是:
- median end-to-end speedup 至少 1.15x;
- p95 latency 不惡化;
- peak memory 留有安全餘裕;
- 品質檢查全部通過。 門檻應由你的產品需求決定,不是照抄拍拍君的數字。
十四. Greedy 品質一致性要逐字比較 #
理論上,正確實作的 greedy speculative decoding 應保留 target model 的決策。 實務上仍要驗證你使用的版本、模型與 cache 組合。 MLX-LM 的公開 issue tracker 曾出現特定組合輸出分歧或 token 異常的回報。
所以 temperature=0 時應做 byte-for-byte 比較:
from pathlib import Path
plain = Path("results/plain-output.txt").read_text(encoding="utf-8")
spec = Path("results/spec-output.txt").read_text(encoding="utf-8")
if plain != spec:
raise SystemExit("greedy outputs diverged")
print("greedy outputs match")
比較前先移除兩邊不同的 CLI 統計行,但不要 normalize 模型正文。 換行、空白與標點差異也要保留,才抓得到真正分歧。 若輸出不同,先記錄可重現案例並停止宣稱「無損加速」。
十五. 常見失敗一:Tokenizer 看似相同,其實不同 #
症狀可能包括:
- acceptance 異常低;
- 輸出突然缺字;
- 中英混合或特殊符號附近出錯;
- server 只警告 vocab size,卻沒有完全拒絕。 處理方式:
- 比較多種文字的 token IDs;
- 檢查 tokenizer config 與特殊 token;
- 優先回到同家族明確配對;
- 保存失敗 prompt,不要只記模型名稱。 「都能載入」不代表「可以安全 speculative」。
十六. 常見失敗二:Prompt Cache 無法 Trim #
Speculative decoding 猜錯時要回退 cache。 如果模型使用不可 trim 的 cache,MLX-LM 會拒絕執行。 這類錯誤不是把 num_draft_tokens 改小就能解決。
先確認:
- 目前 mlx-lm 版本;
- target 與 draft 的模型架構;
- cache 類型;
- upstream 是否已支援該架構。 不要在不理解 cache 狀態的情況下 monkey-patch 掉檢查。 錯誤的 cache 回退可能讓輸出看似正常,實際 token state 已經錯位。
十七. 常見失敗三:記憶體夠載 Target,卻載不下兩個模型 #
Speculative decoding 同時需要:
- target 權重;
- draft 權重;
- target KV cache;
- draft KV cache;
- 暫時 activation 與 MLX allocator 空間。 所以 plain generation 能跑,不代表 speculative generation 也能穩定跑。 若 unified memory 接近上限,先換更小 draft,而不是立即把 target 再量化一輪。 量測 peak memory,也觀察 macOS memory pressure 與 swap。 一旦開始大量 swap,漂亮的理論 tokens/s 很快就會消失。
十八. 一份實用的結果表 #
最後不要只留下終端機截圖。 整理成可以比較的表:
| 設定 | k | median tok/s | speedup | draft share | peak GB | greedy parity |
|---|---|---|---|---|---|---|
| plain | — | 18.2 | 1.00x | — | 14.1 | baseline |
| draft-small | 2 | 22.4 | 1.23x | 61% | 15.0 | pass |
| draft-small | 3 | 24.1 | 1.32x | 68% | 15.0 | pass |
| draft-small | 5 | 23.0 | 1.26x | 70% | 15.1 | pass |
| 表中數字只是格式示範,不是任何特定模型的實測承諾。 你的文章、應用或部署文件應放自己的結果。 同時保存 raw JSONL,讓未來升級 mlx-lm 後能重新比較。 |
十九. 決策 Checklist #
準備開啟 speculative decoding 前,逐項確認:
- target 與 draft 的 tokenizer sample IDs 相同;
- draft 明顯小於 target,但 acceptance 不差;
- plain baseline 已 warm up 並重複測量;
- 至少 sweep 了數個
num_draft_tokens; - TTFT、decode、end-to-end latency 分開記錄;
- peak memory 與 swap 在安全範圍;
- greedy output parity 通過;
- sampling 任務有結構化品質指標;
- 真實 prompt mix 的 median 與 p95 都有改善;
- 模型或 mlx-lm 升級後會重新驗證。 少了最後一點,今天的最佳設定可能變成下個版本的歷史資料。
結語 #
Speculative decoding 的核心不是「小模型幫大模型寫答案」。 Draft 只是在提出候選,最終仍由 target 驗證。 它能不能加速,取決於三者的平衡:
- draft 預測有多便宜;
- target 能接受多長的連續前綴;
- 額外模型與 cache 是否仍在記憶體預算內。
拍拍君建議從
num_draft_tokens=3起跑,先量 plain baseline,再做小範圍 sweep。 速度提升要看 end-to-end,品質則要用 greedy parity 與任務測試守住。 這樣得到的不是一個看起來很厲害的參數,而是一套升級後還能重跑的決策流程。🔭