大模型可解释性可视化:从注意力热力图到SAE特征与激活修补
先说个现象很多人第一次接触“大模型可视化”画出来的东西都是同一张注意力热力图——横轴是输入 token纵轴是输出 token颜色深浅代表权重。图挺好看发出去也有点赞但真要回答“模型为什么输出了这个答案”这张图基本帮不上忙。我自己在这条路上绕了差不多两年从画热力图开始到写 hook 抓激活再到训练稀疏自编码器拆特征、做激活修补验证因果中间踩过的坑比写过的代码多得多。这篇就把“探索 AI 的内部工作机制”这件事从可视化角度彻底拆开讲一遍哪些图是有信息量的哪些只是安慰剂想把内部机制看清楚工程管线该怎么搭以及那些文档里不会写、只有自己动手跑过才会遇到的坑。不管你是刚想入门可解释性还是已经在做特征分析但总觉得结论站不住下面这些内容应该都能直接拿去用。1. 注意力热力图为什么总是“看起来对实际没用”1.1 注意力权重只是路由决策不是内容本身要把这件事说清楚得先回到架构层面。在一个标准的解码器结构里每一层做的事情可以粗略拆成两块注意力子层负责“从哪些位置搬信息过来”前馈子层负责“对搬过来的信息做变换”。注意力权重描述的是第一件事的路由比例它告诉你某个位置在这次前向传播中从哪些位置按什么比例取了向量但它完全不告诉你这些向量里装了什么内容也不告诉你下游的前馈网络拿这些内容做了什么。打个生活化的比方注意力权重相当于快递分拣中心的一张路由单上面写着“这个包裹从 A 区发往 B 区占比 60%”。你拿着这张单子能知道物流路径但完全不知道包裹里装的是衣服还是书。想让模型输出“巴黎”这个词可能确实是某个注意力头把“法国首都”这个信息从前面某个位置搬了过来但这个信息在向量空间里长什么样、被哪几个特征维度承载、后面又被哪一层的哪个方向放大成了最终的概率路由单一个字都没写。更麻烦的是注意力权重还受到 softmax 的强烈扭曲。一个头在 4096 维的隐藏状态上做点积分数分布往往非常尖锐softmax 之后大量权重集中在极少数位置上剩下几百个位置都是接近零的小数。这种长尾分布画成热力图视觉上就是几道亮线加一大片黑看起来“模型很专注”实际上只是 softmax 的数值特性跟“模型理解了”没有必然关系。我在早期做过一个很粗暴的对照把某一层某个头的注意力权重矩阵按行打乱也就是保持每一行的权重集合不变、只打乱它分配给哪些位置然后看输出分布的变化。结果是很多头在打乱之后最终生成的前若干个 token 几乎不变。这说明这些头的具体路由模式对当前这个输入并不关键——它们可能在处理一些更粗糙的、位置无关的信号也可能干脆就是冗余的。1.2 那种“一条亮线”的图多半是被汇聚位置骗了如果你画的注意力热力图里大量头都整齐地指向第一个 token 或者某个标点符号别急着下结论说“模型特别关注开头”。这是序列模型里非常普遍的注意力汇聚现象由于 softmax 必须把权重归一化到 1当某个头在当前上下文里“没什么特别想看的”时它需要一个地方把多余的概率质量丢出去而序列起始位置因为对所有后续位置都可见、且缺乏强语义竞争天然成了这个垃圾桶。它的作用是数值上的不是语义上的。这个现象对可视化的直接影响是你在图上看到的“最亮的那个格子”很可能只是数值上的泄压阀。判断方法很简单看这个头对其他位置的权重分布形状——如果除了汇聚位置之外剩下位置的权重几乎是均匀的一小片那这个头在当前输入下基本处于“空转”状态如果除了汇聚位置还有几个明显的高点那几个高点才值得细看。我在做批量可视化的时候会直接在代码里把这几个固定的汇聚位置屏蔽掉再归一化图会干净很多也能避免自己被自己的图误导。1.3 真正值得盯的三类信号绕开注意力热力图之后我在实际分析中主要看三类东西它们的信息密度比热力图高一个量级第一类是残差流上的方向。残差流是贯穿所有层的那条主干道每一层的注意力和前馈都是在这条道上做加法。把某个位置的残差流向量取出来投影到词表空间你就能看到“模型此刻最想说的是什么词”这是最直接的可视化入口。第二类是前馈层的写入向量。前馈子层在中间层有一个维度通常放大 4 倍左右的隐层可以把它理解成一组“键值记忆”第一层线性把残差流映射到高维激活函数做阈值筛选第二层线性再把筛选结果映射回残差流维度。这个“映射回来”的向量就是这一层对残差流的实际写入量它的方向往往对应非常具体的语义概念比注意力权重好解读得多。第三类是跨层的一致性。同一个概念如果在第 8 层、第 14 层、第 20 层都能被同一个探针稳定检出那它大概率是模型真的在用的表征如果只在某一层偶然出现很可能是过拟合或者噪声。单层观察几乎没有说服力这是我早期最容易犯的错误。下面这张表是我自己总结的对照用来判断手上这张图值不值得继续深挖观察对象能回答的问题不能回答的问题我的使用频率注意力权重信息从哪来信息是什么、有没有被用上低只做辅助定位残差流投影当前层“想说什么”这个想法从哪层开始形成高前馈写入向量某层注入的概念概念如何组合成推理链高单神经元激活几乎什么都答不了语义边界极低稀疏自编码器特征可命名的语义单元单元之间的因果顺序最高2. 把残差流当成主干道从投影到 Logit Lens2.1 一条传送带的直观模型理解残差流最好的类比是一条贯穿整栋楼的传送带。每一层的注意力和前馈网络都是楼上的一个工位工位不替换传送带上的货物只是往上加一点东西注意力负责从别的楼层别的 token 位置取货加进来前馈负责对当前的货做加工再放回去。也就是说第 L 层的残差流 初始嵌入 第 1 层写入 第 2 层写入 … 第 L 层写入。这个“累加”的性质极其重要因为它意味着中间层的状态并不是某种神秘的中间表示而是一个部分完成的答案。有了这个视角“提前解码”这件事就顺理成章了既然第 20 层的残差流已经是初始嵌入加上前 20 层的所有写入那我在第 20 层就把最终的归一化和输出投影接上去看看它此时最想输出什么词这不就是模型“此刻的草稿”吗这就是 Logit Lens 的核心思想实现成本极低信息量却很高。2.2 Logit Lens 的实现与几个必须注意的细节实现上就是在每一层的位置上插一个 hook把该层输出的隐藏状态取出来套上模型最后的归一化层和输出投影矩阵再做 softmax。代码大概是这样import torch def logit_lens(model, input_ids, layers_to_probe): captured {} def make_hook(layer_idx): def hook(module, args, output): # 不同实现的输出结构不同有的是 tuple有的直接是 tensor hidden output[0] if isinstance(output, tuple) else output captured[layer_idx] hidden.detach() return hook handles [] for idx in layers_to_probe: h model.model.layers[idx].register_forward_hook(make_hook(idx)) handles.append(h) with torch.no_grad(): model(input_ids) for h in handles: h.remove() results {} final_norm model.model.norm lm_head model.lm_head for idx, hidden in captured.items(): normed final_norm(hidden) logits lm_head(normed[:, -1, :]) # 只看最后一个位置 probs torch.softmax(logits.float(), dim-1) results[idx] probs return results这段代码本身不复杂但有三个细节如果处理不好得到的结论会完全跑偏。第一个细节是归一化层不能省。中间层的残差流和最后一层的残差流在数值尺度上差异巨大。如果你不做最后的归一化直接乘输出投影得到的 logits 尺度会乱掉虽然排序上有时还能看但跨层比较就没有意义了。而且不同层的残差流范数增长趋势不一样早期层范数小直接投影出来的分布非常平看起来像“模型还没想好”其实只是尺度问题。第二个细节是只取最后一个位置。我见过不少实现把整段序列所有位置的 logit lens 都算出来画成热力图图很壮观但如果你关心的是“模型在生成下一个 token 时心里在想什么”那就只有最后一个位置有意义。中间位置的投影反映的是“模型在处理那个位置时、为了预测那个位置的下一个 token 而形成的中间状态”语义上有微妙差别混在一起看容易产生误读。第三个细节是偏移问题。残差流在进入下一层之前会经过前置归一化所以“第 L 层输出”和“第 L1 层输入”在数值上并不完全等价。严格来说把第 L 层输出直接解码会有一个轻微的层间错位某些概念看起来像是“提前出现了一层”。这个错位不影响趋势判断但如果你要做精细的因果实验就得把它考虑进去比如同时探测 L-1、L、L1 三层做交叉验证。2.3 什么样的 Logit Lens 结果才叫“有突破”跑通代码只是第一步真正有价值的是怎么读结果。我在实际使用中总结了几种典型模式它们各自对应不同的机制假设第一种是阶梯式收敛。某些事实性答案会在某一层突然跃升为主导词之后一直保持。这种通常意味着有一个局部的、可定位的机制在那一层完成了一次关键的“查找-写入”后续层只是在加固。这类模式是最适合做进一步因果验证的因为它有明确的层定位。第二种是逐步细化。早期层给出的是语义相近但不够精确的词随着层数上升逐渐逼近最终答案。比如从类别词慢慢收敛到具体的实例词。这种模式往往意味着信息是多层累积拼装出来的你在任何单层都找不到“完整的答案”必须看组合。第三种是来回震荡。某些层给出高频虚词或者功能词另一些层给出实体词交替出现。这种情况我一开始以为是实现有 bug后来发现是残差流里同时存在多个竞争方向的正常表现——模型在“该不该继续说话”和“该说什么”这两类决策之间反复权衡。这类输入适合做探针检测而不是单点解读。把 Logit Lens 和探针结合起来用效果更好Logit Lens 告诉你“模型想说什么”线性探针告诉你“模型知道什么”。两者同步的情况说明这个知识已经被转化成了输出倾向不同步的情况往往更有意思——模型知道正确答案但输出层被别的东西压住了这在分析幻觉和拒答行为时特别有用。3. 稀疏自编码器把叠加在一起的特征拆成可读的旋钮3.1 为什么单神经元几乎不能当特征用一个激活维度就是一个明确概念——这个假设在早期很有吸引力但实践下来基本不成立。原因在于叠加模型需要在宽度有限的向量空间里表达远多于维度数的概念于是它会把多个概念的表示方向塞进同一个空间用近似正交的方式共存代价是每个概念只在少数维度上有非零值、且这些维度与其他概念共享。结果是你单独看某一个神经元会发现它对法律术语、代码缩进、日语假名、以及某类标点符号都有反应毫无规律可言。我在早期做过一个很典型的观察对某个中间层的单个神经元做激活最大化也就是在输入空间里搜索让它激活值最高的文本片段搜出来的结果五花八门从“本合同”“违约责任”到“\n\n”“|end|”都有。当时我以为这个神经元是“负责格式和法律的混合体”这显然是个荒谬的结论。真正的解释是它在多个叠加特征上都有分量而采样到的这些文本恰好同时激活了其中几个。3.2 稀疏自编码器的结构与损失设计稀疏自编码器的思路是既然特征在原始空间里是叠加的那就学一个过完备的字典把这个向量分解成少量字典原子的稀疏线性组合。它的结构和普通的自编码器很像但有两处关键不同隐层维度远大于输入维度通常是输入维度的 8 到 32 倍损失函数里加了一个强力的稀疏惩罚。import torch import torch.nn as nn import torch.nn.functional as F class SparseAutoencoder(nn.Module): def __init__(self, d_model, expansion16): super().__init__() d_hidden d_model * expansion self.encoder nn.Linear(d_model, d_hidden) self.decoder nn.Linear(d_hidden, d_model, biasFalse) def forward(self, x): # 训练前要先把输入减掉一个全局均值并归一化 pre self.encoder(x) # 用 ReLU 保证激活非负特征才容易被解释成是否出现 features F.relu(pre) recon self.decoder(features) return recon, features def sae_loss(recon, x, features, l1_coef8e-4): recon_loss (recon - x).pow(2).sum(dim-1).mean() l1_loss features.abs().sum(dim-1).mean() return recon_loss l1_coef * l1_loss, recon_loss, l1_loss这里每个参数的取值都有讲究。重建损失用的是平方误差的求和而不是平均这在多数实现里是标准做法因为要跟 L1 惩罚的量纲对齐如果用平均L1 系数的合理区间会跟着维度数漂移换一个模型就得重新调。L1 系数是最关键的超参数它直接控制“稀疏程度”和“重建保真度”的权衡系数太小特征不稀疏每个特征都在回答一大片语义无法解读系数太大特征过度稀疏激活覆盖率极低大量特征从来没被激活过白白浪费字典容量。实践上我通常先跑一个小规模的扫描观察两个指标一是存活特征比例也就是在验证集上至少被激活过几次的特征占比健康值一般在 20% 到 40% 之间二是重建损失占原向量范数的比例这个比例太高说明你丢掉了太多信息后续基于特征做的任何结论都不可靠。如果扫描之后发现不存在一组系数能同时让两个指标都满意那大概率是字典容量不够或者训练数据分布太窄而不是系数没调好。训练数据的选择也远比想象中重要。如果只用某一个领域的语料训练学出来的特征会严重偏向那个领域你在其他输入上做分析时会看到大量特征处于“不激活”状态误以为模型在那里“没有概念”。我的做法是从目标应用的实际流量里分层采样尽量覆盖不同的任务类型和文本长度并且控制单条样本的长度避免长文本把统计量带偏。3.3 从“一堆索引”到“可读标签”的流水线稀疏自编码器训完之后你拿到的是几万个特征索引每个索引对应一个高维方向。这些索引本身毫无意义必须经过解释才能用。我的流水线分三步第一步是找激活样本。对每个特征在整个验证集上扫描收集激活值最高的若干条文本片段同时记录激活的具体 token 位置。这一步看起来很平凡但采样策略影响巨大如果只取全局最高值拿到的往往是极端样本看起来特征很纯如果取中等激活值会看到大量噪声。我的经验是分层采样——取最高的几条、中位水平的几条、以及刚好跨过阈值的几条一起看。三个档位对比才能判断这个特征的边界在哪。第二步是自动打标。把上面收集到的片段整理成结构化的提示交给一个语言模型让它归纳“这些片段共同在说什么”。这一步能极大提速几万个特征纯靠人看是不可能的。但自动打标的结果必须抽检我通常抽 5% 左右人工核对错标率超过 15% 就说明提示设计有问题或者特征本身太杂需要回头调稀疏系数。第三步是一致性校验。把自动生成的标签再拿去反向预测给另一个模型看这个标签和一组新的文本片段让它判断哪些片段会激活这个特征。预测准确率高说明标签是有效的准确率低说明标签只是个似是而非的描述不能用。这一步能筛掉大量“看起来合理但实际没用”的标签。3.4 特征吸收和死亡特征两个必须提前防的坑死亡特征相对好理解如果一个特征在前向传播中从未被激活那它对应的编码器权重就收不到任何梯度永远不会更新等于字典里的一块死区。训练过程中我会定期统计每个特征在最近若干步内的激活次数对连续长时间零激活的特征做重采样——把它编码器里那个方向重新初始化到当前某个高激活样本的方向上。这个操作要小心重采样太频繁会让训练不稳定我一般设一个比较宽松的阈值只在确认死亡时才动。特征吸收更隐蔽也更麻烦。它指的是这样一种情况某个高层特征 A 的语义其实非常清晰但当输入同时包含 A 和一个更宽泛的特征 B 时A 的编码不被激活因为 B 已经能够相当好地重建这个输入重建损失已经足够低L1 惩罚又促使模型尽量少用特征于是 A 被“吸收”进了 B。表现出来就是你查特征 A 的激活样本只看到那些没有 B 的干净例子一旦加上 B 的上下文A 就消失了。这会让特征之间的依赖关系看起来很奇怪也会让你误判某个特征的语义边界。检测方法是做消融对照对特征 B 做消融把它的编码强行置零然后看 A 的激活是否出现。如果 A 在消融 B 之后大量激活说明存在吸收关系。缓解手段包括降低 L1 系数、增大字典容量、以及在训练时对编码做更精细的稀疏约束。我现在的习惯是在正式分析前先跑一批两两消融的抽样检测摸清这个 SAE 有多严重的吸收问题再决定能不能用它的特征语义做结论。4. 相关性不算数用激活修补把因果链钉死4.1 一个让所有分析归零的问题不管你画了多少图、打了多少标签只要没做因果验证你的结论都只是相关性。我见过太多这样的分析某个特征在模型输出正确答案时高度激活于是被命名为“正确答案检测器”。问题是这个特征在模型输出错误答案但很自信时同样高度激活——它检测的是“置信度”不是“正确性”。如果不去干预你永远不会发现这个区别。因果验证在可解释性领域有一套相对成熟的操作范式核心思路是反事实干预构造一对输入它们只在某一个关键信息上不同模型输出也因此不同然后在一对输入之间搬运中间状态看能不能把输出也搬过去。如果搬运成功了说明你搬运的那个位置承载了关键信息。4.2 激活修补的完整操作流程具体做法我用一个经典的小任务来说明模型在被要求重复一段字符串时表现良好但在某些特定字符组合上总是出错。我们想知道是哪一层、哪个位置的计算导致了错误。第一步准备一对输入干净版本模型能正确回答和污染版本模型答错。两者长度要尽量对齐只在关键片段上不同否则位置对齐会出问题。第二步跑干净版本缓存所有层的隐藏状态。第三步跑污染版本在第 L 层、第 P 个位置把缓存里的干净激活替换进去其余位置保持污染版本自己的激活不变得到输出。遍历所有 (L, P) 组合记录输出恢复正确的程度。def patch_and_run(model, clean_hidden, corrupt_ids, layer_idx, pos_idx): def hook(module, args, output): hidden output[0] if isinstance(output, tuple) else output # 只替换指定位置其余保持原样 hidden hidden.clone() hidden[:, pos_idx, :] clean_hidden[layer_idx][:, pos_idx, :] return (hidden,) output[1:] if isinstance(output, tuple) else hidden handle model.model.layers[layer_idx].register_forward_hook(hook) with torch.no_grad(): logits model(corrupt_ids).logits handle.remove() return logits跑完之后你会得到一张二维的热力图横轴是位置纵轴是层号颜色表示“恢复程度”。这张图才真正有价值——它告诉你关键信息在哪一层被写到哪个位置。常见的模式是对角线附近有强响应也就是信息在某一层从源位置被搬到目标位置。4.3 从修补图到电路图得到修补热力图之后下一步是缩小范围既然知道了关键层和目标位置就可以进一步拆解这一层内部是哪些头在起作用。做法是对这一层的每个注意力头单独做修补看哪个头的替换能恢复输出。找到头之后再看它的注意力模式和 value 向量的写入方向就能拼出一条完整的因果链源位置 → 某个头 → 目标位置的残差流 → 前馈层 → 输出。我做过一个印象很深的小案例细节做了匿名化处理某个模型在处理一类重复结构时会在特定层出现一个非常明显的“跳跃式”注意力模式也就是某个头同时看当前 token 和它前面固定间隔的位置。修补实验确认这个头是关键之后我再往前追它的输入端发现它依赖的是前一层某个前馈写入方向而这个方向的激活值强弱直接跟“当前片段长度”相关。整条链路拼出来之后之前所有看起来零散的现象都对上了。这种分析的产出不只是“知道模型怎么工作”它在工程上非常有用。比如在做提示词设计时如果你知道模型是在某一层依赖某个特定的模式信号做决策那你就可以调整输入结构让这个信号更强或更弱。这比盲目调措辞靠谱得多。5. 可视化管线的工程实现显存、采样与前端5.1 激活缓存的格式与规模估算做这类分析最大的工程瓶颈往往不是算法而是数据量。一次前向传播每一层、每个位置都产生一个隐藏状态。假设模型有 32 层、隐藏维度 4096、用半精度存单个 token 的全部层激活就是 32 × 4096 × 2 字节 ≈ 256 KB。一条 1024 token 的样本就是 256 MB。你想覆盖几千条样本就是几百 GB 到 TB 级别。这个量级决定了你不能“全存”必须有取舍。我的取舍原则是三层存少量样本的全层激活用于做验证和可视化几千条足够存大量样本的稀疏采样激活只存指定层、指定位置用于统计特征激活频率不存中间量所有能在前向过程中算出来的统计量最大值、均值、激活特征索引当场算完只留结果。文件格式上稀疏激活我用压缩的稀疏格式存记录索引和值全量张量用二进制张量格式存读写比通用科学计算格式快一个数量级做批量扫描时这个差别很明显。5.2 采样策略决定了你看到的“世界”这一点我觉得比代码实现重要得多。如果你在验证集上随机采样文本那大部分 token 都会是普通的功能词激活分布会严重偏向高频、泛化的特征那些在特定任务上才出现的关键特征几乎采样不到。我看到过不少人因此得出“模型内部特征很泛化、缺乏具体概念”的结论实际上是采样偏差。我现在的做法是按任务类型分层并且对每条样本标注它的“信息密度”——比如是否存在需要多步推理的地方、是否有罕见实体、是否有格式约束。分析某个特征时先按信息密度筛选样本池再在池子里采样。代价是预处理成本高但结论的可靠性完全不同。另一个坑是位置偏差。序列开头和结尾的激活分布差异巨大尤其是最后几个位置残差流里堆积了大量“准备输出”的信息。如果你的采样只取最后一个位置那所有特征看起来都会带上“输出倾向”的色彩。我在做特征语义分析时通常把最后一个位置排除只看中间位置这样得到的语义更“纯粹”。5.3 降维与前端怎么让几万个特征变得可浏览有了特征和标签还需要一个能让人快速浏览、对比、检索的界面。我用过几种方案最后稳定下来的组合是降维用 UMAP检索用向量相似度前端用最简单的方式——一个静态页面加一个搜索框。降维的作用是把高维特征方向投到二维平面让你能看出特征之间的聚类结构。这里有个关键技巧降维的输入不能是原始解码方向而应该是特征在验证集上的激活模式向量。也就是说对每个特征构造一个“它在哪些样本上激活、激活多强”的长向量然后对这个向量做降维。这样得到的邻域关系反映的是功能相似性——两个特征如果在相似的上下文里激活它们就会被放在一起哪怕它们的解码方向在原始空间里并不接近。这个技巧让聚类结果的可解释性提升非常明显我一开始直接用解码方向降维得到的图几乎没法读。前端功能不需要花哨三件事就够给一个样本高亮显示所有被激活的特征及其标签给一个特征展示它的典型激活片段给两个特征对比它们的激活分布重叠度。这三件事覆盖了我日常 90% 以上的分析需求。6. 这些坑我都踩过一份可以直接对照的检查清单把上面这些内容压缩成可执行的检查项我在每次开始一轮新的分析前都会过一遍能省掉大量返工。第一先检查你的输入是不是被归一化过。不同层的残差流范数量级差别很大跨层比较之前必须统一处理。我遇到过最离谱的一次是所有层的 logit lens 结果看起来都在“预测同一个词”排查了半天发现是取错了输出位置拿的是嵌入层的输出。这类低级错误在长脚本里特别容易发生建议在关键节点打印张量形状和范数做断言。第二任何单层结论都要做多层交叉验证。只在某一层观察到的现象先假定它是噪声。验证方法是换几个邻近层、换一批样本、换一个随机种子重跑一遍三次都能复现再往下走。这个习惯帮我避免了好几个后来被证伪的“发现”。第三特征标签一定要做反向预测校验。自动打标出来的描述读起来都很顺这正是危险之处。用“给标签和新样本预测是否激活”这个任务去检验准确率低的标签直接丢掉不要因为描述好看就留着。第四因果实验的对照输入一定要认真做好。干净版本和污染版本如果长度不一致、或者差异涉及多个因素修补结果会完全不可解释。我现在的规矩是对照输入必须先通过一个自动化检查除了目标片段之外其余部分的 token 序列和长度都要满足约束不满足就重新构造。第五别在单条样本上花太多时间。单条样本的现象往往高度依赖具体上下文花两小时分析出来的机制可能在第二条样本上就失效。正确的节奏是快速扫一批样本找出稳定出现的模式再针对模式做深挖。最后分享一个我觉得最有用的习惯把每次分析的中间产物都存下来包括失败的尝试。我在做某次特征分析时前前后后跑了七版不同的采样配置前六版都被后来的实验否掉了。但正因为日志和中间图都留着第七版成功的时候我能立刻定位到“到底哪一处改动起了作用”。可解释性这个领域随机性比想象中大同一个结论换个随机种子可能就不成立没有完整的实验记录你根本分不清自己是发现了规律还是撞上了巧合。这件事没有捷径只能靠把每一步都记清楚慢慢把噪声和信号分开。