# Cloudflare 披露的 KV Cache 跨請求暴露：SGLang 為何仍將被拒絕的請求送入 handoff？

> Canonical: https://vxi.me/zh-tw/posts/glm53-kv-cache-cross-request-exposure/
> Locale: zh-tw
> Published: 2026-10-28
> Updated: 2026-10-29
> Markdown: https://vxi.me/zh-tw/posts/glm53-kv-cache-cross-request-exposure.md

<figure class="glm53-kv-rejection-probe not-prose" lang="zh-tw">
<picture><source media="(prefers-reduced-motion: reduce) and (max-width: 640px)" srcset="/images/posts/glm53-kv-cache-cross-request-exposure/glm53-kv-rejection-probe.mobile.still.zh-tw.svg" type="image/svg+xml"><source media="(prefers-reduced-motion: reduce)" srcset="/images/posts/glm53-kv-cache-cross-request-exposure/glm53-kv-rejection-probe.still.zh-tw.svg" type="image/svg+xml"><source media="(max-width: 640px)" srcset="/images/posts/glm53-kv-cache-cross-request-exposure/glm53-kv-rejection-probe.mobile.zh-tw.svg" type="image/svg+xml"><img src="/images/posts/glm53-kv-cache-cross-request-exposure/glm53-kv-rejection-probe.zh-tw.svg" alt="反例探針：表面上的 HTTP 200 與 transfer-complete 不能證明 KV 頁完整或屬於目前請求。事故路徑來自 Cloudflare 披露；固定版本 CPU 差分不是 GPU/RDMA 或真實跨租戶洩露復現。" width="1200" height="700" loading="eager" fetchpriority="high" decoding="async" style="display:block;width:100%;height:auto"></picture>
<figcaption class="mt-2 text-xs leading-relaxed text-slate-500">反例探針 · 區分公開事故路徑與固定版本 CPU 差分；未復現真實跨租戶洩露。</figcaption>
</figure>

