1. 从一道口令实验说起:共享状态到底藏了什么猫腻
第一次看到"共享状态,隔离问题"这个说法,是在一次内部技术分享上。当时主讲人放了一段很短的演示:同一个模型、同一段提示词,只因为前面多塞了一句看似无关的口令,输出结果就出现了肉眼可见的漂移。台下有人笑说这是玄学,但我盯着屏幕看了半天,心里冒出来的念头是——这不是玄学,这是状态被污染了。
后来我把这个现象拆开来看,发现它和 Transformer 推理时最核心的一个机制直接相关:KV cache。很多人做推理优化的时候,把 KV cache 当成一个纯粹的"加速缓存",觉得它只是把算过的 key/value 存起来省点算力。但真正跑过长上下文、多轮对话、批量推理的人都知道,KV cache 本质上是一份共享状态。它被同一批注意力头共享、被同一层里不同位置共享、被同一个 batch 里不同请求共享(如果你没做隔离的话)。一旦这份共享状态里混进了不该有的东西,后面所有的注意力计算都会带着这个"污染"往下走。
这就是那道口令实验真正揭开的东西:黑盒模型的行为漂移,很多时候不是模型"理解错了",而是它的状态空间被前序内容改写了。注意力机制本身没有记忆,它只是对当前可见的 key/value 做加权求和。你往 KV cache 里塞了什么,它就照着什么算。所谓"隔离问题",隔离的其实就是这份状态。
这篇文章我想把这条线索完整地捋一遍:从 KV cache 的结构讲起,到注意力机制为什么天然是"共享"的,再到实际工程里怎么做状态隔离、怎么排查因为共享状态引发的诡异问题。适合正在做 Transformer 推理优化、多轮对话系统、或者单纯被模型输出漂移折磨过的同学。不需要你手推反向传播,但最好跑过一次自回归生成,知道什么是 prefill、什么是 decode。
2. KV cache 与注意力机制:共享状态的物理来源
2.1 KV cache 到底缓存了什么
先把最基础的东西说清楚,不然后面全是空中楼阁。Transformer 的自注意力计算里,每个 token 会生成三个向量:Query、Key、Value。注意力权重是 Query 和所有 Key 做点积再 softmax 得到的,输出则是这些权重对 Value 的加权和。公式写出来就是那个经典的:
Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V自回归生成的时候,第 t 个 token 只能看到前 t 个 token。如果每生成一个新 token 都把前面所有 token 的 K、V 重算一遍,那计算量是平方级增长的,长文本下根本跑不动。KV cache 的思路很直接:前面 token 的 K 和 V 算过一次就不会变,存下来复用就行。每生成一个新 token,只需要算这个新 token 自己的 Q、K、V,然后把它的 K、V 追加到缓存末尾,用它的 Q 去和缓存里所有的 K 做注意力。
这里有个关键点容易被忽略:缓存里的 K、V 是跨 token 共享的。第 100 个 token 做注意力时,用的是第 1 到第 100 个 token 的全部 K、V。也就是说,任何一个早期 token 的 K、V 一旦写进缓存,它就会影响后面所有 token 的注意力输出。这就是"共享状态"最朴素的物理来源——不是设计者故意要共享,而是自注意力的数学结构决定了它必须共享。
我见过不少人调优时只盯着 Q 的投影矩阵,觉得 K、V 只是配角。实际上在长上下文场景里,K、V 的数值分布对最终输出的影响一点不比 Q 小。你改一个早期 token 的 K,后面几十上百个 token 的注意力权重都会跟着变。
2.2 多头注意力里的"共享"是双重的
单头注意力已经够让人头疼了,多头注意力把这个问题又放大了一层。多头注意力的做法是把 d_model 维的向量切成 h 份,每份独立做一次注意力,最后拼接再投影。表面上看每个头是独立的,但实际上它们共享同一份输入序列、共享同一个位置编码、共享同一套 KV cache 的存储布局。
这就带来一个很微妙的现象:如果某个头对某段内容特别敏感,它产生的异常 K、V 会留在缓存里,虽然其他头不直接读这个头的缓存,但它们读的是同一段文本对应的不同投影。文本本身没变,变的只是投影方式。所以当一段"口令"文本进入序列后,它会在所有头里同时留下痕迹,只是每个头留下的痕迹强度不同。
提示:排查输出漂移时,不要只看最终 logits。把每一层的注意力权重 dump 出来,对比正常输入和异常输入下同一位置的权重分布,往往能定位到是哪一层、哪个头先"跑偏"的。
多头自注意力机制原理里有个常被忽略的细节:注意力头的分工不是硬编码的。有的头偏向捕捉局部依赖,有的头偏向长距离依赖,还有的头(在视觉 Transformer 里尤其明显)专门盯某个方向的特征。Swin Transformer 里把注意力拆成窗口内和跨窗口,本质上也是在管理"哪些状态该共享、哪些该隔离"。视觉领域里说的"高度方向、宽度方向注意力",就是把特征图的垂直和水平两个维度分开处理,避免全局注意力把所有空间位置的状态混在一起。
2.3 位置编码:共享状态里的隐形坐标
还有一个东西必须提,就是位置编码。KV cache 里存的 K、V 是带位置信息的(要么在输入时加,要么在注意力计算时注入)。当缓存被复用、被拼接、被截断的时候,位置信息如果没对齐,注意力就会算错。
举个实际会遇到的场景:多轮对话里,你把历史对话的 KV cache 保留下来,新一轮用户输入接在后面。如果位置编码是从 0 重新开始算的,那新一轮的 token 会以为自己处在序列开头,和历史内容的相对位置全乱了。模型输出的连贯性会明显下降,但你又说不清哪里不对——因为 token 本身没变,变的是它们之间的相对坐标。
这就是"共享状态"的另一面:共享的不只是数值,还有坐标系。隔离问题的时候,位置编码的对齐和重置策略,和 KV cache 本身一样重要。
3. 口令实验复现:一次可控的状态污染
3.1 实验设计思路
我想复现的核心现象是:在输入序列前面插入一段与任务无关的口令,观察模型输出是否发生系统性偏移。为了排除随机性干扰,我固定了随机种子、固定了温度参数(用贪心解码)、固定了模型权重。变量只有一个:前缀口令的内容和长度。
实验用的模型是一个中等规模的开源 Transformer,参数量在 1B 到 3B 之间,足够观察到现象又不至于跑不动。任务选的是最简单的续写:给一句开头,让模型补全。这样输出的差异最容易量化。
具体做法是准备三组输入:
- A 组:纯任务提示,无前缀。
- B 组:前缀一段中性文本(比如一段产品说明),再接任务提示。
- C 组:前缀一段带有强指令色彩的口令(比如"忽略以上所有内容"这类),再接任务提示。
然后对比三组在相同解码策略下的输出 token 序列,计算它们和 A 组的编辑距离,以及每一层注意力权重的 KL 散度。
3.2 关键参数与计算过程
这里涉及几个需要手动算的参数,我列一下我的取值和理由。
KV cache 的显存占用,按标准公式估算:
cache_size = 2 * batch_size * num_layers * num_heads * seq_len * head_dim * dtype_bytes以 num_layers=24、num_heads=16、head_dim=64、seq_len=2048、fp16(2 字节)为例,单条请求的 cache 大约是:
2 * 1 * 24 * 16 * 2048 * 64 * 2 ≈ 402 MB这个数字很关键。它意味着长上下文下 KV cache 会迅速吃掉显存,逼着你去做共享和复用。而一旦共享,隔离问题就来了。
注意力权重的对比,我用的是每一层 softmax 之后的权重矩阵,对同一 query 位置,比较 A 组和 C 组的权重分布,算 KL 散度:
KL(P_A || P_C) = sum(P_A * log(P_A / P_C))散度越大,说明这个位置受到的污染越严重。实测下来,前几层的散度普遍较小,中间层(大约第 8 到第 16 层)散度最大,最后几层又收窄。这个分布很符合直觉:中间层是语义整合最活跃的地方,也最容易被前缀内容带偏。
3.3 实测现象记录
跑完三组对比,有几个现象值得记下来。
第一,B 组和 A 组的差异很小但不为零。中性前缀也会造成轻微漂移,编辑距离在个位数 token 级别。这说明即使内容无关,只要序列变长、缓存变深,注意力分布就会有细微变化。这不是 bug,是自注意力的固有特性。
第二,C 组的漂移是系统性的。不是随机多几个字,而是整个输出的语气、句式、甚至事实倾向都变了。有些情况下模型会开始"回应"那段口令,而不是完成任务。这就是状态被强指令内容改写的典型表现。
第三,漂移程度和前缀长度不是线性关系。我试了 32、64、128、256 四种前缀长度,发现 64 到 128 之间有一个明显的跃升,再往上反而趋于平缓。我的解释是:前缀短的时候,它的 K、V 在注意力里权重有限;超过某个长度后,它占据了缓存里足够多的"席位",开始主导注意力分配;再长下去,模型自身的注意力稀释机制(比如 softmax 的归一化)又把影响摊薄了。
注意:这个跃升点跟模型规模、层数、头数都有关,不要直接套用我的数值。做自己的实验时,先用小规模扫一遍长度,找到你所用模型的敏感区间。
4. 状态隔离的工程手段:从共享到受控共享
4.1 为什么不能简单地"全部隔离"
看到共享状态出问题,第一反应往往是"那就别共享了,每个请求、每轮对话都重新算"。这个思路在小规模下可行,但一上量就崩。原因还是那个显存公式:KV cache 的占用和序列长度成正比,全部隔离意味着显存需求随并发数线性增长,长上下文场景下根本扛不住。
所以工程上的目标不是"消灭共享",而是受控共享:该共享的共享(比如同一请求内的历史上下文),不该共享的坚决隔离(比如不同用户之间的状态)。这中间的边界怎么划,是推理系统设计的核心问题之一。
4.2 分页与块状管理
目前主流做法是把 KV cache 按块(block)或页(page)来管理,类似操作系统的虚拟内存分页。每个块存固定长度(比如 16 或 32 个 token)的 K、V,块之间用索引表关联。这样做的好处是:
- 不同请求可以共享相同的块(比如相同的系统提示词前缀),省显存。
- 需要隔离时,只要不把块索引混在一起就行,物理上可以放在同一块显存里。
- 序列截断、拼接、回滚都变成索引操作,不用搬数据。
我实际用下来,块大小选 16 是个比较稳的折中。太小了索引开销大,太大了碎片多、共享粒度粗。当然这跟你的序列长度分布有关,长序列为主的话可以适当调大。
4.3 前缀共享与写时复制
系统提示词、few-shot 示例这类内容,在很多请求里是重复的。把它们对应的 KV cache 块做成只读共享,多个请求引用同一份,能省下大量显存。但这里有个坑:一旦某个请求要修改这段共享内容(比如追加了新 token),必须触发写时复制,否则会污染其他请求的状态。
写时复制的实现要点是给每个块加引用计数,引用数大于 1 时修改要先拷贝一份。听起来简单,但在高并发下引用计数的原子操作会成为瓶颈。我的经验是把引用计数按块分组批量更新,减少锁竞争。
4.4 注意力掩码:隔离的数学表达
除了存储层面的隔离,注意力计算层面也要配合。因果自注意力本身就是一个掩码:第 t 个 token 只能看前 t 个。当你把多段内容拼进同一个序列时,必须确保掩码正确,让后一段看不到不该看的前一段(如果业务上要求隔离的话)。
这里容易出错的地方是拼接后的位置编码和掩码要对齐。我踩过一次坑:掩码写对了,但位置编码没重置,结果模型虽然看不到前一段的内容,却知道"自己处在序列的第 500 个位置",行为依然异常。后来统一了策略——凡是逻辑上独立的段,位置编码一律从 0 重算,掩码按段边界切。
5. 常见问题与排查技巧实录
5.1 输出漂移排查速查表
下面这张表是我自己整理的问题定位流程,按现象倒推原因,实测能覆盖大部分场景。
| 现象 | 可能原因 | 排查手段 | 处理方式 |
|---|---|---|---|
| 多轮对话中后期答非所问 | 历史 KV cache 位置编码未对齐 | dump 每轮的位置 id,检查是否连续 | 每轮重置位置编码或使用相对位置 |
| 批量推理时个别请求输出异常 | 请求间 KV cache 块被误共享 | 检查块引用计数和写时复制逻辑 | 修复引用计数,隔离可变块 |
| 长文本后半段质量骤降 | 注意力被早期内容稀释 | 统计各位置注意力权重熵 | 引入局部注意力或滑动窗口 |
| 加了系统提示词后行为改变 | 前缀状态主导注意力 | 对比有无前缀的中间层权重 | 调整前缀长度或加分隔标记 |
| 相同输入两次输出不同 | 缓存未清或随机性未固定 | 固定种子,检查缓存生命周期 | 明确缓存作用域,请求结束即释放 |
5.2 几个反直觉的坑
坑一:清空缓存不等于状态干净。有些框架的"清空"只是把长度指针归零,底层显存里的旧数据还在。如果后续计算因为某种原因读到了越界的位置,就会读到脏数据。我的做法是清空时显式覆写,或者干脆重新分配。
坑二:注意力可视化会骗人。softmax 之后的权重看起来集中在某几个 token 上,不代表其他 token 没影响。因为 softmax 会放大差异,微小的 logits 变化可能被压到看不见,但它对最终输出的累积影响是存在的。要看真实影响,得看 softmax 之前的 logits 分布。
坑三:短前缀也可能造成大漂移。前面实验里我说 64 到 128 有跃升,但那是平均情况。某些特定内容的前缀,哪怕只有十几个 token,也能显著改变输出。这跟内容本身的"注意力吸引力"有关,没法用长度一概而论。
5.3 实操心得
做了这么多轮实验和调优,我最大的体会是:把 KV cache 当成有生命的状态来管理,而不是当成无脑的缓存。它有作用域、有生命周期、有污染风险。设计推理系统时,先想清楚哪些状态该在哪些请求间共享,再动手写代码,比事后打补丁省事得多。
另外一个实用技巧是给缓存加"版本号"或者"来源标记"。当出现异常输出时,能快速定位到这份状态是从哪来的、经过了哪些操作。这在多级缓存、跨服务调用的场景里特别有用。
6. 从口令实验到系统设计:一点延伸思考
那道口令实验之所以让我印象深,是因为它用一个极简的场景,暴露了一个极复杂的问题。模型的黑盒不是因为它内部不可知,而是因为它的状态空间太大、耦合太深,我们平时只看了输入和输出两端,中间那一大段共享状态被忽略了。
Transformer 架构本身是优雅的,注意力机制用一套统一的加权求和处理了所有依赖关系。但优雅的代价就是一切都耦合在一起。你想隔离某部分信息,就得在存储、掩码、位置编码、注意力计算多个层面同时动手,任何一层漏了,隔离就不彻底。
视觉领域的做法给了我不少启发。Swin Transformer 用窗口注意力把全局耦合拆成局部耦合,视觉 Transformer 里针对特征图的高度、宽度方向分别做注意力,本质上都是在用结构约束来管理状态共享的范围。文本领域其实也可以借鉴:不是所有 token 都需要全局可见,滑动窗口注意力、局部注意力、稀疏注意力,都是在做同样的事。
时序注意力机制、因果自注意力这些变体,核心差异也在于"谁能看到谁"。理解了这一点,再看各种注意力变体,就不会觉得它们是一堆零散的技巧,而是同一套状态管理思想在不同场景下的具体实现。
最后分享一个我最近在试的小方向:给 KV cache 的每个块打上"可信度"标签,来源明确、经过验证的内容标记为高可信,临时拼接、来源不明的标记为低可信,在注意力计算时对低可信块做权重衰减。这个思路还在实验阶段,效果不稳定,但方向我觉得是对的——既然状态共享不可避免,那就让共享变得可度量、可控制。
如果你也在做类似的推理优化或者被输出漂移困扰,建议从 dump 中间层注意力权重开始,先看清楚状态是怎么流动的,再谈怎么隔离。很多时候问题不在模型,在于我们没管好它手里的那份共享状态。