1. 从一句口令说起:为什么"共享状态"和"隔离问题"会同时出现在一个实验里
第一次看到"共享状态,隔离问题"这个说法,我脑子里蹦出来的不是分布式系统,而是Transformer推理时那块被反复读写的KV cache。这两件事看起来八竿子打不着,但如果你真的动手做过大模型推理优化,就会发现它们其实是同一个问题的两面:多个请求共享同一份历史状态,但每个请求的"问题"必须被严格隔离,不能互相污染。
我最初接触这个方向,是因为一个很朴素的需求——想搞清楚自回归生成时,模型到底"记住"了什么。网上讲KV cache的文章一抓一大把,但绝大多数停留在"缓存K和V避免重复计算"这个层面,很少有人真正去验证:如果我手动构造一个共享的缓存,再往里塞不同来源的token,模型的注意力会怎么分配?它会不会把A请求的上下文泄漏给B请求?
这就是标题里"口令实验"的由来。所谓口令,其实就是一段人为设计的、带有强标识性的输入序列,比如"苹果-香蕉-樱桃"这种顺序固定的词表。我把它喂进模型,观察注意力权重在共享缓存上的分布,试图从黑盒里撬开一条缝。结果发现的东西比预想的有意思得多:共享状态本身不是问题,问题在于你用什么机制去隔离它。隔离做得好,共享就是性能红利;隔离做得差,共享就是灾难现场。
这篇文章适合两类人看。一类是正在做推理服务、想优化显存和吞吐的工程师,你会看到KV cache在真实场景下的行为细节,以及PagedAttention这类方案到底在解决什么。另一类是刚学完Transformer、想动手验证注意力机制的学习者,我会把口令实验的完整设计、代码思路和踩坑过程都摊开讲。不需要你有分布式系统的背景,但最好对自注意力的计算流程有个基本概念——如果连Q、K、V是什么都还不清楚,建议先补一下再回来。
下面我会从实验的设计动机讲起,然后拆解KV cache的共享逻辑,接着用口令实验的数据说明隔离机制为什么必要,最后落到工程实现上,聊聊分页、前缀共享和注意力掩码这几个关键手段。全程不堆公式,能用图说清楚的就用图,能用代码验证的就上代码。
2. 口令实验的设计:用最笨的办法撬开注意力的黑盒
2.1 为什么选"口令"而不是自然语言
做实验最怕的就是变量太多。自然语言里,语义、语法、指代、常识全搅在一起,你根本分不清模型关注某个token是因为位置近、还是因为语义相关、还是因为训练数据里的统计偏差。所以我需要一个结构极简、但位置信息极强的输入。
口令序列正好满足这个条件。比如我构造的序列是:
[CLS] 红 橙 黄 绿 青 蓝 紫 [SEP]七个颜色词,顺序固定,每个词只和相邻的词有强关联(红-橙、橙-黄……),跨距离的关联很弱。这样一来,注意力权重的分布就主要受位置和缓存状态影响,语义干扰被压到最低。如果模型在生成"紫"的时候,注意力异常地跑去看"红",那大概率是缓存或掩码出了问题,而不是语义联想。
另一个好处是可复现。自然语言的实验结果很难复现,换一个随机种子、换一个模型版本,结论可能就变了。口令序列短、确定性强,同样的输入在任何标准Transformer上跑出来的注意力模式应该高度一致。这对验证隔离机制特别重要——我需要一个稳定的基线,才能判断异常是来自共享状态还是来自模型本身的随机性。
提示:如果你也想复现这个实验,建议用GPT-2 small或者TinyLlama这类小模型,单卡就能跑,注意力权重可以直接从
output_attentions=True里拿到。大模型的注意力矩阵太大,可视化反而看不清。
2.2 实验装置:手动构造共享缓存
标准的自回归推理是这样的:每生成一个token,就把它的K和V追加到缓存里,下次计算注意力时,Q只和当前缓存里的所有K做点积。这个过程是串行的,一个请求对应一份缓存。
我要做的是把这个过程并行化:同时喂进多个口令序列,让它们共享同一块KV缓存,但每个序列有自己的注意力掩码。具体来说,假设有两个请求:
- 请求A:
红 橙 黄 - 请求B:
绿 青 蓝
我把它们拼成一个batch,缓存里存的是[红, 橙, 黄, 绿, 青, 蓝]六个位置的K和V。请求A的Q只能看到前三个位置,请求B的Q只能看到后三个位置。如果掩码写对了,两个请求互不干扰;如果掩码写错了,请求A的Q就会看到"绿青蓝",生成结果直接跑偏。
这个装置的关键在于缓存是物理共享的,但逻辑视图是隔离的。这跟操作系统里的虚拟内存很像:物理页框是共享的,但每个进程有自己的页表,看到的是独立的地址空间。KV cache的隔离也是同一个道理,只不过"页表"变成了注意力掩码。
我用PyTorch手写了一个简化版,核心逻辑大概长这样:
# 假设 batch_size=2, seq_len=3, head_dim=64 # 共享缓存:把两个请求的K/V拼在一起 shared_k = torch.cat([k_a, k_b], dim=1) # [2, 6, 64] shared_v = torch.cat([v_a, v_b], dim=1) # [2, 6, 64] # 构造块对角掩码 mask = torch.zeros(2, 3, 6) mask[0, :, :3] = 1 # 请求A只能看前3个位置 mask[1, :, 3:] = 1 # 请求B只能看后3个位置 # 注意力计算 attn = torch.softmax(q @ shared_k.transpose(-2, -1) / sqrt(d), dim=-1) attn = attn * mask # 屏蔽掉不该看的位置 output = attn @ shared_v这段代码看起来简单,但里面藏着一个很容易踩的坑:softmax要在掩码之前做还是之后做?如果你先softmax再乘mask,被屏蔽的位置虽然权重变成0了,但归一化分母已经被污染了,剩下的权重加起来不等于1。正确的做法是先把被屏蔽位置的logits设成负无穷,再softmax。这个细节我在第一次实验时就搞错了,导致请求A的输出里混进了请求B的信息,排查了半天才发现是归一化的问题。
2.3 观察指标:注意力权重到底在看哪里
实验跑起来之后,我需要一个量化的指标来判断隔离是否成功。最直接的就是注意力权重的分布。对于请求A的最后一个token("黄"),它的注意力应该集中在"红橙黄"这三个位置上,对"绿青蓝"的权重应该严格为0。
我定义了三个指标:
| 指标 | 含义 | 理想值 |
|---|---|---|
| 跨请求泄漏率 | 请求A的注意力落在请求B位置上的比例 | 0% |
| 自请求集中度 | 注意力落在自己请求内部的比例 | 100% |
| 位置熵 | 注意力在有效位置上的分散程度 | 随任务而定 |
跨请求泄漏率是最关键的。只要它不为0,就说明隔离机制有漏洞。位置熵则用来判断模型是不是"偷懒"——如果熵特别低,说明它只盯着最近的一个token看,那共享缓存的意义就不大了,因为历史信息根本没被利用。
实测下来,用标准块对角掩码的泄漏率可以稳定在0,但位置熵会随着序列变长而升高,说明模型确实在利用更远的历史信息。这个结果符合预期,也验证了掩码方案的有效性。
3. KV cache的共享逻辑:省的是显存,赌的是隔离
3.1 自回归生成的重复计算到底有多浪费
要理解共享缓存的价值,先得算一笔账。假设模型有L层,每层有h个注意力头,每个头的维度是d,序列长度是n。那么KV cache的大小是:
2 * L * h * n * d * sizeof(dtype)以GPT-2 small为例,L=12,h=12,d=64,n=1024,用fp16存储,算下来大概是:
2 * 12 * 12 * 1024 * 64 * 2 bytes ≈ 37.7 MB单个请求看起来不大,但推理服务是并发的。如果同时有100个请求,每个请求的序列长度都是1024,那就是3.7 GB的显存,还没算模型权重和激活值。更麻烦的是,自回归生成是逐token的,每生成一个新token,都要把之前所有token的K和V重新算一遍——如果不缓存的话。这个重复计算量是O(n²)的,序列越长越恐怖。
KV cache的核心作用就是用空间换时间:把已经算过的K和V存下来,下次直接读,不重算。这一步几乎所有推理框架都会做,没什么争议。真正有争议的是下一步:多个请求之间能不能共享这份缓存?
3.2 共享的三种粒度:从系统提示到前缀树
共享缓存不是"全共享"或"全不共享"的二选一,实际上有三种粒度,每种对应的场景和风险都不一样。
第一种是系统提示共享。很多应用会有一个固定的系统提示,比如"你是一个有用的助手,请用中文回答"。这个提示对所有请求都是一样的,它的KV完全可以只算一次,然后被所有请求复用。这种共享最安全,因为系统提示是只读的,不会被任何请求修改。
第二种是前缀共享。当多个请求有相同的前缀时,比如多轮对话里的历史轮次,或者few-shot示例,这些前缀的KV也可以共享。这种共享需要前缀树(radix tree)来管理,因为不同请求的前缀可能部分重叠。vLLM的Prefix Caching就是干这个的,它把KV cache按块切分,相同的前缀块只存一份。
第三种是全序列共享。这就是我在口令实验里做的,把不同请求的整个序列拼在一起,用掩码隔离。这种共享最激进,省显存最多,但隔离风险也最大。一旦掩码出错,请求之间就会互相污染。
三种粒度的对比如下:
| 共享粒度 | 典型场景 | 显存收益 | 隔离风险 | 实现复杂度 |
|---|---|---|---|---|
| 系统提示共享 | 固定system prompt | 低 | 极低 | 低 |
| 前缀共享 | 多轮对话、few-shot | 中 | 低 | 中 |
| 全序列共享 | 批量推理、实验验证 | 高 | 高 | 高 |
实际生产环境里,前缀共享是性价比最高的方案。系统提示共享收益太小,全序列共享风险太大,前缀共享刚好卡在中间。但前缀共享也有自己的坑,主要是缓存淘汰策略——前缀树会越来越深,显存迟早不够用,你得决定哪些前缀块该被踢出去。LRU(最近最少使用)是最常见的策略,但对多轮对话不太友好,因为老对话的前缀可能突然又被用到了。
3.3 共享缓存的物理布局:连续内存还是分页
确定了共享粒度,下一个问题是怎么在显存里摆放这些KV。最朴素的做法是连续内存:每个请求分配一块连续的空间,缓存就存在里面。这种做法实现简单,但有个致命缺陷——内存碎片。
假设你有100个请求,序列长度分别是10到1000不等。如果每个请求都按最大长度预分配,显存浪费严重;如果按实际长度分配,请求结束释放后,显存里就会出现很多大小不一的空洞。新来的请求如果找不到足够大的连续空间,就只能等或者拒绝服务。这就是典型的内存碎片问题。
vLLM的PagedAttention借鉴了操作系统的分页机制来解决这个问题。它把KV cache切成固定大小的块(比如16个token一块),每个请求的缓存由多个不连续的块组成,块之间用页表映射。这样一来,显存分配就变成了"按块分配",碎片问题基本消失,显存利用率能从连续内存的60%左右提升到90%以上。
分页的代价是地址翻译开销。每次读KV,都要先查页表找到物理块号,再拼出完整的K和V。这个开销在GPU上不能忽略,所以PagedAttention的kernel做了大量优化,把页表查找和注意力计算融合在一起。我实测过,在序列长度超过512之后,分页的吞吐优势就明显压过了地址翻译的开销。
注意:分页机制和前缀共享是天然搭配的。因为前缀共享需要按块复用,而分页正好是按块管理的。vLLM把这两个特性结合在一起,相同的前缀块在页表里指向同一块物理内存,既省显存又省计算。
4. 隔离机制的三道防线:掩码、分页和生命周期管理
4.1 注意力掩码:最直接但也最容易写错的一道墙
注意力掩码是隔离的第一道防线,也是最容易被低估的一道。很多人觉得掩码就是"把不该看的位置设成负无穷",能有多难?但实际写起来,坑一个接一个。
第一个坑是掩码的形状。在批量推理里,掩码的维度通常是[batch_size, num_heads, query_len, key_len]。如果你用的是共享缓存,key_len是共享缓存的总长度,而不是单个请求的长度。这时候掩码必须精确地告诉每个query,哪些key属于自己,哪些不属于。我见过有人直接把padding mask拿来用,结果因为padding mask只区分"有效token"和"padding token",不区分"属于哪个请求",导致跨请求泄漏。
第二个坑是因果掩码和隔离掩码的叠加。自回归生成本身有一个因果掩码,保证每个位置只能看到自己和之前的位置。共享缓存的隔离掩码是另一个维度。这两个掩码要做逻辑与(AND),而不是简单相加。如果你把两个掩码相加,可能会出现某个位置在两个掩码里都是"屏蔽",但相加之后变成"可见"的情况——因为负无穷加负无穷还是负无穷,但如果你用的是0/1掩码,1+1=2,再经过softmax就乱了。
第三个坑是掩码的数据类型。用fp16表示负无穷是有风险的,因为fp16的负无穷是-65504,不是真正的无穷。如果logits本身很大,加上-65504之后可能还是正数,掩码就失效了。稳妥的做法是用fp32做softmax,或者用torch.finfo(dtype).min来填充。
我在口令实验里就踩过第三个坑。当时用fp16跑,掩码位置填的是-1e9,结果发现泄漏率不是0,而是0.3%左右。排查了很久才意识到,某些位置的logits本身就接近1e4,加上-1e9之后变成了-9.99e8,虽然很小,但softmax之后还是分到了一点权重。换成fp32之后,泄漏率才真正归零。
4.2 分页隔离:物理共享、逻辑独立
分页机制在隔离上的贡献,经常被它的显存优化光环掩盖。实际上,分页天然提供了一层隔离:每个请求有自己的页表,页表决定了它能访问哪些物理块。即使两个请求的物理块在显存里紧挨着,只要页表不指向对方,它们就看不到彼此的数据。
这跟进程隔离是一个道理。两个进程的虚拟地址可能映射到同一块物理内存(比如共享库),但它们的页表是独立的,一个进程不能随便读写另一个进程的私有数据。KV cache的分页也是这个逻辑:前缀块可以共享,因为页表显式地指向同一块物理内存;但私有部分必须隔离,因为页表不交叉。
分页隔离的一个实际好处是权限控制更细。在连续内存方案里,如果你想把某个请求的缓存标记为"只读"或"可共享",得在软件层面加很多判断。在分页方案里,只需要在页表项里加一个标志位就行。vLLM的copy-on-write机制就是基于这个思路:当一个共享块需要被修改时,先复制一份,再修改副本,原块保持不变。这样既保证了共享,又保证了隔离。
不过分页隔离也不是万能的。它防的是"意外访问",防不了"恶意访问"。如果攻击者能控制页表,那隔离就形同虚设。所以在多租户场景下,页表本身也需要保护,不能让它被用户态程序随意修改。
4.3 生命周期管理:请求结束了,缓存怎么办
隔离的第三道防线是生命周期管理。一个请求生成完毕,它的KV cache就该被释放。但如果这块缓存被其他请求共享着(比如前缀共享),就不能直接释放,得等所有引用它的请求都结束。
这就是引用计数的用武之地。每个物理块维护一个计数器,记录有多少个请求的页表指向它。请求结束时,计数器减一;减到零时,块才真正被回收。这个机制听起来简单,但实现起来有几个细节要注意。
第一个细节是计数器的原子性。在多线程或异步推理的环境里,多个请求可能同时增减同一个块的引用计数。如果计数器不是原子操作,就会出现竞态条件,导致块被提前释放或者永远不释放。vLLM用CUDA的原子操作来解决这个问题,但在CPU侧调度时也要小心。
第二个细节是循环引用。如果请求A的前缀包含请求B的前缀,请求B的前缀又包含请求A的前缀,引用计数就会陷入死循环,两个块都永远不释放。这种情况在前缀树里理论上不会出现(因为前缀树是树结构,没有环),但如果实现不当,比如用图结构来管理前缀,就可能出问题。
第三个细节是缓存淘汰和引用计数的配合。当显存不够时,调度器需要淘汰一些块。但如果被淘汰的块还有引用,就不能直接扔,得先等引用归零。这就导致淘汰策略不能只看"最近最少使用",还要看"引用计数是否为零"。vLLM的做法是维护一个空闲块队列,只有引用计数为零的块才会进入队列,调度器从队列里取块分配给新请求。
5. 口令实验的实测数据:隔离失败长什么样
5.1 泄漏的三种典型模式
在正式跑实验之前,我先故意制造了几种隔离失败的情况,看看泄漏到底长什么样。结果发现,泄漏不是随机的,而是有明确的模式。
第一种是边界泄漏。当两个请求的缓存在物理上相邻时,请求A的最后一个token会"越界"看到请求B的第一个token。这种泄漏的注意力权重通常不大,在0.1到0.5之间,但足以影响生成结果。比如请求A在生成"黄"的时候,如果看到了请求B的"绿",可能会生成"绿黄"这种不存在的颜色组合。
第二种是全局泄漏。当掩码完全失效时,请求A的每个token都能看到请求B的所有token。这种泄漏的注意力权重分布很均匀,大概等于请求B的长度除以总长度。生成结果会完全跑偏,基本上等于把两个请求的输入混在一起。
第三种是位置泄漏。这种最隐蔽。掩码在大部分位置是对的,但在某些特定位置(比如序列的中间)失效了。泄漏的权重不大,但恰好落在关键位置上,导致生成结果偶尔出错,很难复现。我在调试一个多轮对话系统时遇到过这种泄漏,表现是模型偶尔会"忘记"当前轮次的问题,反而去回答上一轮的问题。查了很久才发现是前缀共享的掩码在某个边界条件下写错了。
三种泄漏模式的对比:
| 泄漏模式 | 注意力权重特征 | 生成结果影响 | 排查难度 |
|---|---|---|---|
| 边界泄漏 | 集中在相邻位置,权重0.1-0.5 | 局部错误 | 中 |
| 全局泄漏 | 均匀分布,权重约1/n | 完全跑偏 | 低 |
| 位置泄漏 | 稀疏分布,权重小但位置关键 | 偶发错误 | 高 |
5.2 掩码写错之后,注意力权重会怎么变
为了更直观地展示隔离失败,我把请求A的掩码故意写错,让它能看到请求B的第一个token。然后对比正确掩码和错误掩码下的注意力权重。
正确掩码下,请求A最后一个token的注意力分布是:
红: 0.15 橙: 0.25 黄: 0.60 绿: 0.00 青: 0.00 蓝: 0.00注意力集中在最近的"黄"上,符合自回归生成的局部性特点。
错误掩码下,分布变成了:
红: 0.12 橙: 0.20 黄: 0.48 绿: 0.20 青: 0.00 蓝: 0.00"绿"分到了0.20的权重,虽然不如"黄"高,但已经足以影响输出了。实测生成结果从"黄"变成了"绿黄",验证了泄漏的实际影响。
这个实验说明了一个重要问题:隔离失败不一定会导致明显的错误,但一定会导致不可预测的行为。在单请求场景下,你可能永远发现不了;但在高并发场景下,这种不可预测性会被放大,变成偶发的、难以复现的bug。
5.3 性能与隔离的权衡:共享到底值不值
说了这么多隔离的风险,那共享到底值不值?我用实测数据来回答。
在单卡A100上,跑GPT-2 small,序列长度512,batch size从1增加到32,对比三种方案:
| 方案 | 显存占用 | 吞吐(token/s) | 隔离风险 |
|---|---|---|---|
| 无共享 | 100% | 100% | 无 |
| 前缀共享 | 65% | 135% | 低 |
| 全序列共享 | 40% | 180% | 高 |
前缀共享用35%的显存节省换来了35%的吞吐提升,而且隔离风险可控,性价比最高。全序列共享虽然吞吐提升更大,但隔离风险太高,除非你能保证掩码绝对正确,否则不建议在生产环境用。
还有一个容易被忽略的点:共享的收益随序列长度增加而增加。序列越长,前缀的占比越大,共享省下的计算越多。在序列长度128时,前缀共享的吞吐提升只有10%左右;到512时提升到35%;到2048时能到60%以上。所以如果你的应用场景是长文本生成,前缀共享几乎是必选项。
6. 从实验到生产:把隔离机制落到代码里
6.1 用PyTorch实现一个最小可用的共享缓存
理论讲完了,该上代码了。下面是一个最小可用的共享缓存实现,支持前缀共享和块对角掩码。代码基于PyTorch,不依赖任何推理框架,方便你理解原理。
import torch import torch.nn.functional as F class SharedKVCache: def __init__(self, num_blocks, block_size, num_heads, head_dim, dtype=torch.float16): self.num_blocks = num_blocks self.block_size = block_size self.num_heads = num_heads self.head_dim = head_dim # 物理块池:所有请求共享 self.k_pool = torch.zeros(num_blocks, num_heads, block_size, head_dim, dtype=dtype) self.v_pool = torch.zeros(num_blocks, num_heads, block_size, head_dim, dtype=dtype) # 空闲块队列 self.free_blocks = list(range(num_blocks)) # 每个块的引用计数 self.ref_count = [0] * num_blocks # 每个请求的页表:block_id -> 逻辑位置 self.page_tables = {} def allocate(self, request_id, num_tokens): """为请求分配块,返回页表""" num_blocks_needed = (num_tokens + self.block_size - 1) // self.block_size if len(self.free_blocks) < num_blocks_needed: raise RuntimeError("显存不足") page_table = [] for _ in range(num_blocks_needed): block_id = self.free_blocks.pop(0) self.ref_count[block_id] = 1 page_table.append(block_id) self.page_tables[request_id] = page_table return page_table def share_prefix(self, src_request_id, dst_request_id, num_prefix_tokens): """把src请求的前缀块共享给dst请求""" num_blocks = (num_prefix_tokens + self.block_size - 1) // self.block_size src_table = self.page_tables[src_request_id] dst_table = self.page_tables[dst_request_id] for i in range(num_blocks): block_id = src_table[i] self.ref_count[block_id] += 1 dst_table[i] = block_id def free(self, request_id): """释放请求的私有块,共享块的引用计数减一""" for block_id in self.page_tables[request_id]: self.ref_count[block_id] -= 1 if self.ref_count[block_id] == 0: self.free_blocks.append(block_id) del self.page_tables[request_id] def build_mask(self, request_id, query_len): """根据页表构造块对角掩码""" page_table = self.page_tables[request_id] total_len = len(page_table) * self.block_size mask = torch.zeros(query_len, total_len, dtype=torch.bool) for i, block_id in enumerate(page_table): start = i * self.block_size end = start + self.block_size mask[:, start:end] = True return mask这段代码的核心是page_tables和ref_count。每个请求有自己的页表,页表里的block_id指向物理块池。共享前缀时,两个请求的页表指向同一个block_id,引用计数加一。释放时,引用计数减到零才真正回收。
build_mask函数根据页表构造掩码。注意这里用的是bool掩码,实际计算时要转成加性掩码(被屏蔽位置填负无穷)。这个转换在fp16下要小心,前面已经讲过。
6.2 掩码构造的常见错误和修复方法
在实现共享缓存的过程中,我踩过的掩码相关的坑至少有五个。这里挑三个最典型的讲。
错误一:用padding mask代替隔离mask。padding mask只区分有效token和padding token,不区分请求归属。如果两个请求的序列长度不同,短请求的padding位置会被长请求看到,造成泄漏。修复方法是显式构造块对角掩码,每个请求只能看到自己页表里的块。
错误二:掩码在softmax之后应用。前面讲过,这会导致归一化分母被污染。修复方法是把掩码转成加性掩码,在softmax之前加到logits上。
错误三:掩码的dtype和logits不一致。如果logits是fp16,掩码填的是fp32的负无穷,PyTorch会自动做类型提升,但提升过程中可能丢失精度。修复方法是统一dtype,或者用torch.finfo(logits.dtype).min来填充。
修复后的掩码应用代码:
def apply_mask(logits, mask): """logits: [batch, heads, q_len, k_len], mask: [batch, q_len, k_len]""" # 把bool掩码转成加性掩码 additive_mask = torch.zeros_like(logits) additive_mask.masked_fill_(~mask.unsqueeze(1), torch.finfo(logits.dtype).min) return logits + additive_mask提示:
torch.finfo(logits.dtype).min在fp16下是-65504,不是真正的负无穷。如果logits本身超过65504,掩码会失效。稳妥的做法是用fp32做softmax,或者把logits先减去最大值再算。
6.3 生产环境的额外考量:并发、淘汰和监控
把实验代码搬到生产环境,还有三件事要处理。
第一是并发安全。实验代码是单线程的,生产环境是多请求并发的。free_blocks队列和ref_count数组的读写必须加锁,否则会出现两个请求抢同一个块的情况。vLLM用CUDA的原子操作和细粒度锁来解决这个问题,但在Python层面,最简单的做法是用threading.Lock包住分配和释放逻辑。
第二是缓存淘汰。显存是有限的,前缀树不能无限增长。淘汰策略要综合考虑引用计数、最近使用时间和块的大小。我的经验是:优先淘汰引用计数为零的块,其次淘汰最久未使用的块,最后才考虑淘汰大块。因为大块往往对应长前缀,重建成本高。
第三是监控。共享缓存的隔离失败往往是静默的,不会报错,只会让生成结果变差。所以必须加监控指标,比如跨请求泄漏率、缓存命中率、块利用率。泄漏率可以通过采样注意力权重来估算,命中率直接统计页表复用次数就行。这些指标要打到监控系统里,设置告警阈值。
我在一个内部服务上部署这套机制时,就因为没有加泄漏率监控,导致一个掩码bug跑了三天才被发现。表现是用户偶尔抱怨"回答串台了",但复现不了。后来加了监控,发现泄漏率在特定并发模式下会飙升到2%,才定位到是页表在某个边界条件下写错了。这个教训让我明白:隔离机制的可观测性和隔离机制本身一样重要。
7. 写在实验之后:共享和隔离从来不是对立的
做完这一轮口令实验,我最大的体会是:共享和隔离不是二选一,而是需要精细设计的组合。KV cache的共享能省显存、提吞吐,但前提是隔离机制足够可靠。掩码、分页、引用计数,这三道防线缺一不可。
如果你正在做推理优化,我的建议是从前缀共享开始,不要一上来就搞全序列共享。前缀共享的收益已经很大,隔离风险也可控。等你的监控体系完善了,再考虑更激进的共享方案。
另外,口令实验这个思路可以扩展到很多场景。比如你可以用它来验证多轮对话的上下文隔离、验证RAG系统的检索结果隔离、验证多模态模型里不同模态的注意力隔离。核心方法是一样的:构造结构极简的输入,观察注意力权重的分布,从异常分布反推隔离机制的漏洞。
最后分享一个调试技巧:当你怀疑隔离出问题时,先把batch size设成1,看看问题还在不在。如果batch size=1时正常,batch size>1时异常,那基本可以确定是隔离问题,而不是模型本身的问题。这个技巧帮我省了很多排查时间。