先看一組我自己量到的數字。gemma4:26b 跑在 M4 Max 上,Ollama 回報的 decode 速度是 90.2 tok/s,在地端算快。同一次請求,我問它「用一句話說明 RAG 是什麼」,第一個答案字出現在 8.33 秒。
這兩個數字不衝突,粗算一次就知道量級。8.33 秒扣掉熱機的 load 與 prefill,有 8.0 秒真的在生成,90 tok/s 乘下去是七百多個 token——**而那七百多個幾乎全進了思考,沒有一個是答案。**它沒騙你,只是沒告訴你這 90 個 token 是在想還是在講。
Day 02 我埋過一句:「四個引擎沒辦法同場,Day 13 要花一整篇講。」Day 08 又留了一筆:「Ollama 那一口徑挪到 Day 13。」今天兩筆一起結——而要講清楚為什麼會有那 8 秒,得先示範一次:大家最想要的那張 tok/s 對照表,做起來有多難。
同一顆 Qwen3.8-27B,在 Mac、在 4070 Ti、在 DGX Spark 上,各跑幾個 token 每秒?
我試著把它做出來,第一格就填不了。
Qwen3.8-27B 的 UD-Q4_K_M 是 16.46 GB = 15.33 GiB。T2 那張 4070 Ti,CUDA 認到 11.99 GiB —— 光權重就差 3.34 GiB,KV cache、compute buffer 與 runtime 自己要用的那些都還沒開始算。
嚴格說不是「跑不了」。llama.cpp 可以只 offload 一部分層,其餘留在 CPU 與系統記憶體,這顆還是會動。但那條路徑已經不是我要比的東西:一旦有層落在 CPU,量到的是 PCIe 與 DDR5 的速度,跟另外兩台權重全程待在 GPU 上完全不是同一件事。
所以第一格不是「慢」,是沒辦法 full offload——Day 09 那條容量線,塞得下才輪到比快慢。
那就降尺寸。7B 級三台都塞得下——這時撞上第二道牆。
問題不在「哪個引擎比較快」,在於沒有一個引擎三台都是主力:
| T1 M4 Max | T2 4070 Ti | DGX Spark | |
|---|---|---|---|
| llama.cpp | ✓ Metal | ✓ CUDA | ✓ CUDA on ARM |
| vLLM / vLLM-Metal | △ 裝得起來,但不對等 | ✓ | ✓ |
| MLX / mlx-lm | ✓ | △ 有 mlx[cuda],但不是這台的主力 |
△ 同左 |
這張表兩年前可以寫成幾個乾脆的叉,現在不行了。MLX 官方已經有 CUDA backend(pip install mlx[cuda]);vLLM 也早有 Apple Silicon 的 plugin,Day 03 講過它裝得起來——只是 Docker 那份同機對照裡,同一顆 1B llama.cpp 快它 1.2–1.3 倍,波動還大得多。
能裝,跟能在這台發揮,是兩件事,而我要比的是後者。卡住的也不是格式讀不讀得進去——MLX 讀得了 GGUF 與 safetensors,vLLM-Metal 現在也吃 GGUF。卡住的是量化檔:Mac 上最快的 MLX/NVFP4、CUDA 上常見的 AWQ 或 FP8、llama.cpp 的 GGUF,通常不是同一份。想比「各平台各自最好的表現」,等於同時放掉權重、量化與 runtime 三個變因。
真要對齊,只有一個交集:llama.cpp 的 GGUF,三台都跑得動。代價是誰都沒發揮最好的自己——Mac 沒用 MLX、Spark 沒用 vLLM。這就是那句「沒辦法同場」的意思:不是誰比較快,是根本沒有同一條起跑線。
共同基準線是 llama-bench、同一顆模型檔(跟官方 Spark 跑分同一個 repo、同一個檔,大小逐一比對過)。T1 與 T2 是我自己跑的,build b10488,照 Day 08 立的那組釘子:-ngl 99 -fa off。
Spark 那欄是別人跑的:llama.cpp 官方在真機上的跑分檔,build 11fb327bf (7941),flag 是 ngl=99, n_ubatch=2048, fa=1, mmap=0, dio=1。⚠️ build 不同,-fa 也不同——我 off,官方 on。 Day 08 就量過這個開關能在跑序效應下造出 ±16% 的假象,而 FA 對 prefill 的影響又比對 decode 大。所以 Spark 那欄是近似對照,不是嚴格 A/B,prefill 那一格尤其只能看量級。

