- 影响与受影响的部署
- 事件经过
- 即时修复与上游修复
- 相关 KV cache 生命周期问题
- 背景
- 问题所在:KVCache 槽位何时才真正空闲?
- 影响与暴露范围
- 确定性复现
- 修复方案:延迟释放 KVCache
- 延迟释放
- 准入修复
- Mooncake 传输批大小调优
- PD 校验和
- 性能影响
- 给运维人员的建议操作
- 结论
- 致谢
- 拉取请求列表
- 参考资料
- 附录
- 相关 issue 与修复
- 披露时间线
2026 年 9 月 9 日,Cloudflare 通知我们,其 Workers AI 上的 GLM-5.3 部署存在跨请求 KV cache 暴露问题。我们与 Cloudflare 合作处理了涉及的请求拒绝路径,并加强了分离式服务中相关的缓存生命周期处理。
我们与 Cloudflare 以及通过 Cloudflare HackerOne 项目报告该问题的研究员协调发布本次披露。Cloudflare 的报告涵盖其生产事故和客户影响,研究员的报告(即将发布)将描述发现过程和外部验证。本文重点介绍底层机制、我们所做的更改以及对 SGLang 运维人员的指导。
影响与受影响的部署
Cloudflare 报告称,该事故在 2026 年 8 月 27 日至 9 月 9 日期间影响了 Workers AI 上的 @cf/zai-org/glm-5.3 。其调查未发现其他 Workers AI 模型受到影响。
Cloudflare 的调查发现,超过预填充 KV cache 容量的请求可能返回另一用户会话的内容。有关该事故范围和客户影响的详细信息,请参阅 Cloudflare 的事故报告。
该问题发生在分离式预填充/解码服务中,即由不同的 worker 处理提示词并生成响应。单节点拒绝路径则返回错误。
事件经过
Cloudflare 的部署配置的上下文长度为 1,048,576 个 token,但其预填充 KV cache 池只能容纳 681,280 个 token。因此,请求可能符合配置的上下文窗口,却超出可用于处理它的内存。
在 SGLang 的分离式预填充/解码路径中,被标记为拒绝的请求仍可能继续通过交接流程。随后解码可能读取并未为该请求填充的缓存页。配置不匹配使拒绝路径变得可达。下文描述的准入修复确保被拒绝的请求在进入分离式交接之前返回错误。它补充了比较池容量与配置上下文长度的部署检查。
即时修复与上游修复
Cloudflare 于 9 月 9 日禁用了对该模型的公开访问。它扩大了预填充池以容纳配置的上下文窗口,并部署了由 Cloudflare 编写的引擎补丁,对被拒绝的请求返回错误。Cloudflare 报告称,在当晚恢复该模型之前,它已根据研究员的复现和自身的金丝雀标记测试验证了这些更改。
上游修复 #36988会在处理被中止请求的最终预填充结果时将其退役。后续更改 #38935 会更早地阻止被准入拒绝的请求,使其在进入分离式交接之前就被拦截,从而避免不必要的工作和延迟。
相关 KV cache 生命周期问题
除准入修复外,我们还与 Cloudflare 合作处理了 SGLang 在请求被中断时如何处理 KV cache 传输的问题。
在预填充-解码(PD)分离式服务中,被中止的请求仍可能有 RDMA 写入正在进行。如果解码在这些写入完成之前释放并重新分配目标槽位,迟到的写入可能覆盖另一请求的 KVCache 并破坏其输出。
Cloudflare 的报告还描述了我们在相关 KV cache 生命周期问题上的联合工作。以下各节解释了延迟释放机制、额外的传输防护措施和完整性检查,以及我们的验证结果和对运维人员的指导。
背景
在 PD 分离式服务中,预填充和解码阶段运行在独立的引擎上,每个请求的 KVCache 必须从预填充引擎移动到解码引擎的内存池中。为提高性能,此传输是异步的。Mooncake、NIXL 或 MORI 等传输引擎将其作为单向 RDMA WRITE 操作发出,在预填充计算仍在进行时,将 KVCache 块流式写入预先保留的目标槽位。
由于传输是异步的,请求可能在其部分写入仍在进行时终止。预填充随后可以丢弃仍在其软件队列中等待的块,但无法停止它已向目标发起的写入,例如当时正在进行的那一块。在早期实现中,当请求在传输中途终止(客户端断开连接、超时、传输故障)时,解码会在终止时将其目标槽位归还给分配器,而不等待此类写入完成。如果分配器在该写入落地之前重新分配了槽位,已终止请求的 KVCache 就会落入另一请求的 KVCache 中。在典型工作负载中,这种损坏很少见,尽管长上下文会使其可能性大大增加(见下文事实 4)。每次发生都是静默的。传输报告成功,没有任何组件抛出异常或记录错误。单个被污染的槽位就会毁掉整个生成过程,因为注意力机制会读取每一个位置。
结果,一个要求模型从 1 数到 15 的请求会返回一串不连贯的无关 token。随后我们着手让该竞态条件可按需复现,并端到端地修复它,从传输引擎层一直到服务引擎。
问题所在:KVCache 槽位何时才真正空闲?
该故障分为四个步骤:
- 客户端断开连接、准入拒绝或传输故障会在请求 A 传输中途将其中止,此时至少有一个已发起的写入尚未落地。
- 解码在中止时释放 A 的 KVCache 槽位。分配器将其标记为空闲,而远程写入器仍在向其写入。
- 分配器将这些槽位交给受害请求 B。
- A 已发起的写入落入 B 的 KVCache,B 用外来的键和值进行解码,且不会报错。
这些步骤适用于任何通过异步 DMA(推送或拉取)在引擎之间移动 KVCache 的 PD 实现,因为每个实现都必须决定槽位何时空闲。我们在 SGLang 中诊断并修复了这一类问题。

