一個長度超過 681,280 tokens 的 prompt,回覆連貫、HTTP 狀態碼是 200,內容卻是別人先前請求留下的東西。
GLM 5.3 的 KV 頁面為何漏給別人
Cloudflare 公布了 Workers AI 上 GLM 5.3 的 KV cache 暴露事件的事後報告。文章開頭的結論很直接:問題已經修復,模型在 9 月 9 日恢復上線;受影響的回應一共 155 個,全部來自長度超過 681,280 tokens 的 prompt;Workers AI 上的其他模型都沒有受影響。這些數字交代完,剩下的問題才是這篇報告真正的內容:一個宣告 1,048,576 tokens context window 的模型,怎麼會在長 prompt 上把別人的資料交出去,而且回給 client 的是成功的 HTTP 200。下面照問題浮現的順序走一遍,從一份來自外部研究員的報告,到兩個疊在一起的原因,再到修復,最後談文章自己承認還沒補完的地方。
HTTP 200 的回應裡,裝著別人的內容
線索來自外部。文章寫道,安全研究員 Gadi Evron 在 9 月 8 日透過 Cloudflare 的 HackerOne 計畫提交報告,內容是「very long prompts」「sometimes returned text that belonged to other users' sessions」,很長的 prompt 有時會拿回屬於其他使用者 session 的文字。報告進來的時間點是 9 月 8 日 23:11 UTC。文章形容這位研究員附上的是一份清楚、可重現的腳本,這一點後面會再用到。
研究員描述的現象,文章換了一種說法重述:收到的回應裡有「data from other customers' requests made to the same model shortly before theirs」,也就是在自己之前不久、打到同一個模型的其他客戶的請求資料。注意這句話的結構。外洩的是前一刻別人才送進來的請求內容。文章後面把頁面裡殘留的東西描述為其他使用者的 prompt 與部分生成結果。這個細節決定了排查的方向:要追的是請求之間共用了什麼。
要看懂為什麼是這個方向,得先交代文章的背景一節怎麼描述 GLM 5.3 的服務架構。流程分兩段:一批 prefill GPU 處理 prompt,產生這個 prompt 的 KV cache,然後透過 RDMA 把這份 cache 交給另一批 decode GPU,由後者生成回應。GPU 記憶體裡的 KV cache 是一個由固定大小的頁面組成的共用池,每個請求在被接納時分到頁面,結束時歸還。最關鍵的一句是:頁面在請求之間目前不會歸零,下一個拿到頁面的請求只是把它覆寫。也就是說,一個頁面如果被分給了某個請求、卻沒有被這個請求寫過,裡面留的就是上一個使用者的東西。這是文章自己的描述,不是我的推論,它也是整件事能成立的物理前提。
於是現象有了一個很窄的形狀。回應是連貫的,文章寫「prompt_tokens matched the long prompt and the reply was coherent」,用量統計與長 prompt 的長度吻合,回覆讀起來也通順。狀態碼也不對勁得很安靜:文章在說明正確行為時寫的是,client 拿到的是 HTTP 200,而不是 HTTP 400。系統內部其實已經判定這個請求不該被服務,對外卻回了成功。後面的段落都在回答同一個問題:誰在哪個環節把「該拒絕」變成了「回 200」。
先排除的一件事:有沒有人在利用它
報告進來之後,要回答的問題有兩個:有沒有人已經在利用這個漏洞,以及為什麼只有長 prompt 會這樣。這個二分是我整理的,文章沒有把它寫成調查結構。文章對第一個問題給的是結論,沒有給方法:「We found no evidence of deliberate exploitation」。支撐這句話的資料,文章只寫了一個條件:排除 Cloudflare 自己的測試與研究員的測試之後,找出來的那些請求,在時間與量上都符合一般的長 context 使用。這句話的措辭值得讀仔細。文章說的是沒有發現蓄意利用的證據,沒有說證明了沒有人利用。至於那 155 個回應是怎麼從流量裡挑出來的,除了上面這個排除條件,文章沒有再說明,所以這一段能確認的只有結論,過程不可核實。
第二個問題,文章給了一組很緊湊的時間線,下圖照它畫出來。報告在 9 月 8 日 23:11 收到,9 月 9 日 08:00 開始驗證;13:22 宣告資安事件,Product Security 用兩個獨立帳號,在 25 分鐘內重現了外洩。14:00 模型被設為 private,這個狀態下請求回的是 HTTP 400,狀態頁同步更新。14:35 找到根因,同一時刻還有一個確認:一個池比較大的 canary prefill 節點,不受這個問題影響。14:52 所有 prefill 節點滾動到修正後的設定。19:04 整個部署用修正設定加上一個 Cloudflare 自己寫的引擎修補程式重建,並用研究員的重現腳本驗證;19:40 模型恢復為 public。
從這條時間線可以讀出一件文章沒有明說的事(以下是我的推論):真正花時間的是 08:00 到 13:22 這一段,也就是驗證報告到重現成功之間;重現之後,下線、找到根因、設定修正,全部在一個多小時內完成。重現成功之前,沒有人能確定這是個真問題。重現成功之後,14:35 的 canary 確認把嫌疑縮到了一個變數上,這是下一節的內容。
第一個答案:池比 context window 小
14:35 的兩條紀錄放在一起讀。第一條是根因:prefill 池比 context length 小。第二條是對照:一個池比較大的 canary prefill 節點,確認不受影響。我的讀法是,這是一組受控比較,兩個節點跑同樣的程式,只有池的大小不同,而問題只在池比較小的那一個出現。文章沒有把這寫成實驗設計,所以這是推論;但時間點與兩句話的並列,指向的就是這個意思。
數字很具體。文章說 GLM 5.3 以 1,048,576 tokens 的 context window 部署,但 prefill GPU 上的 KV cache 池只裝得下 681,280 tokens。兩者差了 367,296 tokens(1,048,576 減 681,280,算術是我做的)。池的大小不是人設定出來的:SGLang 在啟動時,用載入模型權重之後剩下的 GPU 記憶體去決定它。模型宣告的 context length 是另一個獨立的設定值。文章用一句話收束這個結構:兩者沒有任何機制強迫一致,檢查它們是否一致是 Cloudflare 自己的工作。
下面的圖讓你自己撥這個關係。滑桿是 prompt 的長度,上限停在 1,048,576,因為那是模型宣告的 context window,超過它會發生什麼,文章沒有談。切換上線時的池與修復後的池,看同一個長度落在哪一邊。
拖曳 prompt 長度,並切換池的大小 · 長度 0 到 1,048,576 tokens
撥到池是 681,280 的那一側,會發現「受影響」的門檻剛好就是池的邊界,而不是 context window 的邊界。文章講的就是這件事:155 個受影響的回應,全部來自長度超過 681,280 tokens 的 prompt。1,048,576 是模型對外宣告的上限,681,280 才是這台機器真正放得下的上限,兩個數字之間的那一段,就是 prompt 在宣告上合法、在實體上放不下的區間。
但這裡有個不太舒服的事實:這個不一致並不是被忽略的。文章寫的是,他們「were aware of this size mismatch」,知道池與 context length 不一致;只是在上線前的測試裡,看到的是成功的 HTTP 狀態碼,以及沒有 error frame 的串流 body。文章接著說,他們當時只驗證了 HTTP 回應碼與長 context 下的引擎效能。這兩句合起來,就是這次事件第一個層次的原因:測試問的問題是「回應有沒有成功」,而答案是有,因為壞掉的路徑會回 200。
池的大小解釋了「為什麼是長 prompt」:短的放得下,長的放不下,界線在 681,280。但它沒有解釋「為什麼放不下的請求會拿到 200」。一個放不下的請求,正確的下場是被拒絕。文章要說的第二個層次,就在這裡:它是 SGLang 的 bug。
放不下的請求,為什麼沒有被擋下來
文章對 SGLang 行為的描述,核心是一條拒絕路徑。當一個 prompt 放不進池,引擎會在內部把它標成 rejected,並且把 prompt 換成一個 token 的佔位。文章點名了這條路徑裡的函式 Req.set_finish_with_abort:它把要回給 client 的錯誤記錄下來,同時把 prompt 換掉,讓這個請求可以順著一般的 batch 機制排空。換句話說,引擎不是粗暴地丟掉請求,而是讓它偽裝成一個極短的正常請求繼續走,等到該回錯誤的時候再回。這個設計在單一路徑上大概是自洽的。我的推測是,它之所以合理,是因為後面有一個環節會看到那個被記下的錯誤;文章沒有講這個設計當初的理由,這只是推測。
問題出在「後面那個環節」。文章說,在 Cloudflare 使用的 disaggregated 模式下,prefill 與 decode 兩邊都有 admission queue,用來決定請求能不能被接納。被拒絕的請求把拒絕原因存在 to_finish,而不是 finished_reason,於是 finished() 仍然回傳 False。文章接著寫:prefill 的 admission queue 會接納任何 req.finished() 為 False 的請求。文章用一句話總結這個矛盾:一個被拒絕的請求,回報自己還活著。
這裡值得停一下,因為這是整件事最像「兩個事實不能同時為真」的地方。引擎一邊在內部把請求標成 rejected,一邊在 admission 的檢查點上回答「我還沒結束」。兩個說法都是它自己說的,只是出現在不同的欄位。檢查點看的是 finished(),拒絕原因存在 to_finish,兩者沒接上,被拒絕的請求就從 admission 的眼皮底下通過了。
通過之後會發生什麼,文章用一個 8 頁的例子逐步講。router 把 prompt 的長度告訴 decode 節點,圖裡是 8 頁。decode 為這個請求預留了 8 個 KV 頁面。但這個請求實際寫過的只有 P0。P1 到 P7 還留著先前請求留下的東西,文章的原話是「other users' prompts and partial generations」,別的使用者的 prompt 與部分生成。decode 開始生成,attention 讀了全部 8 個預留的頁面。回到前面背景一節的那句話:頁面在請求之間不歸零,下一個請求只是覆寫它。一個沒有被覆寫的頁面,就是上一個主人的資料。最後 client 拿到的是 HTTP 200,不是 HTTP 400。
為什麼只有 P0 被寫過,文章沒有明說。合理的推測是 P0 放的就是那個一個 token 的佔位,因為佔位只佔一頁;這是我的推論,不是文章的原話。也順便解釋了文章另一句觀察:回應抵達的速度比 8 頁的 prefill 該有的快,而且內容來自其他使用者的 KV 頁面。prefill 要處理的其實只有一個 token。
撥兩組開關,看 decode 預留的 8 頁各裝著什麼 · 2 × 2 種組合
撥到「修補後」會看到,修補程式攔的位置很關鍵:不是在 decode 讀到髒頁面的時候,而是在請求剛進 admission queue 的時候。文章描述的修補,是讓帶著待處理拒絕的請求在 disaggregation 的 admission queue 就以原本的錯誤結束;上游 SGLang 對應的變更,則寫成在任何 bootstrap、配置或 KV 傳輸之前就拒絕這類請求。同一個修補程式還讓 decode 節點核對:傳輸過來的 token 數,必須等於它預留的數量。
兩個原因的關係,現在可以講清楚了。只有池比 context window 小,rejected 路徑才會被走到,這是第一個原因讓第二個原因有機會發作;只有 rejected 的請求會回報自己還活著,池小一點只是被拒絕的請求變多,不會變成外洩。合理的推論是,任何一個單獨存在都不會造成這次事件:池夠大,拒絕路徑走不到;引擎正確,放不下的請求會被擋掉。文章把它們叫作 issue one 與 issue two,並且分別修。
還有一件事要區分,因為文章特別做了區分。在這次事件之前,Cloudflare 與 SGLang 團隊處理過另一類問題:請求在 prefill 還在透過 RDMA 把 KV cache 寫給 decode 的時候被中止,已經送出的寫入收不回來;如果 decode 立刻釋放那些頁面,頁面可能被分給別的請求,晚到的寫入就落進別人的 KV cache。這一類的結果,文章形容為輸出損壞,或是一個使用者的資料出現在另一個使用者的回應裡,而且沒有任何錯誤被拋出。然而文章明確說,這次事件的 bug 不一樣:被拒絕的請求每一次都會走過交接,而先前針對競態做的修補,沒有一個涵蓋「prefill 拒絕了請求,decode 卻從來不知道」的情形。兩類問題的症狀看起來相同,機制不同,修的地方也不同。
修復分三層,文章自己列出的還沒補完的洞
設定先修。Cloudflare 把 GLM 5.3 的設定調成與 GLM 5.2 的設定一致,釋放出足夠的 GPU 記憶體,讓 prefill KV 池現在能放約 2.04 million tokens,大約是 1,048,576 tokens context length 的兩倍。文章說明的目的是:讓池容納完整的 context,有效請求就不會走到拒絕路徑。時間線上,14:52 所有 prefill 節點滾動到修正後的設定;合理的推測是,池擴大就是這次滾動的內容,但文章沒有把兩者明確連在一起。
引擎修補程式是第二層,也是文章稱為後盾的那一層。修補程式讓帶著待處理拒絕的請求在 admission queue 就被結束,保留原本的錯誤;decode 節點也會核對傳輸的 token 數。SGLang 方面的版本資訊是:這個問題的修補程式在 v0.5.20 釋出,後續修補在 v0.5.21 與 v0.5.22。文章給 operator 的建議是更新到 v0.5.22,或視情況把修補程式 backport 回去。文章還有一句,我認為是整篇最誠實的一句:引擎端的修補,是在你的第一道檢查出錯的那一天,用來兜底的。
第三層是一串不針對這次事件、但處理同一類症狀的加固:延遲釋放頁面,並且從只涵蓋 abort 擴大到涵蓋每一種失敗類型,也包含 prefill 來源端的頁面;限制 Mooncake 傳輸批次的大小,讓單一次傳輸呼叫不會比釋放逾時的時間更久;改負載平衡器,client 斷線不再讓傳輸中的請求被中止;每次 disaggregated 部署的變更,都新增一項檢查,把 KV 池大小對照 context length。下表把各層放在一起。
| 層 | 問題 | 做法 | 狀態 |
|---|---|---|---|
| 設定 | prefill 池 681,280 tokens,context length 1,048,576 tokens | 池擴大到約 2.04 million tokens | 9/9 14:52 所有 prefill 節點滾動到修正後的設定(與池擴大的關係是推測) |
| 引擎 admission | 被拒絕的請求回報自己還活著,仍被接納 | 在 admission queue 以原本的錯誤結束;decode 核對傳輸的 token 數 | SGLang v0.5.20,後續在 v0.5.21 與 v0.5.22 |
| 頁面釋放與傳輸 | RDMA 寫入送出後無法收回,頁面可能被重新分配 | 延遲釋放涵蓋所有失敗類型;限制 Mooncake 傳輸批次大小 | 文章列為已部署的加固 |
| 負載平衡器 | client 斷線會讓傳輸中的請求被中止 | 斷線不再中止傳輸中的請求 | 文章列為已部署的加固 |
| 偵測 | 損壞發生時沒有錯誤 | decode 在生成前核對傳輸 KV 的 checksum;修補 KV canary | 上游 SGLang 的工作 |
| 長期 | 頁面歸屬只靠流程正確 | 頁面歸屬標籤,在讀取時檢查;為 KV 傳輸協定建立形式化模型 | 文章說這是接下來要做的方向 |
表的最後兩列最值得讀。偵測一列的做法,是在 decode 生成之前核對傳輸 KV 的 checksum;KV canary 則是 SGLang 裡用來偵測並記錄潛在損壞的機制,文章只對它做了這一句描述。長期一列是兩個方向。第一個叫「integrity by construction」:每一個實體 KV 頁面帶一個標籤,頁面被重新分配時標籤會變;每個請求記錄自己預期的標籤,decode 讀取快取之前先檢查這個對應,不符就讓那一個請求失敗,不回傳別人的資料。第二個是把形式化方法用在 KV cache 傳輸引擎上,目標是替正式環境的 SGLang 加 Mooncake 的分散式 KV cache 系統建模,並且在可能的範圍內證明,這一類 bug 不會再出現。文章用的字是「as far as possible」,不是保證。
公開的時間線也值得記一筆。文章說這份報告是協調揭露的一部分,研究員提供了可重現的腳本,並給 Cloudflare 時間修復,也給了與 SGLang 維護者協調的時間;SGLang 團隊則把這當成他們自己的問題。時間表上,v0.5.21 在 10 月 2 日釋出,v0.5.22 在 10 月 19 日釋出,與研究員及 RadixArk 的協調公開則在 10 月 26 日。也就是說,9 月 9 日恢復上線的模型,到文章公開之間,隔了約七週,這是我從兩個日期相減得出的。
給 SGLang operator 的建議,文章列成一串。先量你的 KV 池,跟 context length 比一比。接著不要仰賴 proxy 去執行長度限制,也不要假設引擎會拒絕放不下的請求。版本上,文章建議更新到 v0.5.22,或視情況 backport 這些修補程式。傳輸端要限制 Mooncake 的批次大小,讓單次逾時的呼叫不會留下一長串還在飛的寫入;延遲釋放在上游已改為預設開啟。負載平衡器則要設成讓請求可以一路走到第一個 token,避免觸發一個需要跨節點協調的 abort。把前兩條放在一起讀,會發現文章對引擎的態度:它修了引擎,同時叫你不要把安全建立在引擎上。
回頭看這條追查線,有兩件事是文章自己攤開的。一是知道風險和驗證風險是兩回事:池的不一致被知道,測試卻只問了回應碼。二是同一個症狀可以來自不同機制:競態與拒絕路徑都產生「別人的資料出現在我的回應裡」,但只修競態的修補程式完全沒碰到這次的路徑。文章沒有、也不能保證後面沒有第三種機制,它選擇的回答是上面表格最後一列:把保證從「流程正確」換成「頁面歸屬可檢查」。
Take-away:下次看到部署宣告的上限和機器實際放得下的上限是兩個數字,先拿比上限更長的輸入去打錯誤路徑,確認它回的是錯誤,而不只是確認成功路徑回了成功。