iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
AI Engineering

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

Day 18 - 只換一行位置,15.7 秒變 0.2 秒:你的 prompt 為什麼一直重算?

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260829/20183550TjpdmRf17q.png
同一份手冊、同一個問題、同一行時間戳,我在 M4 Max 上只換了一件事:那行 當前時間:2026-08-28 19:41:07.842 放在 prompt 開頭,還是放在結尾。

放第一行,llama-server 要重算 6,494 個 token、花 15.7 秒;放最後,只剩 31 個、195 ms

那行時間自己只讓 prompt 多 28 個 token。貴的從來不是它,是它把 prefix cache 的斷點推到了哪裡

prefix cache 只有一條規則

等待的第一段是 prefill:模型先把整段 prompt 算成後面生成要用的 KV。相同位置上的相同 token,算過的結果就有機會直接接手。

規則很笨也很簡單:**從第一個 token 往後比,能連續對上多少就重用多少;第一個不同的 token 就是斷點。**斷點之後全部重算,哪怕後面 99% 一字不差。

所以 固定內容 → 固定內容 → 時間戳 能一路重用到最後一格;時間戳 → 固定內容 → 固定內容 第一個 token 就斷了,後面全部救不回來。

只縮 prefill、完全不動 decode(vLLM 的 Automatic Prefix Caching 官方文件明言這一點);不同 runtime 的比對粒度不完全一樣,細節放附錄。

https://ithelp.ithome.com.tw/upload/images/20260829/20183550SvPAouFNTK.png
圖 1:時間戳排在第一行,斷點就落在第 0 個 token——6,494 個裡沒有一個接得回來。

我真的量了一次:15.7 秒 vs 195 ms

四發請求同 process 連打,只差可變的東西擺哪裡:

第幾發 差別 要重算的 token prefill
第一次,全冷 6,466 13,582 ms
一字不差再打一次 1 38.7 ms
時間戳放最前面 6,494 15,671 ms
同一個時間戳放最後面 31 195 ms

T1 M4 Max 128GB、llama.cpp b10488、Gemma 4 12B QAT Q4_0,兩輪中位數。完整參數與重現指令在附錄。

② 的那個 1 是 llama-server 的最低重算量:就算整份 prompt 完整命中,它仍會把最後一個 token 重新算一次。完整命中不等於零計算。

③④ 才是要帶走的那一格。同一個時間戳、同一份文件,只換位置:重算的 token 6,494 → 31,209 倍;prefill 15,671 → 195 ms,80 倍。而 ③ 比全冷的 ① 還多 28 個 token,多出來的正是時間戳自己——把會變的東西放最前面,不是沒省到,是比從頭算還多算一截。

兩個倍數對不起來。token 少了 209 倍,時間只少 80 倍。因為那 31 個 token 不是從空 context 開始算,它仍接在六千多個既有 context 後面;短 batch 的固定成本也更難攤。所以 cache 命中的 token 比例,不能直接當成加速比

這就是 RAG 特別容易踩的坑

Day 01 那台 45 秒的機器就是這樣:每問一題檢索一次,回傳的 chunk 每次不同,還按分數重排。

上一題是 [A,B,C],下一題變成 [A,B,D],cache 至少還吃得到 A 跟 B;但只要 reranker 把它排成 [C,A,B],斷點就直接跑到第一個 chunk。檢索結果夠穩的服務照樣命中得到,打掉 cache 的是動態的 chunk 內容與順序。

而 chunk 通常擺在 system prompt 之後、使用者問題之前,落在整條 prompt 的中段偏前。斷在那裡,它後面的東西全部得重算——實際哪些遭殃,取決於你的 prompt 怎麼排。

修法一句話:穩定前綴,可變後綴。system prompt、固定規則、tools schema、固定 few-shot 盡量形成穩定前綴;時間戳、request ID、動態 metadata,以及其他能安全後移的動態內容,在不改變語義與指令優先級的前提下盡量往後。

