<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>事故复盘 on AI博士 万戈</title>
        <link>https://www.yesmiracle.net/tags/%E4%BA%8B%E6%95%85%E5%A4%8D%E7%9B%98/</link>
        <description>AI博士万戈的技术博客，聚焦 Agentic AI、AI Infra 与 Agent Security，分享 AI 基础设施与工程落地实践。</description>
        <generator>Hugo -- gohugo.io</generator>
        <language>zh-cn</language>
        <managingEditor>admin@yesmiracle.net (万戈)</managingEditor>
        <webMaster>admin@yesmiracle.net (万戈)</webMaster>
        <lastBuildDate>Wed, 28 Oct 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://www.yesmiracle.net/tags/%E4%BA%8B%E6%95%85%E5%A4%8D%E7%9B%98/index.xml" rel="self" type="application/rss+xml" /><item>
        <title>Cloudflare 的 GLM-5.3 把别的用户的对话吐了出来：都是推理系统缓存配置惹的祸</title>
        <link>https://www.yesmiracle.net/post/20261028-cloudflare-glm53-kv-cache-cross-tenant-leak/</link>
        <pubDate>Wed, 28 Oct 2026 00:00:00 +0000</pubDate>
        <author>admin@yesmiracle.net (万戈)</author>
        <guid>https://www.yesmiracle.net/post/20261028-cloudflare-glm53-kv-cache-cross-tenant-leak/</guid>
        <description>&lt;img src="https://www.yesmiracle.net/post/20261028-cloudflare-glm53-kv-cache-cross-tenant-leak/cover.svg" alt="Featured image of post Cloudflare 的 GLM-5.3 把别的用户的对话吐了出来：都是推理系统缓存配置惹的祸" /&gt;&lt;p&gt;你在 Cloudflare 的 Workers AI 上调一个长上下文请求，模型返回的回复里，夹着别人十几分钟前的对话内容。文档编号、代码片段、甚至半截没写完的句子，都不是你的。&lt;/p&gt;
