ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

KV Cache深度解析:长上下文推理为何越聊越慢及优化实践

KV Cache深度解析:长上下文推理为何越聊越慢及优化实践

1. 从一次线上事故说起:为什么聊到第30轮,响应突然卡成幻灯片

去年冬天,我帮一个做智能客服的朋友排查线上问题。他们的系统刚上线时响应飞快,首字延迟稳定在300毫秒以内。但运营两周后,客服人员开始抱怨:跟同一个用户连续对话超过二十轮,回复速度肉眼可见地变慢,到第三十轮左右,首字延迟直接飙到三四秒,用户以为系统卡死了。

我拿到他们的推理日志一看,模型没换、并发没涨、GPU利用率也没爆,唯一在变的变量是对话轮数。每多聊一轮,显存占用就往上爬一截,延迟跟着涨一截。这不是玄学,这是KV Cache在作祟。

很多刚接触大模型推理的朋友会有一个直觉:模型参数是固定的,推理速度应该也是固定的。但实际部署过的人都知道,同一个模型,处理同样长度的输入,在不同上下文状态下,速度可以差出十倍。这个差距的核心来源,就是KV Cache的管理策略。

这篇文章我想把KV Cache这件事讲透。不是那种“KV Cache就是缓存Key和Value”的一句话科普,而是从它为什么存在、它怎么吃掉显存、长上下文真正的成本结构、以及工程上怎么把它压下去这几个角度,把我在实际项目里踩过的坑和验证过的方案摊开来讲。如果你正在做推理服务、正在被长上下文拖慢、或者单纯想搞明白“AI越聊越慢”的底层原因,这篇应该能帮你省下不少试错时间。

2. KV Cache到底是什么:从自注意力的重复计算讲起

2.1 没有缓存时,每生成一个token都在重算历史

要理解KV Cache,得先回到Transformer的自注意力机制。生成式模型是自回归的,也就是一个token一个token往外吐。假设用户输入了“今天天气”,模型要生成“怎么样”这三个字。

生成第一个字“怎”的时候,模型需要计算“怎”这个位置对前面所有token(“今”“天”“天”“气”)的注意力。生成第二个字“么”的时候,模型又要计算“么”对前面所有token(“今”“天”“天”“气”“怎”)的注意力。注意这里,“今”“天”“天”“气”这几个位置的Key和Value,在两次计算里是完全一样的,但如果不做缓存,模型会把它们重新算一遍。

这就是问题的根源。自注意力本身不记忆,每一步都是全量重算。序列越长,重复计算的量越大。生成第N个token时,前面N-1个token的Key和Value都要重新投影一遍。计算复杂度是O(N²)级别的增长,这就是为什么长上下文又慢又贵。

2.2 缓存Key和Value,把O(N²)压成O(N)

KV Cache的思路非常直接:既然前面token的Key和Value算过一次就不会变,那就把它们存下来,下一步直接拿来用,不重算。

具体来说,在每一层注意力里,模型会把输入投影成Query、Key、Value三个矩阵。生成新token时,只需要计算这个新token的Query、Key、Value,然后把新的Key和Value追加到缓存里,Query去和缓存里所有的Key做点积,得到注意力权重,再对缓存里所有的Value加权求和。

这样一来,每生成一个token,计算量从“重算全部历史”变成“只算当前token + 一次注意力查询”。单步复杂度从O(N²)降到O(N),整体生成N个token的复杂度从O(N³)降到O(N²)。这个优化是数量级的,没有KV Cache,现在的大模型推理根本跑不起来。

提示:KV Cache缓存的是每一层、每一个注意力头的Key和Value,不是Query。Query只跟当前token有关,用完就丢,没有缓存的必要。

2.3 缓存的形状:为什么它跟层数、头数、维度都挂钩

