昨天我們把那場 45 秒的 demo 慘案攤開,結論是 GPU 大部分時間在做你沒叫它做的事。今天先別急著修,因為修東西之前有個更基本的問題:你跟同事回報「地端 LLM 好慢」的時候,你到底在說什麼?
「慢」至少有三種長相:等了六秒才蹦出第一個字;字有在出,但一頓一頓像網路不好;第一個字很快,但整段答案拖了一分鐘才寫完。三種病的病灶分別在 prefill、記憶體頻寬、輸出長度——藥完全不同,用同一個「慢」字描述它們,等於逼自己亂投醫。
先講結論:從今天起,「慢」這個字禁用,換成可以量測的數字——TTFT、TPOT、ITL、E2E。昨天說好給你三個,今天加碼成四個,因為 TPOT 和 ITL 是一對常被搞混的雙胞胎,得拆開講。而且不用裝任何工具:這四個裡有三個現在就躺在你 Ollama 回應 JSON 的最後六個欄位裡,只是你從來沒把它們印出來過;第四個 ITL 要多做一件事,等一下一起給你。
昨天的圖 1 畫了一次 RAG 請求的旅程,今天把量測點放上去:
圖 1:量測點放在哪裡,量到的就是誰的問題——server 端 TTFT 從請求抵達起算,不含檢索;使用者的等待感卻從按下 Enter 就開始。
這張圖要記兩件事。第一,生成階段有兩種完全不同的工作:prefill 把整份 prompt 一次平行讀完,吃的是算力;decode 一次只吐一個 token,吃的是記憶體頻寬。這兩條物理定律第 9、10 天會用實測證明,今天先記住:它們分別決定不同的指標。
第二,注意 TTFT 的起點——同一個名字就有兩把尺:server 端的 Prometheus metric 從「請求抵達 server」起算,vllm bench serve 則從「client 送出請求」起算,連網路來回都包進去(來源:vLLM bench 文件)。兩把尺都不含檢索、rerank、gateway——但你的使用者是從按下 Enter 開始等的。很多「LLM 好慢」的案子,量下去發現一半時間花在 LLM 外面,這就是為什麼尺要先放對地方。
vLLM 的壓測工具只認四個 metric 名稱:ttft、tpot、itl、e2el(來源:vLLM bench 文件)。定義與歸屬如下:
| 指標 | 定義 | 主要卡在 | 誰在乎 |
|---|---|---|---|
| TTFT | 請求抵達 → 第一個 output token | 排隊 + prefill | 聊天使用者:「等待感」 |
| ITL | 相鄰兩個 token 的間隔 | 記憶體頻寬 | 聊天使用者:「順不順」 |
| TPOT | 每個 output token 的平均時間(排除首個) | 同 ITL | 工程師:回歸測試的健檢值 |
| E2E(E2EL) | 請求抵達 → 最後一個 token | 以上全部 + 輸出長度 | agent 迴圈、沒開 streaming 的所有人 |
聊天使用者在乎 TTFT,其次 ITL。 Spheron 的 SLO 指引講得直白:600ms TTFT 配 20ms 的順暢 ITL,體感明顯比 200ms TTFT 配 25ms ITL 更差【引用,廠商 blog,非行業標準——業界至今沒有標準 SLO】。感知門檻也支持這個方向:成人默讀約 238 詞/分(Brysbaert 2019 meta-analysis,190 份研究、18,573 人)
【官方】,以 1.3–1.5 token/詞換算約 5–6 tokens/s
【推算】,出字超過 20 tok/s 人眼幾乎無感(經驗值)。所以把 TPOT 從 50ms 調到 30ms,使用者無感;把 TTFT 從 6 秒調到 0.8 秒,使用者會問你是不是換了伺服器。
agent 迴圈在乎 E2E。 工具呼叫鏈裡沒有人類在讀字,上一步的完整輸出是下一步的輸入,每一步的成本都是完整的 E2E——一條 5 步的 chain 就是 5 個 E2E 相加,streaming 幫不上任何忙。
批次在乎吞吐。 每晚要嗑完十萬份文件做摘要,單筆多等兩秒無所謂,整台機器的總 token/s 才是 KPI。這個視角明天壓測時展開。
Day 1 埋的梗今天兌現。第一種變快是 E2E 真的下降:少產 token、prefill 少算、頻寬跑滿,那是第三週的主題。第二種是 E2E 一毫秒都沒變,但體感天翻地覆:開 streaming。
同一個 8 秒的請求(數字為示意):不開 streaming,使用者盯著轉圈圈整整 8 秒,體感是 E2E;開了 streaming,0.6 秒出第一個字,之後順順讀完,體感變成 TTFT + ITL。這也解釋了為什麼線上 chat「感覺很快」、你包了 API 的內部工具「感覺很慢」——兩邊的 E2E 可能根本一樣,差別只在前端有沒有逐字吐。前端沒開 streaming,前三個指標調得再漂亮都白搭。
假設你連打 20 次同一個問題(數字為示意):18 次都是 1.2 秒,但有 2 次剛好撞上模型被卸載後的冷啟,各花 30 秒。平均 4.1 秒——問題來了,沒有任何一次請求是 4.1 秒。

