Cloudflare 披露的 KV Cache 跨请求暴露:SGLang 为何仍将被拒绝的请求送入 handoff?
先给不运行推理集群的读者一个结论:这不是所有 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 披露的生产路径;#36988 于 9 月 6 日已合并,但当时尚未发布到 Cloudflare 的生产版本): ① 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 事故披露逐步描述了这条路径;SGLang issue #30233 与 PR #38935 可交叉核对相关状态与 admission 修复。
单看每一步都像在正常工作,合起来才看出问题:拒绝状态没有及时生效,后面的步骤却继续按正常请求推进。
681,280 与 1,048,576 是两种不同的容量
Cloudflare 披露的部署把模型 context 长度设为 1,048,576 tokens,而事故时 prefill GPU 上的 KV pool 容量为 681,280 tokens。Cloudflare 后来将池容量提高到约 2.04 million tokens,并补上引擎侧的拒绝安全防线。Cloudflare 的容量说明与修复记录。
这两个 681,280 出自同一份披露,说的是两件事:一个是受影响请求的 prompt 长度阈值,一个是当时 prefill GPU 的 KV pool 容量。数字相同,角色不同——前者描述什么样的请求会受影响,后者描述运行时到底能装多少。
这两个数字管的是两码事。context length 是模型配置层面允许请求达到的上下文边界;KV pool 则是运行时真正能为 token 分配并保存键值状态的显存池。把 context 配得更大,并不会自动把 KV pool 也扩到同样的容量。于是,一个请求可能没有超过声明的 context 上限,却已经超过当前 worker 实际能装下的 KV 状态。
池容量不足本应触发拒绝;真正的问题是,拒绝状态没有及时拦住后续 handoff。Cloudflare 披露与上游源码都把问题指向 abort 状态在分离式推理路径中的传播缺口。
拒绝原因存在,不代表 admission 已终止请求
SGLang 的相关路径里,Req.set_finish_with_abort() 会把拒绝原因记录到 to_finish,同时把输入 prompt 换成一个 1-token placeholder。可当时的 finished() 判断读取的是 finished_reason。换句话说,拒绝原因已经记录在请求对象上,但用于判断“请求是否结束”的 finished() 仍然让它看起来像一个还在正常调度中的请求。SGLang PR #38935 的根因说明。
把这个状态差异直接放到源码里更容易复核。下面是固定 commit 的非连续摘录,省略行已标出;完整上下文分别见 set_finish_with_abort()(L2053–2072)、finished()(L1479–1481) 和 update_finish_state()(L1842–1848):
# 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 报告;上游相关 issue #30233。
这里最容易混的是三种“完成”信号:
- HTTP 200 说明客户端收到的 HTTP 响应状态是成功;它本身不证明响应语义正确,更不证明 KV 页属于当前请求。
- prefill completion / transfer-complete 说明某个处理或交接步骤走到了完成信号;它不自动证明实际传输的 token 数覆盖了 decode 预留的全部页面。
- decode 读取到完整且有效的 KV pages 才是更接近真正数据条件的检查。若只核对“流程完成”,没有核对实际写入范围和页面所有权,就可能把“传输步骤结束”误当成“数据完整且属于当前请求”。
流程走完了,不等于数据对了。
#36988、#38935 与 #38704:三者不能写成同一次修复
这几个上游记录的时间顺序和职责很重要。
| 时间 / 记录 | 状态 | 实际解决什么 |
|---|---|---|
| 2026-07-06, issue #30233 | 已关闭 | 早先就报告过 PD disaggregation 中,超长 prompt 只传递 1 token 的相似问题。它是相关缺陷报告,不等于独立复现了 Cloudflare 的完整生产事故。 |
| 2026-09-06, PR #36988 | 已合并;Cloudflare 标注于 v0.5.20 发布 | 修正 aborted prefill result 的终止处理与清理路径:清除待发送 chunk 状态、不把 abort 结果重新排回未完成队列、释放 metadata/KV/HiCache/Mamba 等资源,并忽略已经退休请求的延迟结果。它堵的是正确性缺口。 |
| 2026-09-16, PR #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 | 已关闭,未合并;无发布版本 | Cloudflare 提出的另一种防线方案,不能当成已进入上游主线的修复。 |
#38935 的说明给出关键时间锚点:在包含 #36988 的修复后 main 上,decode 不再提交不足量 handoff。上游 PR 还给出一组 1P1D(2×H200)前后行为对照:合入 #38935 后,decode 从等待 300 秒后超时变为立即返回 400,bootstrap/metadata buffer 不再创建,stub batch 从 1 变为 0。由此可见,#36988 修正 aborted prefill result 的正确性清理,#38935 则把拒绝请求拦在更早的入口、减少无用工作;两者职责不同。
这也解释了为什么入口守卫不能简化成“只要 to_finish 非空就终止请求”。#38935 用 is_unadmitted_reject 识别真正尚未入场、尚未取得资源所有权的拒绝请求;重入或已抢占的请求可能已经持有 KV 或 metadata 等资源,不能仅凭 abort 标记提前丢弃。Decode 侧还要结合重入路径判断,因为部分资源状态不一定保留在 Req 对象上。否则,过早终止请求可能跳过正常调度路径负责的清理,例如 streaming session 的 active slot detach(解除会话对活跃 slot 的占用)。SGLang PR #38935。
后续还有几类相关但不同的防线。PR #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 与 PR #41402 共同修复 drain 确认丢失或未覆盖所有 decode peer 的问题;PR #41404 则把 deferred release 扩展到不只是 decode 主动发起 abort 的其他 transfer failure,避免在途 RDMA 写入仍可能触及页面时就释放或复用页面。这些修复针对 KV page lifetime race,与拒绝请求确定性地走进不足量 handoff 属于不同失效路径。Cloudflare 的防线与版本清单。
固定版本 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(固定源码)。执行的是上游的 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 → head 8f3d6944)。 在修复前,入口拒绝请求仍会进入队列并触发 sender/receiver 创建;修复后,请求在入口被终止处理,不再创建这些交接对象。对照中的有效请求和携带 metadata buffer 的重入请求在两边都被保留,未观察到这两类请求被误退休。
- 实验 B:#36988 prefill 结果清理(base 9c254df8 → head 2ee5742a)。 探针观察到,修复前 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 校验清单放进了公开实验材料包与复现说明。其中固定了两组 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,齐活下班。” 别信这个,真到了线上,配置和生命周期有一百种姿势能绕开你的版本号。要睡个安稳觉,我会把验收拆成四道独立的门:
- Pool / context gate:配置是否与运行时容量一致。 启动 worker 后,先看启动日志里实际打印的 KV pool 容量数字,并与 context 配置核对,确认声明的 context 上限与运行时实际容量一致。配置、GPU 划分或并发策略变化后,重新核验启动日志。负向用例应覆盖“输入没有超过 context 上限、却超过当前 KV pool 能容纳的长度”,并确认系统明确拒绝,而不是继续创建 handoff。
- Reject / admission gate:拒绝状态有没有在入口生效。 检查被拒绝请求是否在 bootstrap、queue admission、metadata buffer 分配和 sender/receiver 初始化之前终止。测试不能只覆盖新请求,还应包含有效请求、重入请求、已抢占请求,以及仍持有 KV、Mamba 或 metadata 资源的请求。不能简单地把所有带有 abort 状态的请求一律扔掉,否则可能破坏原请求的资源清理责任。
- Transfer / read gate:实际传输范围是否覆盖实际读取范围。 对比预留的 token/page 数与实际写入、转移的有效范围。HTTP 200 或 transfer-complete 不能替代长度和所有权验证;若启用
--disaggregation-enable-kv-checksum或--kv-canary,先确认当前模型和 backend 支持,再查看日志确认请求确实经过验证。Cloudflare 披露时这两项尚未覆盖 GLM-5.3 等所有架构,不能把未支持的选项计为有效防线;handoff checksum 也不覆盖每一步 decode 读取。 - Lifetime / ownership gate:失败以后,页面何时才能释放或复用。 对 abort、延迟结果和在途 RDMA write 做负向测试,确保未结束的写入不会污染已重新分配的页面,已终止请求的延迟结果不会再发送 chunk,资源只释放一次。确认 prefill 与 decode 两端都启用了 deferred KV release;对 Mooncake,还要核对 transfer batch 上限与 release timeout 匹配,避免单次超时留下过长的在途写入尾部。含 PR #41023 的版本已对支持的后端默认启用 deferred release,但仍应以实际运行版本和配置为准。#40645 与 #41402 处理 drain 确认路径,#41404 处理其他 transfer failure 的延迟释放;这些生命周期风险与最初的确定性 handoff 缺陷要分别验收。
升级方面,SGLang 的 v0.5.22 release 于 2026-10-19 发布,Cloudflare 建议使用 v0.5.22 或更新版本,或者按需 backport 对应修复。但“版本已升级”不等于“部署已经通过隔离验收”:还需要核对实际运行的代码版本、pool/context 配置、admission 负向用例和页面生命周期。若 checksum 等功能没有启用,不能把它算作已生效的防线。
这次事故最值得记住的不是某一个布尔值写错了,而是错误状态、数据完整性和资源所有权分属不同的边界。拒绝理由存在于请求对象里,不代表 admission 一定看得见;一次 transfer 结束,不代表 decode 读到的每一页都由当前请求写入;一个 abort 被发出,也不代表所有在途写入都已经结束。只有把这几件事分开验证,才不会让一条看起来成功的请求路径替真正的数据隔离背书。
实测日期:2026 年 10 月 28 日。
SGLang 七步事故链
逐步检查拒绝状态如何穿过 admission、KV transfer 与 decode。这是对 Cloudflare 披露路径的交互阅读,不是在浏览器运行 SGLang。
即使没有 JavaScript,以下七步仍完整可读。
- Cloudflare 生产事故披露
1. Bootstrap:预留 KV 页
Router 把原始 prompt 长度告知 decode;decode 预留相应 KV 页。
预留长度来自原始 prompt;这不能证明后续传输覆盖完整长度。
- 固定版本 CPU 差分
2. Prefill admission:拒绝未挡住准入
req.finished() 检查读取 finished_reason,而拒绝原因当时在 to_finish;旧路径仍可能把请求视为未结束。
固定 CPU 差分:旧版拒绝请求仍入队并创建 sender;修复版在 admission 入口 retire。
固定源码 · 8f3d6944b76c · L1479-L1481本地证据: probe-admission-pre-20261028.json本地证据: probe-admission-post-20261028.json
- Cloudflare 生产事故披露
3. Prefill forward:输入缩成 1 token
为跳过超长 prefill,输入替换成 1-token placeholder;这不证明原请求的 KV 状态完整。
Placeholder 是控制流信号,不是完整 KV 内容的证明。
- Cloudflare 生产事故披露
4. KV transfer:一页数据却报告完成
Prefill 只发送 placeholder 对应的一页 KV,却发出 transfer-complete;完成信号不校验覆盖范围。
事故路径来自 Cloudflare 披露;本项目未独立复现 GPU/RDMA 传输。
固定源码 · 8f3d6944b76c · L2053-L2072本地证据: sglang-cpu-p2-receipt.json
- 固定版本 CPU 差分
5. Decode admission:错误状态再次放行
Decode 侧也接受 handoff;固定 CPU 差分显示旧版创建 receiver,修复版在入口 retire 被拒请求。
固定 CPU 差分显示旧版为拒绝的 decode 请求创建 receiver;修复版在 admission 入口 retire。
固定源码 · 8f3d6944b76c · L1479-L1481本地证据: probe-admission-pre-20261028.json本地证据: probe-admission-post-20261028.json
- Cloudflare 生产事故披露
6. Decode attention:读取范围超过有效写入
Decode 按预留长度读取 KV 页,但事故路径中只有第一页由当前请求写入,其余页可能残留先前请求数据。
披露的事故路径;公开 CPU 差分验证相关 abort/队列/清理路径,不是 GPU attention 复现。
固定源码 · 8f3d6944b76c · L1479-L1481本地证据: probe-prefill-pre-20261028.json本地证据: probe-prefill-post-20261028.json
- Cloudflare 生产事故披露
7. Response:HTTP 200 不等于数据正确
Cloudflare 披露客户端收到 HTTP 200 而不是预期 HTTP 400,响应包含其他用户会话内容。
这是厂商披露的事故结果;本项目没有复现真实跨租户响应。
固定源码 · 8f3d6944b76c · L1479-L1481本地证据: sglang-cpu-p2-receipt.json