Wink Pings

当KV cache串台:SGLang跨请求缓存泄露始末

Cloudflare在GLM-5.3生产部署中发现一个安全问题:一个请求的输出可能包含另一个用户会话的KV cache。根因是PD分离架构中缓存槽位释放太早,RDMA在途写入覆盖了别人的内存。SGLang用延迟释放修复了整个bug家族。

当KV cache串台:SGLang跨请求缓存泄露始末

2026年9月9日,Cloudflare向SGLang维护团队报告了一件事:生产环境里,一个请求的输出可能包含另一个用户会话的内容。

不是模型幻觉,不是提示词注入。是KV cache在内存层面被交叉污染了。你打到一半的长文,可能出现在陌生人的回复里。

KV cache是什么

Transformer推理时,每个新token都要关注之前所有token的键值。这些键值被缓存起来避免重复计算,就是KV cache,相当于模型对话过程中的"工作记忆"。

SGLang的PD分离架构把prefill和decode拆到不同引擎上,KV cache通过RDMA异步传输。这个设计让长上下文推理快了不少,但它引入了一个微妙的问题:请求中止和槽位释放之间有一道缝隙。

竞态:四步走

一个被中止请求的已发布写入,落在已被重新分配给其他请求的KV cache槽位中

图1:竞态。中止释放了A的槽位,但A的写入还在落地;B继承了这些槽位,迟到的写入落进B的KV cache。

故障走了四步:

  1. 请求A中止,至少一个已发布的RDMA写入还在飞行
  2. decode把A的KV cache槽位释放,allocator标记为可用
  3. 槽位分配给请求B
  4. A的迟到写入落进B的KV cache,B在别人的记忆上解码,没有任何报错

每一步都像正常的分布式系统行为,但每一步都在偏离物理现实。allocator说槽位空闲,NIC队列里那个写请求不同意。这是分布式use-after-free,悬垂指针是另一台机器网卡队列里的写请求。

四个事实让这类问题成立:

  1. allocator无法知道"没有远程写者还在触碰"。槽位可复用的隐藏条件是"无写者在途"。
  2. RDMA工作请求无法取消。push和pull都逃不掉。Python也中断不了传输引擎的阻塞C++调用,超时后写请求仍在队列里,可能在任何未知时刻落地。
  3. 路由层的liveness处理不够。中止必须同时到达prefill和decode两端,一个只中止单侧的路由器等于保证竞态。
  4. 竞态窗口随上下文长度增长。128K到1M token的prompt,KV cache写入可能飞行数秒到数分钟。长上下文本身也更容易触发客户端超时中止,窗口、尾部、中止率同时放大。

Python调度器之下的传输栈:中止能拦住未来块,但已交给C++引擎或NIC的写请求无法召回

图2:Python调度器之下的传输栈。中止能停止未来的块,但一切已交给C++引擎或NIC的东西都无法召回。

复现:数到80后,输出变成别人的prompt

SGLang团队在2×H200上稳定复现了真实竞态。一个请求被中止,donor的430个槽位被释放,victim请求预分配了其中[1..105]。donor的迟到写入在decode中途落地。

受害者正确数到80,然后输出变成了donor的提示词文本。

另一个请求的KV cache,被读取出来,成了当前请求的"记忆"。全程无报错。

修复:延迟释放

decode无法取消prefill已发布的写入,那就等它结束:

T_hold > t_abort_propagation + t_remaining_chunks + t_in_flight_call

延迟KV cache释放:decode持有槽位直到所有排空确认到达,或配置的超时到期

图3:修复。中止时decode持有A的槽位,而不是释放。每个prefill rank排空已发布写入并回复ABORT_ACK,槽位在最后一个ack后释放,毫秒级完成。超时是兜底,只能延长持有,不能缩短。

核心机制分三步:

  • 中止通知(#27372):decode中止时向每个prefill rank发ABORT,每个rank丢弃队列中尚未发布的块,把t_remaining_chunks压到接近零
  • 延迟释放(#35049,NIXL端口#35360):decode持有请求的所有槽位、请求池行和元数据缓冲,直到所有rank的drain ACK到达或超时。每个rank的worker线程自己发送ACK,因为它才是唯一知道队列排空情况的人
  • 默认开启(#37454、#41023):v0.5.21起,Mooncake和NIXL后端默认受保护

几个关键细节:

  • 失败从不ack。Mooncake发送失败后写入可能还在重试,#43719堵住了这个提前释放路径。失败房间的KV cache被隔离,直接丢弃,不插入radix tree
  • 生成标签(#37077)防止room id复用后消费stale ack
  • 所有故障类型都延迟释放(#41404),不止decode发起的中止,传输故障、prefill失败、hicache恢复失败都算

辅助修复包括准入控制(被拒绝的请求在进入disaggregation handoff前就返回400,不再卡300秒超时)、Mooncake传输batch size上限(#32758,防止单次调用携带30GB数据)、可选的PD校验和(#39500,Adler-32,默认关闭,因为每次传输多一次GPU读取)。

性能代价

2×H100上21轮测试,吞吐量差异在±2%以内,置信区间跨零。默认开启前的16轮2×H200测试,代价是-0.38%,统计上不显著。

中止路径上,ack快路径活着时,持有持续毫秒级。ack路径死了,每次中止都付完整超时。

运营者该做什么

  • 升级到v0.5.21+。Mooncake和NIXL默认受保护。MORI和生成标签需要v0.5.22。没有向更早分支backport
  • 路由器必须配对中止。sgl-router的PD模式做了这件事,单侧中止等于敞开竞态
  • 观察decode_deferred_kv_release_total{outcome}指标(#41128)。健康状态几乎全是drained,timeout占比高说明ack路径出了问题

四句话

  1. 不要释放异步引擎还可能触碰的槽位
  2. 持有直到正面排空信号或有界超时
  3. 每次降级都延长持有
  4. 修复要小到回归测试能证明一件事

这个bug家族有个共性:allocator说槽位空闲,物理世界不同意。AI基础设施里,"内存空闲"第一次有了两个版本的定义。修复的办法不花哨——承认你无法取消已发布的操作,那就等它结束。分布式系统里很多诡异问题,最后都回到这个朴素的道理。

披露与Cloudflare及报告研究员协调进行。Cloudflare的完整事件报告见blog.cloudflare.com,研究员报告即将发布。

发布时间: 2026-10-27 06:41