&lt;p&gt;这不是科幻设定。2026 年 8 月 27 日到 9 月 9 日之间，Cloudflare 托管在 Workers AI 上的 GLM-5.3 出现了跨租户泄漏，一共 &lt;strong&gt;155 条响应&lt;/strong&gt;受影响，全部来自超过 &lt;strong&gt;681,280 token&lt;/strong&gt; 的超长提示。9 月 8 日深夜由安全研究员 Gadi Evron（Knostic）经 HackerOne 报入，Cloudflare 次日复现、下架、修配置、打引擎补丁，当天恢复。10 月 26 日，Cloudflare 和 SGLang 团队做了协同披露。&lt;/p&gt;
&lt;p&gt;我把它这份复盘通读了一遍，还顺手把引用的上游 PR 逐个查了合入状态。这篇不讲「AI 又出安全事故了」，讲一件更具体的事：一个把推理成本压到极致的共享缓存设计，是怎么在配置和状态判断两个小裂缝上漏出别人数据的。&lt;/p&gt;
&lt;h2 id=&#34;155-条全部超过-68-万-token&#34;&gt;155 条，全部超过 68 万 token&lt;/h2&gt;
&lt;p&gt;先把事件本身说清楚，因为它的边界条件非常干净。&lt;/p&gt;
&lt;p&gt;泄漏只在提示长度超过 KV 缓存池容量时发生。Cloudflare 给 GLM-5.3 配的上下文窗口是 1,048,576 token，但他们预填充（prefill）GPU 上的 KV 缓存池只装得下 681,280 token。凡是超过 681,280 的提示，就会走进出问题的那条路径。&lt;/p&gt;
&lt;p&gt;受影响时间这样算：8 月 27 日模型上线那天起就存在。这个洞从上线第一天就在，只是没人往里看。&lt;/p&gt;
&lt;p&gt;Cloudflare 的说法是：排除掉他们自己的测试和研究员的复现请求之后，这些请求在时间和数量上都符合普通的长上下文使用，没有任何反复利用的痕迹，未发现蓄意利用证据。这一点重要。它说明这个洞是被正常用户无意间撞出来的，而不是被谁盯上了。真正让人后背发凉的地方就在这里。&lt;/p&gt;
&lt;p&gt;还有一条：只有 GLM-5.3 受影响。Workers AI 上其它分离式部署的 KV 池都大于其上下文长度，没有请求能走到那条拒绝路径。&lt;/p&gt;
&lt;h2 id=&#34;它到底是怎么漏的一条七步的失败链&#34;&gt;它到底是怎么漏的：一条七步的失败链&lt;/h2&gt;
&lt;p&gt;官方的复盘写得很好，但铺开在几十段里。我压成一张表，看这张表基本就懂了：&lt;/p&gt;
&lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th&gt;步&lt;/th&gt;
          &lt;th&gt;发生了什么&lt;/th&gt;
          &lt;th&gt;错在哪&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td&gt;1 引导&lt;/td&gt;
          &lt;td&gt;路由器告诉解码节点提示长度是 8 页，解码节点预留 8 页 KV&lt;/td&gt;
          &lt;td&gt;正常&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;2 预填充准入&lt;/td&gt;
          &lt;td&gt;准入队列只检查 &lt;code&gt;req.finished()&lt;/code&gt;。被拒绝的请求把错误记在 &lt;code&gt;to_finish&lt;/code&gt;，&lt;code&gt;finished()&lt;/code&gt; 仍返回 False，于是被放行&lt;/td&gt;
          &lt;td&gt;拿「是否完成」当「是否被拒」用&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;3 预填充前向&lt;/td&gt;
          &lt;td&gt;提示早被换成单 token 占位符，跑了一次几乎为空的前向&lt;/td&gt;
          &lt;td&gt;拒绝没有真的终止请求&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;4 KV 传输&lt;/td&gt;
          &lt;td&gt;预填充只发了 1 页 KV，并报了一条「传输完成」&lt;/td&gt;
          &lt;td&gt;收发两端对「完成」的理解不一致&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;5 解码准入&lt;/td&gt;
          &lt;td&gt;解码的准入队列同样只看 &lt;code&gt;finished()&lt;/code&gt;，接受了这次移交&lt;/td&gt;
          &lt;td&gt;与第 2 步同因&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;6 解码注意力&lt;/td&gt;
          &lt;td&gt;对预留的 8 页做注意力。只有 P0 是本次请求写的，P1 到 P7 还留着上几个用户的提示和半截生成&lt;/td&gt;
          &lt;td&gt;页不擦除，且没有所有权校验&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;7 响应&lt;/td&gt;
          &lt;td&gt;客户端拿到 HTTP 200 而不是 400；&lt;code&gt;prompt_tokens&lt;/code&gt; 对得上、回复连贯、还比正常 8 页预填充更快&lt;/td&gt;
          &lt;td&gt;错误被伪装成成功&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;第 7 步那个「更快」是个很妙的信号。正常的 8 页预填充要花时间，而这次只写了一页，所以快。这个 bug 留下了可观测的痕迹，只是当时没人去看。&lt;/p&gt;
