~/blog/chat-template-kv-mtp-free-speedup

改裝 2080 Ti 22G · part 13

[Benchmark] 換一個檔案、改一個數字,2080 Ti 上的 27B 免費快 15%

cat --toc

TL;DR

一張 2080 Ti 上的 Qwen3.8-27B,不加卡、不換量化,decode 從 41.6 拉到 47.6 tok/s(+15%),多輪對話 KV 快取命中 94.5%。改動只有兩個:①把 GGUF 內嵌的舊版 chat template 換成社群修復版——舊版缺歷史 <think> 處理,每輪前綴都在變,快取必定失效;②--spec-draft-n-max 從舊設定的 2 開到 4——MTP 頭的訓練深度是 7,原本只用了兩格。64K 長對話從每輪實測重刷 163 秒變成秒回。

前言

跟人聊天最累的一種狀況:對方每講一句,你都得把前面聊過的全部重講一遍給他聽。我的本地模型就過著這種日子——每一輪對話,伺服器都把 64K token 的歷史從頭算一次,394 tok/s 的 prefill 速度,實測一輪等 163 秒。

Part 12 講過為什麼在 Turing 老卡上換量化格式榨不出速度。這篇反過來:模型不動、量化不動、卡也還是那張 2080 Ti 上的 Qwen3.8-27B,只換一個 jinja 檔、改一個數字,decode +15%,長對話的等待從三分鐘變一秒。

兩個改動都免費,而且你的機器上大概也有同一筆錢沒領。

你會拿到什麼

項目改之前改之後
decode41.6 tok/s47.6 tok/s
多輪 KV 快取命中不穩定(常常整段重刷)94.5%
64K 對話每輪等待實測 163 秒秒級

這表看第二列就好:快取命中才是體感差距的大頭。decode 快 15% 是加分,不用重刷三分鐘才是換人生。

環境:RTX 2080 Ti 22G、llama.cpp(2026-08 build)、Huihui-Qwen3.8-27B-abliterated Q4_K(16.8GB 全上 GPU)。做法對任何用 GGUF 內嵌 template 的 Qwen3.5/3.6/3.8 都適用。

先驗屍:你的 template 八成也是舊的

chat template 就是把對話轉成模型輸入的那層 jinja 樣板。GGUF 檔會內嵌一份,而內嵌的版本是轉檔者當時抄的,不會跟著上游修。我這顆 GGUF 內嵌的就是舊版官方 template,三個病都在。

先把你實際在跑的 template 挖出來——別去翻 GGUF,直接問伺服器:

curl -s http://localhost:8082/props | python3 -c \
  "import json,sys; print(json.load(sys.stdin)['chat_template'])" > current-template.jinja

然後 grep 三個指紋:

grep -n "xhigh" current-template.jinja        # 指紋一:推理預設硬編最高檔
grep -n "raise_exception" current-template.jinja  # 指紋二:effort 名稱白名單,不認就炸
grep -c "think" current-template.jinja        # 指紋三:小於 10 = 沒有歷史思考段處理

我的三發全中:第 47 行寫著 reasoning_effort|default('xhigh')(思考預設開最高檔,token 先燒再說)、第 48 行的 raise_exception(client 傳個 high 進來直接拋錯誤),而 think 只出現 8 次——跟修復版的 98 次一比,前幾輪對話的思考段根本沒人處理

少了這套處理,後果就是快取失效。多輪對話時,template 重新渲染歷史的方式每輪都在變,前綴一變,llama.cpp 的 prefix cache(把算過的前綴留著,下輪直接沿用的快取)就對不上,只好整段重算。你的 64K 就是這樣一遍一遍重刷的。

舊 template 每輪重新渲染歷史導致前綴變動、快取整段重算;修復版前綴穩定、只算新增內容

掛上修復版:一個檔案,兩個參數

froggeric/Qwen-Fixed-Chat-Templates 是社群維護的修復版,同一份 template 處理 Qwen 3.5/3.6/3.8 全系列的已知問題,Apache-2.0。下載、掛上:

wget https://huggingface.co/froggeric/Qwen-Fixed-Chat-Templates/resolve/main/chat_template.jinja

# llama-server 啟動參數加兩個:
llama-server -m your-model.gguf --jinja \
  --chat-template-file ./chat_template.jinja \
  --reasoning-format deepseek \
  # ...其他參數照舊

