vatt'ghern jaskier's ballads

固定模型、固定 decoding 參數、固定 seed、固定送出順序,一個 80-episode 的多輪 agentic tool-use workload 在四位元權重下,打開 prefix cache 讓四分之三的 episode 走上不同的執行路徑;關掉快取,800 次重跑逐位元相同,一次例外都沒有。

同樣的請求,快取開著就不是同一個答案

Prefix caching——serving engine 把 shared prompt prefix 的 key、value tensor 存起來,讓後面的 request 直接重用——在主流 open-source serving stack 裡預設打開,長年被當成一項不改變輸出、只省算力的 transparent optimization。2026 年 9 月掛上 arXiv 的一篇論文測的正是這個預設假設:固定模型、decoding 參數、seed、送出順序,以 batch size 為一的方式逐個序列送出 request,在兩個 serving engine、四種權重格式上重複跑同一個 80-episode 多輪 agentic tool-use workload,分別在快取開與關的情況下量測。結果是,16 位元精度下開著快取讓 36.2% 的 episode 走上不同軌跡,量化到四位元後這個比例升到 75.0%;把快取整個關掉,800 次重跑逐位元相同,一次都沒有例外。

工程師平常對 LLM serving 的預期很直接:只要 model checkpoint、decoding 參數、seed、輸入內容都固定,同一個 request 就該回傳同一個答案——這個假設是回歸測試、agent 框架裡的重播、任何「先跑一次記錄下來,之後拿結果比對」流程的地基。Prefix caching 存在的目的只是省算力,理論上不該碰到這個假設;量化壓縮模型體積,理論上也只影響數值精度,同樣不該碰到「同一個 request 兩次呼叫是否一致」這件事。這篇論文把兩者疊在一起測,結果直接踩碎了這個地基。共享 prefix 的多輪對話、RAG 這類場景早就把 prefix caching 當成標準配置,多數團隊升級 serving stack 版本時,這顆開關甚至不會出現在自己動過的 changelog 條目裡;量化同樣如此,多數團隊決定從 16-bit 換到 4-bit,看的是記憶體占用跟 benchmark 分數,沒有理由去檢查它跟一個看似無關的快取機制之間會不會互相作用。

同一個 request,四位元權重下四分之三的機率走上不同路

36.2% 與 75.0% 這兩個數字本身已經夠刺眼,但更刺眼的是它們之間的落差。同一套 workload、同一套 decoding 設定,唯一變動的是權重精度,divergence 的比例就跟著精度掉的幅度一起往上爬。論文標題寫的是「Quantization Amplifies Cache-Induced Divergence」——量化『放大』了快取引發的分歧。一個合理的讀法是,這個動詞選得有意思:『放大』暗示量化本身不是另外引入一種新的分歧來源,而是把原本就存在的快取效應加劇;但這組對比是本文的推論,論文沒有把兩者明講成對立。

快取開關對同一個 80-episode agentic workload 的軌跡影響,依權重精度分組。資料來源:arXiv 2609.04748 摘要。

論文測了四種權重格式,但摘要完整寫出的只有兩個端點的數字——16 位元與四位元。讀者現在看到的是這條梯度上兩個已知的錨點,不是一整條曲線;斜率有多陡、從哪個精度區間開始明顯爬升,論文沒有在摘要層級給出中間值。這也是上面的圖只畫三根柱子、不嘗試補一條看起來合理的曲線的原因——沒有資料支撐的中間點,不該畫出來。一個合理的讀法是:這裡的「trajectory」指的是 agent 在多輪 tool-use 裡選擇呼叫哪個工具、帶什麼參數的整條路徑,不是文字生成的些微用詞差異,是動作序列本身分岔。照這個讀法,對一個會實際呼叫外部工具、修改檔案、送出 API 請求的 agent 來說,分岔的軌跡意味著兩次執行可能做了不同的事,不只是講法不同。

先排除隨機——關掉快取,800 個 episode 全部一模一樣

第一個直覺的懷疑對象是隨機性本身——浮點運算的加總順序、GPU kernel 排程的非確定性、多執行緒 reduce 的順序差異,這些噪聲源在大規模平行運算裡一向存在,量化之後數值誤差更粗,理論上更容易被這些既有噪聲放大成不同的輸出。論文用一個乾淨的對照組排除了這個候選解釋:把快取整個關掉,同樣的 80-episode workload、同樣的兩個 engine、同樣的四種權重格式各自重複執行,論文原文寫得很直接:「With caching disabled, repeated execution was bit-identical in every configuration, 0 of 800 episodes, which bounds other sources of nondeterminism at 0.5 percent.」800 個 episode,涵蓋所有權重格式與 engine 配置,全部逐位元相同——這也把其他非確定性來源的上界壓到 0.5%。換句話說,不是量化過的模型「每次算出來都有點不一樣」;只要不碰快取,量化模型一樣是確定的。這一步同時排除了兩個候選解釋:不是一般性的隨機噪聲,也不是量化本身在數值層面不穩定。