&lt;h2 id=&#34;两个-bug-叠在一起缺一个都不会漏&#34;&gt;两个 bug 叠在一起，缺一个都不会漏&lt;/h2&gt;
&lt;p&gt;复盘里有一句我认为是全篇的钥匙：这是两个重叠问题的结果。&lt;/p&gt;
&lt;p&gt;第一个是配置错配。他们上线时就&lt;strong&gt;知道&lt;/strong&gt;池子装不下上下文，但没当回事。理由是长上下文测试返回了成功的 HTTP 状态码，流式 body 里也没有错误帧，于是他们推断引擎会用流式或别的方式优雅处理这个差值。这里有一段我想原样引出来，因为它是一个极其典型的测试盲区：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;我们只验证了长上下文下的 HTTP 响应码和引擎性能，没有验证模型输出和连贯性。&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;换句话说，他们测的是请求有没有成功，而不是回答是不是对的。性能指标全绿，内容里却在漏别人的数据。&lt;/p&gt;
&lt;p&gt;第二个是 SGLang 的状态判断 bug。引擎在单机模式下处理超长提示是对的：拒绝、替换成占位符、跑完批次、给客户端报错。到了分离式 prefill/decode 模式（Cloudflare 的常规部署形态）就崩了，因为两边的准入队列都在问「这个请求完成了吗」，而没人问「这个请求被拒绝了吗」。&lt;/p&gt;
&lt;h2 id=&#34;为什么页里会有别人的数据&#34;&gt;为什么页里会有别人的数据&lt;/h2&gt;
&lt;p&gt;要理解 P1 到 P7 里为什么会有别人的内容，得先接受一个反直觉的事实：推理引擎通常不擦除 KV 缓存页。&lt;/p&gt;
&lt;p&gt;Cloudflare 在背景里写得很直白。GPU 里的 KV 缓存是一池固定大小的页，每个请求被接纳时分到页，结束时归还。页在请求之间不做清零，下一个拿到它的请求直接覆写。这个做法在推理引擎里很普遍，也是拿到最好性能所必需的。代价是引擎自己必须负责追踪这些内存页的归属和生命周期。&lt;/p&gt;
&lt;p&gt;而归属追踪在这里不是一件轻松事，有几种同时成立的所有权：同一节点内，token 生成、卸载到主机内存、KV 传输接收方、以及中止与错误路径，可能同时持有同一页；跨节点上，远端预填充节点对同一页还握着一个可变的引用。任何一处提前归还，新请求就会拿到一个正在被读写的页。&lt;/p&gt;
&lt;p&gt;还有一个值得所有做推理的人抄进 checklist 的细节：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;这个池的大小不是一个配置值。SGLang 在启动时按加载完权重后剩余的显存自己算出来。模型对外宣称的上下文长度是另一个独立的配置值。没有任何机制强制两者一致，检查它们是我们自己的责任。&lt;/p&gt;&lt;/blockquote&gt;
&lt;p&gt;池子大小会随量化方式、张量并行度、CUDA graph 尺寸、甚至换一个模型版本而变，而且很容易被漏掉。Cloudflare 的原话是：光靠池子够大是脆弱的。&lt;/p&gt;
&lt;h2 id=&#34;这类-bug-是一族不是一例&#34;&gt;这类 bug 是一族，不是一例&lt;/h2&gt;
&lt;p&gt;Cloudflare 把这类 KV 缓存内存安全 bug 归成两种常见成因，理解这两类，比记住这一次的细节更有用。&lt;/p&gt;
&lt;p&gt;第一类是单节点内的页生命周期。解码节点上可能有多个任务同时持有同一页：token 生成、卸载到主机内存、KV 传输接收方、以及中止与错误路径。任何一个在另一个还在读写时归还了页，新请求就会被分到这一页。他们之前修的 &lt;code&gt;#24226&lt;/code&gt; 和 &lt;code&gt;#30899&lt;/code&gt;（解码卸载要先等卸载完成再回收请求）以及 &lt;code&gt;#30937&lt;/code&gt;（语法错误路径上的双重释放）都属于这一类。&lt;/p&gt;
&lt;p&gt;第二类是跨节点的竞争。请求在预填充还在通过 RDMA 往解码写 KV 时被中止，已经发出的写无法召回。解码如果立刻释放这些页，页会被分给另一个请求，迟到的写就落进了别人的 KV 缓存。他们为此提过 &lt;code&gt;#31357&lt;/code&gt;、&lt;code&gt;#38961&lt;/code&gt;，还附了一个复现脚本 &lt;code&gt;#32564&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这两类的结果都一样：输出被污染，或者一个用户的数据出现在另一个用户的响应里，而且不报错。本次事故的形态不同，被拒绝的请求每一次都会走完整个移交流程，不靠时序运气，但结果一样。&lt;/p&gt;
&lt;h2 id=&#34;修复分两层先把洞堵上再把路基改硬&#34;&gt;修复分两层：先把洞堵上，再把路基改硬&lt;/h2&gt;
&lt;p&gt;配置层的做法是把 GLM-5.3 的配置调成和 GLM-5.2 一致，腾出显存后预填充池现在能装约 204 万 token，约为 1,048,576 上下文的两倍。能放进上下文的提示就一定放得进池子，拒绝路径对合法请求不再可达。单这一步就足以让这个 bug 不被触发。&lt;/p&gt;
&lt;p&gt;引擎层是 Cloudflare 不接受「靠池子够大」当答案，因为那是脆弱的。他们给引擎打了补丁，让带拒绝标记的请求在分离式准入队列就被退场、带着原本的错误返回，早于任何引导、分配或 KV 传输发生。同时解码端要校验实际传输的 token 数与它预留的数量是否一致。这份草案提了上游 &lt;code&gt;#38704&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;我把这些 PR 逐个查了一遍合入状态，结果如下：&lt;/p&gt;
&lt;table&gt;
  &lt;thead&gt;
      &lt;tr&gt;
          &lt;th&gt;类别&lt;/th&gt;
          &lt;th&gt;PR&lt;/th&gt;
          &lt;th&gt;版本&lt;/th&gt;
          &lt;th&gt;作用&lt;/th&gt;
          &lt;th&gt;我查到的状态&lt;/th&gt;
      &lt;/tr&gt;
  &lt;/thead&gt;
  &lt;tbody&gt;
      &lt;tr&gt;
          &lt;td&gt;事故&lt;/td&gt;
          &lt;td&gt;#36988&lt;/td&gt;
          &lt;td&gt;v0.5.20&lt;/td&gt;
          &lt;td&gt;让被中止的预填充结果在到达解码前退场，本次泄漏就此止住&lt;/td&gt;
          &lt;td&gt;已合入&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;事故&lt;/td&gt;
          &lt;td&gt;#38935&lt;/td&gt;
          &lt;td&gt;v0.5.21&lt;/td&gt;
          &lt;td&gt;在准入阶段就拒绝，早于引导、分配与传输，采用了 Cloudflare 草案的思路&lt;/td&gt;
          &lt;td&gt;已合入&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;竞争&lt;/td&gt;
          &lt;td&gt;#40645 / #41402&lt;/td&gt;
          &lt;td&gt;v0.5.21&lt;/td&gt;
          &lt;td&gt;drain 确认丢失时不再干等超时，按确认释放&lt;/td&gt;
          &lt;td&gt;已合入&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;竞争&lt;/td&gt;
          &lt;td&gt;#41404&lt;/td&gt;
          &lt;td&gt;v0.5.21&lt;/td&gt;
          &lt;td&gt;任何传输失败都保留页，不再只处理解码端发起的中止&lt;/td&gt;
          &lt;td&gt;已合入&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;草案&lt;/td&gt;
          &lt;td&gt;#38704&lt;/td&gt;
          &lt;td&gt;—&lt;/td&gt;
          &lt;td&gt;Cloudflare 自己那份「解码端校验 token 数」的补丁&lt;/td&gt;
          &lt;td&gt;已关闭、未合入，被 #38935 取代&lt;/td&gt;
      &lt;/tr&gt;
      &lt;tr&gt;
          &lt;td&gt;观测&lt;/td&gt;
          &lt;td&gt;#39500&lt;/td&gt;
          &lt;td&gt;v0.5.21&lt;/td&gt;
          &lt;td&gt;&lt;code&gt;--disaggregation-enable-kv-checksum&lt;/code&gt;：解码生成前校验 KV 校验和&lt;/td&gt;
          &lt;td&gt;已合入&lt;/td&gt;
      &lt;/tr&gt;
  &lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;有个细节值得单拎出来。这个 bug 在 7 月 6 日就被另一个用户作为 SGLang issue &lt;code&gt;#30233&lt;/code&gt; 报过，Cloudflare 是在查这次事故时才注意到的。而 &lt;code&gt;#36988&lt;/code&gt; 早在 9 月 6 日就已合入 SGLang &lt;code&gt;main&lt;/code&gt;，只是当时还没发版。也就是说，修复比泄漏的公开披露早了两个月，但没人把它发出去。&lt;/p&gt;