圖 1:4070 Ti 的 prefill 長到快出框,decode 卻跟 M4 Max 一樣。
| 頻寬 | pp2048 | tg32 | |
|---|---|---|---|
| Qwen2.5-Coder-7B Q8_0 | |||
| T1 M4 Max(實測) | 546 | 819.74 | 59.64 |
| T2 4070 Ti(實測) | 504 | 6555.93 | 59.27 |
| DGX Spark(官方跑分) | 273 | 2250.28 | 29.43 |
| Gemma-3-4B Q4_0 | |||
| T1 M4 Max(實測) | 546 | 1849.29 | 131.82 |
| T2 4070 Ti(實測) | 504 | 11120.91 | 146.21 |
| DGX Spark(官方跑分) | 273 | 5948.74 | 81.05 |
7B 那組:prefill 三台差到八倍,decode 卻是兩台打平、一台落後一半。4B 那組沒這麼乾淨——prefill 只差六倍,decode 也不是打平,4070 Ti 反而快了 11%。下面那把尺先用 7B 這組講,因為它最極端。
prefill 那欄比較好解釋,它更容易進到 compute-bound 那一側:矩陣運算量隨 prompt 長度成長,GPU 的算力、kernel 實作與量化路徑都會被放大。同一顆 7B,4070 Ti 是 M4 Max 的八倍。
真正有意思的是 decode 那欄。
M4 Max 的 546 GB/s 比 4070 Ti 的 504 多了 8.3%,但 7B 的 tg 是 59.64 對 59.27,差 0.6%。
拿 Day 10 那把尺量一次。兌現率是實測 tg 除以「頻寬 ÷ 每 step 要讀的權重」,再乘回帳面頻寬,就得到權重讀取等效:
| 頻寬 | 兌現率(7B) | 等效 | |
|---|---|---|---|
| T1 M4 Max | 546 | 82.1% | 448 GB/s |
| T2 4070 Ti | 504 | 88.4% | 446 GB/s |
| DGX Spark | 273 | 81.1% | 221 GB/s |
先說清楚這張表不是什麼。同一顆模型下,等效就等於 tg × 每 step 權重,它是把 tok/s 換成比較直覺的單位,不是第二次獨立量測。所以「等效比值等於 tg 比值」是恆等式,拿它當佐證等於自己證自己。要真的驗證這條模型,得換好幾個尺寸去擬合 Day 10 那條 t = W/B + t_fixed,或者直接量記憶體頻寬。
有資訊的是兌現率那一欄。三條硬體 × runtime 路徑(Metal、x86 CUDA、ARM CUDA),dense 7B 的兌現率落在 81% 到 88% 這條窄帶裡。這條帶 Day 10 的 T2(80%)、Day 12(80.3%)各自出現過一次,今天第一次三台同時對上。
M4 Max 帳面多出來的 8.3%,正好被它低了 6.3 個百分點的兌現率吃掉,兩者相乘剩 0.6%。而 Spark 的 decode 是前兩台的一半,成因很直白:頻寬正好一半(273 對 546),兌現率又跟 M4 Max 幾乎相同(81.1% 對 82.1%)。
⚠️ 這兩組裡 Metal 的兌現率都低於 CUDA(7B 差 6.3、4B 差 10.5 個百分點),方向一致,但只有兩顆模型、又跨了平台整包,成因得留給受控實驗。
換成 4B 那顆,三台的兌現率一起掉到 51.9% / 62.4% / 63.8%。方向符合 Day 10 的固定成本模型:每 step 的工作量變小,kernel launch、取樣、排程這些固定開銷佔比就上去。不過這裡同時換了三件事——架構(Qwen2.5 → Gemma 3)、尺寸(7B → 4B)、量化(Q8_0 → Q4_0),所以只能說方向一致,不能把落差全記到尺寸頭上。
上面那張表能做出來,是因為我遷就了官方跑分檔的清單——它只跑了 Qwen2.5、Gemma 3 那一輩——Qwen2.5-Coder 到今天已經快兩年。你現在裝在機器上的不是這些。
換成你真的在用的模型,表就只剩兩欄:Spark 那格沒有人跑過。
這是「沒辦法同場」的第三種形式:不只引擎不同、格式不同,連時間都不同。公開跑分通常落後你手上的模型一到兩代,而你想比的那顆,多半沒有人替你比過。
到這裡,那張三機表已經做不下去了。我本來以為,只要把模型、量化、參數跟引擎全部對齊,這張表就算公平了。後來才發現,就算真的公平,它回答的仍然不是我按下 enter 之後最在意的那件事。
llama-bench 的 tg 量的是純解碼:模型已經載好、prompt 已經算完,穩態下每秒吐幾個 token。你按下 enter 到看完答案,中間還有模型載入、prompt 處理,以及今天的重點:模型自己先想一輪。
換 Ollama 量之前,得先更正 Day 02 的一句話。當時我寫「Ollama 底層不是 vLLM,是 llama.cpp」——這句現在只對一半。2026 年 3 月 30 日的 0.19 在 Apple Silicon 加了 MLX engine;6 月 5 日的 0.30 又把 GGUF/llama.cpp 那條路補回來並加強,官方自己的說法是「augments Ollama's MLX engine」。現在是混合的,同一個 API 底下不保證是同一個 runner。
這件事不用猜,ps 就看得到。三顆模型同時掛著的時候:
ollama runner --mlx-engine --model qwen3.8:27b-mlx --port 54806
llama-server --model .../sha256-3d0b790534fe… -c 40960 …
llama-server --model .../sha256-7121486771cb… -c 262144 …
對回 ollama show,兩邊完全吻合:
| 模型 | architecture | quantization | runner |
|---|---|---|---|
qwen3.8:27b-mlx |
qwen3_5 | nvfp4 |
--mlx-engine |
gemma4:26b |
gemma4 | Q4_K_M |
llama-server |
qwen3.6:latest |
qwen35moe | Q4_K_M |
llama-server |
同一個 ollama run,一顆走 MLX、兩顆走 llama.cpp。「Ollama 的 t/s」這六個字,連底下是不是同一個引擎都不保證。
這反而把今天的主題坐實了。Day 08 那張表量的是純引擎吞吐,Day 02 那張量的是 Ollama 的端到端,口徑不同不能混填——現在還得再加一句:同一個口徑裡也可能混著兩個引擎。
換成端到端口徑再量一次,同一台 M4 Max、同一顆 qwen3.8:27b-mlx,同一句提問問三次(thinking 開著,等一下就知道為什麼要強調):