這個 workload 之所以挑得仔細,是因為多輪 agentic tool-use 本身就是最容易放大微小差異的場景——每一輪的輸出決定下一輪呼叫哪個工具、帶什麼參數,某一步的 token 選擇一旦不同,後面所有步驟都可能跟著改道。批次為一、序列送出,把 batching 帶來的額外變數排除在外,觀察對象只剩「快取開關」跟「權重精度」兩個變數。

那快取本身是不是本來就會這樣——問題不在快取,在一顆設定

既然重跑本身很乾淨,剩下的懷疑對象就該是「打開快取」這件事——但這個候選解釋太粗:如果單純是有快取跟沒快取的差異,分歧率應該在所有開著快取的配置裡都長得差不多,不該隨精度這麼劇烈地滑動。論文接著做了三個實驗,把成因往下鑽。第一個實驗,單獨切換一顆 server-level 的 prompt-cache 設定論文原文:「a single server-level prompt-cache setting moves run-to-run divergence by 37.5 percentage points」——不是某個 request 帶的參數,是掛在 serving engine 行程上、跨所有 request 共享的一顆開關。 的開關,run-to-run 的 divergence 幅度就移動了 37.5 個百分點——這不是四種權重格式加總出來的效果,是單獨一顆設定的開關就能造成的移動幅度。第二個實驗,把同一批 request 換個順序送進去,只有在那顆 prompt-cache 設定處於開啟狀態時,順序才會改變結果;設定關掉,順序怎麼換都一樣。這代表「執行順序」本身不是獨立的成因,它是快取狀態被送出順序牽動之後的副作用。

「server-level」這幾個字才是關鍵——論文說的是這顆設定掛在 serving engine 的行程上,不是隨每一個 request 帶入的參數。工程師習慣把「要不要用快取」這種決策想成 per-request 的事,像是呼叫時帶一個開關參數;這顆設定不是這樣,它是行程層級的組態,不屬於 request payload。一個合理的推測是,這也是它容易被忽略的原因:debug 時,大家習慣檢查 request 帶了什麼參數,不會想到去檢查 server 行程本身開了什麼組態。論文也沒有在摘要層級展開「執行順序為什麼只在這顆設定開啟時才有效果」背後的具體機制,但這兩個實驗擺在一起,已經足以把成因從「隨機」或「量化本身數值不穩」,收斂到「用了哪個版本的 cache,而這個版本又會被送出順序牽動」。

論文沒有給出 run-to-run divergence 的總幅度,所以這 37.5 個百分點佔了其中多少無從得知;能說的是,單獨一顆 server-level 設定的開關就足以移動這麼多。更關鍵的是重測結果:把 cache 設定固定住之後重新量測,16-bit 與四位元之間的分歧梯度依然存在——論文原句是「a gradient that survives re-measurement under a controlled cache configuration」,這個梯度在受控的 cache 設定下重新量測,並沒有被消掉。三個實驗合起來,把成因鎖定在 cache state 本身。

論文標題已經把整個論證濃縮成一句話——「Same Request, Different Answer: Quantization Amplifies Cache-Induced Divergence in LLM Serving」,同樣的請求換來不同的答案,量化把快取引發的分歧放大。三個實驗到這裡為止,已經把成因從模糊的「快取讓結果不穩定」收斂成具體到可以被單獨測試的一顆開關;下一步要做的,是把這顆開關的效果跟「cache state 本身到底是什麼」這件事分開驗證。

真正的成因:cache state 不在 request 裡,也不會被重置

第三個實驗直接把 cache state論文結論句:「Cached serving is deterministic given cache state, and irreproducible in practice because that state is absent from the request and never reset by default.」——是哪個版本的 KV cache 被命中,這個資訊不在 request payload 裡,也不會在下一次呼叫前被清空。 擺到桌面上,不再讓它藏在 server 內部當隱性狀態,而是顯式固定,分別在 cached path 與 recompute path 上,各自在 40 個項目上重新驗證。矩陣裡的三個實驗,各自鎖定因果鏈的不同一段:

三個實驗各自鎖定因果鏈的一段。資料來源:arXiv 2609.04748。
實驗 操作 觀察
① prompt-cache 設定 單獨開/關一顆 server-level 設定 run-to-run divergence 移動 37.5 個百分點
② 執行順序 同一批 request 換順序送出 順序效果只在設定①開啟時出現
③ 顯式還原 cache state cached/recompute 兩條路徑各自在 40 個項目上驗證 各自 40/40 可重現,彼此仍有 14 項不同

「確定但不可重現」聽起來像矛盾,其實不是。決定論在這裡指的是:cache state 一旦固定,代入同一個 request,輸出就固定——這正是第三個實驗用 40/40 證明的事。不可重現指的是另一件事:使用者在下一次呼叫時,沒有辦法指定要用哪一份 cache state,甚至沒有辦法知道現在用的是哪一份。request payload 裡只有 prompt、decoding 參數這些使用者看得到、也控制得到的欄位;cache 命中與否、命中哪個版本,是 server 內部狀態,不會序列化進這個 payload,也不會在下一次呼叫前被清空。系統不是在擲骰子,是在讀一份使用者看不到的筆記——筆記本身不會亂寫,問題是使用者永遠不知道筆記現在寫著什麼。這也是為什麼論文特別把 cached path 跟 recompute path 分開重跑:兩條路徑各自都對得起自己,只是彼此對不上。

同樣講「可重現」,固定的對象不同,答案不一樣。資料來源:arXiv 2609.04748。
情境 比較的對象 重現結果
快取關閉 同一個 request 重複執行,涵蓋所有權重格式與 engine 配置 0/800 episode 分歧
快取開啟,cache state 顯式固定 同一條路徑(cached 或 recompute)自己跟自己比 各自 40/40 可重現
快取開啟,cache state 顯式固定 cached 路徑跟 recompute 路徑互相比 14/40 不同

因果鏈到這裡算是釘死了:給定 cache state,serving 是確定的;問題不是 caching 本身「不穩定」,是 cache state 到底採用了哪個版本這件事,從來不出現在 request 裡,預設也不會被重置。這件事對「每天要跑迴歸測試、比對 agent 輸出」的團隊尤其棘手:如果 eval pipeline 假設同一個 request 該給同一個答案,量化模型加上打開的快取,會讓這個假設在四分之三的案例上失效——而且失效的原因不會出現在任何一次呼叫的 log 裡,因為 cache state 本來就不是 request 的一部分。把整組數字擺在一起,工程師能得到的操作性結論其實很具體:如果一個 serving 環境裡快取是打開的,那麼「同一個量化等級,重複呼叫是否可靠」這件事,不能只看一次性的功能測試通過與否——單次測試通過,證明不了系統在同樣的輸入下每次都給出同一個答案。論文能把這個問題釘出來,靠的正是不滿足於單次量測:800 個 episode 橫跨所有配置重複執行、cached 與 recompute 兩條路徑各自在 40 個項目上驗證,才讓「看起來穩定」與「其實不穩定」的差異浮現。下面這組示意圖把這個對比畫出來——拖曳分隔線比較快取關閉時的單一路徑,跟快取打開、四位元權重下分岔的路徑:

拖曳分隔線比較快取關/開的執行路徑 · 示意圖

cache 關・任何精度 涵蓋 800 個 episode 0/800 分歧 cache 開・4-bit 權重 同一個 request,重複執行 75.0% 分岔到不同路徑
示意圖,非逐點量測資料——分岔比例(75.0%)與 episode 總數(800)取自 arXiv 2609.04748;路徑的具體形狀是示意,不是論文附圖。

這條「快取放大不確定性」的線,很容易跟另一條常見的問題混在一起——量化本身犧牲了多少輸出品質。兩者其實是分開的座標軸。Quesma 八月底一篇針對 Qwen3.8 27B 的量化 benchmark 是個乾淨的對照組:作者 Piotr Migdał 直接寫道,「Not only does my measurement of BF16 replicate the stated result, but, to my surprise, Q4_K_M does as well.」——在 Terminal-Bench 2.1 這個 agentic coding benchmark 上,四位元的 Q4_K_M 追平了 BF16。真正崩掉的是再往下一級:「the scores are around the random guessing level, with the smallest model being below that threshold」,1-bit 量化的分數掉到接近亂猜的水準。這篇論文測的不是這條軸——它測的是同一個權重格式,在快取開著的時候,輸出還穩不穩定。四位元的 Q4_K_M 可以在品質上追平 16-bit,同時仍然在快取開著時比 16-bit 更容易分岔;這兩件事沒有互相矛盾,是兩個獨立的問題各自在起作用。Migdał 給的實務建議是:「For most tasks Unsloth's Q4_K_M should be good enough, without any noticeable difference; for some simpler tasks UD-Q2_K_XL should be more than fine.」——這條建議管的是「輸出品質夠不夠」,不管「同一個 request 兩次呼叫會不會給出不同答案」。換句話說,挑量化等級時可以參考 Terminal-Bench 2.1 或 GPQA Diamond 這類品質基準,但品質基準量的是分數本身,不是「同一個 request 重複呼叫是否給出同一個答案」這件事——兩者是不同的量測目標。這也是為什麼「量化前後跑一次 benchmark 分數差不多」這件事,不能直接當成「這個量化等級可以放心上線」的證據:benchmark 分數量的是品質,這篇論文量的是重複呼叫的一致性,兩份報告都成立,講的卻是兩個不同的風險。

