一次队列校验失误如何引发推理缓存泄露
文章依据 Cloudflare 事故复盘,分析 GLM-5.3 服务中上下文上限超过 KV 缓存容量、队列校验缺陷叠加造成的跨租户内容泄露。作者提供无需 GPU 的模拟仓库,展示故障机制,并指出仅检查状态码和流式输出不足以验证长上下文正确性。
AI 深度解读
Cloudflare 的 GLM-5.3 跨租户泄漏,按原文转述,是容量配置与请求状态判断共同失守:超长请求已经被拒绝,却继续进入推理流程,读到了上一位租户留下的缓存状态。受影响的是 155 条响应,客户端拿到的仍是 HTTP 200。
这篇值得程序员读的地方,不只是大模型基础设施出了什么故障。作者用普通 Python 模拟了整条错误路径,揭示了一个常见问题:代码设置了失败标记,不代表后续流程真的停止。
已经拒绝的请求,为什么还能生成回答
先理解 KV cache。模型处理输入时,会保存后续生成需要重复使用的中间状态,避免每生成一个 token 都重新计算前文。它不是原始文本副本,但残留状态仍可能影响输出,带出其他请求的信息。
SGLang 的 prefill/decode 分离模式,把推理拆成两部分:prefill 处理提示词并构建 KV cache,decode 读取缓存、逐步生成 token,两端通过 RDMA 传输状态。这种拆分方便分别扩容计算密集的 prefill 和受内存访问限制的 decode。
问题出在两处条件同时成立。
第一处是容量不匹配。Workers AI 上的 @cf/zai-org/glm-5.3 配置了 1,048,576-token 上下文窗口,prefill GPU 的 KV 池却只能容纳 681,280 tokens。按原文转述,Cloudflare 上线前已经知道这个差距,但长上下文测试检查的是 HTTP 状态码和流式输出,没有验证回答内容是否正确。
第二处是接纳队列检查错了状态。超长请求被标记为 rejected,队列却只检查 finished。于是,一个已经拒绝、尚未完成的请求继续往下走。
接下来,原始提示词被替换成一个 token 的占位内容,prefill 只把这个 token 写进首个预留 KV 页。decode 却仍读取整个预留区域,通常每个槽位有八页。其余七页没有被本次请求覆盖,还保留着其他租户的状态。
拒绝标记只有能阻止后续执行,才算真正建立了边界。
高性能分配器释放页面时通常不会顺手清零,因为每次清零都会增加写入成本。正常情况下,所有权检查和有效范围应当防止旧内容被读取;这次状态流转出错,让这些残留页进入了回答生成过程。
Python 演示证明了什么,又没证明什么
作者搭建了一个固定八页的模拟池:先写入 tenant A 的内容,再释放槽位但保留数据,最后让 tenant B 提交超容量请求。
下面这段代码提炼了漏洞路径和修复路径的区别,解决的是「已经拒绝的请求是否还能进入后续阶段」:
def admit_vulnerable(req: Request, pool_capacity: int) -> None:
"""真实缺陷:超长请求被标记为 rejected,但接纳队列只检查
finished,因此请求没有退出,仍继续进入 prefill 和 decode。"""
if req.tokens_needed_pages > pool_capacity:
req.rejected = True
# 缺陷:这里没有 return,也没有终止请求,继续进入 prefill。
def admit_fixed(req: Request, pool_capacity: int) -> bool:
"""修复方案(上游 SGLang #38704):在接纳队列处理待拒绝请求,
先于任何页面分配或传输终止它。"""
if req.tokens_needed_pages > pool_capacity:
req.rejected = True
return False
return True
关键是 return False 所在的位置:拒绝发生在分配和传输之前。当然,调用方也必须遵守返回值;仅仅返回一个布尔值,并不会自动终止整条流水线。
作者给出的运行命令如下,用于查看模拟器中的两种结果:
git clone https://github.com/themsquared/cloudflare-glm-kv-cache-leak.git
cd cloudflare-glm-kv-cache-leak
python3 scripts/demo.py
注意最后一行启动的是模拟脚本。漏洞路径的输出包含 tenant B 的占位 token,以及七份 tenant A 的 PROJECT_ZEPHYR_Q4_FORECAST,同时报告 200 OK;修复路径则返回 HTTP 400: request rejected (context length exceeds KV pool capacity)。
作者称演示仓库和命令都在发布前运行过。但它没有使用 Cloudflare 或 SGLang 源码,也不需要 GPU、RDMA 或真实的 681,280-token 状态。因此,它能解释错误状态如何穿过队列、读取旧页面,不能用来测量生产泄漏概率、真实模型输出或性能成本。
修复为什么既要扩容,也要改队列
原文给出的影响范围是 2026 年 8 月 27 日至 9 月 9 日,共 155 条响应,涉及超过 681,280 tokens 的提示词。Cloudflare 表示受影响客户数量较少,未发现蓄意利用的证据,其他 Workers AI 模型没有受到这个缺陷影响。这些是原文转述的厂商调查结论。
修复包含两步:把 prefill KV 池扩到约 204 万 tokens,约为上下文长度的两倍;同时修改 SGLang,让已拒绝请求在接纳阶段退出,早于任何 KV 分配或传输。
扩容缓解容量不足,状态修复封住错误路径,两者承担不同职责。
处置时间线也有一个表述需要留意:HackerOne 报告于 9 月 8 日 23:11 UTC 到达,次日 13:22 前宣布事件并复现,14:00 将模型转为私有,19:40 恢复。原文还写了「25 分钟内」,但没有交代这 25 分钟的明确起点,不能理解成收到报告后 25 分钟完成复现。
上游节奏更慢。类似问题自 7 月 6 日已有公开 issue,接纳队列修复以 #38704 提交,相关修复分别进入 10 月 2 日的 v0.5.21 和 10 月 19 日的 v0.5.22,10 月 26 日公开披露。
对于自建分离推理服务,原文转述的建议还包括:Mooncake、NIXL 路径升级到高于 v0.5.21 的版本,MORI 路径升级到高于 v0.5.22 的版本;中止请求时由路由器同时取消 prefill 和 decode 两端;从运行中的引擎读取真实 KV 容量,并观察 sglang:decode_deferred_kv_release_total{outcome}。其中超时占比偏高,提示释放流程的 drain-ack,也就是排空后的确认环节,需要排查。
我的判断
我认同作者把重点放在请求生命周期上。单看 HTTP 200,只能知道服务返回了成功状态,无法证明输出属于当前租户。容量边界测试如果只检查「还能不能流式返回」,会漏掉内容正确性和隔离性。
不过,这篇没有给出性能对照数据。释放时清零要付出多少成本、增加页面所有权校验会损失多少吞吐、不同方案能覆盖哪些故障,都没有实测比较。不能仅凭高性能分配器的常见做法,就认定清零一定不划算。扩池到约两倍上下文也是此次修复配置,不能直接当作通用容量公式。
作者进一步提到 AI gateway 和用于 LLM、MCP 流量的 OWASP CRS 内容检查,这里要收紧预期。原文谈的是提示词侧检查,没有证明它能识别输出中的跨租户 KV 泄漏。网关可以增加观测和防护,但无法天然知道某段回答究竟属于谁。
内容检查可以补一道防线,请求所有权仍要由后端可靠执行。
对接托管模型的团队也不必从这起事件推导出更大范围的结论。公开材料支持的是这条特定故障链,不能据此断言所有共享推理服务都存在同样漏洞。
能拿来做什么
- 在 Node 请求队列或 AI 编程任务调度中,检查
rejected、errored、aborted、cancelled等终止状态能否阻止后续执行。尤其要追踪取消之后,后台任务和流式响应是否仍在继续。 - 给 AI 接口补充容量边界验证,同时检查错误响应和输出内容。跨租户测试可以放入易识别的虚构标记,验证其他租户的回答是否出现该标记。
- 如果自建 SGLang,按实际使用的 Mooncake、NIXL 或 MORI 路径核对修复版本,并从运行中的引擎确认 KV 池容量,不能只看模型配置里的上下文上限。
- 如果前端或 Node 服务只接托管推理 API,就保留请求标识、取消结果和异常输出的关联记录,让内容串租户的线索能够回溯到具体调用,便于向供应商提交可复现证据。