跳到正文
LMSYS Blog· SGLang Team·· 4 天前精选AI 评分71

SGLang 披露并修复 PD 分离服务中的跨请求 KVCache 竞态问题

SGLang security update: improving request isolation

AI 导读

SGLang 团队与 Cloudflare 协调披露 2026 年 8 月 27 日至 9 月 9 日影响 Workers AI 上 @cf/zai-org/glm-5.3 的跨请求 KVCache 暴露问题。

推荐理由

SGLang 团队复盘了异步 KVCache 传输中的跨请求泄漏机制、复现方法和修复方案,对运维长上下文 PD 部署有直接指导意义。

正文 · AI 翻译

2026 年 9 月 9 日,Cloudflare 通知我们,其部署在 Workers AI 上的 GLM-5.3 存在跨请求 KV 缓存暴露问题。我们与 Cloudflare 合作,修复了涉及的请求拒绝路径,并加强了分离式服务中相关的缓存生命周期处理。

我们与 Cloudflare 以及通过 Cloudflare HackerOne 项目报告该问题的研究员协调发布此披露。Cloudflare 的报告涵盖其生产事故和客户影响,研究员的报告(即将发布)将描述发现过程和外部验证。本文重点介绍底层机制、我们所做的更改,以及对 SGLang 运维人员的指导。

在此阅读 Cloudflare 的报告。

影响与受影响的部署

Cloudflare 报告称,该事故在 2026 年 8 月 27 日至 9 月 9 日期间影响了 Workers AI 上的 @cf/zai-org/glm-5.3。其调查未发现其他 Workers AI 模型受到影响。

Cloudflare 的调查发现,超过预填充 KV 缓存容量的请求可能返回另一用户会话的内容。有关事故范围和客户影响的详细信息,请参阅 Cloudflare 的事故报告。

该问题出现在分离式预填充/解码服务中,即由不同的 worker 处理提示词并生成响应。单节点拒绝路径则会返回错误。

事件经过

Cloudflare 的部署配置了 1,048,576 个 token 的上下文长度,但其预填充 KV 缓存池只能容纳 681,280 个 token。因此,一个请求可能既符合配置的上下文窗口,又超出可用于处理它的内存。

在 SGLang 的分离式预填充/解码路径中,被标记为拒绝的请求仍可能继续通过交接流程。随后,解码可能读取并非为该请求填充的缓存页。配置不匹配使拒绝路径变得可达。下文描述的准入修复确保被拒绝的请求在进入分离式交接之前返回错误。它补充了比较池容量与配置上下文长度的部署检查。

Cloudflare 于 9 月 9 日禁用了该模型的公开访问。它扩大了预填充池以容纳配置的上下文窗口,并部署了 Cloudflare 编写的引擎补丁,对拒绝的请求返回错误。Cloudflare 报告称,在当晚恢复模型之前,它已根据研究员的复现和自身的金丝雀标记测试验证了这些更改。

上游修复 #36988 在处理中止请求的最终预填充结果时将其退役。后续更改 #38935 更早地停止被接收端拒绝的请求,使其在进入分离式交接之前就被拦截,从而避免不必要的工作和延迟。

除准入修复外,我们还与 Cloudflare 合作处理了 SGLang 在请求中断时如何管理 KV 缓存传输的问题。

在预填充-解码(PD)分离式服务中,已中止的请求仍可能有 RDMA 写入正在进行。如果解码在这些写入完成之前释放并重新分配目标槽位,迟到的写入可能覆盖另一请求的 KVCache 并破坏其输出。

Cloudflare 的报告还描述了我们在相关 KV 缓存生命周期问题上的联合工作。以下各节解释延迟释放机制、额外的传输保障和完整性检查,以及我们的验证结果和对运维人员的指导。

背景

在 PD 分离式服务中,prefill 和 decode 阶段运行在各自独立的引擎上,每个请求的 KVCache 必须从 prefill 引擎迁移到 decode 引擎的内存池。为了性能,这一传输是异步的。Mooncake、NIXL、MORI 等传输引擎将其作为单边 RDMA WRITE 操作发出,在 prefill 计算仍在进行时就把 KVCache 分块流式写入预先保留的目标槽位。