KV Cache不是一个抽象概念,它在显存里是一块实实在在的张量。它的形状由几个因素决定:

  • 层数(num_layers):每一层都有自己的KV Cache,层数越多,缓存份数越多。
  • 注意力头数(num_heads):每个头独立缓存,头数越多,缓存越宽。
  • 头维度(head_dim):每个头的Key和Value向量长度。
  • 序列长度(seq_len):缓存随对话轮数线性增长。
  • 批次大小(batch_size):同时服务多少个请求,就存多少份缓存。

把这几个乘起来,就是KV Cache的总元素数。再乘以数据类型字节数(FP16是2字节,FP8是1字节),就是实际显存占用。我见过不少人只盯着模型参数量算显存,结果上线后发现显存被KV Cache吃掉一大半,就是这个原因。

3. 算一笔账:长上下文真正的成本到底在哪

3.1 显存成本:KV Cache可能比模型本身还大

很多人以为模型权重是大头,其实在长上下文场景下,KV Cache经常反超。

拿一个常见的70亿参数模型举例,假设它有32层、32个注意力头、头维度128。那么每一层每个token的KV Cache元素数是 2(K和V)× 32(头)× 128(维度)= 8192个元素。32层加起来,每个token的缓存是 8192 × 32 = 262144个元素。用FP16存储,每个token占 262144 × 2字节 = 524288字节,约0.5MB。

看起来不多?但这是每个token。如果上下文长度是32K,单个请求的KV Cache就是 0.5MB × 32768 ≈ 16GB。而70亿参数的模型权重,FP16下也就14GB左右。也就是说,一个32K上下文的请求,KV Cache比模型本身还占显存。如果并发10个这样的请求,光缓存就要160GB,普通单卡根本扛不住。

这就是长上下文真正的成本所在。不是模型跑不动,是缓存装不下。

3.2 带宽成本:每生成一个token都要把缓存读一遍

显存占用只是静态成本,动态成本在带宽。每生成一个token,注意力计算都要把整个KV Cache读一遍。缓存越大,读取的数据量越大,而显存带宽是有限的。

还是上面的例子,32K上下文,每生成一个token要读16GB的缓存数据。假设显存带宽是2TB/s,光读缓存就要8毫秒。这还没算计算和其他开销。如果上下文拉到128K,单token读取就是64GB,理论下限32毫秒,首字延迟直接爆炸。

所以长上下文慢,不只是因为计算量大,更是因为内存带宽被KV Cache的读取吃满了。这也是为什么很多长上下文优化方案,核心思路都是“少读一点”或者“读快一点”。

3.3 一个直观的对比表

上下文长度单请求KV Cache(FP16,70亿模型)每token读取量理论读取耗时(2TB/s)
4K2GB2GB1ms
16K8GB8GB4ms
32K16GB16GB8ms
128K64GB64GB32ms

这张表能解释很多现象。为什么4K上下文感觉很流畅,32K就开始卡,128K基本没法交互式使用。不是模型变笨了,是缓存的读取成本随长度线性上涨,而人对延迟的容忍度是有限的。

注意:这里的耗时是理论下限,实际还要叠加计算、调度、通信等开销,真实延迟通常是这个数字的两到三倍。

4. 工程上怎么把KV Cache压下去:几条主流路线

4.1 量化:把FP16压成FP8甚至INT4

最直接的思路是降低每个缓存元素的精度。FP16占2字节,FP8占1字节,INT4占0.5字节。精度降一半,显存和带宽都降一半。

但量化不是免费的。Key和Value对精度的敏感度不一样,Value通常比Key更耐量化。实践中常见的做法是只量化Value,Key保持FP16,或者对Key做分组量化,每组共享一个缩放因子。我试过把KV Cache全量INT4,短上下文下几乎无损,但长上下文下注意力权重会漂移,生成质量明显下降。后来改成Key用FP8、Value用INT4,质量就稳住了。

量化还有一个坑:不是所有推理框架都支持KV Cache量化。有些框架只支持权重量化,缓存还是FP16,你以为省了显存,其实没省。上线前一定要确认框架的缓存量化能力。

