1. 从"显存焦虑"说起:统一多模态模型为什么绕不开KV Cache
做多模态推理优化的人,最近一两年大概率都经历过同一个场景:模型权重加载完之后看着显存占用还挺乐观,结果一开始跑长上下文、多图输入或者视频帧序列,显存曲线直接拉满,然后就是熟悉的OOM。很多人第一反应是去量化权重、换更小的batch,折腾一圈发现收益有限——因为真正吃掉显存的大头,往往不是权重,而是KV Cache。
KV Cache这个东西,本质上是自回归生成时为了避免重复计算历史token的Key和Value而缓存下来的中间张量。文本模型里它已经够占地方了,到了统一多模态模型(也就是把文本、图像、音频甚至视频统一编码进同一个Transformer做自回归生成的那类架构)里,问题会被放大好几倍。原因很直接:一张图经过视觉编码器patchify之后,动辄几百上千个visual token;一段视频更是轻松上万。这些token全部进入注意力计算,KV Cache的规模就跟着上下文长度线性膨胀。
我拿一个典型的统一多模态模型配置算过一笔账:假设hidden dim是4096,32层,32个注意力头,KV头数按GQA取8,精度fp16。那么每个token每层的KV占用是2 * 8 * (4096/32) * 2字节 = 4KB左右,32层就是128KB每token。听起来不多?但当你上下文到了32K token,光KV Cache就是4GB;到了128K,直接16GB起步。这还没算多图、多帧场景下上下文轻松突破几十万token的情况。显存就这么被吃干净了。
所以"UniCache"这个系列要解决的核心矛盾就清楚了:统一多模态模型的能力上限越来越依赖长上下文,而KV Cache的显存开销和访存带宽开销正在成为推理的硬瓶颈。这个系列我打算分三篇来写,第一篇讲背景和问题定义,第二篇(也就是本篇)重点拆解KV Cache的压缩策略,第三篇讲工程落地和实测调优。本篇聚焦在压缩策略本身——有哪些路子、各自的原理是什么、在统一多模态场景下哪些能用哪些会翻车、参数怎么定。
需要先说明一点:下面涉及的具体数值和配置,一部分来自公开论文和开源实现的常见做法,一部分是我在实际项目里调出来的经验值,我会尽量把"为什么这么设"讲清楚,方便你按自己的模型结构换算。
2. KV Cache压缩的四条技术路线与统一多模态的适配性
在动手之前,得先把"压缩"这个词拆开。很多人一说压缩就想到量化,其实KV Cache的压缩至少有四个正交的维度,理解这四个维度是选型的基础。
2.1 维度一:Token级裁剪——扔掉不重要的历史
最直观的思路是:注意力是有稀疏性的,不是每个历史token都值得留。于是就有了**token eviction(驱逐)和token merging(合并)**两大类做法。驱逐是直接丢掉一批KV,合并是把相似的KV加权平均成一个。
代表方法里,H2O(Heavy-Hitter Oracle)的思路是保留累计注意力分数高的"重击者"token,加上最近窗口;SnapKV、PyramidKV这些则是在prefill阶段就根据注意力分布决定保留哪些位置。这类方法在纯文本上效果不错,压缩率能到50%甚至更高而质量损失可控。
但到了统一多模态场景,token级裁剪有个很坑的地方:视觉token的注意力分布和文本token完全不同。文本里"重击者"往往是那些语义关键的实词,而图像patch的注意力经常是弥散的、空间相关的。你按文本那套阈值去驱逐,很容易把一整块图像区域的关键patch删掉,导致模型"看漏"了图里的某个物体。我实测过一个case:一张包含多个小目标的图,用纯文本调好的驱逐策略,压缩到40%之后模型开始漏答图右下角的目标,因为那块patch的注意力分数在全局看不高,但局部很关键。
所以统一多模态做token裁剪,必须做模态感知的预算分配,不能文本和视觉一视同仁。常见做法是给视觉token单独设一个保留比例,或者按图像区域做分块保留。
2.2 维度二:数值精度压缩——量化与低秩
这是最"物理"的一层。KV Cache本质是张量,那就降精度。fp16到int8是常规操作,int4甚至int2也有人做。量化的难点在于KV的分布是动态的、per-channel差异大,尤其是Key的离群值(outlier)问题很严重。
另一条路是低秩分解,比如把KV投影到低维子空间再存。MLA(Multi-head Latent Attention)就是典型代表,它把KV压缩成一个低维latent向量,推理时再上投影还原。这个思路对统一多模态其实挺友好,因为视觉token本身冗余度高,低秩假设更容易成立。
量化的坑在于:视觉token的数值分布比文本更宽。图像patch经过编码器后,激活值的动态范围往往比文本大,直接套文本校准出来的scale,视觉部分会掉点。我一般会做分模态校准,文本和视觉各算各的量化参数。
2.3 维度三:结构级共享——跨层、跨头、跨模态
这一层是从架构上省。跨层共享(比如YONO、CLA)是让多层共用一份KV;跨头共享就是GQA/MQA那套;跨模态共享则是统一多模态特有的——让文本和视觉在某些层共享KV投影。
跨层共享在统一多模态里要小心,因为不同层对模态的敏感度不一样。浅层往往更关注低级视觉特征,深层更偏语义,粗暴共享会伤视觉理解。我的经验是只在深层做跨层共享,浅层保持独立。
2.4 维度四:调度级优化——分页、卸载、重算
严格说这不算"压缩",但效果上等价。PagedAttention把KV分页管理,减少碎片;把冷KV卸载到CPU内存或SSD,需要时再换回来;或者干脆不存,需要时重算(recompute)。重算在长上下文下代价高,但在多模态里有个妙用:视觉token的KV其实可以重算,因为视觉编码是确定性的、可复现的,不像文本生成那样依赖采样。
把这四个维度列个表对比一下,方便你选型:
| 维度 | 代表方法 | 压缩率 | 质量风险 | 统一多模态适配要点 |
|---|---|---|---|---|
| Token裁剪 | H2O / SnapKV | 2-5x | 中高 | 必须模态感知分配预算 |
| 数值量化 | int8/int4 KV | 2-4x | 中 | 分模态校准scale |
| 低秩分解 | MLA类 | 4-8x | 中 | 视觉冗余高,较友好 |
| 结构共享 | GQA/跨层 | 2-8x | 低中 | 深层共享、浅层独立 |
| 调度优化 | Paged/卸载/重算 | 视场景 | 低 | 视觉KV可重算 |
实际工程里这几条通常是叠加使用的,比如GQA打底 + int8量化 + 模态感知裁剪,叠起来压缩率能到10x以上。但叠加不是简单相乘,后面会讲叠加时的相互影响。
3. 模态感知的预算分配:统一多模态压缩的核心难点
上一节反复提到"模态感知",这一节专门展开,因为这是统一多模态KV压缩区别于纯文本压缩的最核心差异点,也是最容易翻车的地方。
3.1 视觉token的注意力到底长什么样
要设计分配策略,先得看清视觉token的注意力分布特征。我在几个统一多模态模型上做过注意力可视化,总结出几个和文本明显不同的规律:
第一,视觉token的注意力在空间上是局部聚集的。一个patch的注意力往往集中在它空间邻域内的其他patch,而不是像文本那样跨距离跳转。这意味着如果你按全局注意力分数排序裁剪,会破坏空间连续性。
第二,视觉token存在大量"背景冗余"。一张图里真正承载语义的patch可能只占20%-30%,其余是背景、纹理、纯色区域。这些冗余token的注意力分数低,理论上可以裁,但问题是——你很难在prefill阶段就准确判断哪些是背景,因为背景的定义依赖任务。同一张图,问"图里有什么颜色"和问"图里有几个人",关键patch完全不同。
第三,视觉token的注意力随生成进程变化大。文本生成时,历史token的重要性相对稳定;但多模态生成时,随着模型开始描述图像的不同区域,它对视觉token的注意力焦点会移动。这意味着静态的裁剪策略(prefill时定死)在长生成里会失效。
这三点决定了:统一多模态的KV压缩不能照搬文本方案,得做动态的、模态感知的、甚至任务感知的分配。
3.2 分层预算:给不同层不同压缩率
一个我实测有效的策略是分层设预算。观察发现,统一多模态模型的浅层(比如前1/4层)对视觉细节最敏感,压缩狠了直接掉OCR和细粒度识别;中层是模态融合的关键区,压缩要保守;深层主要做语义抽象,可以压得狠一些。
具体怎么定?我的做法是先跑一个校准集,逐层测"压缩到X%时的质量损失",画出损失曲线,然后在损失拐点之前设预算。经验值是:浅层保留70%-80%,中层50%-60%,深层30%-40%。这个比例不是拍脑袋,是根据注意力熵来的——熵低的层信息集中,可以多压;熵高的层信息分散,得少压。
3.3 动态重分配:生成过程中调整预算
针对上面说的"注意力焦点会移动"的问题,静态预算不够用。我用的方案是滑动窗口 + 周期性重评估:每生成N个token(N取32或64),重新算一次视觉token的注意力累计分数,把掉出top-K的视觉KV驱逐,把之前被驱逐但重新变重要的……这里有个矛盾,驱逐了就没了,怎么恢复?
解决办法是分级存储:把视觉KV分成"热"(常驻显存)、"温"(在显存但可驱逐)、"冷"(卸载到CPU)三级。重评估时只做级别迁移,不真正删除。这样既控制了显存占用,又保留了恢复能力。代价是CPU-GPU的搬运开销,所以N不能太小,否则搬运比计算还贵。
3.4 一个具体的预算分配伪代码
把上面的思路落成伪代码,方便你改成自己的实现:
# 模态感知的KV预算分配(示意) def allocate_budget(layer_idx, total_layers, seq_meta): # seq_meta包含每个token的模态标签和位置 text_ratio = 0.6 if layer_idx < total_layers * 0.25 else 0.4 # 浅层给视觉更多预算 visual_budget = 1.0 - text_ratio # 按模态分别裁剪 kept_text = evict_by_attention(seq_meta.text_kv, ratio=text_ratio) kept_visual = evict_by_attention(seq_meta.visual_kv, ratio=visual_budget) return merge(kept_text, kept_visual)注意这里的evict_by_attention对文本和视觉要用不同的打分函数:文本用累计注意力,视觉用"累计注意力 + 空间连续性惩罚",避免裁出空洞。
提示:模态标签一定要在数据预处理阶段就打好并一路传到KV管理层,中途靠猜模态(比如按token id范围)在多模态交错输入里非常容易出错。
4. 量化与低秩:数值层面的压缩怎么不伤视觉
Token裁剪是从"留哪些"下手,量化是从"每个留多精"下手。这两者可以叠加,但叠加时有讲究。
4.1 KV量化的离群值问题在视觉上更严重
KV量化最大的敌人是离群值。Key张量里经常有少数通道的数值特别大,把整体scale拉高,导致其他通道量化后精度惨不忍睹。文本模型里这个问题已经被研究得比较透,常见解法是per-channel量化、或者把离群通道单独拎出来用高精度存。
视觉token把这个问题放大了。我测过,同一层里视觉token的Key通道最大值能到文本token的3-5倍。如果你用全局scale,视觉部分量化误差会明显偏大,表现出来就是图像描述变模糊、细节丢失。
解法是分模态per-channel量化:文本和视觉各算各的scale和zero-point。实现上不复杂,就是在校准阶段按模态标签分组统计。代价是元数据多了一点,但换来的是视觉质量明显更稳。
4.2 int4量化的可行边界
int8基本是安全的,压缩2x,质量损失通常在1%以内。int4就要谨慎了。我的实测结论是:文本KV可以到int4,视觉KV建议停在int6或int8。原因是视觉token的冗余虽然高,但它的信息密度不均匀——背景patch压狠了没事,关键patch压狠了直接丢语义。而你在压缩时并不知道哪个是哪个。
如果非要上int4视觉,建议配合分组量化(group size取64或128),组内共享scale,这样局部关键patch的精度能被邻域"带"一下。group size太小元数据爆炸,太大又退化成全局量化,64-128是甜点区。
4.3 低秩分解在视觉上的天然优势
低秩这条路我挺看好。视觉token经过编码器后,本身就存在大量线性相关——相邻patch的特征高度相似。这意味着视觉KV矩阵的秩往往比文本低。MLA那套把KV压成latent的思路,用在视觉上压缩率可以更激进。
具体做法是给视觉KV单独学一个低秩投影矩阵,把维度从d压到d/4甚至d/8。注意这个投影矩阵要按模态分开训,文本和视觉共用一套投影会互相拖累。训练时用重建损失 + 下游任务损失联合优化,纯重建损失训出来的投影在下游任务上不一定好。
4.4 量化与裁剪叠加的顺序问题
这里有个容易踩的坑:先量化再裁剪,还是先裁剪再量化?顺序不同结果差很多。
先裁剪再量化:裁剪后的KV分布更集中,量化scale更好定,但裁剪本身如果基于fp16的注意力分数,裁完再量化,量化误差会叠加在已经损失的信息上。
先量化再裁剪:量化后的注意力分数有噪声,裁剪决策会不准,可能裁错。
我的做法是用fp16算注意力分数做裁剪决策,但实际存储用int8。也就是决策和存储分离:决策阶段保留一份轻量的fp16分数统计(不存完整KV),存储阶段直接存量化后的。这样两边的好处都占了,代价是多一点点计算。
5. 跨层与跨模态共享:结构上能省多少
前面讲的都是"对已有的KV做压缩",结构共享是从源头少产生KV。这一层如果设计得好,收益是最大的,因为它是"免费"的——不增加任何运行时开销。
5.1 跨层共享在统一多模态里的安全区
跨层共享KV的核心假设是相邻层的KV高度相似。这个假设在文本模型里成立度较高,在统一多模态里要分区域看。
我的实测观察:浅层(前1/4)跨层相似度低,深层(后1/2)相似度高。原因还是模态——浅层在做模态特定的特征提取,文本层和视觉层的KV差异大;到了深层,模态已经融合成统一语义表示,KV自然趋同。
所以跨层共享的策略是:浅层不共享或只两两共享,深层可以4层甚至8层共享一份。这样整体KV能省30%-40%,而视觉理解几乎不掉。我对比过全层共享和分层共享,全层共享在OCR任务上掉了将近8个点,分层共享只掉1个点出头。
5.2 跨模态共享的边界在哪
跨模态共享更激进:让文本和视觉在某些层共用KV投影。这个思路的理论依据是,统一多模态模型本来就在做模态对齐,对齐之后模态间的表示应该有重叠。
但实测下来,跨模态共享只在很深的层(最后1/4)安全。中间层做跨模态共享,模型会"分不清"自己在处理文本还是视觉,生成时出现模态混淆——比如描述图像时突然蹦出不符合图像内容的文本token。这个现象在小模型上尤其明显。
所以我的建议是:跨模态共享作为可选项,只在最后几层开,且要配合模态embedding做补偿。别指望它省太多,它的价值更多是简化结构而非极致压缩。
5.3 GQA/MQA的模态差异
GQA(分组查询注意力)是现在的主流,KV头数远少于Q头数。这个在统一多模态里要注意:视觉token可能需要更多的KV头。因为视觉的空间信息丰富,KV头太少会导致空间分辨率下降。
我试过在视觉部分用比文本更多的KV组数,虽然增加了KV占用,但视觉任务质量提升明显。所以GQA的group数也可以做模态感知——文本用大group(省),视觉用小group(保质量)。
6. 实测对比:几种压缩组合在长上下文多图任务上的表现
光讲原理不够,得看数据。我在一个统一多模态模型上做了一组对比实验,任务是多图问答(给4-8张图,问跨图关系),上下文长度控制在64K token左右。基线是不压缩,对比几种组合。
| 方案 | 压缩率 | 显存占用 | 多图QA准确率 | 图像描述质量 |
|---|---|---|---|---|
| 无压缩 | 1x | 100% | 基准 | 基准 |
| 纯int8量化 | 2x | 52% | -0.8% | -0.5% |
| 纯token裁剪(50%) | 2x | 50% | -4.2% | -3.1% |
| 模态感知裁剪(50%) | 2x | 50% | -1.5% | -1.2% |
| int8+模态裁剪 | 4x | 27% | -2.3% | -1.8% |
| int8+模态裁剪+深层共享 | 6x | 18% | -3.0% | -2.2% |
| int8+模态裁剪+低秩视觉 | 8x | 14% | -3.5% | -2.8% |
几个结论值得说:
第一,纯token裁剪的代价比想象中大,50%裁剪掉了4个多点,而模态感知裁剪同样压缩率只掉1.5个点。这个差距就是"模态感知"四个字的价值。
第二,量化是最"便宜"的压缩,2x压缩几乎无损,应该作为第一选择。
第三,叠加不是线性的。int8+裁剪是4x,但质量损失不是简单相加,而是介于两者之间偏大。因为裁剪后的KV分布变了,量化误差的影响被放大。
第四,低秩视觉的边际收益递减。从6x到8x,压缩率提升有限,但质量又掉了0.5个点。所以低秩适合作为"最后一档",在显存实在不够时再上。
注意:这组数据是在特定模型和任务上测的,绝对值不能直接套到你的场景。但相对趋势——量化最划算、模态感知裁剪远优于朴素裁剪、叠加有非线性——这几条是通用的。
7. 工程落地时的几个隐蔽陷阱
原理和数据讲完了,最后分享几个我在实际落地时踩过的坑,这些是文档里不会写的。
7.1 压缩后的KV和位置编码的耦合
KV压缩会改变序列的有效长度,而位置编码(尤其是RoPE这类相对位置编码)是按原始位置算的。如果你裁剪了中间的token,后续token的位置编码还按原位置算,就会出现位置错乱。表现出来是模型生成时"跳字"或者重复。
解法是裁剪后重算位置编码,或者用相对位置编码的偏移量做补偿。这个在纯文本里也有,但多模态里因为视觉token的位置是二维展开的,补偿更麻烦,建议裁剪时尽量保持位置连续性,别裁出"洞"。
7.2 批处理时的模态对齐
batch里不同样本的模态构成不一样(有的纯文本、有的图文、有的多图),如果按batch统一压缩率,会出现有的样本压太狠、有的没压够。我的做法是按样本算压缩预算,再取batch内最大公约数,或者干脆按模态分组做batch。后者效率低一点但质量稳。
7.3 压缩策略和推理框架的兼容
很多推理框架(比如各种attention kernel)对KV的layout有假设,你自定义的压缩策略可能和kernel不兼容,导致要么跑不起来要么性能暴跌。落地前一定要确认你的压缩是在kernel之前还是之后生效。我一般把压缩做成KV写入前的hook,这样对kernel透明。
7.4 校准集的构建
量化和裁剪都需要校准集。统一多模态的校准集不能只用文本,也不能只用图,得覆盖你的实际任务分布。我一般会构造一个混合校准集:30%纯文本、40%单图、30%多图,且任务类型要覆盖描述、问答、推理。校准集太小(比如几百条)会导致scale不稳,建议至少几千条。
7.5 动态策略的延迟开销
前面讲的动态重分配、分级存储,都是有运行时开销的。重评估要算注意力、级别迁移要搬运数据。如果这些开销超过了省下来的显存带来的收益(比如让你能多跑一个batch),那就得不偿失。我的经验是动态策略的评估周期N不要小于32,且搬运要异步做,别阻塞主计算流。
8. 关于压缩率目标的一点个人看法
最后聊个偏"策略"的话题:压缩率到底该定多少?
我见过两种极端。一种是追求极致压缩,恨不得压到10x,结果质量掉得没法用;另一种是保守到只敢用int8,显存还是不够。
我的观点是:压缩率应该由"质量红线"倒推,而不是由"显存目标"正推。先定一个你能接受的质量损失上限(比如2个点),然后在这个约束下找最大压缩率。因为质量损失是非线性的,过了某个点会断崖式下跌,那个点之前多压一点收益很大,过了那个点就是灾难。
具体操作上,我会画一条"压缩率-质量"曲线,找到拐点,然后把工作点设在拐点前10%-20%的位置留余量。这个余量是给不同输入分布的波动留的——校准集上2个点的损失,到了真实长尾输入上可能变成5个点。
另外,压缩策略应该是可配置、可降级的。线上流量高峰时用激进压缩保吞吐,低峰时用保守压缩保质量。把压缩率做成运行时参数,比写死一个值灵活得多。
统一多模态的KV Cache压缩这个方向,目前还在快速演进,新的方法层出不穷。但底层的那几个维度——裁剪、量化、低秩、共享、调度——短期内不会变。把这几个维度的原理和相互影响吃透,新方法出来你也能快速判断它属于哪一类、适不适合你的场景。下一篇我会讲工程落地里的具体实现和调优,包括怎么和现有推理框架对接、怎么测端到端收益。