https://ithelp.ithome.com.tw/upload/images/20260829/20183550R8ggJ5vnJI.png
圖 2:可以截圖帶走的排法——第 5 到第 7 項每次都會變,所以只能待在線以下。

怎麼知道自己有沒有命中

Runtime 不要看 判命中看
llama-server cache_n 跳上來、prompt_n 掉下來
Ollama prompt_eval_count prompt_eval_duration 掉下來

Ollama 最容易看錯。同一發 ④,API 回我 prompt_eval_count: 6509,runner 自己的 log 寫的卻是 prompt eval time = 1130.08 ms / 1053 tokens——**6,509 是 prompt 有多長,不是這次重新算了多少。**拿它判命中,你會永遠得到「沒命中」。

https://ithelp.ithome.com.tw/upload/images/20260829/20183550f8REfRKuAy.png
圖 3:三個數字都對,只是在回答不同的問題。

讀錯欄位的人不會看到錯誤訊息,只會得到一個穩定而且錯誤的結論。

(小插曲:同一行時間戳換個位置,連 tokenizer 的切分邊界都會跟著變,28 個 token 會變成 29 個。細節在附錄。)

單人省的是延遲,高併發省的是整台機器

15,671 → 195 ms 這一刀,單人當然有感。但 prefix cache 更大的價值,是不要讓十個、一百個請求重複燒同一段 prefill 算力。

昨天 Top-30 的那份 24K prompt,M4 Max 估算的 91.2 秒裡有 81.6 秒都在 prefill(照實測 pp 曲線內插換算,不是直接量到的秒數)。這種 workload 一旦前綴大量命中,省掉的就不只是某個人的等待,而是所有人共享的 prefill 算力。別用單人 benchmark 決定要不要做 prefix cache。

小結

  • prefix cache 看的是「從第一個 token 開始有多少完全相同」,不是整份 prompt 有幾成相同;斷點之後全部重算,而且 miss 的時候什麼錯都不會報。
  • **會變的東西放太前面,一行 28 個 token 的時間戳就能害後面六千多個全部重算。**同一行字挪到結尾,只要重算 31 個。
  • RAG 的動態 chunk 與重排很容易移動斷點,所以 prompt 記住一個原則就好:穩定前綴,可變後綴

今天的實驗需要什麼

十分鐘就夠:同一份長 prompt 打兩次,llama-server 看 cache_n 有沒有跳上來、prompt_n 有沒有掉下去,Ollama 看 prompt_eval_duration 有沒有大幅下降。再把你的 RAG prompt 印出來,看時間戳、request ID、動態 metadata 是不是塞在最前面。完整指令在附錄。

明天 Day 19 換一個問題:軟體端三刀都砍完還是不夠的時候,該換什麼機器。輪到 Mac Studio M5 Ultra 用自己的尺量一次。

咱們明天見。

附錄:量測條件、runtime 細節與重現

完整條件

llama-server:-ngl 99 -fa off -c 16384 -np 1 --cache-reuse 256(本輪被 context 停用,見下)、n_predict 8temperature 0seed 42。Ollama 0.32.15 跑 gemma4:26b(Gemma 4 26B-A4B、Q4_K_M)、num_ctx 16384OLLAMA_NUM_PARALLEL=1,它自己起 runner 帶的是 --flash-attn auto -b 1024 -ub 1024 --context-shift。四發同 process 連打、順序固定 ①②③④,兩輪中位數,量測時機器上有前景程式(load 2.0–4.2)。

冷 → 熱那一格:llama-server 的 prefill 是 13,582 → 38.7 ms,351 倍;② 的 server log 寫著 need to evaluate at least 1 token for each active slot,於是 n_past 被退回 6,465,最後那一個 token 真的重算了一次。

短批貴在哪裡:③ 的 prefill 跑 414 tok/s,④ 只剩 159。同一顆模型、同一台機器,差別只是一批 6,494 個 token 與一批 31 個。