--reasoning-format deepseek 這名字會讓人誤會——它選的不是模型品牌,是思考標籤的方言deepseek<think>...</think> 這套標籤格式,這套寫法是 DeepSeek R1 帶起來的,所以掛它的名。Qwen 用同一套標籤,所以答案就是 deepseek,選單裡根本沒有 qwen 這個值。

掛上之後驗收,同一組兩輪對話連打兩次,看第二次的快取命中:

# 回應 JSON 裡找這個欄位:
# "prompt_tokens_details": {"cached_tokens": 69}
# prompt_tokens 73、cached_tokens 69 → 命中 94.5%

沒中的那 4 個 token 就是第二輪真正新增的內容,本來就該重算。能快取的部分是 100% 命中——這已經是理論滿分。

頭是七格的,為什麼只用兩格?

第二筆錢在 speculative decoding(投機解碼:讓模型先猜幾個字再一次驗證,猜中就白賺)。Qwen3.8 內建 MTP 頭(Multi-Token Prediction,多 token 預測)——模型自己長的草稿產生器,llama.cpp 用 --spec-type draft-mtp 開。

問題出在深度。z-lab 的 DFlash2 model card 提到一句:Qwen3.8 的 MTP 是 seven-token head,訓練時就是按一次猜七個字設計的。而我 unit 檔裡多年沒動的 --spec-draft-n-max 2 只讓它猜兩個(llama.cpp 現在的預設也才 3)。

我的接受率(猜出來的字被驗證通過的比例)長期掛在 1.00——猜兩個、全中、次次全中。全中就是餵太淺的訊號,像考試每次都滿分,說明考卷太簡單。開到 4:

--spec-type draft-mtp --spec-draft-n-max 4
n-maxdecode (tok/s)接受率每步產出
2(我的舊設定)41.61.003.0
447.60.994.95
547.00.955.8
6 / 7開不了機

這表看第二列:深度 4 是甜點,接受率幾乎沒掉、每步產出從 3 個字變 5 個字。深度 5 開始邊際遞減——猜得更多、中得更少,兩相抵消。深度 6 以上不是變慢,是直接開不了機:MTP 有一塊自己的 buffer,開機時就要先要好、深度越深要越大;深度開到 6 得再多吃 260MB VRAM,我卡上剩 481MB 還帶碎片,要不到記憶體,伺服器啟動就死。細節在進階區。

兩個改動疊起來:41.6 → 47.6 tok/s,加上幾乎不再重刷的快取。都是設定檔層面的事,一毛錢沒花。

收尾

那個「每講一句就要重講全部」的朋友,病根不在記性,在他整理筆記的格式每天都在變,舊筆記永遠對不上。換一份格式穩定的筆記模板,他就記得了。

模型沒換、卡沒換。這筆錢從頭到尾都擺在那沒人領,藏在一個 jinja 檔跟一個多年沒動的舊設定裡。


進階:證據與踩坑全紀錄

不讀這節不影響使用,前面的步驟照做就能拿到同樣的結果。這節是給想深追的人和 AI 的完整除錯紀錄。

template 指紋的完整證據

內嵌的舊版 8,952 字元,froggeric v22.3 有 26,681 字元——大小差了三倍,主要就差在歷史思考段的處理機制。逐字證據:

{%- set resolved_reasoning_effort = reasoning_effort|default('xhigh') %}
{%- if resolved_reasoning_effort not in ('xhigh', 'medium', 'low') %}
    {{- raise_exception('Unexpected reasoning effort ' ~ reasoning_effort ~ '. Supported types are xhigh (default), medium, and low.') }}

這段埋了兩個雷:沒帶 reasoning_effort 的請求一律當 xhigh 處理(思考火力全開,token 先燒了再說);帶了但名稱不在白名單(比如 OpenAI 慣用的 high)直接炸。修復版把預設改成 medium、白名單改成別名對應。

前綴穩定性的驗證方法:拿同一組對話,分別渲染「前一輪」和「前兩輪」,diff 檢查前者是不是後者的嚴格前綴。舊版不是——歷史裡 assistant 帶 <think> 內容時,每輪渲染的處理方式不一致。修復版過了這個檢查,KV 快取能命中 94.5%,就是因為前綴穩了。

深度 6 為什麼是「開不了機」而不是「變慢」

