---
layout: post.njk
source: https://yage.ai/share/pd-disaggregation-cloudflare-kv-leak-20261101.html
web_feed:
  articleId: cmvzw5hx8001f9eye8xt9hdi5
  feedSourceId: webfeed_yage_ai
  guid: https://yage.ai/share/pd-disaggregation-cloudflare-kv-leak-20261101.html
  sourceUrl: https://yage.ai/share/pd-disaggregation-cloudflare-kv-leak-20261101.html
speaker: yage.ai
title: |-
  为什么大模型推理都在做 PD
  分离，而它又如何漏出了别人的代码
date: '2026-11-01'
summary: 文章讨论了在大型模型推理中采用预填充（预填充）和解码分离（PD 分离）架构以提高流式输出速度的工程实践，以及这种架构在高性能下的工程风险。作者分析了预填充阶段主要消耗算力，解码阶段主要消耗显存带宽的特点，并探讨了在同款GPU上通过量化精度、显存页回收策略、以及跨机器RDMA传输等取舍来优化性能的方法。文章通过Cloudflare的跨租户数据泄露事件，详细分析了在这些优化策略的组合下，由于显存池大小不匹配和显存回收机制的缺陷，如何导致解码节点读取了预填充节点留下的旧数据，从而造成跨请求KV缓存泄露的漏洞。最终，文章指出安全边界往往存在于那些半途而废的异常退场路径上，并描述了修复该漏洞的机制，包括引入延迟槽位释放机制来确保数据安全。
area: tech-engineering
category: architecture
tags:
  - model-inference
  - prefill-decoding
  - gpu-optimization
  - kv-cache-leak
  - rdma
people:
  - Gadi Evron
companies_orgs:
  - Cloudflare
products_models:
  - GLM-5.3
media_books: []
draft: true
status: evergreen
---

现在用编程智能体写代码，大家都会随手把整个代码库扔进对话框。很多人可能都有过这样的体验：有时候敲下回车键，光标闪了十几秒才蹦出第一个字；有时候原本正在流畅吐字的对话，突然像卡住一样一顿一顿地往外蹦。

这其实怪不到云厂商的显卡算力头上，症结在于推理引擎把两种非常不同的任务放在了同一个
GPU
上面。为了解决这个问题，大多数生产级推理集群都采用了预填充和解码分离（简称
PD 分离）的架构。这样的设计可以在不增加更多 GPU
的情况下，将线上流式输出速度提升三倍多。然而，今年秋天 Cloudflare
发生的一起跨租户数据泄露事件，也暴露出了这种高性能架构背后的工程风险。

## 一张显卡很难同时干好两件事

要想明白厂商为什么非得把推理集群拆开，得先看看大模型处理一个请求究竟在做什么。平时我们和模型对话，往往觉得这是一个一气呵成的过程。但从显卡的角度出发，它其实是两个截然不同的阶段：预填充和解码。

预填充说白了就是读题。比如你在对话框里贴了几十万 token
的整个代码库，显卡需要把所有输入 token
的注意力关系一次算出来，吐出第一个字，同时把中间算出来的键值状态存在显存里，也就是常说的
KV 缓存。这是成千上万个 token
一起做矩阵乘法，显卡的计算单元全力运转，显存带宽反而很宽裕。它卡在算力上，决定了你敲下回车后要多长时间才出第一个字。

等第一个字蹦出来之后，模型就切进了解码阶段，开始一个字一个字往外写。模型每次只算出一个新
token，再把它拼回历史序列里去算下一个字。因为每次只有 1 个新 token
参与运算，前面热闹的大矩阵乘大矩阵，到这里一下子退化成了瘦弱的矩阵乘向量。

但问题是，为了生成这 1 个字，显卡依然需要把几百 GB 的模型权重和几十
GB 的历史 KV 缓存从显存中完整读取一遍。GPU
的核心大部分时间都在干等数据搬运。这一步卡在显存带宽上，决定了我们肉眼看到的流式输出顺不顺畅。

若把这两件事放在同一台机器上混跑，矛盾立马就爆发了。假设一张显卡正在给几十个用户流式吐字，突然插进来一条几十万
token 的长文预填充，庞大的矩阵运算瞬间把 GPU
占满好几秒。旁边那几十路正在吐字的请求只能干排队，用户屏幕上的字立刻卡成幻灯片。反过来，要是限制预填充让路，新请求又得对着空白屏幕干等半天。

