1. 从一次口令实验说起:为什么共享状态会变成问题源头
第一次让我真正意识到“共享状态”和“隔离问题”之间张力的,不是什么复杂的分布式系统,而是一个看起来人畜无害的口令生成实验。当时的需求很简单:给定一组字符集和长度约束,批量生成一批随机口令,要求同一批次内不重复、跨批次尽量不撞车,同时还要能复现——也就是说,给定相同的种子,必须得到完全相同的序列。
我最初的做法非常“自然”:写一个全局的随机数生成器实例,所有生成逻辑都从这个实例取数。单线程跑的时候一切正常,结果可复现,速度也够快。可一旦把它放进并发环境,问题立刻暴露:多个任务同时从同一个随机状态里取数,谁先谁后完全不确定,导致同一批次内出现重复口令,跨批次复现也彻底失效。更麻烦的是,这种错误不是每次都出现,而是偶发——大概每跑十次才崩一次,排查起来极其折磨人。
这个实验让我意识到一个被很多人低估的事实:共享状态本身不是问题,共享状态在没有隔离边界的情况下被并发访问才是问题。口令生成只是一个缩影,它背后对应的是一整类系统设计难题——从随机数生成器,到缓存系统,再到如今大模型推理里绕不开的 KV cache,本质上是同一个命题的不同化身。
这篇内容我想做的事情,是把这次口令实验当作一个切口,把“共享状态、隔离问题”这条线索一路延伸到 Transformer 推理、注意力机制、KV cache 这些当下最热的技术点上。如果你正在做推理优化、缓存设计,或者只是单纯想搞明白为什么大模型推理里 KV cache 这么关键,那这篇内容应该能给你一些可以直接复用的思路。我会尽量用从业者的口吻,把原理、踩坑和实操细节都摊开讲,而不是停留在概念层面。
先说结论:共享状态带来的收益是真实的,但它带来的隔离成本同样真实,关键在于你能否找到一条清晰的边界,把“该共享的”和“该隔离的”分开。口令实验里这条边界是随机数生成器的状态,KV cache 里这条边界是序列维度和批次维度。找到边界,问题就解决了一半。
2. 口令实验的完整复现:从全局随机源到可复现隔离
2.1 实验目标与初始设计
先把实验本身讲清楚,因为后面所有的类比都建立在这个基础之上。实验目标有三条:第一,批量生成指定长度的口令;第二,同一批次内不允许重复;第三,给定种子必须可复现。字符集我选的是大小写字母加数字,长度固定 16 位,单批生成 10 万条。
初始设计非常直接:用一个全局的伪随机数生成器,种子固定,所有生成任务共享这个实例。生成逻辑就是循环取随机索引,从字符集里拼字符。单线程下这套逻辑跑得飞快,10 万条口令不到一秒就能出结果,而且因为种子固定,每次跑出来的序列完全一致,复现性完美。
问题出在我为了提速,把生成任务拆成了多个并发单元。每个单元都去调用同一个全局随机源,这时候随机源内部的状态被多个执行流同时读写,取数的顺序不再确定。结果就是:同一批次里开始出现重复口令,而且复现性彻底崩了——同样的种子,两次运行得到的结果完全不同。
2.2 并发下的状态竞争是怎么发生的
要理解这个问题,得先明白伪随机数生成器的本质。它并不是真的“随机”,而是维护一个内部状态,每次取数时用确定性算法把当前状态变换成新状态,并输出一个结果。也就是说,随机性来自于状态的确定性演化,只要状态序列一致,输出序列就一致。
当多个执行流共享同一个状态时,状态演化就变成了交错执行。A 取一次数,状态从 S0 变成 S1;B 紧接着取一次,状态从 S1 变成 S2。但如果调度顺序变了,可能是 B 先取,状态从 S0 变成 S1',A 再取变成 S2'。S1 和 S1' 通常不相等,于是后续所有输出全部偏离。这就是典型的状态竞争。
更隐蔽的是,这种竞争不会稳定复现。它依赖于调度时机,可能跑一百次才出一次问题。这就导致很多人在测试环境里根本发现不了,一上生产就偶发故障。我在口令实验里就吃了这个亏,前几轮测试全部通过,直到压测并发度拉高才暴露。
2.3 隔离方案:每个执行流独立状态
解决思路其实很清晰:给每个执行流一份独立的随机状态。具体做法是,不再共享一个全局实例,而是为每个并发单元派生一个独立的随机源,种子由主种子加上单元编号派生得到。这样每个单元内部的状态演化是串行的、确定的,单元之间互不干扰。
派生种子的方式我用的是主种子与单元编号做一次混合运算,保证不同单元得到不同种子,同时整体又可复现——只要主种子和单元编号规则固定,整个批次的结果就完全确定。这个方案的好处是隔离彻底,代价是每个单元都要维护一份状态,内存开销随并发度线性增长。
实测下来,10 万条口令、16 个并发单元,内存增量可以忽略不计,速度反而比共享状态更快,因为消除了锁竞争。这里有个经验:隔离带来的性能收益,很多时候会超过它带来的资源开销,因为共享状态往往需要加锁保护,而锁才是真正的性能杀手。
2.4 这个实验真正教会我的事
口令实验本身很小,但它揭示的模式非常普遍。任何“共享可变状态 + 并发访问”的组合,都会面临同样的问题。解决方式无非三种:加锁串行化、状态隔离、或者改成无状态设计。加锁最简单但性能最差,无状态设计最优雅但适用场景有限,状态隔离则是折中方案里最实用的。
我把这个模式记在心里之后,后来在看 KV cache、注意力机制这些技术时,发现它们其实都在回答同一个问题:哪些状态必须共享,哪些状态必须隔离,边界画在哪里。口令实验里的边界是执行流,KV cache 里的边界是序列和批次。理解了这一点,再看那些复杂系统,思路会清晰很多。
3. KV cache 的本质:把注意力里的重复计算变成可复用状态
3.1 自回归生成为什么离不开缓存
要讲 KV cache,得先讲清楚自回归生成的过程。Transformer 做文本生成时,是一个 token 一个 token 往外吐的。每生成一个新 token,模型都要基于前面所有已生成的 token 计算注意力。如果每次都把前面所有 token 重新算一遍,计算量会随着序列长度平方级增长,推理速度会慢到无法接受。
KV cache 的核心思想就是:前面 token 的 Key 和 Value 在生成后续 token 时不会改变,所以可以缓存起来复用。每生成一个新 token,只需要计算这个新 token 的 Query、Key、Value,然后把新的 Key、Value 追加到缓存里,注意力计算时直接读取缓存中的历史 K、V 即可。这样每步的计算量从与序列长度平方相关,降到与序列长度线性相关。
这个优化带来的加速是数量级的。以我实测的一个中等规模模型为例,序列长度到 2048 时,开启 KV cache 相比不开启,单步生成耗时能降到原来的十分之一以下。序列越长,收益越明显。所以现在几乎所有推理框架,KV cache 都是默认开启的。
3.2 缓存里到底存了什么
KV cache 存的不是原始输入,而是每一层注意力计算出来的 Key 和 Value 张量。具体来说,对于每一层、每一个注意力头,都会有一份对应的 K 缓存和 V 缓存。形状上,通常是[batch_size, num_heads, seq_len, head_dim]这样的四维结构。
这里有个容易混淆的点:Query 不需要缓存。因为 Query 只用于当前步的注意力计算,用完就丢,历史 Query 对后续生成没有任何贡献。只有 Key 和 Value 需要跨步保留。这也是为什么叫 KV cache 而不是 QKV cache。
缓存的大小直接决定了显存占用。粗略估算一下:假设模型有 L 层,每层 H 个头,头维度 D,序列长度 S,批次大小 B,数据类型是 FP16(2 字节),那么 KV cache 的显存占用约为2 * L * H * S * B * D * 2字节。以一个 32 层、32 头、头维度 128 的模型为例,序列长度 4096、批次 1 时,KV cache 大约占用 2 * 32 * 32 * 4096 * 1 * 128 * 2 ≈ 2GB。这个数字在长序列场景下会迅速膨胀,成为显存瓶颈。
3.3 共享与隔离在 KV cache 里的体现
KV cache 天然就是一个“共享状态”系统。同一个批次里的多个序列,如果前缀相同,理论上可以共享前缀部分的 KV 缓存,这就是 prefix caching 的思路。共享能省显存、省计算,但共享的前提是这些序列的前缀确实完全一致,否则就会串味。
而隔离问题则体现在批次维度上。不同序列的 KV 必须严格隔离,不能混在一起,否则注意力会算错。所以 KV cache 的管理,本质上就是在“哪些能共享、哪些必须隔离”之间做权衡。这和口令实验里“哪些状态能共享、哪些必须隔离”是同一个命题。
我见过一些实现,为了省事把所有序列的 KV 拼在一个大张量里,靠索引区分。这种做法在批次固定时没问题,但一旦有序列提前结束、需要动态调整批次,索引管理就会变得极其复杂,稍有不慎就会读到别的序列的缓存。这就是隔离边界没画清楚的典型后果。
4. 注意力机制里的共享与隔离:多头、因果与视觉注意力
4.1 多头注意力:共享输入,隔离子空间
多头注意力是 Transformer 的核心组件,它的设计本身就体现了共享与隔离的哲学。输入张量被共享给所有注意力头,但每个头有自己独立的投影矩阵,把输入映射到不同的子空间。也就是说,输入是共享的,子空间是隔离的。
这种设计的好处是,不同的头可以关注不同的模式。有的头可能关注语法结构,有的头可能关注语义关联,有的头可能关注位置邻近性。如果所有头共享同一套投影,那就退化成了单头注意力,表达能力会大幅下降。
从实现角度看,多头注意力的计算可以写成:先把输入投影成 Q、K、V,然后按头维度切分,每个头独立做注意力,最后拼接再投影。切分这一步就是隔离,拼接这一步就是重新共享。整个流程里,共享和隔离是交替出现的,这也是 Transformer 灵活性的来源之一。
4.2 因果自注意力:用掩码实现时间维度的隔离
因果自注意力是自回归生成的关键。它的作用是保证每个位置只能看到自己和之前的位置,不能看到未来。实现方式是在注意力分数矩阵上加一个上三角为负无穷的掩码,softmax 之后未来位置的权重就变成零。
这个掩码本质上是一种时间维度上的隔离。它强制模型在预测下一个 token 时,只能依赖历史信息,不能作弊偷看答案。如果没有这个隔离,模型在训练时会直接抄未来 token,损失降得很低,但推理时完全没法用,因为推理时未来 token 根本不存在。
因果掩码和 KV cache 是绝配。因为有了因果隔离,历史 K、V 才不会被未来信息污染,缓存起来才有意义。如果注意力是双向的,那每生成一个新 token,历史 K、V 都可能因为新 token 的加入而改变,缓存就失效了。所以 KV cache 能成立,因果注意力是前提。
4.3 视觉注意力:高度和宽度方向的隔离思路
视觉领域的注意力机制,尤其是 Swin Transformer 这类工作,把隔离思想用到了空间维度上。图像特征图有高度 H 和宽度 W 两个方向,全局注意力会让每个位置和所有其他位置计算关系,计算量是 H*W 的平方,高分辨率下根本扛不住。
Swin 的做法是把特征图划分成不重叠的窗口,注意力只在窗口内部计算。这就是空间上的隔离:窗口内共享注意力,窗口间暂时隔离。但纯窗口隔离会切断跨窗口的信息流动,所以 Swin 又引入了移位窗口机制,下一层把窗口偏移半个窗口大小,让信息能跨窗口传递。
这种“隔离 + 移位”的组合,和口令实验里“隔离 + 派生种子”的思路异曲同工。都是先划边界做隔离,再用某种机制把隔离单元重新连接起来,兼顾效率和表达力。视觉注意力里的高度方向、宽度方向注意力,本质上也是在两个空间维度上分别做隔离和聚合。
4.4 通道注意力:在特征维度上做选择性共享
SE 通道注意力是另一个典型。它不改变空间结构,而是在通道维度上做文章。先对每个通道做全局池化,得到一个通道描述向量,然后通过两层全连接学习每个通道的权重,最后用权重对通道做加权。
这里的共享与隔离体现在:所有通道共享同一套权重学习网络,但每个通道得到独立的权重。共享的是学习机制,隔离的是权重取值。这样模型可以自动判断哪些通道重要、哪些通道可以抑制,而不需要人工设计。
把通道注意力和空间注意力放在一起看,会发现它们分别对应了“在哪个维度上做隔离”的选择。空间注意力隔离的是位置,通道注意力隔离的是特征通道。选择哪个维度,取决于任务需要模型关注什么。这个思路迁移到 KV cache 管理上同样成立:你可以选择在序列维度隔离,也可以在批次维度隔离,甚至可以在头维度隔离,关键看瓶颈在哪里。
5. 把隔离思路落到 KV cache 管理上:分页、前缀共享与批次隔离
5.1 连续缓存为什么会碎片化
最朴素的 KV cache 实现是给每个序列预分配一块连续显存,大小按最大序列长度算。这种做法在短序列场景下没问题,但一旦序列长度差异大,就会产生严重的显存碎片。短序列占着大块显存用不完,长序列又可能不够用,整体显存利用率很低。
更麻烦的是,序列是动态增长的。你没法预知一个序列最终会生成多长,预分配多了浪费,预分配少了又要中途扩容。扩容意味着重新分配和拷贝,开销不小。我早期做推理服务时,就因为预分配策略太保守,导致并发上不去,显存明明还有余量却接不了新请求。
这个问题的根源,还是共享状态没有做好隔离粒度。整块连续显存是一个粗粒度的共享单元,一旦分配给某个序列,就没法被其他序列复用。要提升利用率,必须把隔离粒度做细。
5.2 分页缓存:把隔离粒度做细
分页注意力(PagedAttention)的思路借鉴了操作系统的虚拟内存分页。它把 KV cache 切成固定大小的块(block),每个块可以存放若干个 token 的 K、V。序列的 KV 不再要求连续存放,而是通过一张块表(block table)记录逻辑位置到物理块的映射。
这样一来,显存分配的最小单位从“整个序列”变成了“一个块”,碎片问题大幅缓解。序列增长时,只需要按需分配新块,不需要扩容拷贝。不同序列之间还可以共享物理块,只要它们的逻辑内容一致。
这个方案的精髓在于:把粗粒度的共享状态拆成细粒度的隔离单元,再用映射表把隔离单元重新组织起来。这和口令实验里“每个执行流独立状态 + 主种子统一派生”是同一个套路。隔离单元变小了,灵活性就上来了。
5.3 前缀共享:相同前缀的 KV 复用
前缀共享是分页缓存之上的进一步优化。很多请求的前缀是相同的,比如系统提示词、few-shot 示例、多轮对话的历史。这些前缀对应的 KV 是完全一样的,没必要每个序列都算一遍、存一份。
做法是:把前缀对应的物理块标记为共享,多个序列的块表指向同一批物理块。只有当某个序列要修改这些块时(比如前缀之后开始生成不同内容),才触发写时复制,给它分配独立的块。这就是典型的读时共享、写时隔离。
实测下来,在多轮对话场景里,前缀共享能把首 token 延迟降低一半以上,显存占用也能省下相当可观的一块。但要注意,前缀必须精确匹配才能共享,差一个 token 都不行。所以实际部署时,通常会对前缀做哈希,用哈希值做共享索引。
5.4 批次隔离:别让不同序列互相污染
批次隔离是底线。无论怎么共享,不同序列的 KV 在逻辑上必须严格区分。分页缓存通过块表天然实现了这一点:每个序列有自己的块表,读缓存时按自己的块表索引,不会读到别人的块。
但这里有个坑:如果实现时为了省事,把多个序列的 KV 拼在一个大张量里,靠偏移量区分,那一旦序列长度动态变化,偏移量管理就会出错。我见过一个实现,在序列提前结束时没有正确更新后续序列的偏移,导致后续序列读到了前一个序列的残留缓存,生成结果完全乱套。
所以我的经验是:批次隔离一定要用显式的映射结构,不要用隐式的偏移计算。显式映射虽然多一层间接,但正确性有保障,调试也容易。隐式偏移看起来省事,出问题时极难定位。
6. 实操中的坑与经验:从显存估算到并发调优
6.1 显存估算别拍脑袋
KV cache 的显存占用必须提前算清楚,否则很容易出现“模型加载成功但一推理就 OOM”的情况。前面给过公式,这里再强调几个容易忽略的点。第一,注意力头的数量和头维度要按实际模型配置来,不同模型差异很大。第二,批次大小是动态的,估算时要按峰值算,不能按平均值。第三,除了 KV cache,激活值、临时缓冲区也要占显存,通常要留 20% 到 30% 的余量。
我一般会做一个简单的表格,把不同序列长度、不同批次下的 KV cache 占用列出来,部署前对照显存容量做决策。下面是一个示例估算,假设 32 层、32 头、头维度 128、FP16:
| 序列长度 | 批次大小 | KV cache 估算 |
|---|---|---|
| 1024 | 1 | 约 0.5 GB |
| 2048 | 1 | 约 1 GB |
| 4096 | 1 | 约 2 GB |
| 4096 | 8 | 约 16 GB |
| 8192 | 4 | 约 16 GB |
从表里能直观看到,长序列加高并发是显存杀手。实际部署时,要么限制最大序列长度,要么限制并发批次,要么上分页缓存提升利用率,三者通常要组合使用。
6.2 并发调优要盯着首 token 延迟和吞吐
推理服务的两个核心指标是首 token 延迟和整体吞吐。KV cache 对这两个指标的影响方向不完全一致。缓存命中率高时,首 token 延迟低、吞吐高;但缓存占用显存多了,能并发的批次就少了,吞吐反而可能下降。
调优时我一般会先固定序列长度分布,然后扫批次大小,找到吞吐的拐点。拐点之前,增大批次能提升吞吐;拐点之后,显存不够导致批次被截断,吞吐反而下降。这个拐点位置和 KV cache 的显存占用直接相关,所以分页缓存、前缀共享这些优化,本质上都是在把拐点往后推。
还有一个经验:动态批次比静态批次效果好很多。静态批次要等所有序列都结束才能释放资源,短序列会拖累长序列。动态批次可以在序列结束时立刻补入新序列,资源利用率高得多。但动态批次对 KV cache 管理的要求也更高,必须支持细粒度分配和回收。
6.3 复现性问题在推理里同样存在
口令实验里复现性崩掉是因为状态竞争,推理里也有类似问题。如果推理服务用了随机采样(比如 temperature、top-p),而随机源是全局共享的,那并发请求之间会互相影响随机序列,导致同样的输入两次请求得到不同输出。
这在需要结果可复现的场景里是致命的。解决办法和口令实验一样:给每个请求派生独立的随机源,种子由请求 ID 或用户指定的种子派生。这样每个请求内部的随机序列是确定的,请求之间互不干扰。代价是每个请求要维护一份随机状态,但这点开销相比整个推理过程可以忽略。
我在实际项目里就遇到过这个问题:同一个 prompt,用户反馈两次生成结果差异很大,怀疑模型不稳定。排查后发现是全局随机源被并发请求共享,导致采样序列不可复现。改成请求级随机源之后,问题消失。
6.4 监控要盯住缓存命中率和碎片率
KV cache 的监控指标里,除了常规的显存占用,我特别关注两个:缓存命中率和碎片率。命中率反映前缀共享的效果,命中率低说明前缀匹配没做好,或者请求前缀差异太大。碎片率反映分页管理的效果,碎片率高说明块分配策略有问题,可能需要调整块大小。
块大小的选择也有讲究。块太小,映射表开销大,管理复杂;块太大,碎片多,利用率低。实践中我一般从 16 个 token 一块开始试,根据序列长度分布调整。序列普遍很长时,块可以大一些;序列短且差异大时,块要小一些。
7. 从口令到 Transformer:共享状态与隔离问题的通用解法
回过头看,口令实验和 KV cache 管理虽然领域不同,但面对的是同一类问题:共享状态带来效率和一致性收益,但并发访问会破坏隔离,导致不可复现和错误结果。解法也高度一致:找到合适的隔离粒度,把共享状态拆成独立单元,再用某种机制把单元重新组织起来。
口令实验里的隔离粒度是执行流,重组机制是主种子派生。KV cache 里的隔离粒度是块,重组机制是块表映射。多头注意力里的隔离粒度是头,重组机制是拼接投影。视觉注意力里的隔离粒度是窗口,重组机制是移位。通道注意力里的隔离粒度是通道,重组机制是加权。
这个模式可以推广到很多系统设计场景。只要你发现某个共享状态在并发下出问题,就可以问自己三个问题:第一,这个状态能不能拆成更细的独立单元?第二,拆开之后怎么重新组织?第三,重组带来的开销是否小于隔离带来的收益?回答清楚这三个问题,方案基本就出来了。
最后分享一个我自己的习惯:每次设计涉及共享状态的系统时,我都会先在纸上画一条线,左边写“必须共享的”,右边写“必须隔离的”,然后看这条线画在哪里最自然。这条线画对了,后面的实现会顺很多;画错了,后面全是补丁。口令实验教会我的,就是先找这条线,再动手写代码。