4.2 分页与共享:PagedAttention和前缀复用

PagedAttention的核心思想借鉴了操作系统的虚拟内存分页。传统KV Cache要求连续显存,一个长请求可能因为找不到连续空间而失败。PagedAttention把缓存切成固定大小的块,物理上可以不连续,逻辑上通过页表拼接。

这个方案带来两个好处。一是显存碎片大幅减少,利用率能提升到90%以上。二是前缀共享变得容易。多个请求如果有相同的系统提示词,这部分前缀的KV Cache可以共享同一份物理块,不用每个请求都存一遍。在客服、助手这类系统提示词很长的场景,前缀共享能省下可观的显存。

我实测过一个场景,系统提示词占2K token,并发50个请求。不做前缀共享,光提示词的缓存就要 50 × 2K × 0.5MB/K ≈ 50GB。做了共享之后,这部分降到1GB左右,差距非常明显。

4.3 驱逐与压缩:不是所有历史都值得留

长对话里,早期的内容往往不再重要。与其把所有历史都缓存着,不如按重要性驱逐。

常见的策略有几种。H2O(Heavy Hitter Oracle)的思路是保留注意力权重高的token,丢掉权重低的。StreamingLLM的思路是保留开头的几个“注意力汇聚”token和最近的滑动窗口,中间的直接丢。还有基于语义相似度的压缩,把冗余的历史合并。

这些方法的效果因场景而异。我个人的经验是,滑动窗口加注意力汇聚在对话场景下最稳,实现也简单。纯按权重驱逐容易在长文档问答里丢关键信息,因为关键信息可能出现在中间位置,权重不一定高。

提示:驱逐策略一定要配合业务做验证。我见过一个团队直接套用论文的驱逐比例,结果在合同审查场景下把关键条款丢了,生成结果完全不可用。

4.4 架构层面:GQA和MQA从源头减少头数

前面说的都是推理时的优化,其实模型架构本身也能减负。多头注意力(MHA)里每个头都有独立的KV,头数多,缓存就大。分组查询注意力(GQA)让多个Query头共享一组KV头,缓存直接按分组比例缩小。多查询注意力(MQA)更激进,所有Query头共享一组KV,缓存缩到最小。

现在新出的模型大多默认用GQA,比如分组数设为8,缓存就比全MHA小8倍。这是从训练阶段就定好的,推理时不用额外操作,但选模型的时候要留意。如果你要部署长上下文,优先选GQA或MQA架构的模型,能省下大量缓存开销。

5. 实操:在推理服务里把KV Cache管起来

5.1 先量后调:搞清楚缓存到底占了多少

优化之前先测量。我习惯用几个指标定位问题:

  • 单请求缓存占用:用框架的显存分析工具,或者手动按公式估算。
  • 缓存占总显存比例:如果超过50%,说明缓存是瓶颈。
  • 每token延迟随上下文长度的变化曲线:如果延迟随长度线性上涨,基本就是带宽瓶颈。

有了这几个数,才能判断该走量化、分页还是驱逐。盲目上优化,很可能改了半天没效果。

5.2 配置示例:以常见推理框架的参数为例

不同框架参数名不一样,但核心逻辑相通。下面是一组典型的配置思路,用伪代码表示:

# 伪代码,示意KV Cache相关配置 engine_config = { "max_model_len": 32768, # 最大上下文长度 "gpu_memory_utilization": 0.9, # 显存利用率上限 "kv_cache_dtype": "fp8", # 缓存精度 "block_size": 16, # 分页块大小 "enable_prefix_caching": True, # 开启前缀共享 "max_num_seqs": 64, # 最大并发序列数 }

几个参数的经验值:

  • kv_cache_dtype:优先FP8,质量损失小,显存省一半。INT4要谨慎,务必做质量回归。
  • block_size:16或32比较常见。太小页表开销大,太大碎片多。
  • enable_prefix_caching:系统提示词长的场景必开。
  • max_num_seqs:这个值直接决定并发时的缓存总量,要结合显存反推。