由于传输是异步的,请求可能在部分写入仍在途中时终止。此时 prefill 可以丢弃仍在其软件队列中等待的分块,但无法停止已经发往目标的写入,比如当时正在传输的那个分块。在早期实现中,当请求在传输中途终止(客户端断开、超时、传输故障),decode 会在终止时立即把目标槽位归还给分配器,而不等待这类写入完成。如果分配器在写入落地前重新分配了这些槽位,已终止请求的 KVCache 就会落到另一个请求的 KVCache 里。在典型负载下这种污染很少发生,但长上下文会大幅提高概率(见下文第 4 点)。每次发生都是静默的:传输报告成功,没有任何组件抛出异常或记录错误。只要一个槽位被污染,整个生成过程就会崩溃,因为注意力机制要读取每一个位置。

结果就是,让模型从 1 数到 15 的请求,返回的却是一串毫不相关的 token,毫无连贯性。于是我们着手让这个竞态可以按需复现,并从传输引擎层到服务引擎端到端地修复它。

问题所在:KVCache 槽位究竟何时才算真正空闲?

故障分四步发生:

  1. 客户端断开、准入拒绝或传输故障导致请求 A 在传输中途中止,此时至少有一个已发出的写入尚未落地。
  2. decode 在中止时释放 A 的 KVCache 槽位。分配器将其标记为空闲,而远端写入方仍在向这些槽位写入。
  3. 分配器把这些槽位交给受害请求 B。
  4. A 已发出的写入落入 B 的 KVCache,B 便用别人的键和值进行 decode,且没有任何报错。

这些步骤适用于任何用异步 DMA(推送或拉取)在引擎间迁移 KVCache 的 PD 实现,因为每个实现都必须决定槽位何时空闲。我们在 SGLang 中诊断并修复了这一类问题。

An aborted request's launched writes land in KV cache slots that have already been reassigned to another request.

图 1:竞态过程。中止操作释放了 A 的槽位,而 A 已发出的写入仍在落地;B 继承了这些槽位,迟到的写入便落入了它的 KVCache。

系统的四个事实使每一步都成为可能。

1. 分配器可以在写入仍在途中时就把槽位标记为空闲。在 PD 分离中,decode 引擎会为每个到来的请求预先保留目标 KVCache 槽位,其分配器将这些槽位记录为已分配或空闲。但“空闲”还有第二个隐藏条件:只有当没有任何远端写入方还能触及该槽位时,它才可以安全复用。这一类 bug 正源于这两个定义之间的差距。每一个都是分布式 use-after-free,其中悬空指针是停留在另一台机器网卡队列中的写请求。修复方案让第二个条件变得显式、可观测且有界。

2. 已发起的 RDMA 工作无法取消,push 和 pull 都是如此。SGLang 的 KVCache 传输采用 push 模式 [3]。Prefill 写入 KVCache 到 decode 的内存,因为 prefill 知道每个 chunk 产生的时刻,可以逐 chunk 地将传输与正在进行的计算重叠。在网卡及以下层面,取消很难做到,而且不损害可用性。RDMA API 没有调用可以取消已经提交到网卡队列对的单个工作请求。Python 无法在调用中途打断传输引擎的阻塞式 C++ 调用(例如 Mooncake 的 batch_transfer_sync)。如果该调用超时,它的工作请求会一直处于已提交状态,没有完成记账,因此这些写入可能在之后某个未知时刻落地。单边 RDMA WRITE 绕过接收端 CPU,所以 decode 对每次写入都收不到可以等待的信号。网卡还是按包放置数据:一次传输中途被中断的写入,仍会在目标端留下部分写入的前缀,所以即便是看起来像取消的路径,也可能改动目标内存。

基于 pull 的设计也逃不掉。如果 decode 改为发起 RDMA READ,那么中途到达的中止会让 decode 自己已提交的读落入它刚刚释放的槽位,因为已提交的 READ 和已提交的 WRITE 一样无法收回。在 pull 模式下,危险还会反向发生:如果 prefill 在中止时释放了源页,而分配器在 READ 仍在途中时把它们重新分配出去,那次读就会把另一个请求的 KVCache 取到 decode。释放后又被重新分配的槽位,会在两端破坏传输。

The transfer stack below the Python scheduler: aborts stop future chunks while launched transfers and posted NIC writes continue.