&lt;h2 id=&#34;给所有跑推理的人的五条作业&#34;&gt;给所有跑推理的人的五条作业&lt;/h2&gt;
&lt;p&gt;Cloudflare 列了五条建议，我认为这是全文最有复制价值的部分，逐条抄下来：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;量你的 KV 池，和上下文长度对一遍。在每一个预填充和解码节点上、在引擎的启动日志里读，并且在每次改动权重、量化、并行度或显存比例之后重测。池子小就把上下文长度降下来。不要指望代理层去拦，也不要假设引擎会拒绝放不下的东西。这就是他们没强制的那条检查。&lt;/li&gt;
&lt;li&gt;升级到包含 &lt;code&gt;#36988&lt;/code&gt; 和 &lt;code&gt;#38935&lt;/code&gt; 的版本（v0.5.22 或更高），这样超长请求在移交前就被拒，与池子大小无关。这是引擎侧的兜底。&lt;/li&gt;
&lt;li&gt;两个角色都开 deferred KV release（&lt;code&gt;#41023&lt;/code&gt; 之后默认开启），并给 Mooncake 传输批次设上限，避免一次超时的调用留下无界的写入尾巴。&lt;/li&gt;
&lt;li&gt;在你的模型支持的前提下打开损坏检测。&lt;code&gt;--disaggregation-enable-kv-checksum&lt;/code&gt; 和 &lt;code&gt;--kv-canary&lt;/code&gt; 目前还没覆盖所有模型架构，先看日志确认请求真的在校验。&lt;/li&gt;
&lt;li&gt;确保你的代理没有制造多余的 abort。把负载均衡配成让请求跑到第一个 token，避免触发需要跨节点协同的中止。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&#34;更长的那条路让读到别人的页在运行时读不出来&#34;&gt;更长的那条路：让「读到别人的页」在运行时读不出来&lt;/h2&gt;
&lt;p&gt;这篇复盘我最欣赏的是它没有停在「我们修好了」。Cloudflare 把长期方案分成两条，名字起得很好。&lt;/p&gt;
&lt;p&gt;一条叫 integrity by construction，构造即正确。配置检查和生命周期修复只能减少误用的路径，不能让误用变得不可能，而下一个 bug 的形状一定是没见过的。持久的答案是让引擎在读取时就发现「这个请求即将对一页它并不拥有的内存做注意力」，然后只让这一个请求失败，而不是把别人的数据返回出去。&lt;/p&gt;
&lt;p&gt;他们的原型是这样：每个物理 KV 页带一个标签，页被重新分配时标签就变；每个请求记录它期望的标签；解码读缓存之前校验这个映射。测试里吞吐和尾延迟的代价不到 1%。他们 8 月中在 GLM-5.2 上测过这个原型，但很快就因可靠性问题撤了。这里我想给 Cloudflare 加一句实话：原型是主动撤下的，说明「读时校验」在工程上比它听起来难。而他们的 &lt;code&gt;#39500&lt;/code&gt; 逐次传输校验和是第一小步，只覆盖 prefill 到 decode 那一次移交；页级标签走得更远，因为它检查每一次读，包括传输很久之后的读。&lt;/p&gt;
&lt;p&gt;另一条叫 integrity by mathematical impossibility，数学上不可能。RDMA 是直接从一台机器的内存拷到另一台，两台机器必须对「数据写到哪、多大、什么时候算完」有一致认识，任何不一致都可能让接收方读到错误的数据。Cloudflare 说这本质上是一个分布式系统的一致性失败，两台机器对系统状态的看法不一致。他们打算把在 Topaz、Meerkat 的 QuePaxa 上用过的那套形式化验证方法搬到 KV 缓存传输协议上，先建模、再证明安全性，去追踪并修补任何偏离。他们的措辞很克制：现实里会存在证明器覆盖不到的空隙，但把系统其余部分证清楚，能让那些空隙被更仔细地审查和监控。&lt;/p&gt;
&lt;h2 id=&#34;写在最后&#34;&gt;写在最后&lt;/h2&gt;
&lt;p&gt;如果只把这件事读成「Cloudflare 的 AI 服务漏了别人的数据」，药方会开错。它的根因和模型能力、和 prompt 注入、和「AI 安全」那套叙事都没关系。它是一个分布式系统里，两台机器对「这次传输完成了」这件事的理解不一致，加上一层共享内存的所有权跟踪漏了一处。&lt;/p&gt;
&lt;p&gt;更值得记的是那个结构性张力。为了把推理成本压下来，引擎必须复用 KV 缓存页，而页在请求之间不清零；为了不擦页，引擎就必须把所有权追踪做对，而这在有本地任务、有远端可变引用、有中止和错误路径的情况下很难做全。也就是说，**把成本降下来的那个设计，和泄漏的成因是同一枚硬币的两面。**这一条对每一家做托管推理的厂商都成立，Cloudflare 只是第一个把复盘写出来的。&lt;/p&gt;
&lt;p&gt;它给出的答案方向也值得抄：不要指望配置永远对，也不要指望引擎永远不乱，让引擎在读的那一刻发现越界，并让单个请求失败。以及，认真对待「池子大小不是配置项、和上下文长度没有任何机制强制一致」这句话。这类坑不挑厂商，只挑你测不测内容。只验状态码不验输出，是所有长上下文部署里最容易漏的一格。&lt;/p&gt;
&lt;p&gt;相关阅读：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a class=&#34;link&#34; href=&#34;https://www.yesmiracle.net/post/20261020-deepinfer-deepseek-inference-framework-teardown/&#34; &gt;《DeepInfer 拆解》&lt;/a&gt; — 推理框架内核、并行与确定性推理的源码级拆解&lt;/li&gt;
&lt;li&gt;&lt;a class=&#34;link&#34; href=&#34;https://www.yesmiracle.net/post/20261021-litellm-supply-chain-attack-1070/&#34; &gt;《LiteLLM 又被投毒了》&lt;/a&gt; — 另一类推理基础设施安全事故：供应链投毒&lt;/li&gt;
&lt;li&gt;&lt;a class=&#34;link&#34; href=&#34;https://www.yesmiracle.net/post/20261019-opencode-byok-console-credential-breach/&#34; &gt;《OpenCode 凭据泄露》&lt;/a&gt; — 数据外泄的另一条路径：凭据管控&lt;/li&gt;
&lt;/ul&gt;
</description>
        </item>
        
    </channel>
</rss>