圖 2:紅色的思考,比綠色的答案還長。
| 看到第一個答案字 | 端到端 | load | prefill | decode | |
|---|---|---|---|---|---|
| 第一問(剛卸載完) | 3.16s | 4.31s | 0.954 | 0.191 | 3.160 |
| 第二問 | 1.98s | 3.15s | 0.045 | 0.189 | 2.916 |
| 第三問 | 2.25s | 3.25s | 0.045 | 0.000 | 3.205 |
load + prefill 只花 1.15 / 0.23 / 0.05 秒,第一個答案字卻要等 3.16 / 1.98 / 2.25 秒。中間差的那兩秒模型都在想,而思考的 token 也是 token,算在 decode 那一欄裡。(第一問那格 3.16 跟它自己的 decode 3.160 撞在一起是巧合——那次答案剛好講了 1.15 秒,跟 load + prefill 一樣長。)
順帶把口徑釘一下:**這一欄不是 Day 02 那個 TTFT。**思考的 token 早就開始生了,我量的是第一個 message.content 非空的時間——第一個「答案」字。對會思考的模型,這兩個口徑差的就是整段思考。
三次的 decode 都在三秒上下,端到端卻從 4.31 秒掉到 3.15 秒。變的是前面那兩段:第一次多花的 0.95 秒全記在 load_duration,第三次連 prefill 都歸零——prefix cache 全中,那是 Day 18 的主題。
⚠️ 這裡的「冷」還不夠冷。ollama stop 卸掉的是 model runner、放掉它佔住的記憶體,模型檔本身還躺在 macOS 的 page cache 裡,所以只花 0.954 秒。真正沒讀過的第一次會慢得多——我第一輪量到的是 6.07 秒。Day 02 寫過「冷啟可達數十秒」但只是社群回報的量級,這筆帳 Day 17 單獨算。
前面那張表還是熱的、還是短問題,而且只有一顆模型。把 thinking 當開關,換三顆模型再問一次:
同一句「用一句話說明 RAG 是什麼」、temperature 0、seed 42、不設 num_predict(Ollama 預設不限,一般人也不會去改)。

