Cloudflare Workers AI 安全事件复盘:跨租户 KV 缓存泄漏应急手册
概要
学习 Cloudflare Workers AI 的 GLM-5.3 跨租户 KV 缓存泄漏事故应急手册:从故障根因、检测信号到修复步骤,掌握池大小断言、金丝雀标记与边界测试,修复 SGLang 拒绝路径缺陷,并将可复用的 C# 代际计数器和隔离检查清单应用于实际游戏后端会话池。
一个面向玩家的 AI 功能返回了包含另一家工作室提示词文本的响应。不是崩溃,不是 500 错误。而是一个连贯且切题的回答,以 HTTP 200 返回,token 数量也与调用者自身的请求一致。
这就是多租户 bug 最糟糕的形态:输出看起来合情合理,所以没有人提交工单。2026 年 8 月 27 日,Cloudflare 恰好遇到了这种情况——GLM-5.3 Workers AI 模型的 155 个响应包含了其他客户会话的数据。每个受影响的请求都超过 681,280 个 token。修复需要两个变更——一个配置、一个引擎补丁——而检测思路比修复本身更具启发性。
以下是应急手册:什么出了问题,如何在你的技术栈中检测同类故障,如何修复,以及如何防止它再次发生。
实际出了什么问题
两个独立的问题叠加在一起。单独任何一个问题都是可控的。
问题一:无人断言过的容量不匹配
Cloudflare 部署 GLM-5.3 时,上下文窗口为 1,048,576 个 token,而 prefill GPU 上的 KV 缓存池只能容纳 681,280 个 token。
这个差距很关键,因为池大小并不是配置值。SGLang 在启动时根据加载模型权重后剩余的 GPU 内存来确定它。而宣传的上下文长度是另一个配置值。系统里没有任何机制强制两者一致,也没有任何机制在它们不一致时发出警告。
上线前测试通过了。HTTP 状态码是 200,流式响应没有错误帧,吞吐量看起来正常。测试验证的是传输,而不是内容。
问题二:被拒绝的请求仍然报告自己存活
当提示词超过池容量时,SGLang 的调度器会拒绝它。拒绝路径会为客户端记录错误,并将提示词替换为一个单 token 占位符,以便请求在不分配内存的情况下流过批处理机制。在单节点上,这没有问题:占位符运行,批次完成,客户端收到错误。
在分离式 prefill/decode 模式下——prefill GPU 构建 KV 缓存并通过 RDMA 传输给生成 token 的 decode GPU——流程如下:
- 引导(Bootstrap)。 路由器告诉 decode 提示词有 8 页长。decode 保留了 8 页 KV。
- Prefill 准入。 队列接受任何
req.finished()返回 False 的请求。被拒绝的请求将其拒绝状态存储在to_finish中,而不是finished_reason,因此finished()仍然返回 False。被拒绝的请求被准入。 - Prefill 前向传播。 提示词已经被替换为单 token 占位符。prefill 对该 token 执行了一次平凡的前向传播。
- KV 传输。 prefill 向 decode 发送了一页 KV,并发出“传输完成”信号。
- Decode 准入。 decode 的队列使用了相同的
finished()检查,并接受了交接。 - Decode 注意力。 decode 在关注所有 8 个保留页的同时生成 token。只有第 0 页由该请求写入。第 1 到 7 页仍然保留着其他租户的提示词和部分生成内容。
- 响应。 客户端收到 HTTP 200、匹配的
prompt_tokens数量,以及由陈旧缓存页拼凑而成的连贯答案。
每个超过 681,280 token 阈值的请求都这样表现。该 bug 已于 2026 年 7 月 6 日在上游提交为 issue #30233,而受影响的部署尚未包含该修复。
在你的玩家发现之前检测出来
你无法用状态码来测试隔离性。你需要三个信号。
信号 1:从运行中的引擎读取池大小与上下文长度
在启动时和 CI 中检查这一点,而不是从配置文件读取。池是从剩余内存推导出来的,因此量化变化、张量并行变化或 CUDA graph 大小调整都会悄无声息地改变它。
# deploy_gate.py - run before promoting any disaggregated inference config
import json, os, sys, urllib.request
CONTEXT_LEN = 1_048_576 # the model's advertised window
HEADROOM = 1.25 # pool must exceed context, not merely match it
def server_info(base_url: str) -> dict:
with urllib.request.urlopen(f"{base_url}/get_server_info", timeout=10) as r:
return json.load(r)
def kv_pool_tokens(info: dict) -> int:
# SGLang reports the pool it sized at startup; field names vary by version
for key in ("max_total_num_tokens", "kv_pool_tokens", "max_total_tokens"):
if key in info:
return int(info[key])
raise RuntimeError("KV pool size not exposed by this build - fail closed")
def main(prefill_url: str) -> None:
pool = kv_pool_tokens(server_info(prefill_url))
required = int(CONTEXT_LEN * HEADROOM)
print(f"kv_pool={pool} required={required} context={CONTEXT_LEN}")
if pool < required:
print("FAIL: pool smaller than context window; over-length prompts reach the rejection path")
sys.exit(1)
print("OK: pool covers the full context window")
if __name__ == "__main__":
main(os.environ.get("PREFILL_URL", "http://localhost:30000"))
在负载均衡器接收流量之前,对每个 prefill 节点运行它。非零退出码会阻止部署。
信号 2:KV 缓存中的金丝雀标记
在一个长提示词中植入唯一的哨兵字符串,让请求完成,然后发送一个绝不应包含该哨兵的第二个长提示词。检查返回的文本,而不是状态码。
import uuid, requests
def canary_check(base_url: str, token: str, long_prompt_tokens: int) -> None:
sentinel = f"CANARY-{uuid.uuid4()}"
filler = "lorem ipsum " * (long_prompt_tokens // 2)
requests.post(base_url, json={
"prompt": f"{sentinel} {filler}",
"max_tokens": 16,
}, headers={"Authorization": f"Bearer {token}"}, timeout=300)
# A fresh, unrelated request must never surface the previous sentinel
r = requests.post(base_url, json={
"prompt": f"Summarize this: {filler}",
"max_tokens": 16,
}, headers={"Authorization": f"Bearer {token}"}, timeout=300)
assert sentinel not in r.text, f"KV cache leak: {sentinel} surfaced in a fresh response"
针对你打算接受的最长提示词运行此测试,分别在精确边界和边界 + 1 token 处运行。边界用例正是拒绝路径触发的地方。
信号 3:好得不正常的延迟
完整长度的 prefill 需要可测量的时间。如果一个 8 页提示词在 1 页提示词应有的时间内返回,那你正在读取别人的缓存。按提示词长度记录 prefill 耗时,并对长上下文桶的 p5 发出告警。异常快速的长提示词是泄漏特征,而不是胜利。
按顺序执行的修复
1. 让池容纳完整上下文
Cloudflare 将 GLM-5.3 的配置改为与 GLM-5.2 一致,释放了足够的 GPU 内存,使 prefill 池现在大约能容纳 204 万个 token——约为 1,048,576 token 上下文的两倍。现在,适合上下文窗口的提示词总是适合池,因此有效请求永远不会进入拒绝路径。
2. 无论如何让拒绝路径保持安全
仅靠池大小是脆弱的。任何对量化、张量并行、CUDA graph 大小或模型版本的改动都会移动池,而且很容易被忽略。
引擎补丁会在分离式准入队列中,在任何引导、分配或 KV 传输发生之前,以原始错误终止带有待处理拒绝状态的请求。decode 节点还会验证传输的 token 数量与它保留的数量一致。该补丁作为 PR #38704 合入上游,后续有 #38935,并随 SGLang v0.5.20 到 v0.5.22 发布。
如果你在任何地方运行 SGLang,请锁定 v0.5.22 或更高版本。这不是一个“排到下个 sprint 再说”的升级。
3. 用复现脚本验证,而不是靠感觉
在恢复模型之前,Cloudflare 使用研究者的复现脚本和自己的金丝雀标记测试验证了这两项变更。两个独立的测试,一个对抗性,一个内部。这是你重新启用模型或整个集群之前应该达到的正确标准。
为什么“返回 200”不是测试
测试失败的根源很狭窄,而且完全普通:团队验证了长上下文长度下的 HTTP 响应码和引擎性能,却没有验证模型输出和连贯性。基准测试通过,是因为基准测试问的是“它响应了吗?”而不是“它响应得正确吗?”
对于托管 AI 功能,正确性检查成本很低。对于你自己的游戏后端,对应的鸿沟通常更大。这里有一条可迁移的规则:任何共享资源池都需要一个断言,将池大小与系统将接受的最大请求大小绑定,并且该断言必须针对运行中的进程执行,而不是针对配置文件。
同类 Bug 也存在于游戏后端
把“KV 缓存页”替换成“会话槽位”,这个事故几乎可以逐行复现。
你分配一个固定的匹配槽位、玩家状态槽位或 actor 缓冲区池。一个请求保留 8 个槽位。准入检查测试的是 state != Finished 而不是 state == Valid,因此被拒绝的保留还是被准入。槽位被移交给下一个大厅时,只有槽位 0 被初始化。槽位 1 到 7 仍然保留着上一场比赛的玩家 ID、库存和位置数据。
这正是我们在设计能够抵御入侵的游戏后端中讨论过的同一类失败模式:泄漏不需要复杂的攻击,只需要一个代码并没有真正执行的归属模型。
下面是有问题的模式和修复方案,使用 C#:
public sealed class SessionSlotPool
{
private readonly SessionSlot[] _slots;
private readonly Queue<int> _free = new();
// BUG: release without invalidation leaves stale state for the next tenant
public void ReleaseUnsafe(int[] indices)
{
foreach (var i in indices) _free.Enqueue(i);
}
// FIX: invalidate on release, stamp a generation, verify on read
public void Release(int[] indices)
{
foreach (var i in indices)
{
_slots[i].Reset(); // zero player list, inventory, match id
_slots[i].OwnerId = null;
_slots[i].Generation++; // stale handles now fail the check
_free.Enqueue(i);
}
}
public bool TryRead(int index, long expectedGeneration, out SessionSlot slot)
{
slot = _slots[index];
return slot.Generation == expectedGeneration && slot.OwnerId != null;
}
}
代际计数器是成本最低的部分。它把“我们希望槽位已经被覆盖”变成一种硬性的、可测试的拒绝。任何针对陈旧句柄的读取都会失败,而不是静默返回另一个玩家的数据。
隔离检查清单
- 在启动时从运行中的进程断言池大小与上下文长度。 不是从配置读取。非零退出码阻止部署。
- 将“已拒绝”视为与“已完成”不同的终态。 在准入时、任何分配或交接之前中止,并返回原始错误。
- 验证双方的传输计数。 如果 decode 保留了 8 页,它必须确认 8 页已到达。一页传输却报告“完成”正是那个 bug。
- 用内容断言测试失败路径。 金丝雀标记、哨兵字符串、边界加一提示词。检查响应体,永远不要检查状态码。
- 每次容量变更时重新运行这些门禁。 量化、张量并行、batch size 和模型版本都会移动池。将每一项都视为与隔离相关的变更。
第 5 项正是团队经常跳过的。每次容量变更同时也是安全变更,因为池是从剩余内存推导出来的,而不是声明的。同样的零和思维适用于任何你手动调整大小的资源池——在零浪费服务器休眠分析中提到的权衡计算,就是同样的算术。
这需要付出什么代价
隔离不是免费的,而且权衡是诚实的。
将 KV 池从 681,280 扩大到大约 204 万个 token,会消耗原本可以容纳更多并发请求的 GPU 内存。并发请求减少意味着每个生成 token 的成本更高。在 prefill 集群中,这种余量是你无法出售的容量。
有一种更便宜的选择:保留小池,并在请求到达调度器之前,在边缘以显式的 HTTP 400 拒绝超长提示词。这保持了吞吐量,但会让合法的长上下文请求失败——对于 agentic 编码模型来说,失败的是大部分产品功能。
Cloudflare 选择了以容量为代价的正确性。对于面向玩家的功能,这几乎总是正确的选择。而另一种选择——快速、廉价、看似合理的响应却包含另一个玩家的数据——不是你在事后复盘时能够辩护的权衡。
隔离负担真正落在哪里
每个多租户系统都要做出这个决定:哪些共享资源可以池化复用,以及谁负责失效。对于 GPU KV 页,答案是“推理引擎,而且很糟糕,直到 v0.5.22 才改善。”
对于游戏中的玩家状态,答案通常是“你的数据库,责任在你”。如果你完全不想自己维护池语义,账号绑定的状态服务可以转移攻击面:horizOn 提供账号绑定的云存档,支持带版本感知的读写,由客户端解决冲突,而不是由你的服务器猜测哪个槽位是陈旧的。
这并不能消除隔离工作——没有任何东西能消除。它只是把隔离工作缩小到你真正控制的边界。
本周要做的事
审计你拥有的每一个共享池,并问三个问题。池大小是否可证明地覆盖系统接受的最大请求?被拒绝的请求是否在任何分配之前终止?接收端是否验证了到达的内容与保留的内容一致?
如果任何答案是“我们假设是这样”,那么你就有同样的 bug 在等待一个足够长的请求。从内容若出现在其他玩家响应中会造成最大破坏的那个池开始。
如果你准备好不再为玩家状态维护那个池,可以免费试用 horizOn——包括身份验证、账号绑定云存档、排行榜、远程配置和崩溃报告,而无需从头搭建数据库和会话模型。API 文档大约十分钟就能让你了解集成表面。