圖 2:平均值落在一個沒有任何請求存在的地方——p50 告訴你平常長怎樣,p95 告訴你倒楣的人有多慘。
換成百分位就清楚了:p50 = 1.2 秒,一半請求比這快;p95 = 30 秒,最慘的 5% 長這樣。而那兩根尖峰有具體病因:Ollama 預設 keep_alive 五分鐘
【官方】,閒置就卸載模型,下一發請求得重新載入,冷啟可達數十秒(量級為社群回報)。
只看平均的人會做出災難決策——把 TPOT 調快兩成,平均變好看了,那兩根 30 秒的尖峰一根都沒動。所以本系列所有實測一律報 p50 + p95,不報單獨的平均。
而這兩根尖峰有個你一定遇過的名字:「第一問等超久,後面就順了」。它不是一個原因,是一排第一次成本疊起來的,而且分屬兩個世界【推算,機制拆解】:
keep_alive 到期卸載後重載、權重第一次從 SSD 讀進記憶體(第二次走 page cache)、kernel 首次編譯。全部記在 load_duration,上面的指令印得出來。keep_alive;向量庫的 index 第一次查詢才進記憶體;reranker 同理;再加連線池、TLS handshake、框架那層的 lazy import。這些一格都不在那六個欄位裡,因為它們發生在請求抵達 server 之前。所以下次有人說「你的 RAG 好慢」,第一句先問**「這是第幾問?」**——如果只有第一問慢,你要修的多半不是模型。而如果第一問慢在 LLM 外面,今天這四個指標會全部報給你「一切正常」:這正是本篇開頭說尺要放對地方的理由。前綴那一格 Day 18 會單獨處理,檢索那一格留到第四週。
上面兩段借了 vLLM 的說法,等一下要打的卻是 Ollama 的指令。先講清楚一件很多人搞混的事:Ollama 底層不是 vLLM,是 llama.cpp。
| 你打的東西 | 中間那層 | 真正跟 GPU 講話的 |
|---|---|---|
Ollama(:11434) |
自家 Go server:模型下載、Modelfile、keep_alive |
llama.cpp/GGML → Metal 或 CUDA |
llama-server(:8080) |
無 | llama.cpp/GGML → Metal/CUDA/Vulkan |
vLLM(:8000) |
PyTorch:PagedAttention、continuous batching | CUDA/ROCm;Mac 要另掛 vllm-metal,底層換成 MLX【官方】 |
mlx_lm.server |
無 | MLX → Metal |
四條互不相干的棧,連權重檔都是不同格式(GGUF/safetensors/MLX)——這正是 Day 13 要花一整篇講「四個引擎沒辦法同場」的原因。那為什麼用這組名詞?因為 TTFT/ITL/TPOT 是業界共用語,NVIDIA 的官方效能文件用的就是這組定義
【官方】;Ollama 自己沒有對應詞彙,它只給欄位名(prompt_eval_duration 之類),概念得你自己接上去(只有 e2el 這個拼法才真的是 vLLM 壓測工具的叫法)。今天用 Ollama 量,是因為它人人裝得起、Mac 上也是主力——術語跟 runtime 本來就是兩件事,明天你會看到,vLLM 那支壓測工具照樣打得動 Ollama。
不用壓測工具,今天就能量。Ollama 每個非 streaming 回應的尾端都帶著完整時間帳,單位是奈秒(來源:Ollama API 文件):
curl -s http://localhost:11434/api/generate -d '{
"model": "qwen3:8b", "stream": false,
"prompt": "用一句話解釋什麼是 KV cache"
}' > /tmp/r.json
python3 - <<'PY'
import json
r = json.load(open('/tmp/r.json'))
ms = 1e6
print(f"load {r['load_duration']/ms:9.1f} ms(冷啟才會大)")
print(f"prefill {r['prompt_eval_duration']/ms:9.1f} ms / {r['prompt_eval_count']} tok")
print(f"decode {r['eval_duration']/ms:9.1f} ms / {r['eval_count']} tok")
print(f"TPOT~ {r['eval_duration']/r['eval_count']/ms:9.2f} ms/tok(= {r['eval_count']/(r['eval_duration']/1e9):.1f} tok/s)")
print(f"E2E {r['total_duration']/ms:9.1f} ms")
PY
但要誠實:
這六個欄位給不出全部四個指標。 官方文件列的時間欄位只有 total_duration、load_duration、prompt_eval_duration、eval_duration【官方,Ollama API 文件】:
對得上的只有兩個半:
E2E = total_duration、prefill = prompt_eval_duration(TTFT 的主體)。
TPOT 只算近似——eval_duration ÷ eval_count 含首個 token,嚴格定義要排除
(所以上面印成 TPOT~);TTFT 也只能拿 load_duration + prompt_eval_duration 湊,排隊與網路都不在裡面。
ITL 則是完全沒有:它是逐 token 間隔的分布,一個總和欄位再怎麼除也變不出來。
要拿到真的 TTFT 與 ITL,只有一條路:開 streaming,自己對每個 chunk 打時戳。多五行,今天就補完:
curl -s -N http://localhost:11434/api/generate -d '{
"model": "qwen3:8b", "stream": true,
"prompt": "用一句話解釋什麼是 KV cache"
}' | python3 -c '
import sys, json, time
t0 = time.perf_counter(); ts = []
for line in sys.stdin:
if not line.strip(): continue
ts.append(time.perf_counter())
if json.loads(line).get("done"): break
g = sorted(b - a for a, b in zip(ts, ts[1:]))
print(f"TTFT {(ts[0]-t0)*1000:8.1f} ms (client 端起算,含連線)")
print(f"ITL p50 {g[len(g)//2]*1000:8.1f} ms")
print(f"ITL p95 {g[int(len(g)*0.95)]*1000:8.1f} ms")
print(f"TPOT {(ts[-1]-ts[0])/(len(ts)-1)*1000:8.1f} ms (排除首個 token)")
'
這一版才對得起那張表:TTFT 從 client 起算(就是圖 1 說的「按下 Enter」那把尺),TPOT 用 (末 − 首) ÷ (N−1) 正確排除首個 token,ITL 有了分布就看得到 p95——平均值藏得住的頓挫,p95 藏不住。
llama-server 更直接,timings 物件連除法都做好了(來源:llama.cpp server 文件):
curl -s http://localhost:8080/completion \
-d '{"prompt": "用一句話解釋什麼是 KV cache", "n_predict": 64}' \
| python3 -m json.tool | grep -A 9 '"timings"'
# prompt_ms / prompt_per_second → prefill
# predicted_per_token_ms → ≈ TPOT(同樣含首個 token)
# predicted_per_second → decode tok/s
跑五次,第一次當暖身不計,取中位數填進來。這張表刻意留白:第一排是你的作業,我的 T1/T2 兩排留到 Day 8 用完整壓測揭曉,免得你被我的數字定錨:
| 機器 | backend | prompt tok | prefill ms | TPOT ms/tok | decode tok/s |
|---|---|---|---|---|---|
| 你的機器 | 【你來填】 | 【你來填】 | 【你來填】 | 【你來填】 | 【你來填】 |
| T1 M4 Max 128GB | Metal(Ollama) | 【Day 8 揭曉】 | 【Day 8 揭曉】 | 【Day 8 揭曉】 | 【Day 8 揭曉】 |
| T2 2×4070 Ti | CUDA(Ollama) | 【Day 8 揭曉】 | 【Day 8 揭曉】 | 【Day 8 揭曉】 | 【Day 8 揭曉】 |
backend 不同的數字不能直接互比,所以這張表永遠帶著 backend 欄——這是本系列的規矩。
任何一台跑得動 Ollama 或 llama.cpp 的機器,模型多小都行——今天只是把回應 JSON 的欄位印出來,8GB 的機器跑一顆 3B 模型也能完整跟完。
不過今天量的一切都有個前提:一個人、打一發。明天 Day 03「壓測不是 for 迴圈」——當 8 個請求同時進來,這四個指標會各自往不同方向崩壞,而用 for 迴圈打 API 量出來的數字,從方法上就是錯的。明天講為什麼,以及正確的工具怎麼拿。
咱們明天見。