Ollama 那組四發的 prompt_eval_count 是 6,481/6,481/6,509/6,509(一格沒動),prompt_eval_duration 是 4,866/16.7/5,743/1,072 ms —— 冷熱 291 倍,時間戳前後只有 5.4 倍。它跟正文那個 80 倍不是同一場比賽:llama-server 那顆是 12B dense 的 Q4_0、-fa off,Ollama 那顆是 26B-A4B 的 Q4_K_M、--flash-attn auto,連 batch 大小都不一樣。兩列不可互相相除,只能各自比自己的冷熱(Day 08 判定過跨 stack 直比是錯誤示範)。

還有一個口徑別記錯:上面量的是 prompt_msprompt_eval_duration,是 prefill 的內部計時,不是 TTFT。② 命中後 prefill 只剩 38.7 ms,那一發的總時間卻是 201 ms——命中之後,決定使用者等多久的已經不是 prefill 了。

重現

# 造三份 prompt:base 沒有時間戳,front/back 只差它擺哪。本文完全相同(約 6.5K token)
python3 - <<'PY'
import json
doc = open('manual.txt').read()                       # 換成你自己的長文件
q   = "\n\n問題:PX-450 主軸該用哪顆螺絲、扭力多少?"
ts  = "當前時間:2026-08-28 19:41:07.842\n"
for k, p in {"base": doc + q, "front": ts + doc + q,
             "back": doc + q + "\n" + ts}.items():
    json.dump({"prompt": p, "n_predict": 8, "temperature": 0, "seed": 42,
               "cache_prompt": True}, open(f"req-{k}.json", "w"), ensure_ascii=False)
    json.dump({"model": "gemma4:26b", "prompt": p, "stream": False,
               "options": {"temperature": 0, "seed": 42, "num_predict": 8,
                           "num_ctx": 16384}}, open(f"oll-{k}.json", "w"), ensure_ascii=False)
PY

# ① llama-server:base 打兩次看冷→熱,再看時間戳擺哪。-c 一定要蓋過 prompt,不然會被靜默截斷
./llama-server -m gemma-4-12b-it-qat-q4_0.gguf -c 16384 -np 1 -ngl 99 -fa off --port 8099 &
for R in base base front back; do
  curl -s localhost:8099/completion -d @req-$R.json \
    | jq '.timings|{cache_n, prompt_n, prompt_ms}'
done

# ② Ollama:看 prompt_eval_duration,不是 count。量冷啟前先 stop,num_ctx 自己給滿
export OLLAMA_NUM_PARALLEL=1
ollama stop gemma4:26b && sleep 3
for R in base base front back; do
  curl -s localhost:11434/api/generate -d @oll-$R.json \
    | jq '{prompt_eval_count, prompt_eval_duration}'
done
grep "prompt eval time" ~/.ollama/logs/server.log | tail -4   # runner 真正算了幾個

# ③ vLLM APC(本輪未量:T2 連線 timeout)。counter 是 token 級累計,時間要自己計
vllm serve 你的模型 --max-model-len 16384 --enable-prefix-caching
curl -s localhost:8000/metrics | grep -E "prefix_cache_(queries|hits)"

cache_n / prompt_n:兩格一起看

cache_n 是這一發直接接回幾個 token,prompt_n 是真的重新算了幾個。①全冷是 0/6,466,②一字不差是 6,465/1,④時間戳放最後是 6,464/31——本輪這四發,兩格相加都等於整份 prompt 的長度。

④ 加起來是 6,495,比 ③ 的 6,494 多一個,這不是量錯。llama-tokenize 實測:同一行時間戳貼在最前面讓 prompt 多 28 個 token,接在文件後面卻多 29 個,中間那個換行把切分邊界推掉一格。tokenizer 是對整串文字切的,換個位置連它自己的長度都會變。

Ollama 那邊的 ④ 也不是全 miss:runner 實際算了 1,053 個、重用了 5,456 個,83.8% 沒有重算。我原本以為這輪只量到「近乎全中」與「全不中」兩種,是錯的,只是 API 不告訴你。