圖 3:綠色那截才是答案,兩顆 MoE 幾乎看不見。
| 模型 | thinking | 開始講 | 端到端 | 想幾字元 | 答幾字元 |
|---|---|---|---|---|---|
gemma4:26b |
開 | 8.33s | 8.65s | 2332 | 40 |
| 關 | 0.28s | 0.82s | 0 | 78 | |
qwen3.6 |
開 | 8.90s | 9.45s | 2437 | 68 |
| 關 | 0.21s | 0.66s | 0 | 61 | |
qwen3.8-mlx |
開 | 2.08s | 3.35s | 185 | 87 |
| 關 | 0.11s | 0.99s | 0 | 68 |
開場那 8.33 秒,答案在這裡。兩顆 MoE 想了兩千三、兩千四百個字元,才講出四十到七十個字元(是輸出字串長度,不是 token 數;gemma4 那 2,332 字元約對應七百多個 token)。「開始看到答案」被推遲 18 到 42 倍,端到端差 3.4 到 14.3 倍。全部都是模型自己講完(stop),沒有任何人為截斷。
而 decode 速度呢?90.2 對 98.4、85.2 對 88.8、46.3 對 44.6——幾乎沒變。
引擎沒有變慢,拉長等待的是模型為這道一句話就能答的題做了一輪用不上的推理。thinking 對數學、程式、多步規劃是真的有用,只是這題不需要——而純引擎吞吐那張表,一個字都看不出這件事。這正是 Day 02 說的:只看那四個指標,它們會全部報給你「一切正常」。
怎麼把這個開關關掉、關掉之後答案品質掉多少,Day 16 整篇處理。今天只需要記住一件事:它不是 decode 變慢,所以單看 tok/s 永遠抓不到它。
一台機器就能重現後半段,指令是 Ollama 的 /api/chat:
curl -s http://localhost:11434/api/chat -d '{
"model":"gemma4:26b",
"messages":[{"role":"user","content":"用一句話說明 RAG 是什麼。"}],
"stream":true, "think":false,
"options":{"temperature":0,"seed":42}}' --no-buffer
把 "think" 在 true / false 之間切,量兩件事:第一個 content 非空的時間、以及整串跑完的時間。
⚠️ 兩個我自己踩過的坑,第一個連我原本的診斷都是錯的。
我第一版用 /api/generate,拿到 "response":"" 但 eval_count 照跳,歸因成「它不套 chat template」。回頭查官方文件才發現不是——它一樣會套 template,除非你自己指定 raw:true。真正的原因是思考的 token 進了另一個欄位:兩個 endpoint 都有 thinking(/api/chat 是 message.thinking 對 message.content),我只讀 response 當然是空的。兩個都能用,只讀一欄才會量到「生成空內容」的速度。
第二個坑:不要設 num_predict。我第一版設了 512,模型還在想就被砍斷,量出「永遠拿不到答案」的假象——那是我造成的,不是模型的行為。
明天,Day 14:把今天量到的 t/s 換算成「這台機器能同時服務幾個人」。Little's Law 從紙上算出它該在哪個 rate 崩,掃描表從實機量出它實際在哪裡崩——兩個數字擺在一起,公式才算被驗過。
咱們明天見。