玩具模型复现:被拒绝的超长请求进入 decode 导致跨请求泄露

按 Cloudflare 复盘描述的机制,用纯 Python 模拟 KV 池小于上下文长度、入口只检查 finished 时的泄露,并对比引擎修复、扩大 KV 池和 canary 检测三种手段的效果。

已验证 Python 3.12.15 耗时 0.1s 运行于 2026-10-28 22:07 UTC
结论

玩具模型中泄露只发生在「池子小于上下文 + 引擎只查 finished」的组合,状态码测试全过而 canary 测试全部抓到

检查 7/7 通过
  • 池子小于上下文长度且引擎只查 finished 时,会出现返回 200 但带着别人数据的响应
  • 泄露的响应全部是 HTTP 200,只看状态码发现不了
  • 只看状态码的测试全部通过(这正是问题所在)
  • canary 测试能找出每一条泄露的响应
  • 引擎在入口按 rejected 拦截后,不再泄露
  • 池子不小于上下文长度时,拒绝路径根本走不到
  • 启动时检查「KV 池容量 ≥ 上下文长度」能提前发现小池子配置
实验指标(个)
泄露响应数(小池子 + 原始引擎)27个
泄露响应数(小池子 + 引擎修复)0个
泄露响应数(大池子 + 原始引擎)0个
泄露响应数(大池子 + 引擎修复)0个
只看状态码的测试报警数0个
canary 测试报警数27个

数值取自 GitHub Actions 实际运行输出中的 METRIC 行

以上内容从实际运行输出中提取,完整输出见下方。

python main.py
请求数 400,其中长度超过小池子但未超过上下文长度的 27 个
METRIC: 泄露响应数(小池子 + 原始引擎) = 27 个
METRIC: 泄露响应数(小池子 + 引擎修复) = 0 个
METRIC: 泄露响应数(大池子 + 原始引擎) = 0 个
METRIC: 泄露响应数(大池子 + 引擎修复) = 0 个
METRIC: 只看状态码的测试报警数 = 0 个
METRIC: canary 测试报警数 = 27 个
[PASS] 池子小于上下文长度且引擎只查 finished 时,会出现返回 200 但带着别人数据的响应
[PASS] 泄露的响应全部是 HTTP 200,只看状态码发现不了
[PASS] 只看状态码的测试全部通过(这正是问题所在)
[PASS] canary 测试能找出每一条泄露的响应
[PASS] 引擎在入口按 rejected 拦截后,不再泄露
[PASS] 池子不小于上下文长度时,拒绝路径根本走不到
[PASS] 启动时检查「KV 池容量 ≥ 上下文长度」能提前发现小池子配置
RESULT: 玩具模型中泄露只发生在「池子小于上下文 + 引擎只查 finished」的组合,状态码测试全过而 canary 测试全部抓到

目的

Cloudflare 在 GLM-5.3 KV 缓存泄露复盘 中写到两个叠加的原因:

  1. 模型对外声明的上下文长度大于 prefill 节点 KV 池的容量;
  2. SGLang 在 prefill/decode 分离模式下,把放不下的请求标记为 rejected 并换成 1 个 token 的占位,但入口队列只检查请求是否 finished,于是请求被放行到 decode,decode 读到了池子里其他请求残留的数据,客户端还收到 HTTP 200。

本实验用纯 Python 写一个玩具模型(不是 SGLang 代码)复现这个机制,并对比:

  • 引擎修复:入口按 rejected 拦截,不做 KV 传输;
  • 配置修复:KV 池容量不小于上下文长度(复盘里扩到约两倍);
  • 检测:只看状态码的测试 vs 在每个请求里放 canary 标记的测试。

步骤

python main.py

程序会打印每种配置下的泄露响应数(METRIC:)、7 项 [PASS]/[FAIL] 检查和最后一行 RESULT:;任何检查失败时以非零状态退出。

预期结果

  • 只有「小池子 + 原始引擎」会出现泄露,泄露的响应全部是 HTTP 200
  • 只看状态码的测试全部通过,canary 测试能找出每一条泄露
  • 任意一种修复(引擎拦截或扩大 KV 池)都能让泄露归零

启示与可以继续尝试

  • 自部署推理服务时,在启动日志里核对「KV 池容量 ≥ 声明的上下文长度」,每次改权重、量化、并行度或显存参数后都要重新核对
  • 长上下文测试不要只看 HTTP 状态码,要检查输出内容;可以在测试请求里放唯一标记,看是否串到别的响应里
  • 可以调大 N_REQUESTS 或改变长请求比例,观察泄露数量如何变化

内网复现

  • 只用 Python 标准库,不需要安装任何依赖,requirements.txt 仅作说明
  • 程序完全离线运行,不访问网络,也不需要 API key 或 GPU
  • 如需对照真实修复,SGLang 的相关 PR 和 v0.5.22 发布说明需要外网或内网 GitHub 镜像查看

代码

main.py141 行
"""
离线玩具模型:复现「被拒绝的超长请求仍进入 decode」这一类 KV 缓存跨请求泄露,
并对比三种修复:引擎在入口按 rejected 拦截、把 KV 池扩到不小于上下文长度、用 canary 检测输出。

这不是 SGLang 的代码,只是按 Cloudflare 事故复盘中描述的机制写的最小模拟。
完全离线、只用标准库。
"""
import random
import sys

CONTEXT_LEN = 1024          # 对外声明的上下文长度(按比例缩小)
POOL_SMALL = 665            # 约为上下文长度的 0.65 倍,对应复盘里 681,280 / 1,048,576
POOL_LARGE = 2 * CONTEXT_LEN  # 复盘里修复后的做法:池子约为上下文长度的两倍
N_REQUESTS = 400
SEED = 20261029