图 1: 该竞态。中止操作释放了 A 的槽位,而 A 已发起的写入仍在落地;B 继承了这些槽位,迟到的写入便落入其 KVCache 中。
关于该系统的四个事实使每一步成为可能。
1. 分配器可以在对某个槽位的写入仍在进行时将其标记为空闲。 在 PD 分离中,解码引擎会为每个传入请求预先保留目标 KVCache 槽位,其分配器将这些槽位跟踪为已分配或空闲。但“空闲”还有第二个隐藏条件:只有当 没有任何远程写入者仍能触及该槽位时,槽位才可以安全复用。这一类缺陷源于这两个定义之间的差距。每一个都是分布式 use-after-free,其中悬空指针是停留在另一台机器 NIC 队列中的写请求。修复方案使第二个条件显式化、可观测且有界。
2. 已发起的 RDMA 工作无法取消,推送和拉取皆然。 SGLang 的 KVCache 传输基于推送 [3]。预填充将 KVCache 写入 解码的内存,因为预填充知道每个块产生的时刻,可以逐块地将传输与正在进行的计算重叠。在 NIC 及以下层面,取消很难在不损害可用性的情况下实现。RDMA API 没有可以取消已提交到 NIC 队列对的单个工作请求的调用。Python 无法在调用中途中断传输引擎的阻塞式 C++ 调用(例如 Mooncake 的 batch_transfer_sync)。如果该调用超时,其工作请求会保持已提交状态且没有完成记账,因此这些写入可能在未知的较晚时间落地。单边 RDMA WRITE 绕过接收方的 CPU,因此解码不会收到任何可以等待的逐次写入信号。NIC 还按数据包放置数据:一次在传输中途被中断的写入仍会在目标端留下部分写入的前缀,因此即便是看似可以取消的路径也可能改动目标内存。
基于拉取的设计也无法逃脱这一点。如果解码改为发起 RDMA READ,那么在中途拉取时到达的中止会使解码 自己 已提交的读取落入它刚刚释放的槽位,因为已提交的 READ 和已提交的 WRITE 一样无法收回。在拉取模式下,危险还会反向发生:如果预填充在中止时释放其源页面,而分配器在 READ 仍在进行时重新分配这些页面,该读取就会把另一请求的 KVCache 取到解码端。释放后又被重新分配的槽位会在任一端破坏传输。

