model.generate() 不會在每一步把機率分布交出來,所以 simple_backend.py 自己跑迴圈:每個 token 做一次 forward pass(帶 KV cache),依序套用 temperature、top-k、top-p,再抽樣。每個 token 因此都能回報:在模型原始分布裡的機率和排名、取樣時的機率、top-k / top-p 之後剩幾個候選,以及前 5 名候選。Greedy 模式下的輸出和 generate() 實測一致;兩個 token 機率一樣時(bfloat16 的 logits 打平比想像中常見)拿 id 小的,和 generate() 的 argmax 一樣。
解碼時每個 token 都要跑一次完整的 forward,時間主要花在把權重從記憶體讀出來;同一次 forward 多算幾個位置幾乎不會更慢(Gemma 4 E2B 在 Apple M5 上,1 個位置約 56 ms,7 個位置約 70 ms)。所以只要先便宜地猜好接下來幾個 token,主模型就能一次 forward 全部驗證。Google 為 Gemma 4 訓練了這樣的草稿模型(MTP,multi-token prediction),transformers 的 generate(assistant_model=…) 會用它,但同樣看不到每一步的分布,所以 simple_backend.py 把它接進自己的解碼迴圈:
- 猜(
_draft):草稿模型gemma-4-E2B-it-assistant約 78M 參數、4 層。它沒有自己的 KV cache,直接讀主模型的 key / value(Gemma 4 E2B 後 20 層本來就共用前面兩層算好的那份),輸入是上一個 token 的 embedding 接上主模型最後一層的 hidden state。一次 greedy 猜 6 個 token(checkpoint 的num_assistant_tokens)。 - 驗證(
_drafted):主模型對「剛確定的 token + 6 個猜測」做一次 forward,拿回 7 個位置的分布,依序交給_sample()檢查。收下的猜測不用另外 forward;第一個被退回的猜測,由主模型自己在那個位置選的 token 取代,後面全部作廢,KV cache 用crop()退回去;6 個都收下時,最後一個位置還能多給一個 token。 - 輸出不變:greedy 時,猜測剛好是第 1 名才收下,所以輸出和不開 MTP 一樣(
tests/test_real_model.py用真的模型比對)。抽樣時,猜測以「temperature、top-k、top-p 處理後它的機率」被收下,沒收下就從其他候選裡抽,每個 token 最後出現的機率完全不變(speculative sampling,tests/test_simple_backend.py用兩萬次抽樣檢查)。
收下的比例很看內容。在網頁裡用 Gemma 的預設解碼設定實測(Apple M5):
| 這一步在寫什麼 | 不開 MTP | 開 MTP | 草稿收下 |
|---|---|---|---|
| 工具呼叫(「台北現在幾點?紐約呢?」第 1 步) | 12.6 tok/s | 40.9 tok/s | 34 / 35 |
| 根據工具結果回答(同一題第 2 步) | 13.0 tok/s | 22.6 tok/s | 42 / 106 |
| 英文程式碼,約 750 tokens | 8.8 tok/s | 11.6 tok/s(單獨跑 simple_backend.py:8.1 → 29.0) |
51% |
| 中文長文(「用三段話介紹台北。」) | 10.3 tok/s | 10.3 tok/s | 7% |
用 transformers 時只有 Gemma 4 E2B 有草稿模型。在有 GPU 的 vLLM 伺服器上兩個模型都可以推測解碼,Gemma 4 E2B 用同一個草稿模型,Qwen3.5 用它自己 checkpoint 裡的 MTP 層:Docker 裡的 vLLM 在 GPU 上預設就會,自己的伺服器要加上 --speculative-config(見「在 vLLM 上跑模型」)。