class KVPool:
    """按 token 槽位记录「这个槽位里是谁的数据」。释放时不清零,模拟页面被复用前仍留着旧数据。"""

    def __init__(self, size):
        self.size = size
        self.owner = [None] * size
        self.cursor = 0

    def alloc(self, n):
        # 环形分配:从 cursor 开始取 n 个槽位
        slots = [(self.cursor + i) % self.size for i in range(n)]
        self.cursor = (self.cursor + n) % self.size
        return slots

    def write(self, slots, rid):
        for s in slots:
            self.owner[s] = rid


def run(pool_size, engine_fix, requests):
    """返回每个请求的 (http_status, 读到的数据归属集合)。"""
    pool = KVPool(pool_size)
    results = {}
    for rid, length in requests:
        if length > CONTEXT_LEN:
            results[rid] = (400, set())          # 网关按声明的上下文长度拒绝
            continue
        rejected = length > pool.size             # prefill 节点放不下
        if rejected:
            if engine_fix:
                # 修复:在入口按 rejected 直接返回错误,不做任何 KV 传输
                results[rid] = (400, set())
                continue
            # 缺陷路径:换成 1 个 token 的占位,标记 finished=True、rejected=True
            placeholder = pool.alloc(1)
            pool.write(placeholder, rid)
            finished = True
            # 入口只检查 finished,于是请求被放行到 decode;
            # decode 按原始长度读取页面,读到的是池子里残留的旧数据
            if finished:
                slots = [(placeholder[0] + i) % pool.size for i in range(min(length, pool.size))]
                seen = {pool.owner[s] for s in slots if pool.owner[s] is not None}
                results[rid] = (200, seen)
            continue
        slots = pool.alloc(length)
        pool.write(slots, rid)
        seen = {pool.owner[s] for s in slots}
        results[rid] = (200, seen)
    return results


def leaked(results):
    return sorted(rid for rid, (status, seen) in results.items() if status == 200 and seen - {rid})


def status_only_test(results):
    """只看状态码的测试:所有可服务长度的请求都返回 200 就算通过。"""
    return [rid for rid, (status, _) in results.items() if status != 200 and status != 400]


def canary_test(results):
    """canary 测试:每个请求写入自己的标记,输出里出现别人的标记就报警。"""
    return [rid for rid, (status, seen) in results.items() if status == 200 and seen - {rid}]


def main():
    rng = random.Random(SEED)
    # 大部分请求较短,少量接近上下文上限,模拟「看起来像正常长上下文使用」
    requests = []
    for rid in range(N_REQUESTS):
        if rng.random() < 0.1:
            length = rng.randint(600, CONTEXT_LEN)
        else:
            length = rng.randint(20, 300)
        requests.append((rid, length))
    over_pool = sum(1 for _, l in requests if POOL_SMALL < l <= CONTEXT_LEN)

    configs = [
        ("小池子 + 原始引擎", POOL_SMALL, False),
        ("小池子 + 引擎修复", POOL_SMALL, True),
        ("大池子 + 原始引擎", POOL_LARGE, False),
        ("大池子 + 引擎修复", POOL_LARGE, True),
    ]
    ok = True
    print(f"请求数 {N_REQUESTS},其中长度超过小池子但未超过上下文长度的 {over_pool} 个")
    leak_counts = {}
    for name, size, fix in configs:
        res = run(size, fix, requests)
        lk = leaked(res)
        leak_counts[name] = len(lk)
        print(f"METRIC: 泄露响应数({name}) = {len(lk)} 个")

    base = run(POOL_SMALL, False, requests)
    base_leaks = leaked(base)
    status_alerts = status_only_test(base)
    canary_alerts = canary_test(base)
    print(f"METRIC: 只看状态码的测试报警数 = {len(status_alerts)} 个")
    print(f"METRIC: canary 测试报警数 = {len(canary_alerts)} 个")

    def check(cond, msg):
        nonlocal ok
        print(("[PASS] " if cond else "[FAIL] ") + msg)
        ok = ok and cond

    check(over_pool > 0 and leak_counts["小池子 + 原始引擎"] > 0,
          "池子小于上下文长度且引擎只查 finished 时,会出现返回 200 但带着别人数据的响应")
    check(all(base[r][0] == 200 for r in base_leaks),
          "泄露的响应全部是 HTTP 200,只看状态码发现不了")
    check(len(status_alerts) == 0, "只看状态码的测试全部通过(这正是问题所在)")
    check(sorted(canary_alerts) == base_leaks, "canary 测试能找出每一条泄露的响应")
    check(leak_counts["小池子 + 引擎修复"] == 0, "引擎在入口按 rejected 拦截后,不再泄露")
    check(leak_counts["大池子 + 原始引擎"] == 0, "池子不小于上下文长度时,拒绝路径根本走不到")
    check(POOL_LARGE >= CONTEXT_LEN and POOL_SMALL < CONTEXT_LEN,
          "启动时检查「KV 池容量 ≥ 上下文长度」能提前发现小池子配置")

    if ok:
        print("RESULT: 玩具模型中泄露只发生在「池子小于上下文 + 引擎只查 finished」的组合,状态码测试全过而 canary 测试全部抓到")
        return 0
    print("RESULT: 有检查未通过,见上方 [FAIL]")
    return 1


if __name__ == "__main__":
    sys.exit(main())
requirements.txt1 行
# 仅使用 Python 标准库,无需安装依赖
Esc
↑ ↓ 选择Enter 打开

键盘快捷键

j / k
下一条 / 上一条
o
展开或收起当前条目
Enter
打开当前条目详情
c
复制当前条目链接
s
收藏 / 取消收藏当前条目
/ 或 ⌘K
搜索
1 2 3
切换密度:标题 / 摘要 / 完整
t
切换主题
?
显示本帮助