图 2: Python 调度器之下的传输栈。中止可以阻止未来的块,但所有已交给 C++ 引擎或 NIC 的内容都无法收回,而接收方 CPU 根本看不到这些写入。
3. 完整的存活处理始于路由器,但不能止于路由器。 只有当 P/D 对的 两 半都得知中止时,中止才是安全的。设计良好的 PD 路由器会成对取消,因此任何一方都不会为一个永远不会到来的对端等待 300 秒超时。在 sgl-router, #41612 中,PD 配对建立在 #39461的断开即中止机制之上。该对共享一个由路由器生成的请求 id。客户端断开连接会中止解码,预填充失败会丢弃解码请求从而使引擎中止它,而预填充通过解码的引擎侧 ABORT 通知得知中止。 sgl-model-gateway 将同样两个事件配对(#19524 用于断开连接, #29017 用于预填充失败)。在引擎侧通知出现之前,只中止一方的路由器必然导致该竞态:解码释放其槽位,而从未被告知的预填充继续推送。即便是完美的路由器也只能缩小窗口。中止是异步到达预填充的,经过一些调度和网络延迟,而传输引擎已经发起的一切(进行中的 C++ 调用、已提交的工作请求)无论如何都会继续。路由器决定谁死(中止编排),引擎决定内存何时安全(排空正确性)。
4. 竞态窗口随预填充上下文长度而扩大。 SGLang 对传输分块并与预填充计算重叠,因此暴露窗口跨越了预填充本身的大部分时间。短提示词的传输只持续几毫秒。128K–1M token 的上下文,根据模型不同,每个 token 的 KVCache 为数十到数百 KB,会使写入持续进行数秒到数分钟,跨越许多块和许多已提交的工作请求。中止 之后 的尾部也随之增长。长上下文意味着更多块在进行中,中止落地时仍有更多工作请求在排空,因此已发起的写入可能在分配器回收已释放槽位之后很久仍在落地。在内存压力下,中止一个长请求会使写入后复用成为预期结果。长上下文工作负载也正是客户端超时并在请求中途断开连接的情形。因此,长上下文同时扩大窗口、延长中止后的尾部并提高中止率,所以随着部署转向长上下文服务,这一类缺陷会浮现出来。
影响与暴露面
此处描述的中止传输竞态需要三个条件:
- PD 分离已启用,并配有 KVCache 传输后端(Mooncake、NIXL、MORI 或自定义
CommonKVManager后端)。没有此跨引擎传输路径的单实例部署不受这一特定竞态的影响。 - 请求在传输中途终止(客户端断开连接、路由器取消、超时、传输故障、预填充失败或 hicache 恢复失败),同时其至少一个写入已发起但尚未落地。
- 分配器在已发起的写入落地之前将释放的槽位重新分配给另一请求,这在内存压力下会在毫秒级发生。
移除三者中的任意一个,该损坏便不会发生。在 PD 部署中,默认配置从 v0.4.x 系列(2025 年初)起受到影响,当时 PD 分离随 RDMA 后端一同发布,直至 v0.5.21,MORI 则为 v0.5.22。具有相同签名的另一个变体需要解码侧分层缓存卸载,该功能默认未开启(已由 #24226 以及 #30899).
是确定性的还是概率性的? 在生产环境中,该竞态是概率性的,因此运维人员偶尔会看到无法复现的损坏输出。一旦窗口被保持打开,它就能按需复现(我们在下一节中的测试装置,以及社区复现器见 #32564)。客户端可以故意提高概率:发送长上下文请求并在传输中途断开连接,从而最大化竞态窗口和中止率。客户端无法选择受害者或落地槽位,且数据流是单向的。被中止请求的 KVCache 会落入 到另一个请求的 KVCache,且没有任何数据回流到中止方。因此,现实影响是单向的跨请求泄漏:被中止请求的上下文片段可能出现在另一个客户端的输出中,正如我们在复现中所见。中止方什么也学不到,但在多租户服务中,这既是完整性问题,也是数据泄漏问题。部署者能否判断暴露是否已经发生?部署者没有可靠的方法来判断,因为这种损坏不会留下 另一个请求的 KVCache,且没有任何数据回流到中止方。因此,现实影响是单向的跨请求泄漏:被中止请求的上下文片段可能出现在另一个客户端的输出中,正如我们在复现中所见。中止方什么也学不到,但在多租户服务中,这既是数据泄漏问题,也是完整性问题。
部署者能否判断暴露是否已经发生? 部署者没有可靠的方法来判断,因为这种损坏不会留下任何错误或日志行。可选的 PD 校验和(#39500)可以捕获一些延迟写入(参见 PD 校验和)。升级后,请使用 #41128 用于确认防护已生效的指标(参见“运维人员建议操作”)。
确定性复现
我们需要先复现这个竞态,才能声称已修复,因此我们构建了一个测试装置,可在 1P1D 组合上按需触发该竞态。在预填充的 send_kvcache 持有一个已启动但仍在传输中的写入, --max-total-tokens 96 会强制立即复用槽位,并且插桩会记录每个请求的槽位范围以及每一次释放或保留的决策。
在窗口保持打开的情况下,重叠每次都会出现:Decode 释放请求 A 的槽位 [14..44] 在其写入仍在传输中时,请求 B 分配了 [1..32],并且这两个范围在 [14..32]软件延迟本身无法让写入在单个 P/D 对上迟到落地,因为传输队列会将发往同一解码引擎的写入串行化。为了展示 在预填充的 中持有一个已启动但仍在传输中的写入, 会强制立即复用槽位,并且插桩会记录每个请求的槽位范围以及每一次释放或保留的决策。在窗口保持打开的情况下,重叠每次都会出现:Decode 释放请求 A 的槽位 在其写入仍在传输中时,请求 B 分配了 ,并且这两个范围在 上发生冲突。软件延迟本身无法让写入在单个 P/D 对上迟到落地,因为传输队列会将发往同一解码引擎的写入串行化。为了展示不仅槽位编号重叠,我们还在受害者的提交点把被中止请求已释放的页复制到了重叠区域之上,这正是延迟写入所做的事。没有修复时,受害者的输出是乱码。有了修复后,解码端保留了捐赠者的槽位,受害者获得了新槽位,输出与基线完全一致。
真实的延迟落地在实时硬件上也能端到端复现:只要两个预填充引擎共享同一个解码引擎,就没有任何机制会将它们的写入串行化。我们在禁用保留机制的情况下,于一对 2×H200(通过 RDMA 运行 Mooncake)上进行了测试。一次单边中止释放了捐赠者的 430 个槽位,受害者预分配了 [1..105] 在它们内部,捐赠者已启动的写入在解码中途落地。受害者正确计数到 80,随后其输出变成了捐赠者的提示文本:另一个请求的 KVCache,从其生成过程中被读回。启用保留机制并采用相同的时间安排后,解码日志记录了保留、排空确认和释放,受害者等待释放后产生的输出与基线逐字节完全一致。完整配方(测试装置补丁、启动脚本、驱动程序以及观察到的输出)位于一个 公开的 gist.
修复方案:延迟释放 KVCache
Decode 无法取消 prefill 已启动的写入,因此它会让被中止请求的槽位保留的时间长于这些写入能够持续落地的时间:
T_hold > t_abort_propagation + t_remaining_chunks + t_in_flight_call
只有当这个不等式在每条中止路径上都成立时,该修复才是安全的。设计的其余部分确保每条中止路径都保持槽位(左侧)并约束右侧的每一项(详见 #44186)。等待完成也与硬件相契合:可靠连接 RDMA 按顺序执行工作请求,因此“所有写入均已排空”在发送端只是一次完成队列观察。
延迟释放
我们分三步构建了这一机制:
- 中止时通知预填充 (#27372)。在解码端中止时,解码会发送
ABORT到每个预填充 rank,并且每个 rank 都会丢弃该房间软件队列中仍在等待的所有块。这会将t_remaining_chunks降至接近零。如果没有该通知,传输中剩余的每个块都会启动并落地,因此该术语涵盖了预填充阶段剩余的全部时间。t_in_flight_call是中止生效时已在执行的调用的一个单独术语。在 Mooncake 按房间串行化的队列上,该通知之后不会留下该调用之外的任何内容,而像 NIXL 这样异步投递数据块的后端,仍可能有多个已启动的数据块尚未完成。无论哪种情况,已启动的工作都会继续写入;该通知只停止尚未启动的工作。 - 延迟释放解码端 KVCache (#35049、NIXL 端口 #35360)。中止时,解码 持有 请求所拥有的全部内容,而不是将其释放:它的 KVCache 槽位、它在请求池中的行,以及它的元数据缓冲区条目。这使得写入方的存活状态变得明确。每个预填充 rank 按房间跟踪其未完成的工作,并在出队时统计数据块,因此计数为零即可证明已经出队的任何内容都不再处于传输中。仍在队列中等待的数据块之所以安全,原因则不同:预填充在设置确认之前先将该房间标记为失败,因此工作进程会在出队时丢弃它们。该 rank 回复
ABORT_ACK只有当该计数降至零时才释放。仅当每个已通知的 rank 都已确认(正向排空信号)或超时触发(有界回退)时,才解码释放。超时是一种实用的兜底手段,而非证明。其时长(默认为 30 秒,在 #35049)与传输引擎的每次调用预算相匹配,该预算在 abort 之前就已开始计时,而在健康路径上,每次已启动的写入都会远在其内完成。在降级路径上超时的调用可能会留下引擎仍在重试的写入,因此预填充绝不会将失败的调用确认为已排空(#43719),并且释放操作会等待整个超时时间结束。Decode 绝不会将以这种方式释放的槽位插入基数树;它会隔离失败房间的 KVCache,并在不读取的情况下将其丢弃。 - 能力门控与默认开启 (#37454, #41023)。该保持机制仅在会发送确认的后端上有帮助。它最初作为可选功能发布,带有按后端的能力门控,并在 v0.5.21 中成为默认行为,此时 Mooncake 和 NIXL 都已支持排空确认。
在 #35049,一个房间的所有数据块都会进入同一个传输队列,由单个工作线程处理。该工作线程是该房间唯一的写入者,它发送 ABORT_ACK 本身,而不是将其留给引导线程。Decode 也会统计来自虚拟 rank 的确认;这些 rank 会立即确认。一旦所有预期的确认都已到达,任何预填充 rank 都无法再写入这些槽位。
房间 ID 会在不同请求之间被复用,因此 decode 会为每次 abort 创建一个新的 ack 跟踪器,并忽略它并未跟踪的房间的 ack。 #37077 后来又在消息本身中加入了代际标签。
我们在 2×H200 上进行了测试,通过保持路径在传输中途中止了 132 个请求。没有发生崩溃,并且每个被保持的槽位在其保持解除后都被归还。

图 3: 修复方案。发生中止时,decode 会保留 A 的槽位,而不是将其释放。每个预填充 rank 都会排空其已启动的写入,并回复 ABORT_ACK,而槽位会在收到最后一个 ack 时释放,单位为毫秒。超时是一种有界兜底机制,只能延长保持时间,绝不会缩短它。
Decode 会保留这些槽位,直到所有必需的排空确认到达或释放超时到期。缺少确认会使 decode 等待超时。基于超时的释放只有在届时未完成的写入已经停止时才是安全的;仅凭超时到期并不能确认这些写入已经排空。该机制仅在中止路径上保留槽位(参见性能影响)。即使在中止路径上,除非池接近耗尽且传入请求无法分配直到这些槽位归还,否则保留不会带来任何代价;此时它会表现为短暂的准入延迟。一项正在开发中的功能将进一步降低这一代价:Decode 将先把 KVCache 接收到主机内存中,仅当传输完成且请求即将开始解码时才将其复制到 VRAM。届时保留将占用主机内存,而不是 GPU KVCache 容量。
失败的 Mooncake 发送
#43719于 2026 年 10 月 12 日合并,封堵了 Mooncake 排空确认中的一条提前释放路径。在此之前,如果提交的 Mooncake 发送返回错误,预填充会将房间标记为失败,并且一旦 decode 的 ABORT 到达。来自以下位置的错误: batch_transfer_sync 并不意味着写入已经停止:超时之后,切片仍会继续重试,而在一批失败之后,其他任务仍可能处于进行中。Decode 在收到该 ack 时释放了槽位,而残留的写入可能落入另一个请求的 KVCache。#43719 对任何 KVCache、状态或辅助发送失败的房间都不予发送排空 ack,因此 decode 会保留这些槽位,直到其释放超时。现在每次发送失败都会带来这一保留代价,直到 Mooncake 能够报告某个失败的批次是否已经排空。#43719 已合入 main,将在 v0.5.22 中发布。
准入修复
准入控制从路由器开始。PD 路由器会把每个请求同时发送给一个预填充 worker 和一个解码 worker,而解码在没有预填充的 KVCache 时无法生成 token。如果预填充拒绝或未能处理某个请求,解码这一半过去会在占用一个解码槽位的同时等待其 300 秒超时结束,因此一波预填充失败可能同时占住大量解码槽位。 #29017 在 sgl-model-gateway 以及 #41612 在 sgl-router 现在,一旦预填充返回错误,就立即丢弃解码请求,解码引擎会在数秒内中止该请求。
在引擎内部, #38935于 2026 年 9 月 16 日合并,撤销了一项请求:当该请求进入任一准入队列(PrefillBootstrapQueue.add 或 DecodePreallocQueue.add),此时尚不存在任何交接状态。在此之前,被拒绝的请求仍会将自己报告为存活,因此它会完成一次引导握手、预留一个元数据缓冲区,并在一段单 token 的桩数据上执行一次前向传播; #36988 只有在这次通过之后才将其退役。如果两个 worker 都拒绝某个请求,decode 现在会立即返回 400,而不是在 300 秒后超时。这样可让被拒绝的请求不进入分离交接流程。
Mooncake 传输批大小调优
保护 Mooncake 传输调用有两个设置。第一个是 Mooncake 自身的 MC_TRANSFER_TIMEOUT (默认 30 秒),这会使任何 batch_transfer_sync 的调用耗时更长。以这种方式失败的调用仍可能留下在途写入(事实 2),这就是为什么预填充在发送失败后会暂不发送排空 ack。
第二个是 #32758(2026 年 9 月 8 日合并)中新增的,它限制了每次调用的数据量,从而也限制了每次调用的时长。在默认值 0 下,SGLang 会在一次调用中发送一个分块的全部 KVCache,这是高效的路径。正值会将传输拆分为有序的多次调用,每次调用最多包含该数量的索引,因此一次挂起的调用最多只会搁置一批已提交的写入,而第一次失败的调用会在其余批次提交之前结束循环。较小的批次会牺牲吞吐量和 TTFT,因此只有在需要时才降低该限制。 SGLANG_MOONCAKE_MAX_TRANSFER_BATCH_INDICES(于 2026 年 9 月 8 日合并)中新增的 #32758 (于 2026 年 9 月 8 日合并),它限制了每次调用的数据量,从而也限制了每次调用的时长。在默认值 0 下,SGLang 会在一次调用中发送一个分块的全部 KVCache,这是高效的路径。正值会将传输拆分为有序的多次调用,每次调用最多包含该数量的索引,因此一次挂起的调用最多只会搁置一批已提交的写入,而第一次失败的调用会在其余批次提交之前结束循环。较小的批次会牺牲吞吐量和 TTFT,因此只有在极端上下文长度下作为额外保障时才降低该限制。在 #32758 的测试中,一个 800K token 请求的单次调用携带了 30 GB。
SGLANG_DISAGGREGATION_DEFERRED_DECODE_KV_RELEASE_TIMEOUT 然后设置 T_hold60 到 120 秒的取值给保持留出了很大余量。更大的值几乎不会增加安全性,却会在中止风暴期间成倍增加被保持的 KVCache。如果释放都落到超时,被保持的容量约为中止率 × T_hold × 每次请求的页数,因此请按该乘积来设定解码池的大小。
PD 校验和
#39500(于 2026 年 9 月 16 日合并)新增了对传输的 KVCache 的可选完整性校验,默认关闭。预填充在 GPU 上对连续区域以及带索引的 KVCache 和状态页计算 Adler-32 校验和,并随最后一个分块发送。解码在提交传输时记录该校验和,并在请求加入运行中的批次之前,用该校验和检查收到的 KVCache,因此它能捕获在该时点之前落地的延迟写入;保持机制会阻止后续写入进入被复用的槽位。解码在撤回后仍保留该校验和。校验和不匹配会中止请求并递增传输失败计数器;在 CI 中,校验和不匹配则会抛出错误。健康检查请求会跳过该校验。
要启用它,请向预填充引擎和解码引擎都传入 --disaggregation-enable-kv-checksum 传入预填充引擎和解码引擎;它们的 KVCache 布局必须一致。该标志会添加一个可选的元数据缓冲区。它默认关闭,因为一旦开启,每次传输都要额外付出一次 KVCache 的 GPU 读取和一次额外的同步点。
我们还评估了在提交时对传输长度进行检查的方案,并决定不采用:公布的传输长度来自与判定 Success 相同的预填充记账,因此两者只可能一致。
性能影响
在 2×H100 上进行的 21 次运行中(1P1D Mooncake,并发 256 下 1024 个请求,每组重复 7 次),补丁前以及启用或禁用该特性时的吞吐量在统计上无法区分。所有差值均在 ±2% 以内,且每个 95% 置信区间都跨越零。在默认值切换前五周,另一项在 2×H200 上进行的 16 次运行扫描测得,将该特性默认开启的成本为 −0.38%(CI −2.12 至 +1.36,不显著)。
在 abort 路径本身上,开销取决于 ack 快速路径。若该路径正常,被挂起的请求会在 drain 后几毫秒内释放;若该路径失效,每次 abort 都要付出完整的释放超时代价。
对运维人员的建议操作
对于 PD 部署,我们建议采取以下措施:
- 升级。 v0.5.21 默认保护 Mooncake 和 NIXL 部署,覆盖所有故障类型。我们尚未将此修复线回移植到更早的发布分支。MORI 部署以及带 generation 标记的 ack 卫生,需要构建版本达到或超过以下合并点: #37077 (2026-10-03,于 v0.5.22)。实践中,将构建版本截断至 #41128,并于同日十二小时后合并,因此它也包含下文所述的发布指标。当预填充和解码使用不同的确认格式时,drain 握手可能失败,释放会回退到配置的超时。新的解码会丢弃来自旧预填充的无 generation 确认,而旧的解码则收不到来自新预填充的任何确认。在这些情况下,安全释放取决于未完成的写入能否在保持期内停止。
- 运行一个将成对中止进行配对的路由器。 引擎侧的保持机制只有在 abort 到达引擎之后才能保护部署。如果路由器把成对请求的两半都取消(正如
sgl-router(的 PD 模式所做的)是第一层防御。 - 在 v0.5.19 或 v0.5.20 上,设置
SGLANG_DISAGGREGATION_DEFERRED_DECODE_KV_RELEASE=1在预填充和解码服务器上均如此。这使得解码发起的 abort(最常见的一类)能够被保持,不过大多数释放仍会等到超时。在 v0.5.20 上,还需设置SGLANG_MOONCAKE_MAX_TRANSFER_BATCH_INDICES(#32758)以及一个宽松的SGLANG_DISAGGREGATION_DEFERRED_DECODE_KV_RELEASE_TIMEOUT(60–120 秒),大小按 Mooncake 传输批大小调优中所述设置。两者都不能替代升级,因为在 v0.5.21 之前,除解码发起的 abort 之外的故障类型仍不受保护。在当前构建版本上,默认值是正确的,而这些调节项只是增加余量。 - 观看
sglang:decode_deferred_kv_release_total{outcome}在 rollout 之后。该指标随 v0.5.22(#41128)。健康的部署几乎会通过以下方式释放所有保持:drained超时占比过高意味着 ack 路径需要关注,而且每次中止都在等待完整的保持期结束。
结论
这些规则适用于 KVCache 异步移动的任何场景,无论是在引擎之间、内存层级之间,还是流水线阶段之间:
- 绝不要释放异步引擎可能仍会访问的槽位。
- 保持等待,直到出现正的 drain 信号或达到有界超时。
- 让每一次降级都延长保持时间。
- 将每个修复都做得足够小,使其回归测试恰好只证明一件事。
如果 PD 部署出现这些修复无法解释的输出损坏,或任何看起来属于这一类的问题,请在 sgl-project/sglang 包括拓扑结构(预填充/解码并行度、传输后端、版本)、事件前后的请求模式,以及可用的 decode_deferred_kv_release 指标。维护者会优先处理这一类报告。
致谢
我们感谢报告此问题的研究员识别出该暴露面、提供可复现的报告并协调披露。我们尤其感谢 Cloudflare,其工程师分享了生产环境背景、开发了最初的应急补丁,并与我们一同完成了上游修复和加固审计。
我们感谢以下人员在这些修复中所做的工作:
- SGLang 社区/RadixArk:蔡尚明、刘般若、姜文博、万诚、石杨晨等众多贡献者
- Cloudflare:Kevin Flansburg、Kastan Day
我们还感谢 @AsterWang 以及 @KastanDay,其早期 PR(#24580, #32564并协调披露。我们尤其感谢 Cloudflare,其工程师分享了生产环境背景、开发了最初的应急补丁,并与我们一同完成了上游修复和加固审计。我们感谢以下人员在这些修复中所做的工作:SGLang 社区/RadixArk:蔡尚明、刘般若、姜文博、万诚、石杨晨等众多贡献者Cloudflare:Kevin Flansburg、Kastan Day我们还感谢 @AsterWang 以及 @KastanDay,其早期 PR(#24580, #32564)指出了这一竞态条件,并提议在复用前先保持页面。
拉取请求列表
- 路由器中止配对: #41612, #39461, #19524, #29017
- 准入修复: #36988, #38935
- 向预填充发送中止通知: #27372
- 延迟解码侧 KVCache 释放: #35049, #35360
- 能力门控与默认开启: #37454, #41023
- 快速确认路径: #40645, #41402
- 请保留所有失败类型: #41404
- 发送失败的 Mooncake 请求从未被确认为已排空: #43719
- FakeKVReceiver 回归修复: #41618
- 带代际标记的确认与 MORI 支持: #37077
- 跨后端协议对齐: #35281, #36612
- Hicache 撤回竞态: #24226, #30899
- Hicache 恢复 rank 共识: #43562
- Mooncake 传输批大小: #32758
- PD 校验和: #39500
- 发布指标: #41128
- 早期提案、复现程序与审计(未合并): #24580 (@AsterWang), #32564 (@KastanDay), #38961 (@kflansburg)
参考
[1] Mooncake:一种以 KVCache 为中心的 LLM 服务分离式架构。 https://github.com/kvcache-ai/Mooncake
[2] SGLang PD 分离部署文档。 https://docs.sglang.io
[3] 在 96 块 H100 GPU 上以 PD 分离和大规模专家并行部署 DeepSeek。 https://www.lmsys.org/blog/2025-05-05-large-scale-ep/
附录
相关问题与修复
下表总结了上文讨论的问题与修复,从请求路由开始,依次涵盖 KV cache 处理。
| 层 | 问题 | 修复 |
|---|---|---|
| 路由器 | 单向中止:解码侧已死亡而预填充侧仍在继续推送(或反之,各自等待一个已死亡的对端 300 秒) | PD 路由器会取消这一对: #41612 中 sgl-router (在 #39461); #19524 以及 #29017 中 sgl-model-gateway |
| 准入 | 在准入阶段被拒绝的请求仍会进入引导流程、预留缓冲区,并执行一次桩前向计算 | 在准入环节就拒绝并清理,此时尚不存在任何交接状态(#38935,基于 #36988(它会在前向传播后清理已中止的请求) |
| 停止新的写入 | 预填充端在不知晓解码中止的情况下,仍会继续排队数据块 | 解码侧中止会通知每个预填充 rank(#27372) |
| 保持至排空 | 已启动的写入无法取消 | 延迟释放槽位,直到各 rank 排空 ABORT_ACK 或超时(#35049, #35360) |
| 保持快速路径畅通 | 确认丢失,转而采用 clear() 竞态 → 每次中止都会付出完整超时的代价 | 保留 ack 目标,直到未完成的写入排空(#40645) |
| 每个房间一个 ack 槽位 → MLA fan-out 各 rank(prefill TP < decode TP)从未收到 ack | 将 ack 分发到该房间已知的每个解码对等端(#41402) | |
| 确认在解码跟踪器启用之前到达 → 被丢弃 | 武装跟踪器 SGLang PD 分离部署文档。 https://docs.sglang.io[3] 在 96 块 H100 GPU 上以 PD 分离和大规模专家并行部署 DeepSeek。 https://www.lmsys.org/blog/2025-05-05-large-scale-ep/附录相关问题与修复下表总结了上文讨论的问题与修复,从请求路由开始,依次涵盖 KV cache 处理。层问题修复路由器单向中止:解码侧已死亡而预填充侧仍在继续推送(或反之,各自等待一个已死亡的对端 300 秒)PD 路由器会取消这一对: #41612 中 (在 #39461); #19524 以及 #29017 中 准入在准入阶段被拒绝的请求仍会进入引导流程、预留缓冲区,并执行一次桩前向计算在准入环节就拒绝并清理,此时尚不存在任何交接状态(#38935,基于 #36988(它会在前向传播后清理已中止的请求)停止新的写入预填充端在不知晓解码中止的情况下,仍会继续排队数据块解码侧中止会通知每个预填充 rank(#27372)保持至排空已启动的写入无法取消延迟释放槽位,直到各 rank 排空 或超时(#35049, #35360)保持快速路径畅通确认丢失,转而采用 竞态 → 每次中止都会付出完整超时的代价保留 ack 目标,直到未完成的写入排空(#40645)每个房间一个 ack 槽位 → MLA fan-out 各 rank(prefill TP < decode TP)从未收到 ack将 ack 分发到该房间已知的每个解码对等端(#41402)确认在解码跟踪器启用之前到达 → 被丢弃武装跟踪器 发送 (#41402)在每条路径上保持该保持机制仅针对解码发起的 abort 启用;传输/预填充故障在 t=0 时释放对每种失败类型都延迟(#41404)协议卫生被复用的 room id 可能消费到过期的 ack;MORI 则完全没有 ack在公共层中按代打标签 / ;MORI 选择加入该保持机制(#37077)同级管道hicache 撤回路径在设备→主机卸载拷贝仍在进行时释放了设备槽位,因此被污染的 KVCache 持续进入主机层撤回会等待卸载 t 发送 ABORT (#41402) | |
| 在每条路径上保持该保持机制 | 该保持机制仅对解码发起的 abort 启用;传输/预填充故障在 t=0 时释放 | 对每种失败类型都延迟(#41404) |
| 协议卫生 | 复用的 room id 可能消费到过期的 ack;MORI 则完全没有 ack | 按代打标签的 ABORT/ABORT_ACK 在公共层中;MORI 选择加入该保持机制(#37077) |
| 同级管道 | hicache 撤回路径在设备→主机卸载拷贝仍在进行时释放了设备槽位,因此被污染的 KVCache 持续进入主机层 | 撤回会等待卸载排空(#24226, #30899) |
| Rank 共识 | 在 MIN 归约后的 KVCache 轮询之后,TP 各 rank 根据本 rank 的 hicache 恢复状态产生分支。一个 rank 的恢复失败导致该组失步(队列长度不匹配、调度器崩溃),其准入记账在一个 tick 后出现偏差。 | 将恢复结果并入已经归约的 KVCache 轮询,使每个 rank 只做一次决策(#43562) |
| 检测 | 躲过预防机制的腐败是看不见的 | 可选启用的传输校验和(#39500);提交时长度校验已被评估并拒绝(见 PD Checksum) |
| 观察 | 无法判断 ack 快速路径在生产环境中是否存活 | 保持计数、持续时间和 drained/timeout/error 结果指标(#41128) |
中间几行来自一次社区审计。该审计于 2026 年 9 月 11 日发布(#38961), @kflansburg 又发现了竞态可能重新打开的两条路径。drain ack 经常未能到达,因此释放回退到超时。decode 自身未发起的故障从未触发保持机制,从而通过另一条路径重新打开了原始缺陷。我们在 2026 年 9 月 22 日至 28 日期间在 main 上关闭了这两条路径:现在保持机制会在每种失败类型上触发,drain ack 现在也能到达 decode。审计还标记了 prefill 自身的源页面,而我们的分析表明该路径已经安全,因为这些字节只能落入 decode 保持机制正在隔离的槽位。 #43562,于 2026 年 10 月 11 日合并,修复了另一项发现:rank 分歧的 HiCache 恢复问题。
披露时间线
| 日期 | 事件 |
|---|---|
| 2026-09-09 | Cloudflare 向 SGLang 维护者通报了生产环境中存在的跨请求 KVCache 暴露问题,并分享了后续安全检查,详见 #38704 |
| 2026-09-10 | SGLang 维护者与 Cloudflare 会面,就上游修复达成一致 |
| 2026-09-11 | 加固审计(#38961)发现 ack 快速路径在生产环境中经常回退到超时 |
| 2026-09-16 | 准入修复(#38935)以及可选启用的 PD 校验和(#39500)已合并 |
| 2026-09-22 → 09-28 | 快速路径已恢复(#40645, #41402);每种失败类型都会触发延迟释放(#41404);默认开启(#41023);hicache 修复(#30899) |
| 2026-10-02 | v0.5.21 已发布,默认保护(Mooncake、NIXL) |
| 2026-10-03 | MORI 与带代际标记的 ack 已合并(#37077);随 v0.5.22 发布 |
| 2026-10-11 | 针对 decode 阶段 HiCache 恢复的 rank 共识修复已合并(#43562) |
| 2026-10-12 | 失败的 Mooncake 发送不再被确认为已排空(#43719);随 v0.5.22 发布 |
| 2026-10-22 | 失败的 NIXL 和 MORI 发送不再被确认为已排空(#45973);随 v0.5.23 发布 |
| 2026-10-26 | 由 SGLang 与 Cloudflare 协调披露。 |