大模型推理这件事,真正做过线上部署的人都有一个共同体会:模型权重加载完之后,真正的麻烦才刚刚开始。显存里那块被KV Cache吃掉的空间,往往比模型本身还大,而且它随着并发数和上下文长度动态膨胀,稍不留神就把服务打爆。我从最早用单卡跑7B模型做demo,到后来在集群上折腾长上下文推理服务,踩过的坑基本都围绕着一个核心问题——显存里的KV Cache到底该怎么管。这篇文章不打算复述Transformer的注意力公式,而是想从工程视角,把KV Cache到AI内存系统这条演进线索讲清楚,顺带把每一步背后的取舍逻辑拆开。如果你正在做推理服务、显存优化,或者单纯好奇为什么现在的推理框架都在讲PagedAttention、前缀共享、分层缓存这些概念,那这篇内容应该能帮你把零散的知识点串成一条线。
1. 先把KV Cache这件事说透:它为什么存在,又为什么让人头疼
1.1 自回归解码的重复计算问题
Transformer的解码是逐token生成的。每生成一个新token,模型都要对当前序列做一次注意力计算。如果每次都对完整序列重新算一遍Key和Value,那第n个token的生成就要做n次矩阵乘法,整体复杂度是O(n²)级别的重复劳动。这在早期demo阶段无所谓,因为序列短、请求少。但一旦上生产,batch里几十个请求、每个请求上下文几千token,重复计算直接把GPU算力浪费在已经算过的东西上。
KV Cache的思路非常直接:把每个已生成token对应的Key和Value张量缓存下来,下一个token只需要计算自己的Query,然后和缓存的K、V做注意力即可。这样每步的计算量从O(n²)降到O(n),代价是显存里多存了一份K和V。
具体占多少?以常见的配置估算:层数L、注意力头数H、头维度d、序列长度S、batch大小B、精度字节数p(FP16为2),KV Cache大小约为2 × L × H × d × S × B × p。拿一个13B模型举例,L=40、H=40、d=128,单条4096长度序列的KV Cache就是2×40×40×128×4096×2 ≈ 3.4GB。注意这只是单条序列,batch开到16就是54GB,比模型权重还夸张。这就是为什么长上下文和高并发场景下,KV Cache成了显存的第一大消耗源。
1.2 显存碎片与动态增长的矛盾
KV Cache最麻烦的地方在于它不是静态分配的。请求来了要分配,请求走了要释放,序列还在不断变长。早期做法是给每个请求预分配一个最大长度的连续显存块,比如按4096长度预留。问题是大部分请求根本用不到那么长,预分配造成巨大浪费;而如果按需增长,又会产生大量不连续的碎片,GPU显存分配器在碎片化严重时会出现"明明总量够却分配不出来"的尴尬。
我最早遇到这个问题是在一个对话服务上,单卡24G,模型占14G,剩下10G理论上能撑不少并发。结果压测时并发刚过8就开始OOM,排查发现就是预分配策略太粗放,每个请求都按2048预留,实际平均只用300多token,浪费了将近七成。这个经历让我意识到,KV Cache的管理不能停留在"分配一块内存"的层面,它需要一套真正的内存管理系统。
1.3 从"缓存"到"系统"的认知转变
很多人一开始把KV Cache当成一个简单的数据结构,觉得无非是存K和V的数组。但当你真正要优化它时,会发现它涉及的问题和操作系统的虚拟内存管理惊人地相似:分配、回收、碎片整理、换入换出、共享。这就是"AI内存系统"这个说法出现的背景——它不再是一个被动缓存,而是一个需要主动调度、分层管理、支持共享的完整系统。
这个认知转变很关键。一旦你把它当系统看,很多设计就有了理论依据:分页机制解决碎片、引用计数解决共享、分层存储解决容量、调度策略解决吞吐。后面几节我会沿着这条线,把每个机制讲清楚它解决了什么、怎么实现的、实际用起来什么感觉。
2. PagedAttention:把操作系统的分页思想搬进显存
2.1 连续分配的浪费到底有多严重
在PagedAttention出现之前,主流做法是连续分配。每个序列的KV Cache在显存里占一段连续空间,按最大可能长度预留。这种做法的浪费来自三个方向:预留但未使用的内部碎片、序列间无法复用的外部碎片、以及为了防碎片而过度预留的安全余量。
有研究统计过,在真实负载下,连续分配的显存有效利用率往往只有20%到40%。也就是说你买了100G的显存,真正存了有用KV的只有二三十G,其余都在空转。这个数字在长上下文场景下更难看,因为预留长度要按最坏情况来,而最坏情况可能比平均值大一个数量级。
2.2 分页机制的核心设计
PagedAttention的思路是把KV Cache切成固定大小的块(block),每个块存若干个token的K和V。序列不再要求连续,而是通过一张块表(block table)把逻辑上连续的token映射到物理上分散的块。这和操作系统用页表把虚拟地址映射到物理页帧是同一个套路。
这样做的好处立竿见影。首先,块按需分配,序列长到哪就分配到哪,内部碎片最多浪费一个块的大小,而不是整个预留长度。其次,块可以来自显存中任意空闲位置,外部碎片问题基本消失。第三,块表让共享变得自然——两个序列如果前缀相同,可以让它们的块表指向同一批物理块,实现前缀共享。
块大小是个需要权衡的参数。块太小,块表本身占空间、管理开销大;块太大,内部碎片又上来了。实践中常见的块大小是16个token,这个值在多数负载下能取得不错的平衡。我在自己的测试里对比过8、16、32三档,16在吞吐和显存利用率上综合表现最好,32在长序列上略优但短序列浪费明显。
2.3 实际部署中的观察
切换到PagedAttention之后,最直观的变化是同样显存能撑的并发数上去了。之前那个24G卡跑8并发就OOM的服务,换成分页管理后稳定跑到20以上,显存利用率从三成提到七成多。但也不是没有代价:块表的查找和间接寻址带来了一点额外开销,在极短序列、超高并发的场景下,这个开销占比会上升。
提示:分页机制在长序列、中等并发下收益最大。如果你的场景全是几十token的短请求,分页带来的管理开销可能抵消掉部分收益,这时候要实测对比再决定。
还有一个容易忽略的点:块表的生命周期管理。请求结束时必须正确回收所有块,否则会泄漏。我在早期自己实现时漏掉了一个异常分支的回收逻辑,结果服务跑几天后显存缓慢增长,最后OOM。这类bug很隐蔽,因为正常路径没问题,只有异常路径才触发。用成熟框架能避免这类问题,但理解它的回收机制仍然必要。
3. 前缀共享与引用计数:让相同的内容只存一份
3.1 系统提示词的重复存储问题
现在很多应用都会给每个请求加一段固定的系统提示词,比如角色设定、格式要求、few-shot示例。这段前缀可能几百到几千token,而且每个请求都一样。如果每个请求都独立存一份前缀的KV Cache,那就是纯粹的浪费。
前缀共享要解决的就是这个问题:把相同前缀的KV块在多个序列间共享,物理上只存一份。实现上,当新请求的前缀和已有序列匹配时,直接让新序列的块表指向已有的物理块,而不是重新计算和存储。
这里有个前提:前缀的KV必须已经算好。如果前缀是固定的,可以预先计算并缓存,新请求直接复用,连prefill的计算都省了。这就是所谓的"前缀缓存"(prefix caching),在系统提示词很长的场景下,能把首token延迟降一大截。
3.2 引用计数怎么保证安全回收
共享带来一个新问题:什么时候能释放一个块?如果块被多个序列共享,某个序列结束了不能直接释放,因为别的序列还在用。解决办法是引用计数——每个物理块维护一个计数器,记录有多少序列在引用它。序列结束时计数减一,减到零才真正回收。
这个机制听起来简单,但实现时要注意原子性。在高并发下,多个请求可能同时增减同一个块的计数,如果操作不是原子的,就会出现计数错误,要么提前释放导致数据损坏,要么永远不释放导致泄漏。成熟框架会用锁或者原子操作来保证,自己实现时这是必须小心的地方。
另一个细节是写时复制(Copy-on-Write)。如果两个序列共享了前缀块,但其中一个序列要继续生成新token,它需要往块里写数据。这时候不能直接写共享块,否则会影响另一个序列。做法是复制一份出来单独写。这个机制保证了共享的安全性,代价是首次写入时多一次拷贝。
3.3 共享粒度与命中率的权衡
共享的粒度直接影响命中率。按块共享意味着只有完整块相同才能共享,如果两个前缀只差最后一个不完整的块,那前面的块仍然能共享,只是最后一块要各自算。这个设计在实践中命中率相当高,因为系统提示词通常很长,差异往往出现在末尾。
我实测过一个场景:系统提示词固定2000token,用户输入平均200token。开启前缀共享后,prefill阶段的计算量下降了约85%,首token延迟从400多毫秒降到80毫秒左右。这个收益在交互式应用里体感非常明显。但要注意,如果每个请求的前缀都不一样,共享就无从谈起,这时候收益为零,反而多了查找匹配的开销。
4. 分层缓存:当显存装不下时往哪里放
4.1 显存、内存、存储的速度容量阶梯
显存再大也有上限,而长上下文场景下KV Cache的需求可以无限增长。这时候就需要分层:热数据放显存,温数据放主机内存,冷数据放本地存储。这三层的带宽和容量差异巨大——显存带宽是TB/s级,主机内存是百GB/s级,本地存储是GB/s级,容量则反过来。
分层缓存的核心问题是:哪些KV该放在哪一层,什么时候换入换出。这又回到操作系统的页面置换问题。LRU(最近最少使用)是最常用的策略,但在推理场景下不一定最优,因为序列的访问模式有特点:正在生成的序列访问频繁,已完成的序列可能再也不访问,而等待中的序列处于中间状态。
4.2 换入换出的时机与代价
把KV从显存换出到内存,需要一次数据传输,这个传输本身要占用带宽和时间。如果换出太频繁,传输开销会吃掉收益;如果换出太保守,显存又不够用。所以换出策略要预测哪些块短期内不会被访问。
一个实用做法是结合序列状态:正在活跃生成的序列的块留在显存,被抢占(preempted)的序列的块换出到内存,已完成的序列如果可能被复用(比如多轮对话)则保留一段时间。这种基于状态的策略比纯LRU更贴合推理负载。
换入的代价更敏感,因为它直接影响延迟。当一个被换出的序列要恢复生成时,需要把它的KV块从内存搬回显存,这个延迟会体现在用户感知的响应时间上。所以换入要尽量提前,或者用异步传输掩盖延迟。
4.3 实际系统中的分层实践
在实际部署里,分层缓存通常和调度器配合。调度器决定哪些序列这一轮参与计算,缓存管理器负责把这些序列需要的块准备好。如果显存不够,调度器会先抢占一些序列,把它们的块换出,腾出空间给高优先级序列。
这里有个反直觉的点:有时候主动换出比被动OOM更好。当显存接近满时,与其等到分配失败再处理,不如提前把低优先级序列换出,保证高优先级序列的稳定。这种"预防式"换出需要监控显存水位,设置合理的阈值。
注意:分层缓存引入了数据一致性风险。换出到内存的块如果被修改(比如写时复制),必须同步更新,否则换回来的是旧数据。这个同步逻辑要仔细设计。
我在测试分层缓存时发现,主机内存的带宽往往是瓶颈。如果换入换出太频繁,内存带宽被打满,反而拖慢整体。所以分层不是免费的,它适合显存确实不够、且序列有明显的冷热分化的场景。如果所有序列都活跃,分层帮不上忙。
5. 调度与批处理:内存系统之上的决策层
5.1 连续批处理与内存的互动
连续批处理(continuous batching)是提升吞吐的关键技术:不等一个batch里所有序列都结束,而是每当有序列完成就立刻填入新序列。这让GPU尽量保持满载。但它对内存系统提出了更高要求——序列的进出变得频繁,KV Cache的分配和回收压力大增。
连续批处理和PagedAttention是绝配。因为分页让分配回收变得轻量,连续批处理才能高效运转。如果还是连续分配,频繁的进出会导致严重碎片,连续批处理根本跑不起来。这两个技术是相互成就的关系。
5.2 抢占策略:谁该让出显存
当显存不够时,必须抢占一些序列。抢占的粒度可以是整个序列,也可以是部分块。整序列抢占简单,但可能抢占过多;部分块抢占精细,但实现复杂。多数框架采用整序列抢占,把被抢占序列的块换出或直接丢弃(如果重新计算比换出更划算)。
丢弃还是换出,是个值得算的账。如果序列很短,重新计算的代价可能小于换入换出的传输代价,那就直接丢弃,等它恢复时重算。如果序列很长,重算代价高,就换出保留。这个决策要基于序列长度和当前负载动态判断。
5.3 公平性与吞吐的取舍
调度还要考虑公平性。如果总是优先服务短序列,长序列可能饿死;如果严格按到达顺序,短序列又被长序列堵住。实践中常用的是折中策略,比如给每个序列分配一定的计算配额,或者按序列已等待时间动态调整优先级。
这个取舍没有标准答案,取决于业务。交互式对话要求低延迟,倾向于优先短序列和活跃序列;批量处理要求高吞吐,可以接受长序列等待。理解自己的业务特征,才能选对调度策略。
6. 从工程视角看这套系统的演进逻辑
6.1 每一步都在解决前一步暴露的问题
回头看这条演进线,逻辑其实很清晰。最开始KV Cache只是朴素缓存,暴露了显存浪费问题,于是有了分页管理;分页之后发现相同前缀重复存储,于是有了共享和引用计数;共享之后显存还是不够,于是有了分层;分层和连续批处理结合,又对调度提出了新要求。每一步都是被前一步的问题逼出来的,不是凭空设计。
理解这个因果链,比记住每个技术的名字更重要。因为你的场景可能只走到其中某一步就够了,不需要全套上。比如短上下文、低并发的场景,朴素KV Cache完全够用,上分页反而是过度设计。
6.2 不同规模下的选型建议
| 场景特征 | 推荐方案 | 理由 |
|---|---|---|
| 单请求、短上下文 | 朴素KV Cache | 无并发压力,管理开销不值得 |
| 中等并发、变长序列 | PagedAttention | 解决碎片,提升显存利用率 |
| 固定长前缀、多请求 | 分页+前缀共享 | 大幅降低prefill计算 |
| 超长上下文、显存紧张 | 分页+共享+分层 | 容量不够时靠分层兜底 |
| 高吞吐批处理 | 全套+连续批处理 | 吞吐优先,接受调度复杂度 |
这张表不是绝对的,实际选型要压测验证。我见过有人在小规模场景硬上全套,结果管理开销比收益还大,得不偿失。
6.3 自己实现还是用框架
如果你的目标是快速上线,直接用成熟推理框架,它们把这些机制都实现好了,而且经过大量生产验证。自己实现的价值在于理解原理和特殊定制。我建议至少自己实现一个简化版的PagedAttention,哪怕只支持单序列,这个过程能让你真正理解块表、引用计数、写时复制这些概念,用框架时遇到问题也能快速定位。
自己实现时最容易出错的地方是并发安全和异常路径的回收。正常流程跑通不难,难的是高并发下的计数正确性和各种异常分支的资源释放。这两块要重点测试。
7. 几个实际踩过的坑和对应经验
7.1 块大小选错导致吞吐上不去
前面提过块大小要权衡,但具体怎么选很多人凭感觉。我的经验是先用16跑基准,然后根据负载特征调整。如果序列普遍很长(几千token以上),可以试32,减少块表开销;如果序列普遍很短(几百token),试8或16,减少内部碎片。调整后一定要压测对比吞吐和显存利用率,不要只看单一指标。
有个细节:块大小一旦确定,已分配的块不能改大小,所以调整需要重启服务。这意味着块大小是个部署时决策,不能动态改。选之前想清楚。
7.2 前缀共享的匹配开销被低估
前缀共享要查找是否有可复用的前缀,这个查找本身有开销。如果前缀库很大,查找可能成为瓶颈。实践中要用哈希或者前缀树来加速匹配,不能线性扫描。我早期用线性扫描,前缀库上千条时,匹配耗时占了prefill的相当比例,后来换成前缀树才降下来。
另外,共享的前缀块要防止被意外修改。写时复制机制必须正确实现,否则一个序列的写入会污染其他序列的KV,导致输出错乱。这种bug很难复现,因为依赖特定的并发时序。
7.3 分层缓存的传输成为新瓶颈
分层缓存听起来美好,但传输带宽是硬约束。如果换入换出太频繁,PCIe带宽被打满,整体性能反而下降。我的经验是给换入换出设置速率限制,并且优先保证换入的带宽,因为换入影响延迟,换出可以慢慢来。
还有一个坑是内存碎片。主机内存也会碎片化,频繁分配释放大块内存会导致分配失败。用内存池预分配能缓解这个问题。
7.4 监控指标要选对
优化这套系统,监控是关键。要盯的指标包括:显存利用率、块利用率、共享命中率、换入换出频率、抢占次数、首token延迟、每token延迟。这些指标能帮你判断瓶颈在哪。比如显存利用率高但块利用率低,说明碎片或预留过多;共享命中率低,说明前缀差异大,共享没收益。
我习惯在压测时把这些指标都打出来,对照着看。单看一个指标容易误判,组合起来才能定位问题。
8. 写在最后的一点个人体会
这套东西我从头到尾折腾下来,最大的感受是:推理优化本质上是内存管理问题。算力固然重要,但在大多数实际场景里,瓶颈在显存、在带宽、在调度,而不是在FLOPS。谁能把内存管好,谁就能在同样的硬件上跑出更好的效果。
另一个体会是,不要盲目追新。每个机制都有适用场景,用之前先问自己:我的瓶颈到底在哪?是显存不够,还是延迟太高,还是吞吐上不去?定位清楚再选方案,比堆技术栈有效得多。我见过太多人上来就全套PagedAttention加分层,结果负载根本不匹配,白折腾。
最后,这套系统的演进还在继续,新的思路不断出现。但底层的逻辑——分页、共享、分层、调度——是相对稳定的。把这些搞懂,新东西出来你也能快速判断它解决了什么问题、适不适合你的场景。这比记住某个具体框架的API有用得多。