那怎么破局呢？一个很直观的解法就是把集群一分为二：一部分机器专门干预填充，把算力榨干；另一部分机器专门干解码，把显存带宽跑满。Cloudflare
做过推理架构实测，发现在 GPU
数量固定的前提下，把集群拆成两个池子后，线上 P90
的吐字间隔从原来剧烈抖动的一百毫秒左右一下降到了二十到三十毫秒，吐字速度直接提升了
3 倍多。

单机混跑与 PD 分离流水线对比

## 两边用的是同款显卡，只是跑法不一样

很多人会想，既然预填充主要吃算力，解码主要吃显存带宽，云厂商是不是需要采购两种不同型号的
GPU？其实不需要。在生产集群里维护异构的 GPU
池子很麻烦，更常见的做法是采购同样的 GPU（比如英伟达
H200），但是在两边采用不同的软件配置。光靠三个软件配置上的取舍，就能在同款
GPU 上榨出截然不同的性能。

第一个取舍是两边用不同的量化精度。解码端因为显存搬运是瓶颈，最简单的加速方式就是把模型压小。Cloudflare
在模型压缩与安全实测里做过一个对比：把模型权重从 FP8 压缩到
INT4，显存占用从七百多 GB 降到四百二十 GB
左右，每次吐字少搬四成的数据，单路解码速度立刻快了五成半。如果再把 KV
缓存压低一档精度，单机能装的并发上下文直接翻倍，峰值总吞吐又多出四成。

但同样的 INT4
权重用在预填充节点上，反而会拖后腿。预填充本来算力就紧张，改用低精度权重后，每次做大规模矩阵乘法之前还要额外花算力把权重反量化解包，实测吞吐反而掉了一成半。所以最划算的配法，是让预填充节点跑体积大、精度高的
FP8 权重保算力，让解码节点跑体积小的 INT4 权重省带宽。

但这也会带来另一个潜在问题。虽然预填充节点和解码节点用的是同一个型号的
GPU，总显存大小相同，但因为预填充节点加载的权重更大，留给 KV
缓存池的剩余显存自然比解码节点小一大截。

第二个取舍是显存页回收时不擦除旧数据。在 Cloudflare
使用的开源推理引擎 SGLang
中，节点启动时会把剩余的显存切成固定大小的缓存页，不同的用户轮流借用这些缓存页来存放自己的上下文。几十万
token 的上下文动辄占几十 GB
显存，如果每次用完归还都把显存清零，就太浪费显存带宽了。

所以引擎直接跳过清零这一步，靠一条心照不宣的规则来保证安全：一个新请求进来之后，预填充阶段算出的新数据一定会完全覆盖分配给它的每一页显存，然后解码节点才去读。只要这个先全覆盖、后读取的顺序不乱，上一个用户在显存里留下的旧数据就不会漏出来。

第三个取舍是让网卡跨机器直写显存。两个池子一拆，预填充算好的几十 GB
KV 缓存得跨网传给解码节点。为了不打扰 CPU，集群用 Mooncake
这类传输层，走网卡的单边远程直接内存访问（简称单边
RDMA）：解码节点先把显存空位预留好，预填充节点的网卡顺着网线直接把数据写到解码显卡的显存里，两边各自维护排队队列，传完只发个完成信号对表。

正常情况下，这三个省事的优化配合得天衣无缝。但一旦遇上异常路径，它们又会严丝合缝地撞在一起。

## 三个提速捷径撞在一起，漏出了别人的上下文

今年十月底，Cloudflare 发布了一份关于跨请求缓存泄露的复盘报告：Workers
AI 上托管的代码大模型 GLM-5.3
在八月底到九月初的十来天里，发生了跨租户数据泄露。在安全机构 Knostic
的研究员 Gadi Evron
发现并上报之后，官方在立项后的四十分钟内就下线了模型并在同一天完成修复，最终查出有
155 条超长上下文的回复中掺杂了其他用户的对话或代码片段。

那这个漏洞是怎么产生的呢？一言以蔽之，解码节点在显存里划了八页带旧数据的空位等着覆盖，预填充节点只覆盖了第一页就通知说全部传完，害得解码节点把后面七页别人的旧数据当成当前用户的内容读了出来。拆开来看，这三步差错恰好一步步踩中了上面提到的三个提速取舍。

这里边踩的第一个坑是两边显存池大小不一样，加上显存回收不清零。这个模型对外公开了大约
105 万 token
的上下文窗口。解码节点因为权重小，显存充足，完全可以装下；但预填充节点的高精度权重太占地方，剩下的显存池只能装
68 万 token（精确值是
681,280）。一旦有用户发来超过这个上限的超长请求，假设需要八页显存，解码节点这边完全没问题，立刻在显存池里划出八个旧页面用来接收数据。别忘了前面提到的第二个取舍，这八个页面刚从上一个用户那里收回来，为了节省带宽没有清零，八个页面里满满当当全是上一个用户的代码和对话。