三個 stack,其實只有兩套機制

Ollama 0.32.15 在這顆 GGUF 上起的 runner 就是 llama.cpp 的 llama-server(log 寫著 using llama-server for model,後面接完整命令列)。兩顆跑 --version 甚至報同一個 commit 9d77fa172,只差 build 編號(我的 10488、它的 1)與編譯器小版本。所以 cache 機制是同一套,差別在啟動參數,以及 API 轉不轉出欄位。

機制 作用範圍 判命中看哪一欄
llama-server(預設就開) slot 內的 KV,加上 --cache-ram(預設 8192 MiB)存在主記憶體的 prompt cache 與 context checkpoint;會跨請求回頭找更好的前綴 timings.cache_nprompt_n
Ollama 0.32.15 同上;底層是它自己 bundle 的 llama-server build,啟動參數與上列不同 判冷熱看 prompt_eval_duration;API 不提供本輪實算的 token 數,要讀 runner log
vLLM APC block hash(一般單一 attention cache group 下預設 16 tokens 一塊),跨請求、跨併發共享 prefix_cache_queriesprefix_cache_hits

https://ithelp.ithome.com.tw/upload/images/20260829/20183550ZaNuXktl7N.png
圖 4:同一組冷熱,四個欄位裡只有一個不動——偏偏是最多人拿來判命中的那個。

作用範圍比「當前這個 slot」大得多。我的第四發排在第三發之後,而第三發的前綴從第 0 個 token 就斷了,第四發卻仍然接回 6,464 個 token——第一、二發留下的東西沒有被沖掉。

Ollama 那邊同一發看得更清楚。log 先寫 found better prompt with f_keep = 0.998:它翻了一遍 prompt cache,撈回兩發之前留下的 state,共同前綴有 6,476 個 token。但這次真正還原得回來的只到 restored context checkpoint (pos_max = 5456),所以 5,456 之後那 1,053 個還是得重算——其中 1,020 個明明落在共同前綴裡,只是沒被還原的 state 蓋到。找到共同前綴,不代表整段共同前綴都恢復得回來。

vLLM 那一列還有一層落差:共同前綴不足一塊時效益直接是零;差異落在某一塊裡時,那一塊與它後面的都不能沿用,前面完整對上的塊仍然算數。

⚠️ hybrid/多 cache group 另有 prefix_match_unit(官方定義是「前綴命中能落到的最細 token 邊界」),粒度不一定永遠是 16,動手前查你那版。那兩條 counter 累計的是查了多少 prefix token、其中多少命中,可以算 token 級命中率,但不會告訴你這一發省了幾毫秒。

至於想救斷點之後那一段的 --cache-reuse 256,這組 model/context 不支援:log 寫著 cache_reuse is not supported by this context, it will be disabled。不是這台機器跑不動,是它自己把功能關掉,所以這一輪量不到。

還有,量 Ollama 的冷啟之前先 ollama stop。runner 沒卸載,上一輪的 cache 跨 CLI 呼叫還活著:我第二輪的「冷」只花 72.6 ms,量到的是上一次的 cache。

網路上那兩個倍數

SGLang RadixAttention 的 6.4x 量的是 throughput【引用,arXiv 2312.07104】,Prompt Cache 論文的 8x 是另一種可重組非前綴片段的機制【引用,arXiv 2311.04934】,vLLM APC 官方文件則從頭到尾沒給過倍數。你的倍數只能自己量。


上一篇
Day 17 - Top-K 開到 30 才答得準,為什麼 M4 Max 多等 67 秒、4070 Ti 只多 8 秒?
下一篇
Day 19 - 1,792 GB/s 的 5090 對 1,200 GB/s 的 M5 Ultra:為什麼帳面 decode 上界只差 6%?
系列文
不是模型太慢,是你沒算過這筆帳:地端 LLM 工程實戰 30 天25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言