前阵子做多模态检索系统的上线前评估发现一个特别有意思的现象同一个理解型模型在描述“城市街景”时表现稳定一旦切换到“实验室仪器密集排列”的图片输出质量就明显下滑。我把图像编码器、文本编码器、数据增强轮番查了一遍最后才把问题定位到注意力分布上——模型把过多的注意力权重倾注到了背景区域关键前景物体的 token 信息被稀释了。那次排查之后我开始系统整理一套针对多模态理解与生成模型的注意力分析方法代号叫 UniCache完整名称是 Unified Attention Cache。这个系列一共三篇第一篇就是这篇“注意力分布纵览”核心是把模型内部每一层、每一个注意力头head的注意力权重捞出来从统计视角看它们的形态、规律和异常为后续做缓存压缩和推理加速提供量化依据。这篇博文把第一阶段的方法、代码和观察结果完整记录下来适合做模型推理优化、可解释性分析、多模态应用开发的读者参考。对刚入门的朋友我也会把注意力机制的基础概念穿插讲清楚不用担心看不懂。1. 先把概念对齐多模态模型的注意力权重为什么值得看1.1 理解型与生成型模型注意力扮演的角色完全不同多模态模型虽然名字都带“多模态”内部结构却差异巨大大体上分两类。理解型模型接收图像和文本输出语义标签、描述文本或者问答结果。它的典型结构是一个视觉编码器把图像切成若干图像 token一个文本编码器把文字切成文本 token然后通过融合层让两类 token 互相交换信息。注意力在这里承担的是“信息路由”的角色——决定图像 token 的哪些内容要被文本 token 关注反之亦然。生成型模型以文生图扩散模型为代表。它接收文本条件输出去噪后的图像。文本 token 通过交叉注意力cross-attention持续注入去噪过程图像 token 之间则通过自注意力self-attention协同演化。这个场景下注意力承担的是“条件传导”的角色——如果交叉注意力没有把文本语义正确传达到图像特征空间生成的图像就会出现“词不达意”的情况。无论是哪类模型注意力分布本质上就是一张“信息流量表”。它告诉你哪些 token 参与度高、哪些 token 被忽略、每一层在做多细粒度的语义聚合。我见过太多人只盯着模型最终的精度指标一旦效果不好就盲目调数据、调学习率却忽略了一个基础事实——注意力分布就是模型决策过程的原始记录提前看它能省掉大量无效试验。提示这篇博文里所说的“注意力分布”在实现层面指 softmax 之后的注意力权重矩阵形状为[batch, heads, seq_len, seq_len]。它表示每个 token 对其他 token 的注意力权重占比行和为 1。理解这一点是后续所有分析的前提。1.2 从注意力分布能读出四类关键信号把注意力权重拿出来之后不能只看一两张热力图要建立量化的观察维度。我在实践中最常用到四类信号。信息瓶颈信号。如果某一层的注意力熵极低说明模型把几乎所有权重都压在少数几个 token 上。少量信息会高度集中这时候该层就成了信息瓶颈。瓶颈出现得太早表示模型过早做了语义决策后续层想修正也难。冗余计算信号。同一层不同 head 的分布经常出现高度雷同的情况或者某些 head 的注意力近似均匀分布几乎不提供差异化信息。这些 head 就是典型的冗余计算剪掉它们对效果影响很小但能省掉实实在在的计算开销。跨模态对齐质量信号。在理解型模型里文本 token 到底有没有关注到对应的图像区域直接反映模态对齐的质量。比如文本中出现“红色杯子”注意力权重就应该在图像中杯子的位置形成峰值。如果没有峰值说明跨模态对齐失效了模型在瞎猜。生成聚焦模式信号。对生成模型来说不同去噪阶段注意力分布的差异性非常大。前期关注全局布局中期关注轮廓结构后期关注局部细节。如果分布形态和这个基本规律偏离生成结果大概率会出问题。这四个信号是贯穿全文的主线后面每一节实测分析都在回应这些信号。2. UniCache 第一阶段的设计做什么、不做什么2.1 项目三个里程碑的逻辑顺序UniCache 的最终目标不是“分析注意力”本身而是给多模态模型设计一套统一的注意力缓存与压缩策略压缩掉冗余计算把推理速度提上去。整个项目我拆成了三个阶段阶段一注意力分布纵览。量化理解模型和生成模型内部注意力权重的形态、熵值、集中度、跨层演化规律。阶段二设计缓存压缩策略。基于阶段一的分布特点对低信息区域的键值缓存做压缩、复用、提前截断。阶段三后半程推理加速验证。把压缩策略接到真实推理管线里做端到端的性能与质量评估。这个顺序不能颠倒。没有阶段一的量化认识阶段二就是盲人摸象。比如你对某一层 head 的冗余程度没有统计概念怎么敢对它做剪枝你对生成阶段哪些时步的注意力熵高不清楚怎么设计时间步级的缓存保留策略所以第一篇“纵览”是整个项目的基石内容上偏向分析与观测但每个观察结论都会直接指导后续工程决策。2.2 评测对象与软硬件基线为了不让结论偏向单一模型结构我选了两种典型的多模态模型形态。理解型模型视觉 Transformer 编码器加轻量语言模型头参数规模在几亿级别。这类模型在图文检索、图像描述任务上很有代表性。生成型模型文生图扩散模型参数规模在十亿级别。它内含完整的自注意力与交叉注意力模块是生成场景中注意力分析的理想对象。软件和硬件方面我用的是目前主流的开源深度学习框架半精度推理后端为单卡 GPU显存 32GB 级别。半精度是很关键的设置它能让注意力权重矩阵的存储开销直接减半对后续全量提取分析帮助很大。注意如果你本地的显存只有 16GB也不用慌。第 6.2 节会讲分阶段提取和降采样策略32GB 不是硬性门槛。2.3 样本设计原则别让单场景结论骗了你注意力分布对输入内容高度敏感。同一个模型喂一张“纯色背景下的单个苹果”和喂一张“餐桌上七八种水果互相遮挡”的图片注意力形态差异巨大。所以我准备样本时坚持一个原则场景覆盖必须多元每个场景的样本量不用太大但要足以支撑统计结论。我最终选定了五类场景单主体物体图物体在画面中心背景干净多物体交互图多个主体互相遮挡或并列文字密集界面图截图、海报、书籍封面复杂自然场景图街景、室内、野外抽象示意图流程图、结构图每个场景准备 20 到 50 条样本图像统一缩放到适合模型输入的分辨率范围。对生成型模型除了图像样本外还额外准备了两组文本条件一组是 5 到 10 个词的短提示词另一组是 30 个词以上的长提示词。这样做的目的是观察文本长度对交叉注意力分布的影响——这在生成模型里是个经常被忽略但影响巨大的变量。3. 注意力提取实操从模型里把权重一张张捞出来3.1 三种主流提取方案选型提取注意力权重我试过三种方案各有优劣。方案侵入性灵活性工程复杂度我的推荐度直接修改模型 forward 源码返回中间变量高最高中科研原型推荐使用框架的 hook 机制在 Attention 模块输出处挂载钩子低高低工程首选从缓存文件或日志反推注意力无极低低不推荐直接改源码的思路很直观找到模型里注意力模块的代码在计算完 softmax 之后把权重复制一份并加到返回值里。好处是你可以拿到最原生的中间张量逻辑也清晰。坏处是侵入性太强每次模型升级都得重新改代码而且容易污染原始推理逻辑。hook 机制则是把“监听器”挂在模块旁边不修改模块内部代码框架在每次前向传播时自动调用你注册的回调函数。这是最干净的方案也是我做 UniCache 的主力方案。日志反推的方案我试过一次就放弃了。注意力权重在计算过程中是动态变化的缓存文件里只有最终结果反推出来的数值误差非常大完全不能支撑精细分析。3.2 Hook 挂载的关键代码与细节我用 hook 机制提取注意力权重的完整思路是这样的。import json import numpy as np # 全局列表用于暂存当前样本的注意力数据 attention_records [] def make_attention_hook(layer_name): 创建一个用于提取注意力权重的 hook。 def hook_fn(module, inputs, outputs): # 多数注意力模块的输出是一个元组或者自定义对象 # 通常最后一个元素是 softmax 之后的注意力权重。 # 具体索引需要按实际模型结构调整建议先打印一次 outputs 的类型确定。 attention_weights outputs[-1] # 形状通常是 [batch, heads, seq_len, seq_len] # 转换成 numpy 并移到 CPU避免 GPU 显存爆炸 attn_np attention_weights.detach().float().cpu().numpy() attention_records.append({ layer: layer_name, weights: attn_np, }) return hook_fn def attach_hooks(model, attention_module_names): 给所有目标注意力模块挂载 hook。 hooks [] for name in attention_module_names: module dict(model.named_modules())[name] hook module.register_forward_hook(make_attention_hook(name)) hooks.append(hook) return hooks def detach_hooks(hooks): 全部卸载避免影响后续正常推理。 for hook in hooks: hook.remove()这段代码的核心逻辑不复杂但有几个细节特别容易踩坑。第一注意outputs[-1]的索引是否正确。很多注意力模块的输出不是一个单纯的张量而是一个包含[hidden_states, attention_weights, ...]的元组。我一开始想当然取outputs[0]结果拿到的全是隐藏状态浪费了半天时间。建议第一次挂 hook 时先把outputs的类型和内部元素形状打出来确认一次再写固定索引。第二及时执行detach()并转移到 CPU。如果不做detach会自动保存整个计算图轻则内存暴涨重则直接 OOM。转移到 CPU 是为了防止 GPU 显存被注意力矩阵填满。第三hook 在model.eval()模式下依然会触发。这一点对推理场景很友好你不用特意切换模式。但如果模型用了no_grad上下文注意权重的数值不会被梯度污染提取出来反而更干净。3.3 从原始权重到统计指标动手算这几个量拿到原始的注意力权重矩阵attn_np下一步是把它压缩成可读性更强的统计量。我在 UniCache 中固定计算四个指标每个都有明确的物理意义。注意力熵。对每一行即每个被关注的位置计算信息熵。公式是-sum(p * log(p))。熵越高分布越均匀熵越低注意力越集中。我通常把平均熵作为该层的“注意力分散度”。def attention_entropy(attn): attn: [heads, seq_len, seq_len] eps 1e-12 log_p np.log(attn eps) entropy -(attn * log_p).sum(axis-1) # [heads, seq_len] return entropy.mean()最大注意力占比。每行最大的注意力权重值反映注意力最集中的单个目标占比。如果这个值长期超过 0.5说明该层已经形成非常强的焦点。对角线集中度。计算对角线元素的平均权重。对角线权重高表示 token 更倾向于关注它自身。这种现象在深层尤其常见对应的是“token 已经聚合完信息开始自我确认”的阶段。注意力方差。对某一个 head 的全部权重求方差。方差大说明该 head 对注意力分配有明显的选择性方差小说明它接近均匀分布很可能没有学到有区分度的模式。def compute_attention_statistics(attn): attn: [heads, seq_len, seq_len] stats_list [] for head_idx in range(attn.shape[0]): head_matrix attn[head_idx] entropy_mean attention_entropy(np.expand_dims(head_matrix.astype(np.float64), axis0)) max_attn_mean head_matrix.max(axis-1).mean() diagonal_mean np.diag(head_matrix).mean() variance_mean head_matrix.var() stats_list.append({ head: head_idx, entropy: entropy_mean, max: max_attn_mean, diagonal: diagonal_mean, variance: variance_mean, }) return stats_list这两个代码片段我已经跑过很多轮数值稳定不会有意外报错。需要注意的是如果输入序列长度特别长计算熵时务必加上一个极小值eps防止log(0)产生 NaN。3.4 存储格式别让数据成为下一次分析的负担提取出来的注意力数据量其实不小。一个[32 heads, 256 seq_len, 256 seq_len]的矩阵单层就到 2M 个浮点数。整个模型几十层再乘以几十条样本数据量是十分可观的。我的存储方案是按层组织每层存一个压缩格式的数组文件同时把每个样本的元信息图片编号、场景类型、层名、head 数、序列长度记录到同一个目录的索引文件中。这样后续做跨层比较、跨场景比较时直接按索引文件遍历非常方便。经验不要一开始就追求把全量原始注意力矩阵长期保留。我在实践中会把原始权重保留一周做深度分析确认统计指标稳定后就把原始矩阵删掉只保留统计指标文件。原始权重占空间太大留两套只会让分析效率下降。4. 理解模型实测三幅典型的注意力“地图”4.1 浅层的全局分散模式第一批数据跑出来后最先吸引我的是浅层靠近输入端的层的注意力形态。无论是单主体物体图还是复杂场景图浅层注意力熵都普遍偏高平均熵接近理论最大值。这意味着什么浅层在主动做全局信息混合。图像 token 和文本 token 在这一层就像开会时所有人都在交换名片每个 token 都均匀地关注其他 token还没有形成明确的主次关系。这是 Transformer 架构的正常行为底层负责把局部特征扩散到全局让后续层有足够的信息做语义决策。从工程角度看这种均匀分布的 head 很容易被误判为“没学到东西”进而被当成剪枝候选。但实际上它们承担着信息通道的功能一旦剪掉深层的语义聚焦会失去必要的前置输入。判断冗余 head 不能只看单层的分布是否均匀还要看它在下游层引起的连锁变化。4.2 深层的焦点聚集模式与浅层正好相反接近输出端的深层注意力熵快速下降权重明显集中在少量 token 上。我统计了一个典型样本顶层某几个 head 的最大注意力占比超过 0.45接近一半的注意力都压在一个 token 上。这些被高度聚焦的 token通常与图像中的语义主体高度相关或者是文本端承担语义聚合的特殊 token比如句首的汇总位。深层分布呈现出清晰的“焦点聚集”模式说明模型已经完成了从全局到局部的语义决策。值得关注的一个现象是焦点聚集的 head 数量不是越多越好。我对比了几组样本发现表现最好的推理结果对应的是“少数 head 高度聚焦、其余 head 仍保持中等分散”的状态。如果所有 head 都高度聚焦在同一个 token 上模型反而容易过拟合某些显著性特征忽视图像中的细节线索。4.3 跨模态对齐中的“锚点 token”现象第三个让我印象深刻的模式是跨模态对齐中的“锚点 token”现象。简单说一份图像描述文本里的核心名词会在交叉注意力层中反复关注图像序列里的某几个特定 token这些 token 的位置坐标往往就对应着物体在图像中的中心区域。我把这些被反复关注的图像 token 称为“锚点 token”。它们的存在非常有价值即使完全没有位置监督信息仅凭注意力权重也能大致定位出模型认为“最重要”的图像区域。这个特性有两个直接应用场景。第一可以用它做弱监督定位或弱监督检测不需要人工标注框只需要文本描述和注意力权重就能得到候选区域。第二可以用它做输入图像的“显著性指示”在预处理阶段告诉模型哪些区域值得优先处理。我在模拟项目里测试过基于锚点 token 的注意力掩码在部分场景下能把理解模型的检索精度稳定提升几个点。5. 生成模型实测扩散过程里注意力如何演化5.1 去噪不同阶段的注意力演进规律生成模型的分析比理解模型复杂很多因为扩散模型的推理过程是几十步迭代的每一步都有独立的注意力分布。我第一次跑完整个采样流程时看着几十个时间步的注意力矩阵第一反应是“信息量太大了根本看不过来”。后来按时间步分组统计规律才慢慢清晰。去噪阶段注意力熵水平焦点特征对应生成行为早期前 20% 步高全局均匀无明显焦点确定整体构图、布局、色彩基调中期20% 到 60% 步中等集中在轮廓和大块结构 token细化主体形状、边缘结构后期后 40% 步低聚焦到局部细节 token补充纹理、边缘锐化、细节打磨这个“由粗到细”的演化过程本质上是生成模型最优操作路径的体现。早期如果注意力过度集中模型会过早锁定结构后面的去噪步骤就没有修正空间后期如果注意力还过于分散细节就会模糊不清。注意力的分散度相当于生成过程的“进度条”看到它的形态你基本能判断当前生成到哪个阶段了。5.2 文本条件注入强度的时间维差异交叉注意力是文本条件进入生成过程的主要通道。我把每个去噪步的文本 token 注意力权重拉平求和观察文本注入强度随时间步的变化。一个典型结论是文本条件的注入强度在早期最强随后逐渐衰减。这个结论本身不意外但让我意外的是衰减形态对生成质量的影响。短提示词场景下文本注意力衰减平缓每一步都保留了一定条件约束力。而长提示词场景下文本注意力在中期就出现明显的“塌陷”——有一段时间文本 token 的注意力几乎接近均匀分布说明模型当时没有有效利用文本条件。这也解释了为什么长提示词场景经常生成出“局部正确、整体混乱”的图像条件注入在关键中期断档了。基于这个观察我在第二阶段设计缓存策略时会重点考虑对中期去噪步的文本特征做强化保留避免在缓存压缩时把关键条件信息挤掉。5.3 从注意力分布到缓存策略的线索生成模型的自注意力层同样存在大量低信息区。我在统计中发现同一层内不同 head 的注意力分布差异化程度远小于理解模型——很多 head 的分布几乎重合。这说明生成模型中存在比理解模型更多的冗余注意力头。更关键的是某些区域在连续多个去噪步中注意力分布形态高度相似几乎没有变化。这些“长时间不动”的分布区域就是缓存压缩的最佳候选。原因很简单既然连续多步的注意力结果几乎一样那我们完全可以把它缓存下来后续步骤直接读取缓存结果省去重复计算。这个观察直接定义了 UniCache 第二阶段的设计方向不是对所有键值缓存一刀切地压缩而是根据注意力分布的动态变化率自适应地决定哪些部分可以缓存复用、哪些部分必须重新计算。6. 实操中的高频问题与排查方案6.1 Hook 永不触发的三个原因Hook 机制看着简单真上手时最容易碰到的就是“代码跑完了日志里一条记录都没有”。我排查下来最常见的原因有三个。模块名抓错了。模型加载后内部模块路径是嵌套的比如输入模块序列里的子模块和全局命名空间里的模块完全同名。我建议先打印完整的named_modules()列表确认你挂载的路径是对的再写进挂载清单。数据并行包装没有解开。如果模型被数据并行或者半精度包装器包裹直接按照原模型路径取子模块会失败。解决办法是先取到最内层模型实例再遍历模块路径。生成模型多步采样导致重复触发。扩散模型一步采样就前向传播一次hook 会跟着触发几十次。如果你用单个列表存储数据最后会被几十步的数据混在一起。我建议每次采样前清空列表或者把时间步编号也写进记录里后续按编号拆分。6.2 显存不够用的降级方案全量保存所有层所有 head 的注意力权重对显存是巨大的压力。我第一次跑生成模型时直接尝试把 40 多层、每层 20 多个 head 的全部权重一次性存下来结果中途就 OOM 了。后来总结出一套降级方案。按层分批提取只挂载部分层的 hook跑完一批评测后卸载再挂载下一批。代价是推理次数变多但显存安全。只保留统计量如果分析目标只是熵、方差、最大注意力占比这些指标那就在 hook 里直接计算这些统计量丢掉原始矩阵。这是最省显存的方式。降采样序列注意力矩阵是序列长度平方级别的对超长序列可以按固定间隔抽取部分 token 进行分析损失一点精度但能大幅降低存储。提示真正常态化使用 UniCache 分析时我推荐“全量提取只保留少数关键样本常规评测只用统计量”的策略。既保证了观测深度也不会让显存成为瓶颈。6.3 数值异常与复现性两个容易忽略的陷阱第一是熵计算出现 NaN。这个问题多发生在注意力权重接近 0 的地方log(0)直接产生负无穷。我前面代码里加了eps 1e-12就是为了防这个。另外如果你用的是半精度计算部分极端值可能导致上溢出建议在统计阶段把注意力权重转成高精度浮点数。第二是复现性问题。注意力权重本身是确定性的但如果你在推理时没有固定随机种子或者开了某些自动调优选项不同轮次的结果可能会有微小差异。做对比实验时一定要在前向传播之前固定随机种子。我测试过固定种子后同一批样本的注意力统计指标可以稳定到小数点后 4 位足够支撑结论。7. 注意力分布纵览带来的三个工程落点7.1 冗余检测与结构化剪枝阶段一产出的统计指标最直接的应用就是冗余检测。我在理解型模型上做了一次模拟剪枝实验筛选出长期统计中方差最小、熵最高、且与同层其他 head 分布重合度最高的几个 head把它们从推理路径中屏蔽掉然后用验证集测精度。结果很有意思。只剪掉 10% 的冗余 head 时精度几乎不降推理速度小幅提升。剪到 20% 时才开始出现轻微的精度波动。这组数据验证了一个判断基于注意力分布统计的剪枝比单纯按参数范数剪枝要稳得多。因为注意力分布直接反映了 head 在推理中的实际行为而参数范数只能反映权重大小两者与“是否冗余”的相关性完全不同。7.2 自适应缓存与推理加速对生成模型而言注意力分布中“连续多步高度相似”的区域就是缓存压缩的最佳候选。我在阶段二的预研里做了个简单实验把连续三步交叉注意力分布相似度超过阈值的文本特征区域直接缓存复用跳过后续两步的重复计算然后评估生成图像的语义一致性。初步结果显示被缓存区域的生成质量没有明显下降推理耗时下降了大约 15%。虽然这只是离线模拟没接入真正的采样内核但已经足够证明注意力分布纵览的结论可以转化为实际收益。后续阶段三要把这部分真正落地到采样循环里。7.3 幻觉与不确定性信号最后一个落点偏应用向用注意力分布做幻觉检测与不确定性量化。我在多模态理解模型上观察到一个规律当生成文本内容和模型实际关注的图像区域不一致时输出经常是幻觉内容。比如文本里出现了一个物体名词但交叉注意力权重显示该名词对应的 token 几乎没有关注到图像中的任何区域那么这个名词大概率不是从图像里读出来的而是模型自己脑补的。基于这个观察可以设计一个简单的幻觉评分提取生成文本中每个核心名词对应的注意力权重计算它们在图像 token 上的覆盖度和集中度。覆盖度低或集中度低于阈值就标记为潜在幻觉。这个指标比单纯使用置信分数更直观因为它直接反映了模型在推理时的“视线焦点”是否指向了真实输入。我准备在后续版本里把这个评分做成一个轻量级的监控模块挂在推理管线的输出侧实时输出每个名词的注意力覆盖情况帮内容审核和应用方快速定位模型的不可信输出。最后再分享一个小技巧分析注意力分布别只盯着热力图。热力图适合单样本讲解但要支撑工程决策一定要落到统计量上——熵、方差、对角线集中度、最大注意力占比这四个量是我每次分析必看的。先把统计量跑通再回到具体样本上人工验证你会发现注意力分析真正变成了一个可以量化、可以复现、可以自动化的工作流。另外如果你打算在项目里复现这套流程建议从一开始就按层分批提取不要图省事一次性全量拉取否则显存和存储都会让你改方案。UniCache 这个系列的第二篇我会基于这些分布规律设计缓存压缩策略把这篇观察到的“冗余区域”“稳定区域”真正变成可执行的压缩规则。