第二步是预填充节点装不下，但阴差阳错只算了 1
个字。预填充节点收到这个八页长的请求，发现自己显存不够大，当即给它打上了拒绝标记。按理说这时直接报错退回就好，但推理引擎有个单机时代遗留的习惯：为了避免打断显卡正在运行的批处理流水线，它不会立刻把这个超限请求踢出队列，而是偷偷把几十万
token 的原输入替换成一个仅有 1 个 token
的空壳占位符，原本想让这个空壳跟着流水线空跑完当前批次，到出口再报错扔掉。

可谁能想到，这种设计搬到 PD
分离的架构里会出问题。负责跨机交接的模块漏看了那个拒绝标记，没认出这是个待报错的空壳，见它算完了这
1 个占位
token，就当成正常请求放行了。这就触发了第三个取舍：预填充网卡启动单边
RDMA，把这 1 个 token
生成的缓存写进了解码节点的第一页显存，随后直接发出了传输完成信号。

解码节点拿到完成信号，没查究竟传了多少页，也没看后面七页到底有没有动过。它笃信预填充一定会把所有页面都覆盖掉，于是把整整八页显存读出来生成答案。偏偏第一页是刚写上去的占位符，第二页到第八页全是上一个用户的旧数据。模型就在别人的私有代码上认真作答，还返回了正常的
HTTP 200。

GLM-5.3 跨请求 KV
缓存泄露三步传导链

## 为什么越出错反而跑得越快

这个事故里有个比较反直觉的地方：SGLang
启动时其实已经在日志里打出了预填充池的真实容量，Cloudflare
团队在上线前也注意到了它比对外开放的百万级上下文窗口要小一截。那为什么还是带病上线了呢？因为他们跑长上下文压测时，发现所有超长请求都正常返回了
HTTP 200
和流式文本。他们的压测脚本只检验状态码和吐字时间，没有去核对模型回答的内容对不对。团队看到压测全绿，就误以为引擎在底层通过流式分块之类的机制自动兜住了多出来的上下文，或者真装不下时会自动报错。

更隐蔽的是，超长请求一旦落到预填充入口，就直接缩成了一个占位
token，省去了真刀真枪计算八页预填充的时间，报错分支反而比正常算完几十万
token
的响应更快。解码节点拿到的又是前序请求留下的真实缓存页，吐出的文字在语法上依然通顺，单看监控指标一片绿灯，越出错反而跑得越快。巧合的是，Cloudflare
团队在八月初刚展示过一套损耗不到 1%
的缓存页归属标签原型，每次读取前都会核对页面归属，本来正好能拦住这种越界读取；结果八月中旬在旧模型上测试时遇到了稳定性问题，加上当时还受限于特定的注意力后端，团队在事故发生前不久把它关掉了。

事后，Cloudflare
把这个模型的预填充配置改成了跟老模型一样，把缓存池扩到了大约 204 万个
token（大约是上下文窗口大小的两倍），并且和开源社区一起通过 SGLang
的安全协同披露修补了引擎漏洞。新版本确保带拒绝标记的请求在跨机准入队列入口直接退役报错，不再进入任何显存分配和
RDMA 传输流程；解码节点在收货时也会检查实际传过来的 token
数量和预留的数量是否一致。

顺着这次排查，双方还把 PD 分离架构下另一种更隐蔽的单边 RDMA
跨节点竞态一并修复了。几十万 token 的长上下文缓存高达 30
GB，跨机器传输可能需要几秒时间。如果客户端在传输中途断开连接或者报错取消，预填充网卡已经发出的单边
RDMA
写指令是无法撤销的。要是解码节点立刻把显存页回收并分给下一个用户的请求，正在网线上飞的旧数据包随后落地，就会悄无声息地写进新用户的显存里，连一行报错日志都不会留下。

因此新版引擎默认启用了延迟槽位释放机制：只要传输中途取消或失败，两边节点先把涉及的显存页冻住，等预填充端把在途的
RDMA
写入排空、并回传带请求专属标记的确认消息后，才真正回收显存，同时补上了传输批次上限和端到端校验和。在多租户共享的推理集群里，正常路径拼的是吞吐，而真正的安全边界，往往守在那些半途而废的异常退场路径上。