图 2:Python 调度器之下的传输栈。中止可以阻止后续 chunk,但已经交给 C++ 引擎或网卡的一切都无法收回,而接收端 CPU 根本看不到这些写入。

3. 完整的存活处理从路由器开始,但不能止于路由器。只有当 P/D 对的两半都得知中止时,中止才是安全的。设计良好的 PD 路由器会成对取消,这样双方都不会为一个永远不会到来的对端空等 300 秒超时。在 sgl-router 中,#41612 在 #39461 的断连即中止机制之上构建了 PD 配对。这对共享一个由路由器生成的请求 id。客户端断连会中止 decode,prefill 失败会丢弃 decode 请求,从而让引擎中止它,而 prefill 通过 decode 引擎侧的 ABORT 通知得知中止。sgl-model-gateway 把同样两个事件配对(#19524 处理断连,#29017 处理 prefill 失败)。在引擎侧通知出现之前,只中止一侧的路由器必然导致竞态:decode 释放了槽位,而从未被告知的 prefill 还在继续推送。即便路由器完美,也只能缩小窗口。中止是异步到达 prefill 的,要经过一些调度和网络延迟,而传输引擎已经发起的一切(进行中的 C++ 调用、已提交的工作请求)仍会继续。路由器决定谁死(中止编排),引擎决定内存何时安全(排空正确性)。

4. 竞态窗口随预填充上下文长度而扩大。 SGLang 会把传输分块,并与预填充计算重叠执行,因此暴露窗口覆盖了预填充的大部分时间。短提示词的传输只持续几毫秒。而 128K–1M token 的上下文,每个 token 的 KVCache 视模型不同有几十到几百 KB,写入会在数秒到数分钟内持续在途,跨越许多分块和许多已提交的工作请求。中止之后的尾部也随之变长。长上下文意味着更多分块在途、更多工作请求在中止落地的那一刻仍在排空,因此已发起的写入可能在分配器回收空闲槽位之后很久仍在落地。在内存压力下,中止一个长请求几乎必然导致写后重用。长上下文工作负载也正是客户端容易超时、在请求中途断开的场景。因此长上下文同时扩大了窗口、拉长了中止后的尾部、提高了中止率,这类 bug 也就随着部署转向长上下文服务而浮现。

影响与暴露面

这里描述的中止传输竞态需要三个条件同时成立:

  1. 启用了 PD 分离,并配有 KVCache 传输后端(Mooncake、NIXL、MORI 或自定义 CommonKVManager 后端)。没有这条跨引擎传输路径的单实例部署不受此特定竞态影响。
  2. 请求在传输中途终止(客户端断开、路由取消、超时、传输故障、预填充失败或 hicache 恢复失败),而此时它至少有一个写入已发起但尚未落地。
  3. 分配器在已发起的写入落地之前,把释放的槽位重新分配给另一个请求——在内存压力下这只需几毫秒。

