KV 缓存池比上下文窗口小的时候,别人的提示词会从你的回答里漏出来
2026 年 10 月 26 日,Cloudflare 和 LMSYS 同时发文,讲清了一件事:8 月 27 日上线的 GLM-5.3(@cf/zai-org/glm-5.3)在 Workers AI 上存在跨请求 KV 缓存暴露,一直持续到 9 月 9 日修复。共 155 个响应受影响,全部来自超过 681,280 token 的提示词,官方调查没有发现有人故意利用。这事的价值不在于「某家云出了事故」,而在于它把一个很少被公开讨论的问题摆到了台面上:在一个 disaggregated 推理部署里,KV 缓存池的大小和模型宣称的上下文长度之间,没有任何机制强制它们一致,而负责这件事的是运维,不是引擎。
这次泄漏不需要任何攻击手法:一个被拒绝的请求照样走完了 prefill 到 decode 的交接。
一、先说背景:KV 缓存是一池不擦除的页
Cloudflare 用 SGLang 跑 GLM-5.3,架构是 disaggregated:一组 prefill GPU 处理提示词、生成 KV 缓存,再通过 RDMA(Mooncake)把缓存交给一组 decode GPU 去生成。KV 缓存是 GPU 显存里一片固定大小的页池,请求被接纳时分配页,结束时释放。关键细节是:页在请求之间不清零,下一个拿到页的请求直接覆盖写。这对性能是必要的,代价是引擎必须自己管好每一页的归属和生命周期——不仅在单机内(token 生成、卸载到主机内存、传输接收方、中止与错误路径都可能同时持有同一页),而且还跨节点(远程 prefill 节点持有一份可变的引用)。更麻烦的是,这个页池大小根本不是配置项:SGLang 在启动时按「加载完模型权重之后剩下的显存」自己算出来,而模型的上下文长度是另一个配置值。两者没有任何强制性约束。
# The check Cloudflare now runs before any disaggregated deployment change.
# SGLang does NOT force the KV pool size to match the context length,
# so read both numbers from the running engine and compare them.
# 1) startup log prints the pool size it sized from leftover GPU memory
kubectl -n workers-ai logs deploy/glm-prefill | grep -iE "kv.?cache|pool|max_total_tokens"
# -> max_total_num_tokens: 681280
# 2) context length is a separate config value you set yourself
curl -s localhost:30000/get_model_info | jq '.max_context_length'
# -> 1048576 <-- 681280 < 1048576: this deployment is unsafe
# 3) the metric mirror of the same number, for alerting
curl -s localhost:30000/metrics | grep sglang:max_total_num_tokens
# Rule: pool >= context, on BOTH prefill and decode nodes,
# re-checked after every change to weights, quantization,
# tensor parallelism or memory fraction.二、两个问题叠在一起,才漏了数据
第一个问题是配置:GLM-5.3 的上下文窗口是 1,048,576 token,而 prefill GPU 上的 KV 池只装得下 681,280 token。这个不一致在发布前就被发现了,但当时的判断是「引擎会返回错误」,而且长上下文压测返回的是成功状态码,所以没被当成问题。第二个问题是 SGLang 在 disaggregated 模式下处理超长请求的方式:装不下的提示词被标记为 rejected,然后被替换成一个 token 的占位符,好让请求沿正常批处理流程排空。单机部署下这套是安全的——占位符跑一遍、批次结束、客户端收到错误。但在 disaggregated 模式下,prefill 和 decode 两侧的接纳队列检查的是 req.finished(),而拒绝状态记录在 to_finish 里、不在 finished_reason 里,于是被拒请求仍然自称「活着」。接下来的链条就很难看了:decode 先按 8 页预留了 KV,prefill 只跑了一个 token、只传了一页,decode 收到「传输完成」后按 8 页去算 attention,P1 到 P7 里还是别人上一次请求留下的内容。客户端拿到的是 HTTP 200,prompt_tokens 对得上,回答也通顺,只是里面混着别人的数据。
# A canary-marker test: does a reply ever carry bytes from
# another request? Cheap to run, and it fails loudly.
import uuid, requests
MARKERS = {}
def ask(prompt_tokens: int, session: str) -> str:
marker = f"CANARY-{session}-{uuid.uuid4().hex[:8]}"
MARKERS[marker] = session
body = {"model": "glm-5.3", "max_tokens": 16,
"prompt": marker + " x" * prompt_tokens}
return requests.post("http://prefill:30000/generate", json=body, timeout=600).text
# Long request that the pool cannot hold, then a short one right behind it.
ask(700_000, "big")
out = ask(64, "small")
for marker, owner in MARKERS.items():
if marker in out and owner != "small":
raise SystemExit(f"LEAK: response for 'small' carried {owner}'s marker")三、恢复服务之前他们做了什么
时间线值得照抄:9 月 8 日 23:11(UTC)收到 HackerOne 报告;9 月 9 日 08:00 开始复现;13:22 宣布安全事件,25 分钟内用两个独立账号复现泄漏;14:00 把模型置为私有(请求返回 HTTP 400);14:35 定位到 prefill 池小于上下文长度;14:52 所有 prefill 节点滚动到修正配置;19:04 用「修正配置 + Cloudflare 自己写的引擎补丁」重建完整部署,并用研究者的复现脚本验证;19:40 恢复。两处修复都很直接:一是把池扩到约 204 万 token,大约是上下文长度的两倍,这样「能进上下文的请求一定装得进池」,拒绝路径对合法请求根本不会被走到;二是不只依赖池大小,把拒绝路径本身改安全——带待处理拒绝的请求在 disaggregation 接纳队列就被带着原始错误退掉,发生在任何 bootstrap、分配、KV 传输之前。后者他们提了上游 #38704,SGLang 方面先有 #36988(v0.5.20)、再有 #38935(v0.5.21)。
KV 池大小在启动日志里会打印,并作为指标暴露出来。要拿去跟上下文长度对比的就是这个数。
# The rejection path, before and after. The bug is not memory maths,
# it is which flag the admission queues looked at.
# BEFORE (SGLang < v0.5.20, disaggregated prefill/decode)
def set_finish_with_abort(req, err):
req.to_finish = err # error recorded here ...
req.prompt = PLACEHOLDER_1TOK # ... prompt swapped for 1 token
# req.finished() still returns False: it reads finished_reason,
# not to_finish. So both admission queues admit the request.
def admit(req):
return not req.finished() # rejected request reports itself live
# AFTER (v0.5.20 / #36988, v0.5.21 / #38935)
# 1. rejected request is retired at the disaggregation admission queue,
# with its original error, before bootstrap, allocation or KV transfer
# 2. decode verifies transferred token count == reserved page count
# 3. #38935 rejects at admission, so the handoff never starts四、真正该被记住的是测试的盲区
报告里最诚实的一句是:发布前他们只验证了长上下文下的 HTTP 状态码和引擎性能,没有验证模型输出的内容和连贯性。换句话说,他们测的是「没报错」,而不是「答对了」。这条经验对所有跑推理服务的人都成立——错误码是健康检查,不是正确性检查。他们现在的规矩是:任何 disaggregated 部署变更上线前,直接从运行中的引擎读 KV 池大小和上下文长度做对比,不再靠响应码推断。除此之外还加了一批工程加固:deferred KV release 扩展到 prefill 的源页,并且覆盖所有失败类型;给 Mooncake 传输批次设上限,避免单个超时调用留下无界的在途写;改负载均衡器,客户端断开不再中止传输中的请求;加上 --disaggregation-enable-kv-checksum 和 kv canary 这类损坏检测。
# Pre-flight gate for a disaggregated SGLang rollout.
# Four conditions; fail the deploy if any is missing.
set -euo pipefail
POOL=$(sglang_pool_tokens prefill) # from startup log or metric
CTX=$(sglang_context_length prefill)
[ "$POOL" -ge "$CTX" ] || { echo "pool $POOL < context $CTX"; exit 1; }
# deferred KV release must be on for BOTH roles
sglang_flag_on prefill deferred_release
sglang_flag_on decode deferred_release
# bounded transfer batches so one timed-out call cannot leave
# an unbounded tail of writes in flight
sglang_flag_le mooncake_transfer_batch_max 64
# corruption detection where the architecture supports it
sglang_flag_on decode kv_checksum # --disaggregation-enable-kv-checksum
echo "pre-flight ok: pool=$POOL ctx=$CTX"五、如果你也在跑 SGLang,今天就可以做这四件事
第一,量一次:在每个 prefill 和 decode 节点上,从启动日志或指标里读 KV 池大小,跟上下文长度对比;池小就把上下文调小,别指望代理层或者「引擎应该会拒绝」来兜底。第二,升级到 v0.5.22 或更新版本(包含 #36988 和 #38935),这是第一步检查出错时的兜底。第三,两个角色都开 deferred release,并限制 Mooncake 传输批次。第四,模型支持的话打开损坏检测,并去日志里确认请求真的在被校验——这两个开关目前还没覆盖所有架构。Cloudflare 同时说清了长期方向:配置检查和生命周期修复只能减少「页被误用」的路径,不能让它不可能发生。真正的答案是让引擎在读的时候就能发现「这个请求要读的页不属于它」,然后只让这一个请求失败。他们的原型是给每一页挂一个在重新分配时变化的标签,请求记录自己期望的标签,decode 读之前校验——据他们说代价在 1% 以内。
Cloudflare 在恢复模型之前,用修正后的配置加上引擎补丁重建了整个部署。
# Where this class of bug goes next: check ownership at read time.
# Cloudflare prototyped page tagging over the summer; in their tests
# the check cost under 1% of throughput.
def decode_attention(req, pages):
for page in pages:
# every physical KV page carries a tag that changes when it
# is reallocated; the request records the tags it expects
if page.tag != req.expected_tags[page.index]:
raise KvOwnershipError(
page=page.index,
expected=req.expected_tags[page.index],
found=page.tag,
) # fail THIS request, never return someone else's data
attn(page)
# Config checks and lifetime fixes shrink the set of ways a page
# can be misused. They do not make misuse impossible.📌 常见问题 FAQ
这是 SGLang 独有的问题吗?
报告说得很清楚:这个具体 bug 属于 SGLang 的拒绝路径,但「页在请求之间不清零、靠引擎追踪归属」是推理引擎的通用做法。所以任何跑 disaggregated prefill/decode 的引擎都值得问一句:超长请求被拒之后,它真的在交接之前就退掉了吗?
为什么 HTTP 200 反而更危险?
因为监控看的是状态码。这次受影响请求返回 200、prompt_tokens 对得上、文本也连贯,只比正常快一些。如果只靠错误率和延迟做告警,这种事故会完全隐形。真正有效的信号是内容级校验,比如 canary 标记测试和 KV 校验和。
我们自己部署时,怎么确认池够大?
从运行中的引擎读两个数:启动日志里打印的池 token 数(或 max_total_num_tokens 指标),以及你自己配置的上下文长度。池必须不小于上下文,并且在改权重、量化、张量并行度或显存比例之后重新检查一遍——这些改动都会改变池的大小。
升级到 v0.5.22 就够了吗?
它是引擎侧的必要条件,不是全部。Cloudflare 的做法是「配置修正 + 引擎补丁 + 部署前检查 + 损坏检测」四件一起上,因为他们认为单靠池大小很脆弱,单靠引擎补丁也不覆盖其他会导致页被误用的路径。另外要注意 checksum 和 canary 这两个开关还没覆盖所有模型架构。
155 个响应属于什么规模?
Cloudflare 说受影响的是「少量客户」,共 155 个响应,全部来自超过 681,280 token 的提示词;在排除自家测试和研究者测试之后,这些请求的时间分布和量级与普通长上下文使用一致,没有发现有人反复利用。这不是说影响可以忽略,而是说它更像一个「长上下文用户偶然撞上」的故障。