反推公式:max_num_seqs × max_model_len × 单token缓存 × 精度字节 ≤ 可用显存。先算清楚再设,别拍脑袋。

5.3 一个真实的调优过程

回到开头那个客服系统。我做的第一件事是量缓存。他们用的是70亿模型,上下文设了16K,并发32。按公式算,单请求缓存8GB,32并发就是256GB,而他们的卡总共才80GB显存。这明显是超配了,框架靠频繁换入换出硬撑,延迟自然高。

调优分三步。第一步,把上下文从16K降到8K,因为实际对话很少超过8K,缓存直接减半。第二步,开启FP8缓存量化,再减半。第三步,开启前缀共享,系统提示词那部分不再重复存。三步下来,32并发的缓存从256GB降到约24GB,显存压力解除,首字延迟回到500毫秒以内。

这个过程说明一个道理:KV Cache优化不是单点技术,是组合拳。长度控制、量化、共享,每一层都能省,叠起来效果才明显。

6. 常见问题与排查速查

6.1 为什么我开了缓存量化,显存没降

最常见的原因是框架只量化了模型权重,没量化缓存。去文档里确认kv_cache_dtype这类参数是否真的生效。另一个可能是缓存本身不是瓶颈,降了也看不出来。先量再调。

6.2 长上下文下生成质量突然变差

优先怀疑缓存量化精度不够,尤其是Key的量化。Key决定注意力权重,量化误差会放大。试试Key保持FP16,只量化Value。如果还不行,检查驱逐策略是不是丢了关键信息。

6.3 并发一高就OOM,但单请求正常

这是缓存总量超了。算一下并发数 × 单请求缓存,跟显存对比。解决办法要么降并发,要么降上下文,要么上分页和共享。别指望框架自动帮你兜底,它只会OOM。

6.4 首token延迟正常,后续token越来越慢

典型的带宽瓶颈。每生成一个token都要读全部缓存,缓存越大越慢。优化方向是降缓存精度、减上下文长度、或者用驱逐策略减少实际读取量。

现象可能原因排查方向
显存随对话轮数持续上涨缓存未释放或未分页检查请求结束后的缓存回收
长上下文质量下降缓存量化精度不足Key保持高精度,Value可量化
高并发OOM缓存总量超显存降并发/降长度/开共享
后续token变慢带宽瓶颈降精度/驱逐/减长度

6.5 几个容易忽略的坑

第一个坑是缓存没及时释放。有些框架在请求结束后不立即回收缓存,导致显存缓慢泄漏。上线前压测长时间运行,观察显存曲线。

第二个坑是前缀共享的命中率。如果每个请求的系统提示词都有细微差异,共享就失效了。要把提示词模板标准化,提高命中率。

第三个坑是驱逐策略和业务冲突。技术上看合理的驱逐,业务上可能丢关键信息。驱逐比例一定要用真实业务数据验证,不能只看论文指标。

7. 我对KV Cache这件事的几点体会

做了几个推理服务项目之后,我越来越觉得KV Cache是那种“不懂会吃亏,懂了能省大钱”的东西。它不像模型架构那么显眼,但直接决定了你的服务能不能扛住长上下文、能不能撑起高并发。

我的建议是,做推理部署之前,先把缓存的账算清楚。单token缓存多大、目标上下文多长、并发多少、显存够不够,这几个数一乘,心里就有底了。很多线上事故,根源就是上线前没算这笔账。

另外,优化要分层做。长度控制是最便宜的,量化是性价比最高的,分页和共享是规模化的关键,驱逐是最后的手段。别一上来就上复杂方案,先把简单的做扎实。

最后分享一个小技巧:压测的时候,专门构造长对话场景,观察延迟随轮数的变化曲线。如果曲线是平的,说明缓存管理到位;如果一路往上翘,说明还有优化空间。这条曲线比任何单点指标都直观。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进