iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0
AI Engineering

不是模型太慢,是你沒算過這筆帳:地端 LLM 工程實戰 30 天系列 第 2

Day 02 - TTFT、TPOT、ITL:別再說「好慢」,一次請求的時間帳

  • 分享至 

  • xImage
  •  

昨天我們把那場 45 秒的 demo 慘案攤開,結論是 GPU 大部分時間在做你沒叫它做的事。今天先別急著修,因為修東西之前有個更基本的問題:你跟同事回報「地端 LLM 好慢」的時候,你到底在說什麼?

「慢」至少有三種長相:等了六秒才蹦出第一個字;字有在出,但一頓一頓像網路不好;第一個字很快,但整段答案拖了一分鐘才寫完。三種病的病灶分別在 prefill、記憶體頻寬、輸出長度——藥完全不同,用同一個「慢」字描述它們,等於逼自己亂投醫。

先講結論:從今天起,「慢」這個字禁用,換成可以量測的數字——TTFT、TPOT、ITL、E2E。昨天說好給你三個,今天加碼成四個,因為 TPOT 和 ITL 是一對常被搞混的雙胞胎,得拆開講。而且不用裝任何工具:這四個裡有三個現在就躺在你 Ollama 回應 JSON 的最後六個欄位裡,只是你從來沒把它們印出來過;第四個 ITL 要多做一件事,等一下一起給你。

時間軸攤開,尺要放在對的地方

昨天的圖 1 畫了一次 RAG 請求的旅程,今天把量測點放上去:
https://ithelp.ithome.com.tw/upload/images/20260813/20183550xLD4PVZs01.png
圖 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 名稱:ttfttpotitle2el(來源: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,前三個指標調得再漂亮都白搭。

平均值會說謊:一個 keep_alive 的例子

假設你連打 20 次同一個問題(數字為示意):18 次都是 1.2 秒,但有 2 次剛好撞上模型被卸載後的冷啟,各花 30 秒。平均 4.1 秒——問題來了,沒有任何一次請求是 4.1 秒。

https://ithelp.ithome.com.tw/upload/images/20260813/20183550ng6Ou4B4Ud.png
圖 2:平均值落在一個沒有任何請求存在的地方——p50 告訴你平常長怎樣,p95 告訴你倒楣的人有多慘。

換成百分位就清楚了:p50 = 1.2 秒,一半請求比這快;p95 = 30 秒,最慘的 5% 長這樣。而那兩根尖峰有具體病因:Ollama 預設 keep_alive 五分鐘
【官方】,閒置就卸載模型,下一發請求得重新載入,冷啟可達數十秒(量級為社群回報)。
只看平均的人會做出災難決策——把 TPOT 調快兩成,平均變好看了,那兩根 30 秒的尖峰一根都沒動。所以本系列所有實測一律報 p50 + p95,不報單獨的平均。

而這兩根尖峰有個你一定遇過的名字:「第一問等超久,後面就順了」。它不是一個原因,是一排第一次成本疊起來的,而且分屬兩個世界【推算,機制拆解】:

  • LLM 側——keep_alive 到期卸載後重載、權重第一次從 SSD 讀進記憶體(第二次走 page cache)、kernel 首次編譯。全部記在 load_duration,上面的指令印得出來。
  • LLM 外面——embedding 自己也是一顆模型,也要冷啟也吃 keep_alive;向量庫的 index 第一次查詢才進記憶體;reranker 同理;再加連線池、TLS handshake、框架那層的 lazy import。這些一格都不在那六個欄位裡,因為它們發生在請求抵達 server 之前。

所以下次有人說「你的 RAG 好慢」,第一句先問**「這是第幾問?」**——如果只有第一問慢,你要修的多半不是模型。而如果第一問慢在 LLM 外面,今天這四個指標會全部報給你「一切正常」:這正是本篇開頭說尺要放對地方的理由。前綴那一格 Day 18 會單獨處理,檢索那一格留到第四週。

等一下,這些名詞是 vLLM 的,我的 Mac 跑的是 Ollama?

上面兩段借了 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。

這些數字就躺在你的回應 JSON 裡

不用壓測工具,今天就能量。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_durationload_durationprompt_eval_durationeval_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 模型也能完整跟完。

小結

  • 「慢」禁用,拆成四個數字:TTFT(等待感)、ITL(順不順)、TPOT(健檢值)、E2E(全程)。
  • 誰在乎哪個:聊天在乎 TTFT 與 ITL,agent 迴圈在乎 E2E,批次在乎吞吐。
  • 變快有兩種:讓 E2E 真的下降,或用 streaming 把體感從 E2E 換成 TTFT——後者一行設定,今天就能開。
  • 平均值是沒有人經歷過的虛構數字,一律報 p50 + p95。
  • Ollama 底層是 llama.cpp 不是 vLLM——術語共用,runtime 不共用。
  • 回應 JSON 直接給你 E2E、prefill 與近似 TPOT;真的 TTFT 與 ITL 要開 streaming 自己打時戳
  • 「第一問特別慢」是一排冷啟疊起來的,而且有一半發生在 LLM 外面——今天這四個指標抓不到。

不過今天量的一切都有個前提:一個人、打一發。明天 Day 03「壓測不是 for 迴圈」——當 8 個請求同時進來,這四個指標會各自往不同方向崩壞,而用 for 迴圈打 API 量出來的數字,從方法上就是錯的。明天講為什麼,以及正確的工具怎麼拿。

咱們明天見。


上一篇
Day 01 - 為什麼你的地端 RAG 一問就是 45 秒?
下一篇
Day 03 - 壓測不是 for 迴圈:vllm bench serve 的正確用法
系列文
不是模型太慢,是你沒算過這筆帳:地端 LLM 工程實戰 30 天6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言