Cloudflare 在 2026 年 10 月公開披露，Workers AI 上的 GLM-5.3 曾發生跨請求 KV cache 暴露。依照 Cloudflare 的報告，事故窗口為 8 月 27 日至 9 月 9 日，共有 155 個回應受到影響，相關 prompt 均超過 681,280 tokens。報告沒有提供同期總請求量，因此無法據此計算受影響比例。[Cloudflare 事故披露](https://blog.cloudflare.com/kv-cache-exposure-remediation/)；[SGLang 協調披露](https://www.lmsys.org/blog/2026-10-26-cf-security)。

先給不會執行推論叢集的讀者一個結論：這不是所有 AI API 的普遍風險。Cloudflare 表示，其他 Workers AI 模型未受影響，GLM-5.3 已於 9 月 9 日完成修復並恢復服務。披露中明確受影響的是事故窗口內輸入超過 681,280 tokens 的 GLM-5.3 請求。如果你只是呼叫 API，先確認自己是否屬於這個情境；如果你維運自有的 SGLang prefill/decode 分離式叢集，可以直接看文末的四道驗收門。

值得追查的不是「模型怎麼會洩露」這種寬泛問題，而是一個更具體的狀態邊界：**請求已經被判定不該繼續，為什麼它仍能帶著只有 1 token 的佔位輸入進入 prefill/decode handoff？** 已拒絕，卻還在往下走，這就是最反常的地方。這次事故把兩個問題疊在一起：宣告的 context 上限大於實際 KV pool 能容納的長度；拒絕狀態又沒有在當時的分離式推論入口及時終止請求。前者觸發拒絕，後者讓被拒絕的請求繼續走上不該走的路徑。

**事故發生時的七步流程（Cloudflare 披露的生產路徑；PR #36988 雖於 9 月 6 日合併，但在 Cloudflare 9 月 9 日部署引擎修正時，尚未進入其生產環境）：** ① **Bootstrap：** router 將原始 prompt 長度告知 decode，decode 先預留相應的 KV 頁；② **Prefill admission：** admission 檢查 **req.finished()**，但拒絕原因記錄在 **to_finish** 而不是 **finished_reason**，所以請求仍可能被視為未結束並獲准入場；③ **Prefill forward：** prefill 處理被替換成 1-token 佔位輸入；④ **KV transfer：** prefill 只傳送該佔位輸入對應的一頁 KV，卻發出 transfer-complete 訊號；⑤ **Decode admission：** decode 端相同的結束狀態檢查也接受了 handoff；⑥ **Decode attention：** decode 按預留長度讀取全部 KV 頁，但只有第一頁由目前請求寫入，其餘頁面仍可能留有先前請求的資料；⑦ **Response：** Cloudflare 表示客戶端收到的是 HTTP 200，而不是預期的 HTTP 400，回應內容包含其他使用者工作階段的資料。[Cloudflare 事故披露](https://blog.cloudflare.com/kv-cache-exposure-remediation/)逐步描述了這條路徑；[SGLang issue #30233](https://github.com/sgl-project/sglang/issues/30233) 與 [PR #38935](https://github.com/sgl-project/sglang/pull/38935) 可交叉核對相關狀態與 admission 修復。

單看每一步似乎都在正常運作，合起來才看出問題：拒絕狀態沒有及時生效，後續步驟卻繼續把請求當成正常請求推進。

## 681,280 與 1,048,576 是兩種不同的容量

Cloudflare 披露的部署把模型 context 長度設為 1,048,576 tokens，而事故發生時 prefill GPU 上的 KV pool 容量為 681,280 tokens。Cloudflare 後來把 pool 容量提高到約 2.04 million tokens，並補上引擎端的拒絕安全防線。[Cloudflare 的容量說明與修復紀錄](https://blog.cloudflare.com/kv-cache-exposure-remediation/)。

這兩個 681,280 出自同一份披露，指的是兩件事：一個是受影響請求的 prompt 長度門檻，另一個是當時 prefill GPU 的 KV pool 容量。數字相同，角色不同——前者描述什麼樣的請求會受到影響，後者描述執行環境實際能容納多少。

這兩個數字管的是不同事情。context length 是模型設定層允許請求達到的上下文邊界；KV pool 則是執行時真正能為 token 分配並保存鍵值狀態的顯存池。把 context 設得更大，不會自動把 KV pool 也擴到同樣容量。因此，一個請求可能尚未超過宣告的 context 上限，卻已超過目前 worker 實際能容納的 KV 狀態。

pool 容量不足本應觸發拒絕；真正的問題是拒絕狀態沒有及時攔住後續 handoff。Cloudflare 披露與上游原始碼都把問題指向 abort 狀態在分離式推論路徑中的傳播缺口。

## 拒絕原因已記錄，不代表 admission 已終止請求

SGLang 的相關路徑中，**Req.set_finish_with_abort()** 會把拒絕原因記錄到 **to_finish**，同時把輸入 prompt 換成 1-token placeholder。但當時的 **finished()** 判斷讀取的是 **finished_reason**。換句話說，拒絕原因已記錄在請求物件上，但用來判斷「請求是否結束」的 **finished()** 仍可能讓它看起來像一個還在正常排程中的請求。[SGLang PR #38935 的根因說明](https://github.com/sgl-project/sglang/pull/38935)。

把這個狀態差異直接放到原始碼中，更容易核對。以下是固定 commit 的**非連續摘錄**，省略行已標示；完整上下文分別見 [`set_finish_with_abort()`（L2053–2072）](https://github.com/sgl-project/sglang/blob/8f3d6944b76c428d884582cb6c82a39db1465360/python/sglang/srt/managers/schedule_batch.py#L2053-L2072)、[`finished()`（L1479–1481）](https://github.com/sgl-project/sglang/blob/8f3d6944b76c428d884582cb6c82a39db1465360/python/sglang/srt/managers/schedule_batch.py#L1479-L1481) 與 [`update_finish_state()`（L1842–1848）](https://github.com/sgl-project/sglang/blob/8f3d6944b76c428d884582cb6c82a39db1465360/python/sglang/srt/managers/schedule_batch.py#L1842-L1848)：

```python
# Req.set_finish_with_abort() — excerpt
self.origin_input_ids = array(
    "q", [0]
)  # set it to one token to skip the long prefill
# ... omitted ...
self.to_finish = FINISH_ABORT(error_msg, status_code, err_type)

# Req.finished()
return self.finished_reason is not None

# Req.update_finish_state()
if self.to_finish:
    self.finished_reason = self.to_finish
    self.to_finish = None
```

這不代表 `to_finish` 永遠不會生效：`update_finish_state()` 之後會把它移到 `finished_reason`。問題在於 admission 先檢查 `finished()`，此時狀態轉換還沒發生。

Cloudflare 在 9 月 9 日部署引擎修正之前，這種狀態差異會沿著分離式 prefill/decode 流程繼續傳播：admission 把請求當成仍可入場的請求；decode 端為它預留足夠的 KV pages；prefill 實際處理的卻是 1-token 佔位輸入，只產生並轉移不足量的 KV 狀態。decode 之後可能讀取目前請求根本沒有覆寫的頁面。Cloudflare 將實際暴露歸因於先前請求留下的 KV 資料。[Cloudflare 報告](https://blog.cloudflare.com/kv-cache-exposure-remediation/)；[上游相關 issue #30233](https://github.com/sgl-project/sglang/issues/30233)。

這裡最容易混淆的是三種「完成」訊號：

- **HTTP 200** 表示客戶端收到成功的 HTTP 狀態碼；它本身不證明回應語意正確，更不證明 KV 頁屬於目前請求。
- **prefill completion / transfer-complete** 表示某個處理或交接步驟發出了完成訊號；它不會自動證明實際傳輸的 token 數量覆蓋了 decode 預留的所有頁面。
- **decode 讀取到完整且有效的 KV pages** 才是更接近真正資料條件的檢查。如果只核對「流程完成」，沒有核對實際寫入範圍與頁面所有權，就可能把「傳輸步驟結束」誤當成「資料完整且屬於目前請求」。

流程走完了，不等於資料正確。

## #36988、#38935 與 #38704：不能把三者寫成同一次修復

這幾筆上游紀錄的時間順序與職責很重要。

| 時間 / 紀錄 | 狀態 | 實際解決什麼 |
| --- | --- | --- |
| 2026-07-06，[issue #30233](https://github.com/sgl-project/sglang/issues/30233) | 已關閉 | 早先已回報 PD disaggregation 中，超長 prompt 只傳遞 1 token 的相似問題。它是相關缺陷報告，不等於獨立復現了 Cloudflare 完整的生產事故。 |
| 2026-09-06，[PR #36988](https://github.com/sgl-project/sglang/pull/36988) | 已合併；Cloudflare 標示於 v0.5.20 發布 | 修正 aborted prefill result 的終止處理與清理路徑：清除待傳送的 chunk 狀態、不把 abort 結果重新排回未完成佇列、釋放 metadata/KV/HiCache/Mamba 等資源，並忽略已退役請求的延遲結果。它補的是正確性缺口。 |
| 2026-09-16，[PR #38935](https://github.com/sgl-project/sglang/pull/38935) | 已合併；Cloudflare 標示於 v0.5.21 發布 | 把尚未入場的拒絕請求提前在 prefill 與 decode admission 入口終止，避免不必要的 bootstrap、metadata buffer、sender/receiver 初始化與 forward pass。PR 作者明確說明，這是減少資源浪費與延遲的修復，不是 #36988 之後第二次獨立的 correctness 修復。 |
| 2026-09-09 建立、2026-10-10 關閉，[PR #38704](https://github.com/sgl-project/sglang/pull/38704) | 已關閉、未合併；沒有發布版本 | Cloudflare 提出的另一種防線方案，不能當成已進入上游主線的修復。 |

#38935 的說明提供了關鍵時間錨點：在包含 #36988 的修復後 main 上，decode 不再提交不足量 handoff。上游 PR 也給出一組 1P1D（2×H200）前後行為對照：合入 #38935 後，decode 從等待 300 秒後逾時變成立即回傳 400，bootstrap/metadata buffer 不再建立，stub batch 從 1 變為 0。這是上游提供的前後比較，不是本文自行執行的效能測試。

這也說明為什麼入口守衛不能簡化成「只要 **to_finish** 非空就終止請求」。#38935 使用 **is_unadmitted_reject** 識別真正尚未入場、尚未取得資源所有權的拒絕請求；重入或已搶占的請求可能已持有 KV 或 metadata 等資源，不能只憑 abort 標記就提前丟棄。decode 端也需要考慮重入路徑，因為部分資源狀態不一定保留在 **Req** 物件上。否則，過早終止請求可能跳過正常排程路徑負責的清理，例如 streaming session 的 active slot detach（解除工作階段對活躍 slot 的占用）。[SGLang PR #38935](https://github.com/sgl-project/sglang/pull/38935)。

後續還有幾類相關但不同的防線。[PR #39500](https://github.com/sgl-project/sglang/pull/39500) 加入可選的 handoff KV checksum；Cloudflare 也介紹了 KV canary（偵測並記錄潛在損壞）與 read-time page tagging。後者是 Cloudflare 在 2026 年夏季測試的原型，曾在 GLM-5.2 上試用，之後因可靠性問題停用，且依賴特定 attention backend 與模型架構；它不是目前已上線的通用防線。Cloudflare 同時說明 checksum 與 canary 尚未涵蓋所有模型架構，GLM-5.3 仍在擴大支援範圍；handoff checksum 驗證的是 prefill 到 decode 的傳輸，不代表每一步 decode 讀取都經過驗證。[PR #40645](https://github.com/sgl-project/sglang/pull/40645) 與 [PR #41402](https://github.com/sgl-project/sglang/pull/41402) 共同修復 drain acknowledgement 遺失或未涵蓋所有 decode peer 的問題；[PR #41404](https://github.com/sgl-project/sglang/pull/41404) 則把 deferred release 擴大到不只是 decode 主動發起 abort 的其他 transfer failure，避免在途 RDMA 寫入仍可能碰到頁面時就釋放或重用頁面。這些修復處理的是 KV page lifetime race，與拒絕請求確定性地進入不足量 handoff 是不同的失效路徑。[Cloudflare 的防線與版本清單](https://blog.cloudflare.com/kv-cache-exposure-remediation/)。

## 固定版本 CPU 驗證：證實程式碼路徑，不是復現租戶資料洩露

這輪不只閱讀事故報告與 PR。驗證分成三層，但每一層能證明的事情不同。

| 證據 | 觀察結果 | 證據能支持到哪裡 |
| --- | --- | --- |
| 自建 Python 合成狀態機 | handoff 模型 6/6；admission / re-entry 模型 9/9 | 說明我們構造的狀態模型守衛符合預期；不代表 SGLang 執行期結果。 |
| 固定 SGLang commit 的上游 CPU 單元測試（P1） | 兩份測試合計 19 passed；其中 admission 測試另有 12 個 subtests | 執行了固定版本的真實上游 mixin、queue 與 retire 程式碼；transport、cache、scheduler 的部分協作者仍是 Mock。沒有執行真實 prefill/decode 叢集。 |
| 修復前後差分（P2） | #38935 admission 差分與 #36988 prefill cleanup 差分都觀察到行為變化 | 說明固定輸入在兩個版本觸發了不同程式碼路徑；差分僅涵蓋 CPU 程式碼與探針行為。 |

P1 固定在 SGLang commit **8f3d6944b76c428d884582cb6c82a39db1465360**（[固定原始碼](https://github.com/sgl-project/sglang/commit/8f3d6944b76c428d884582cb6c82a39db1465360)）。執行的是上游的 **test_prefill_abort_result_cleanup.py**（11 passed）與 **test_admission_abort_not_enqueued.py**（8 passed，另有 12 個 subtests），合計 19 passed。環境為 Linux CPU-only 虛擬機、Python 3.12.3、pytest 9.2.0 與 CPU 版 torch；沒有 GPU、模型權重、RDMA 或租戶資料。

P2 則分別隔離每個 PR 的前後版本，避免把多個修復混在同一組比較中：

- **實驗 A：#38935 admission 入口（base [17ba2c2e](https://github.com/sgl-project/sglang/commit/17ba2c2e7c7b81f31a8a9e693e7435ab262c16b4) → head [8f3d6944](https://github.com/sgl-project/sglang/commit/8f3d6944b76c428d884582cb6c82a39db1465360)）。** 修復前，入口拒絕的請求仍會進入佇列並觸發 sender/receiver 建立；修復後，請求在入口就被終止處理，不再建立這些交接物件。對照中的有效請求，以及攜帶 metadata buffer 的重入請求，在兩邊都被保留，未觀察到這兩類請求被誤退役。
- **實驗 B：#36988 prefill 結果清理（base [9c254df8](https://github.com/sgl-project/sglang/commit/9c254df8cdbe99ef5f58e7fcd932da052f258538) → head [2ee5742a](https://github.com/sgl-project/sglang/commit/2ee5742af92db2727236460473554c2ee8c4ddaa)）。** 探針觀察到，修復前 abort 的最終結果會被當成未完成快取處理並重新進入 inflight 佇列；已終止請求的延遲結果仍可能觸發 KV chunk 傳送。修復後，請求按預期完成清理，延遲結果不再觸發這些副作用。

P2 的 7/7 與 P1 的 11 passed 來自不同版本。P2 使用 #36988 head commit **2ee5742a** 當時的 7 個測試案例；P1 固定的 **8f3d6944** 後來加入 sampling-mask 參數化測試，展開後該檔案有 11 項。因此，P2 的 **0/7 → 7/7** 是同一批 7 個案例的修復前後對照，不能與 P1 的 11 passed 混算。P2 的 7 個 pre-fix 失敗中，6 個是程式碼落入正常 transfer 路徑後產生的 **AttributeError**，另 1 個是 **ValueError**；測試 stub 沒有建模正常路徑，所以這些例外不是 7 個業務斷言失敗。更有解釋力的是探針觀察到的重新入列、清理與延遲傳送行為。

### 公開複核材料

我把兩份差分探針、四份原始 JSON 輸出、P2 收據與 SHA-256 校驗清單放進[公開實驗材料包與重現說明](/evidence/glm53-kv-cache-cross-request-exposure/README.md)。材料固定了兩組 base/head commit，並記錄 Mock 邊界與 CPU-only 限制。P2 的 pre-fix 0/7 是 stub fall-through（6 個 `AttributeError`、1 個 `ValueError`），不是 7 個斷言失敗；這些材料證明的是固定版本的程式路徑差分，不是 GPU/RDMA 或真實跨租戶洩露復現。

## 部署端應該驗收哪四道門

如果你正在維護多租戶推論服務，或正在使用 SGLang 的 prefill/decode 分離架構，最容易犯的懶就是：「群組裡說升到 v0.5.22，隨便發一條長 prompt 測試返回 400，就可以下班了。」別這樣做；線上配置與生命週期路徑有很多方式會繞過只看版本號的驗收。我會把檢查拆成四道獨立的門：

1. **Pool / context gate：設定是否與執行時容量一致。** worker 啟動後，先看啟動日誌實際列出的 KV pool 容量，再與 context 設定核對，確認宣告的 context 上限與執行時實際容量一致。設定、GPU 分配或並行策略變更後，重新核驗啟動日誌。負向案例應涵蓋「輸入沒有超過 context 上限，卻超過目前 KV pool 能容納的長度」，並確認系統明確拒絕，而不是繼續建立 handoff。
2. **Reject / admission gate：拒絕狀態是否在入口生效。** 檢查被拒絕的請求是否在 bootstrap、queue admission、metadata buffer 分配與 sender/receiver 初始化之前終止。測試不能只涵蓋新請求，也應包含有效請求、重入請求、已搶占請求，以及仍持有 KV、Mamba 或 metadata 資源的請求。不能把所有帶有 abort 狀態的請求一律丟掉，否則可能破壞原請求的資源清理責任。
3. **Transfer / read gate：實際傳輸範圍是否涵蓋實際讀取範圍。** 比較預留的 token/page 數量與實際寫入、傳輸的有效範圍。HTTP 200 或 transfer-complete 不能取代長度與所有權驗證；如果考慮啟用 <code>--disaggregation-enable-kv-checksum</code> 或 <code>--kv-canary</code>，先確認目前模型和 backend 支援，再查看日誌確認請求確實經過驗證。Cloudflare 披露時，這兩項尚未涵蓋 GLM-5.3 等所有架構，不能把未支援的選項算成有效防線；handoff checksum 也不會驗證每一次 decode 讀取。
4. **Lifetime / ownership gate：失敗後，頁面何時才能釋放或重用。** 對 abort、延遲結果與在途 RDMA write 加入負向測試，確保未結束的寫入不會污染重新分配的頁面、已終止請求的延遲結果不會再傳送 chunk，並且資源只釋放一次。確認 prefill 與 decode 兩端都啟用了 deferred KV release。對 Mooncake，還要核對 transfer batch 上限與 release timeout 是否匹配，避免單次逾時留下過長的在途寫入尾端。包含 [PR #41023](https://github.com/sgl-project/sglang/pull/41023) 的版本已對支援的後端預設啟用 deferred release，但仍應以實際執行版本與設定為準。#40645 與 #41402 處理 drain 確認路徑，#41404 處理其他 transfer failure 的延遲釋放；這些生命週期風險應與最初的確定性 handoff 缺陷分開驗收。

SGLang 的 [v0.5.22 release](https://github.com/sgl-project/sglang/releases/tag/v0.5.22) 於 2026-10-19 發布。Cloudflare 建議使用 v0.5.22 或更新版本，或視需求 backport 對應修復。但「已升級版本」不等於「部署已通過隔離驗收」：還要核對實際執行的程式碼版本、pool/context 設定、admission 負向案例與頁面生命週期。如果 checksum 等功能沒有啟用，就不能把它算成已生效的防線。

這次事故最值得記住的不是某一個布林值寫錯，而是錯誤狀態、資料完整性與資源所有權分屬不同邊界。拒絕原因存在於請求物件中，不代表 admission 一定看得到；一次 transfer 結束，不代表 decode 讀到的每一頁都由目前請求寫入；發出 abort，也不代表所有在途寫入都已結束。只有把這些事情分開驗證，才不會讓一條看似成功的請求路徑替真正的資料隔離背書。

*測試日期：2026 年 10 月 28 日。*

## Verification context

- Level: source-inspected
- Verified: 2026-10-28T00:00:00.000Z
- Environment: Linux; AMD EPYC 9D25; 2 vCPU; 7 GB RAM
- Environment: Python 3.12.3; pytest 9.2.0; torch 2.14.1+cpu
- Evidence mode: mixed
- Public repository: https://github.com/sgl-project/sglang
- Evidence commit: 8f3d6944b76c428d884582cb6c82a39db1465360
- Evidence receipt: https://vxi.me/evidence/glm53-kv-cache-cross-request-exposure.json