我當時的預想:深度開太深,接受率下滑,速度變慢,量到數字後退回來就好。做的事:unit 檔把 n-max 改 6,重啟,等健康檢查過了就跑量測。

結果量測腳本回報 connection refused,journal 裡是這個:

E ggml_backend_cuda_buffer_type_alloc_buffer: allocating 260.02 MiB on device 0: cudaMalloc failed: out of memory
E common_speculative_init_result: failed to create MTP context
E srv    load_model: failed to create MTP context
E srv  llama_server: exiting due to model loading error

MTP 的草稿驗證需要自己的 context buffer,大小隨深度長。深度 4 的 buffer 在開機時已經配好了,深度 6 要再多 260MB——我的卡當時剩 481MB,還是碎的,配置失敗,伺服器直接退出。

反思預想:我以為深度只影響「每步猜幾個」的執行期行為,實際上它先影響「開機要配多少記憶體」。在一張塞到只剩零頭的卡上,很多參數是先被 VRAM 擋在開機那一關,輪不到效能曲線說話——你還沒量到變慢,就先看到開不了機。

這個 OOM 還附贈一個陷阱:會回應的殭屍

深度 6 的伺服器死掉後,systemd 自動重拉,再死,再拉——crash-loop。陷阱在這:每個活不過幾秒的 process,掛掉前那幾秒 health check 都會過。它綁了 port、能回 /v1/models、連 tokenize 都能做(這些都不碰 GPU),然後在第一個真正的生成請求上斷氣。

這個檢查我改了三版:先是「curl 通就算好」——被上一個還在優雅退場的舊實例騙過;加上「PID 換了新的」——被 crash-loop 的短命新實例騙過;最後加到「新 PID + 回 200 + 真的能生成出字」才穩。存活檢查這件事,問「你活著嗎」沒用,要問「你做個活給我看」。

探針數字與真實流量的落差

乾淨探針量到的接受率 0.99,同一時段真實工作流量(agent 在寫 code)的接受率是 0.67-0.78,每步產出 3.7-4.1。真實內容比探針用的句子難猜——這不是壞消息,是提醒:探針數字拿來比對設定 A 跟設定 B 很準,拿來預估實戰速度要打個八折。

我還因此誤判過一次:看到 journal 裡一批 0.76 的接受率,以為是深度 6 的量測結果出來了、驚呼跳水——回頭查 PID 才發現服務根本沒重啟過,那批數字是別的 session 正在幹活的真實流量。量測期間混進線上流量,數字再漂亮都是別人的。

收穫

最花時間的地方:不是改設定,是驗證「改動真的生效」。template 換沒換、快取中沒中、投機開沒開,每一項都要從伺服器的回應和 journal 裡找直接證據,而不是看設定檔有沒有寫。設定檔說的是意圖,journal 說的才是事實。

可搬走的診斷方法:curl /props 挖出實際在用的 template + grep 指紋,這招對任何 llama.cpp 部署都通用;cached_tokens 欄位驗快取命中,比感覺可靠;就緒檢查要驗到「能出字」,不是「能回應」。

常見問題

怎麼檢查我的 GGUF 內嵌 chat template 有沒有問題?
對跑著的 llama-server 打 curl http://localhost:8082/props,回傳 JSON 裡的 chat_template 就是實際在用的版本。拿到後 grep 三個指紋:reasoning_effort|default('xhigh')、raise_exception、以及 think 出現的次數。三個都中就是舊版官方 template。
--reasoning-format deepseek 是不是只給 DeepSeek 模型用?
不是。這個參數選的是「思考標籤的格式」,deepseek 指的是 <think>...</think> 這套標籤寫法,這套寫法是 DeepSeek R1 帶起來的,所以用它命名。Qwen 的思考模型用同一套標籤,所以解析 Qwen 也是選 deepseek,選單裡沒有 qwen 這個值。
MTP 草稿深度開越深越快嗎?
不是。實測 Qwen3.8-27B 在 2080 Ti 上甜點在 4:深度 4 的接受率 98.75%,深度 5 掉到 95% 左右、速度持平,深度 6 以上 MTP 的 buffer 要再多吃 260MB VRAM,卡上沒空間就直接開不了機。

接著讀

不想錯過新文章?

訂閱我確保不漏接!

隨時一鍵退訂。