這兩種失效模式在症狀上不難分辨。量化的品質損失,是同一個 request 反覆問,答案本身持續變差、甚至變得像亂猜——Migdał 量到的 1-bit 崩潰就是這種:分數掉到隨機猜測的水準。快取放大的不可重現,則是同一個 request 反覆問,某幾次答案完全正常、某幾次卻走上另一條路——不是持續變差,是時好時壞,而且兩種結果可能各自都合理,只是彼此不同。前者用一次品質基準就測得到;後者要像這篇論文一樣,刻意開關快取重跑同一批 request,才看得出來。如果一個團隊照 Migdał 的建議選了 Q4_K_M、又在 serving 端維持預設打開的 prefix cache,等於同時踩進兩個獨立的風險:一個在品質基準上量得到、大概率沒事;另一個要靠關掉快取重跑才會現形,多數團隊只會注意到第一種,因為它會反映在分數上,第二種通常要有人主動去比對兩次呼叫的差異才會被發現。這篇論文提醒的正是後面這一種。

這種「狀態存在但沒被追蹤」的失效模式,其實不是 LLM serving 獨有的新問題——分散式系統裡任何一個沒有明確納入 request 契約的快取層,都有同樣的風險:兩台 replica 各自維護不同版本的快取,同一個呼叫打到不同 replica 就可能得到不同結果。這篇論文的貢獻是把這個老問題量化到一個具體場景——量化模型的 agentic serving——並且給出可以重複驗證的數字,而不是停在「理論上快取可能造成不一致」這種空泛的警告。

結論其實很樸素:cache state 是一個確定性的東西——給定同一份 state,serving 完全可重現;但這個 state 從來不是 request 的一部分,也不會在下一次呼叫前被清空或重置。量化沒有創造這個問題,量化只是把模型對「用了哪個版本的 cache」這件事變得更敏感。一個合理的機制推測是:四位元權重下數值精度變粗,原本只在小數點後幾位的差異,更容易在 agentic workflow 的某個決策點被放大成完全不同的 tool call、完全不同的分支——但論文本身沒有給出這層數值機制,這是本文的推測,也跟「關掉快取時量化模型逐位元相同」這個事實存在張力,值得讀者自行保留判斷。回到已經核實的部分:正因為關掉快取時逐位元相同這件事夠乾淨,把快取整個關掉才是排除故障最乾淨的第一步——不是因為關掉快取是正確的長期解法(犧牲掉的算力效率不小),而是因為它能在幾分鐘內確認問題是不是出在這裡,不用先去猜是量化的數值精度,還是別的什麼原因。

把這套結果直接轉成 checklist,是三件明天就能做的事:先確認 serving stack 是否存在一顆 server-level 的 prompt-cache 開關,不要預設它不存在;如果那顆開關是開著的,不要假設 request 送出的順序無關緊要,尤其是在批次調度、重試、或多個 client 共用同一個 server 的情況下;如果重現性真的重要,不能只靠固定 seed 與 decoding 參數,還得想辦法讓 cache state 本身變得可觀察、可固定——否則「同一個 request」這個說法,實際上漏了一個從不出現在 log 裡的變數。寫回歸測試或建置 agent eval 時,也該把「快取開/關」當成一個要顯式控制的維度,而不是預設它跟結果無關。

整個排查過程其實示範了一種思路:先用最乾淨的對照組(關掉快取)畫出安全邊界,再逐一切換懷疑的變數(設定、順序),最後用顯式控制驗證機制(固定 cache state)收尾。這個順序,比一開始就去猜測量化數值本身有沒有問題,更快找到真正的成因。

Take-away:下次量化模型跑出跟以前不一樣的結果,別急著先懷疑數值精度本身——先問 serving stack 有沒有一顆預設打開、且不出現在 request 裡的 prefix-cache 或 prompt-cache 設定;那顆設定、以及它背後那份沒有被追蹤的 cache state,才是真正的自由變數。