三者去掉任何一个,损坏都不会发生。在 PD 部署中,默认配置从 v0.4.x 系列(2025 年初)PD 分离随 RDMA 后端发布时起就受影响,一直到 v0.5.21,MORI 则是到 v0.5.22。另一个特征相同的变体需要解码侧分层缓存卸载,该功能默认关闭(已由 #24226 和 #30899 修复)。

是确定性还是概率性? 在生产环境中这个竞态是概率性的,所以运维人员会看到偶发的输出损坏,却无法复现。一旦把窗口撑开,它就能按需复现(下一节是我们的测试装置,社区复现见 #32564)。客户端可以故意提高概率:发送长上下文请求并在传输中途断开,从而最大化竞态窗口和中止率。客户端无法选择受害者或落地槽位,且数据流是单向的。被中止请求的 KVCache 会落到另一个请求的 KVCache 里,不会有任何东西回流给中止方。因此现实影响是单向的跨请求泄漏:被中止请求的上下文片段可能出现在另一个客户端的输出中,正如我们在复现中看到的那样。中止方什么也学不到,但在多租户服务中,这既是完整性问题,也是数据泄漏问题。

部署者能否判断暴露是否已经发生? 部署者没有可靠办法判断,因为这种损坏不会留下错误或日志。可选的 PD 校验和(#39500)能捕获部分迟到写入(见 PD 校验和)。升级后,可用 #41128 指标确认防护已生效(见给运维人员的建议)。

确定性复现

在宣称修复之前,我们必须先能复现这个竞态,因此我们搭建了一套测试装置,让它能在 1P1D 组合上按需触发。prefill 的 send_kvcache 里有一个由环境变量控制的延迟,让已发起的写操作保持在进行中;--max-total-tokens 96 强制立即复用槽位;插桩则记录每个请求的槽位范围以及每一次释放或保留的决策。

把窗口撑开后,重叠每次都会出现:Decode 释放请求 A 的槽位 [14..44],而它的写操作仍在进行中;请求 B 分配 [1..32];两个范围在 [14..32] 上发生冲突。软件延迟无法让写操作本身在单个 P/D 组合上迟到落地,因为传输队列会把发往同一 decode 引擎的写操作串行化。为了展示输出错误,而不只是槽位编号重叠,我们在受害者的提交点把被中止请求已释放的页复制到重叠区域上,这正是迟到写操作会做的事。没有修复时,受害者的输出是乱码。有了修复后,decode 保留了捐赠者的槽位,受害者拿到了新槽位,输出与基线完全一致。

一旦两个 prefill 引擎共享同一个 decode,真实的迟到落地也能在真实硬件上端到端复现,因为此时没有任何机制串行化它们的写操作。我们在 2×H200 组合(基于 RDMA 的 Mooncake)上运行,并禁用了保留机制。一次单边中止释放了捐赠者的 430 个槽位,受害者在其中预分配了 [1..105],而捐赠者已发起的写操作在 decode 中途落地。受害者一直正确计数到 80,随后其输出变成了捐赠者的提示词文本:另一个请求的 KVCache,被从它的生成过程中读了出来。启用保留机制并保持相同时间后,decode 记录了保留、排空确认和释放,受害者等待释放后产生的输出与基线逐字节一致。完整配方(测试装置补丁、启动脚本、驱动程序和观察到的输出)在公开 gist 中。

修复方案:延迟释放 KVCache

Decode 无法取消 prefill 已发起的写操作,因此它会把被中止请求的槽位保留得比这些写操作可能落地的时间更长:

T_hold  >  t_abort_propagation + t_remaining_chunks + t_in_flight_call

只有当这个不等式在每条中止路径上都成立时,修复才是安全的。设计的其余部分让每条中止路径都保留槽位(左侧),并约束右侧的每一项(详见#44186)。等待完成也符合硬件特性:可靠连接 RDMA 按顺序执行工作请求,因此“所有写操作已排空”在发送端只是一次完成队列观察。

延迟释放

我们分三步构建了这个机制:

  • 中止时通知 prefill(#27372)。在 decode 侧中止时,decode 向每个 prefill rank 发送 ABORT,每个 rank 丢弃该房间软件队列中仍在等待的所有块。这把 t_remaining_chunks 降到接近零。如果没有这个通知,传输中剩余的每个块都会发起并落地,因此该项覆盖了 prefill 的整个剩余部分。t_in_flight_call 是一个单独的项,对应中止生效时已经在执行的那次调用。在 Mooncake 按房间串行的队列上,通知之后除了那次调用之外不会留下任何东西;而像 NIXL 这样异步提交块的后端,仍可能有多个已发起的块尚未完成。无论哪种情况,已发起的工作都会继续写入;通知只能阻止尚未发起的工作。
  • 延迟释放解码侧的 KVCache(#35049,NIXL 移植 #35360)。请求中止时,解码侧保留该请求占用的所有资源,而不是立即释放:包括它的 KVCache 槽位、请求池中的行,以及元数据缓冲区条目。这样写入方的存活状态就变得明确。每个 prefill rank 按 room 跟踪自己尚未完成的工作,并在出队时统计 chunk 数量,因此计数归零就证明已经出队的 chunk 都不再处于传输中。仍在队列中等待的 chunk 则是出于另一个原因而安全:Prefill 在设置 ack 之前就把该 room 标记为失败,所以 worker 会在出队时丢弃它们。只有当计数降到零时,该 rank 才回复 ABORT_ACK。解码侧只有在所有被通知的 rank 都已 ack(正向排空信号)或超时触发(有界兜底)时才释放。超时只是实际可用的兜底手段,并非严格证明。它的时长(#35049 中默认 30 秒)与传输引擎的单次调用预算一致,该预算在中止之前就已开始计时,而在正常路径上,每次发起的写入都能远在预算内完成。在降级路径上超时的调用可能留下引擎仍在重试的写入,因此 prefill 绝不会把失败的调用确认为已排空(#43719),释放操作会等满整个超时时间。解码侧绝不会把以这种方式释放的槽位插入基数树;它会隔离失败 room 的 KVCache,并在不读取的情况下将其丢弃。
  • 能力门控与默认开启(#37454、#41023)。保留机制只在支持 ack 的后端上才有用。它最初以按后端门控的可选功能形式发布,在 Mooncake 和 NIXL 都支持排空 ack 之后,于 v0.5.21 成为默认行为。

在 #35049 中,一个 room 的所有 chunk 都进入同一个传输队列,由单个 worker 线程处理。该 worker 是这个 room 唯一的写入方,它自己发送 ABORT_ACK,而不是交给 bootstrap 线程去做。解码侧也会统计来自 dummy rank 的确认,这些 rank 会立即确认。一旦所有预期的确认都到达,就不可能有任何 prefill rank 还在写入这些槽位。

Room ID 会在不同请求之间复用,因此解码侧会为每次中止新建一个 ack 跟踪器,并忽略它未跟踪的 room 的 ack。#37077 后来又在消息本身中加入了代际标签。

我们在 2×H200 上做了测试,通过保留路径在传输中途中止了 132 个请求。没有发生崩溃,每个被保留的槽位都在其保留状态解除后归还。

Deferred KV cache release: decode holds slots until all required drain acknowledgments arrive or the configured timeout expires.

图 3:修复方案。中止时,解码侧保留 A 的槽位而不是释放它们。每个 prefill rank 排空自己发起的写入并回复 ABORT_ACK,槽位在收到最后一个 ack 时释放,耗时以毫秒计。超时是有界兜底,只会延长保留时间,绝不会缩短它。

Decode 会一直保留这些槽位,直到所有必需的 drain 确认到达,或者释放超时到期。缺少确认会让 decode 一直等到超时。基于超时的释放只有在届时未完成的写入已经停止时才安全;仅仅超时并不能确认这些写入已经排空。该机制只在 abort 路径上占用槽位(见性能影响)。即使在 abort 路径上,除非池子接近耗尽、新请求必须等这些槽位归还才能分配,否则这种占用没有代价;一旦出现这种情况,就会表现为短暂的准入延迟。一项正在开发的功能会进一步降低这一代价:Decode 会先把 KVCache 接收到主机内存,等传输完成、请求即将开始解码时再拷贝到显存。届时这种占用将占用主机内存,而不是 GPU 的 KVCache 容量。

Mooncake 发送失败

#43719 于 2026 年 10 月 12 日合并,堵上了 Mooncake drain ack 中的一条提前释放路径。在此之前,如果提交的 Mooncake 发送返回错误,prefill 会把该 room 标记为失败,但等 decode 的 ABORT 到达后仍会将其确认为已排空。batch_transfer_sync 返回错误并不意味着写入已经停止:超时之后,切片仍会继续重试;一批失败之后,其他任务也可能仍在执行。Decode 收到该确认后就释放了槽位,而残留的写入可能落到另一个请求的 KVCache 里。#43719 对任何 KVCache、state 或 aux 发送失败的 room 都不再发送 drain ack,因此 decode 会一直保留槽位,直到其释放超时。现在每次发送失败都要付出这段占用的代价,直到 Mooncake 能够报告失败的批次是否已经排空。#43719 已进入 main,将在 v0.5.22 发布。

准入修复

准入控制从 router 开始。PD router 会把每个请求同时发给一个 prefill worker 和一个 decode worker,而 decode 没有 prefill 的 KVCache 就无法生成 token。如果 prefill 拒绝或处理失败了一个请求,decode 这一半过去会一直等到 300 秒超时,期间一直占着一个 decode 槽位,所以一波 prefill 失败就可能同时占住大量 decode 槽位。sgl-model-gateway 中的 #29017 和 sgl-router 中的 #41612 现在会在 prefill 返回错误后立即丢弃 decode 请求,decode 引擎会在几秒内将其中止。

在引擎内部,#38935(2026 年 9 月 16 日合并)会在请求进入任一准入队列(PrefillBootstrapQueue.add 或 DecodePreallocQueue.add)时被 worker 在入口拒绝的情况下直接将其退役,此时还没有任何交接状态。在此之前,被拒绝的请求仍会把自己报告为存活,于是会完成 bootstrap 握手、预留元数据缓冲区,并在一个单 token 的桩上跑一次前向计算;#36988 要等这次计算之后才将其退役。如果两个 worker 都拒绝一个请求,decode 现在会立即返回 400,而不是等 300 秒后超时。这样被拒绝的请求就不会进入分离式交接流程。

Mooncake 传输批大小调优

有两个设置会约束 Mooncake 传输调用。第一个是 Mooncake 自带的 MC_TRANSFER_TIMEOUT(默认 30 秒),任何耗时超过它的 batch_transfer_sync 调用都会失败。以这种方式失败的调用仍可能留下未完成的写入(事实 2),这就是 prefill 在发送失败后不发送 drain ack 的原因。

第二项是 SGLANG_MOONCAKE_MAX_TRANSFER_BATCH_INDICES,在 #32758 中引入(2026 年 9 月 8 日合并),它限制了每次调用的数据量,从而也限制了每次调用的时长。默认值为 0 时,SGLang 会在一次调用中发送一个 chunk 的全部 KVCache,这是高效路径。设为正值后,传输会被拆成若干次有序调用,每次最多携带指定数量的索引,这样即使某次调用挂起,最多也只有一批已提交的写操作被搁置,而第一次失败的调用会在剩余批次提交之前结束循环。批次越小,吞吐和 TTFT 的代价越大,所以只有在上下文长度极端的情况下,才建议调低这个上限作为额外保护。在 #32758 的测试中,一个 800K token 的请求单次调用携带了 30 GB 数据。

随后 SGLANG_DISAGGREGATION_DEFERRED_DECODE_KV_RELEASE_TIMEOUT 会设置 T_hold。取值 60 到 120 秒能给这段保持期留出充足余量。取值更大对安全性的提升有限,却会在中止风暴期间成倍放大被保持的 KVCache。如果释放操作一直拖到超时,被保持的容量约等于中止速率 × T_hold × 每请求页数,因此解码池要按这个乘积来规划容量。

PD 校验和

#39500 于 2026 年 9 月 16 日合并,为传输的 KVCache 增加了一项可选的完整性校验,默认关闭。Prefill 在 GPU 上对连续区域以及带索引的 KVCache 和状态页计算 Adler-32 校验和,并随最后一个 chunk 一起发送。Decode 在提交传输时记录该校验和,并在请求加入运行批次之前,用它对收到的 KVCache 进行核对,因此能捕获在该时点之前落地的迟到写操作;而保持机制会把更晚的写操作挡在复用槽位之外。Decode 在撤回(retraction)过程中会保留校验和。校验不匹配会中止请求并递增传输失败计数器;在 CI 中,不匹配则直接报错。健康检查请求会跳过这项校验。

要启用它,需要给 prefill 和 decode 引擎都传入 --disaggregation-enable-kv-checksum;两者的 KVCache 布局必须一致。该标志会额外增加一个可选的元数据缓冲区。它默认关闭,因为一旦开启,每次传输都要多付一次 KVCache 的 GPU 读取和一次额外的同步点。

我们还评估过在提交时校验传输长度,但最终放弃了:对外发布的长度来自与 Success 判定相同的 prefill 记账,所以两侧只可能一致。

性能影响

在 2×H100 上进行了 21 轮测试(1P1D Mooncake,1024 个请求,并发 256,每组重复 7 次),打补丁前与开启或关闭该特性时的吞吐在统计上无法区分。所有差异都在 ±2% 以内,且每个 95% 置信区间都跨越零。在默认值切换前五周,另一项在 2×H200 上进行的 16 轮扫描测得,将该特性默认开启的代价为 −0.38%(置信区间 −2.12 到 +1.36,不显著)。

在中止路径本身,代价取决于 ack 快速路径是否可用。快速路径正常时,被保持的请求会在排空后几毫秒内释放;快速路径失效时,每次中止都要付满整个释放超时。

给运维人员的建议

对于 PD 部署,我们建议如下:

  • 升级。v0.5.21 默认保护 Mooncake 和 NIXL 部署,覆盖所有故障类型。我们没有把这条修复回移到更早的发布分支。MORI 部署以及带 generation 标记的 ack 清理,需要基于 #37077(2026-10-03,包含在 v0.5.22 中)合并之后的构建。实际操作中,请基于 #41128 之后的版本构建——该 PR 在同一天晚十二小时合并,因此也包含下文所述的发布指标。当 prefill 和 decode 使用不同的确认格式时,drain 握手可能失败,释放会回退到配置的超时时间。新的 decode 会丢弃来自旧 prefill 的无 generation 确认,而旧的 decode 收不到来自新 prefill 的确认。在这些情况下,安全释放取决于未完成的写入能否在保持期内停止。
  • 运行会配对中止的路由器。引擎侧的保持只有在 abort 到达引擎之后才能保护部署。一个能同时取消配对两半的路由器(正如 sgl-router 的 PD 模式所做的那样)是第一道防线。
  • 在 v0.5.19 或 v0.5.20 上,在 prefill 和 decode 服务器上都设置 SGLANG_DISAGGREGATION_DEFERRED_DECODE_KV_RELEASE=1。这样可以为 decode 发起的 abort(最常见的一种)启用保持,不过大多数释放仍要等满超时。在 v0.5.20 上,还要设置 SGLANG_MOONCAKE_MAX_TRANSFER_BATCH_INDICES(#32758)和一个足够宽裕的 SGLANG_DISAGGREGATION_DEFERRED_DECODE_KV_RELEASE_TIMEOUT(60–120 秒),大小按 Mooncake Transfer Batch Size Tuning 一节所述来定。这两项都不能替代升级,因为在 v0.5.21 之前,除 decode 发起的 abort 之外的故障类型仍然不受保护。在当前构建上,默认值就是正确的,这些开关只是增加余量。
  • 上线后关注 sglang:decode_deferred_kv_release_total{outcome}。该指标随 v0.5.22 发布(#41128)。健康的部署几乎所有的保持都通过 drained 释放。如果超时占比很高,说明 ack 路径需要关注,每次 abort 都在等满整个保持期。

结论

只要 KVCache 是异步移动的,无论是在引擎之间、内存层级之间还是流水线阶段之间,这些规则都适用:

  1. 绝不要释放异步引擎可能仍会访问的槽位。
  2. 保持到出现明确的 drain 信号,或到达有上限的超时。
  3. 让每一种降级都延长保持时间。
  4. 每个修复都要足够小,使其回归测试恰好只证明一件事。

如果 PD 部署出现这些修复无法解释的输出损坏,或任何看起来属于这一类的问题,请在 sgl-project/sglang 上开 issue,附上拓扑(prefill/decode 并行度、传输后端、版本)、事故前后的请求模式,以及可用的 decode_deferred_kv_release 指标。维护者会优先处理这类报告。

致谢

我们感谢报告该问题的研究员识别出这一暴露面、提供可复现的报告并协调披露。我们尤其感谢 Cloudflare,其工程师分享了生产环境背景、开发了最初的应急补丁,并与我们一起完成了上游修复和加固审计。

我们感谢以下人员在这些修复上的工作:

  • SGLang 社区/RadixArk:Shangming Cai、Banruo Liu、Wenbo Jiang、Cheng Wan、Shiyang Chen 以及许多其他人
  • Cloudflare:Kevin Flansburg、Kastan Day

我们还感谢 @AsterWang 和 @KastanDay,他们早期的 PR(#24580、#32564)指出了这一竞态,并提议在复用前保持页面。

Pull Request 列表

参考资料

[1] Mooncake: A KVCache-centric Disaggregated Architecture for LLM Serving。https://github.com/kvcache-ai/Mooncake

[2] SGLang PD 分离文档。https://docs.sglang.io

[3] Deploying DeepSeek with PD Disaggregation and Large-Scale Expert Parallelism on 96 H100 GPUs。https://www.lmsys.org/blog/2025-05-05-large-scale-ep/

附录

相关问题与修复

下表汇总了上文讨论的问题与修复,从请求路由开始,再到 KV 缓存处理。

层级问题修复
路由器单边中止:解码侧已死,预填充侧仍在推送(或反之,双方各自等待一个已死的对端 300 秒)PD 路由器取消这对请求:sgl-router 中的 #41612(基于 #39461);sgl-model-gateway 中的 #19524 和 #29017
准入在入口被拒绝的请求仍进入了 bootstrap、预留了缓冲区,并跑了一次 stub forward在准入环节就拒绝并清理,此时尚未建立任何交接状态(#38935,基于 #36988,后者在 forward 之后清理被中止的请求)
停止新写入预填充侧不知道解码侧已中止,仍在排队发送数据块解码侧中止时通知每个预填充 rank(#27372)
挂起直到排空已启动的写入无法取消延迟释放槽位,直到各 rank 排空 ABORT_ACK 或超时(#35049、#35360)
保住快速路径确认因 clear() 竞态丢失 → 每次中止都要付满超时保留确认目标,直到未完成的写入排空(#40645)
每个 room 只有一个确认槽位 → MLA 扇出 rank(预填充 TP < 解码 TP)永远收不到确认把确认扇出到该 room 已知的每个解码对端(#41402)
确认在解码跟踪器就绪之前到达 → 被丢弃在发送 ABORT 之前就绪跟踪器(#41402)
所有路径都挂起挂起只对解码侧发起的中止生效;传输/预填充故障在 t=0 就释放对每种失败类型都延迟(#41404)
协议卫生复用的 room id 可能消费到过期的确认;MORI 则完全没有确认在公共层给 ABORT/ABORT_ACK 加上代际标记;MORI 选择启用挂起(#37077)
兄弟管道hicache 撤回路径在设备→主机卸载拷贝进行中释放了设备槽位,导致被污染的 KVCache 残留到主机层撤回操作会等待卸载排空(#24226、#30899)
Rank 共识在经 MIN 归约的 KVCache 轮询之后,各 TP rank 依据各自本地的 hicache 恢复状态产生了分歧。一个 rank 恢复失败导致整个组失去同步(队列长度不匹配、调度器崩溃),其准入记账也在下一个 tick 出现偏差把恢复结果并入已经归约过的 KVCache 轮询,让每个 rank 只做一次决策(#43562)
检测绕过预防措施的损坏无法被发现可选的传输校验和(#39500);提交时的长度校验经过评估后被否决(见 PD Checksum)
观测无法判断 ack 快速路径在生产环境中是否还活着记录持有次数、时长,以及 drained/timeout/error 结果指标(#41128)

中间几行来自一次社区审计。该审计于 2026 年 9 月 11 日发布(#38961),@kflansburg 在其中又发现了两种可能让竞态重新出现的方式。排空 ack 经常没能送达,于是释放操作退回到超时处理。由 decode 自身未发起的故障从不会触发持有,这又通过另一条路径重新打开了最初的 bug。我们在 2026 年 9 月 22 日至 28 日期间在 main 上修复了这两处:现在任何类型的失败都会触发持有,排空 ack 也能送达 decode 了。审计还标记了 prefill 自身的源页面,但我们的分析表明那条路径本来就是安全的,因为这些字节只可能落入 decode 持有正在隔离的槽位中。2026 年 10 月 11 日合并的 #43562 修复了另一项发现,即 rank 分歧的 HiCache 恢复。

披露时间线

日期事件
2026-09-09Cloudflare 向 SGLang 维护者通报了生产环境中跨请求的 KVCache 暴露问题,并在 #38704 中分享了后续安全检查
2026-09-10SGLang 维护者与 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-02v0.5.21 发布,默认受保护(Mooncake、NIXL)
2026-10-03MORI 和带代际标记的 ack 合并(#37077);随 v0.5.22 发布
2026-10-11针对 decode HiCache 恢复的 rank 共识修复合并(#43562)
2026-10-12失败的 Mooncake 发送不再被 ack 为已排空(#43719);随 v0.5.22 发布
2026-10-22失败的 NIXL 和 MORI 发送不再被 ack 为已排空(#45973);随 v0.5.23 发布
2026-10-26由 SGLang 和 Cloudflare 协调披露。

来源:LMSYS Blog · lmsys.org