训练好的神经网络就是一个没有源码的二进制——权重是「编译产物」,前向推理是「运行」,但没人写过它内部的逻辑。机制可解释性就是反汇编 + 单步调试:在不改权重的前提下,给这个黑盒挂上「断点」和「distributed tracing」,逐层看每个组件读了什么、写了什么、把哪一跳的信号传给了下游。目标不是「模型预测得准不准」,而是「它内部到底跑了什么算法」。
传统 ML 可解释性(如 SHAP、特征重要度)只告诉你「哪个输入特征影响了输出」——这是黑盒外部的归因。机制可解释性更激进:它要还原模型内部的计算电路,像读懂一段汇编一样读懂权重在做什么。核心抓手是 Transformer 的一个结构事实——残差流(residual stream):
关键洞察来自 Anthropic 的 A Mathematical Framework for Transformer Circuits(2021):因为每个 attention 头和 MLP 都是把结果加回残差流(而非覆盖),整条总线就是各组件贡献的线性叠加——你可以把任意一跳单独「拆」出来看。最著名的发现是 induction head(归纳头):一种只在≥2 层模型里才出现的注意力回路,做的事是「在上文找到当前 token 上次出现的位置,把它后面那个 token 复制过来」——这正是 in-context learning(看几个例子就会照做)的核心机制之一。
# pip install transformer_lens —— Neel Nanda 的机制可解释性标准库 from transformer_lens import HookedTransformer model = HookedTransformer.from_pretrained("gpt2-small") # run_with_cache:跑一次前向,同时把每一层的中间激活都"录"下来 tokens = model.to_tokens("The cat sat on the mat. The cat sat on the") logits, cache = model.run_with_cache(tokens) # 取第 5 层、第 1 个 attention 头的注意力权重矩阵 attn = cache["pattern", 5][0, 1] # shape: [seq, seq] # 看最后一个 token 把注意力放在哪——induction head 会指向上一次 "cat" 之后 print(attn[-1].argmax().item()) # 预期:指向第一句 "cat" 后面的位置
模型只有 ~3000 个神经元,却要表示几万个概念——它的办法是把多个概念打包进同一个维度,像 bit-packing 把几个布尔标志塞进一个 int,或像哈希把无限的 key 映射到有限的桶。代价是单个神经元变得多义(polysemantic):一个神经元同时对「DNA 序列」「阿拉伯文」「法语」亮起,你读不懂它。SAE 就是那个解包器:把压在一起的表示「解压」回一组稀疏、单义的命名字段——每个字段只代表一个概念。
这个「打包」现象有个名字叫 superposition(叠加):Anthropic 的 Toy Models of Superposition(2022)证明,当特征「又多又稀疏」时,网络会故意把 n 个特征压进远小于 n 维的空间里,靠特征极少同时出现来容忍冲突——这是一种有损压缩,也正是神经元读不懂的根因。
SAE 的机制是 dictionary learning(字典学习):训练一个超宽的自编码器,把 d 维激活向量 x 编码成一个远比 d 宽、但几乎全是 0 的特征向量,再解码重建回 x。数学上最小化:
为什么 L1 惩罚能逼出单义?因为「只许少数维度激活」等价于强迫每个维度去抢占一个独立、可复用的概念,而不是和别人合用。Anthropic 的 Towards Monosemanticity(2023)在一个小模型上用 16× 宽的 SAE 跑出近 15000 个特征,人工评估约 70% 干净对应单一概念;2024 的 Scaling Monosemanticity 把它放大到生产级的 Claude 3 Sonnet,找到了著名的「金门大桥特征」——把它的激活强行钳到 10× 最大值,模型会满嘴都是金门大桥、甚至自认为就是那座桥。这证明特征不只是相关,而是因果可操控的。
# pip install sae-lens —— 加载社区预训练好的 SAE,不必自己训 from sae_lens import SAE from transformer_lens import HookedTransformer model = HookedTransformer.from_pretrained("gpt2-small") sae, cfg, _ = SAE.from_pretrained("gpt2-small-res-jb", "blocks.7.hook_resid_pre") _, cache = model.run_with_cache(model.to_tokens("The Golden Gate Bridge is")) acts = cache["blocks.7.hook_resid_pre"] # 第 7 层残差流激活 features = sae.encode(acts) # 解包成超宽稀疏特征向量 top = features[0, -1].topk(5) # 最后一个 token 上最亮的 5 个特征 print(top.indices, top.values) # 每个 index 对应一个可解释概念 # 用 Neuronpedia 查这些 index 的含义:neuronpedia.org
单个特征像一个微服务,特征回路就是它们组成的调用链 / DAG:早层特征「检测到这是个人名」→ 中层特征「这是法国地名」→ 后层特征「该输出法语」。特征是节点,权重是边,整张图就是一份 attribution graph(归因图)——和你画过的微服务依赖图、Spark DAG 是同一种东西。机制可解释性的终极目标,就是把模型的某个行为还原成一条可读、可验证的回路。
知道「有哪些特征」(SAE 干的事)只是元件清单,不等于懂电路。要懂行为,得知道特征怎么连、谁触发谁。验证连接的核心方法是 activation patching(激活打补丁,又叫 causal tracing)——一种干净的因果实验:
这比单纯看「哪个神经元激活高」强得多:高激活只是相关,而 patching 是主动干预——它直接回答「拿掉/替换这一跳,结果会不会变」。把每条边都这样验一遍,就能拼出一张 attribution graph。2025 年 Anthropic 的「circuit tracing / attribution graphs」工作把这套方法用到生产级 Claude 上,画出了多步推理、诗歌押韵、心算等行为背后的真实回路——发现模型有时会提前规划(写诗时先想好韵脚再倒推句子),这是 self-report 完全看不到的。
# 用 activation patching 定位"哪个层对正确答案最关键" import torch clean = model.to_tokens("The Eiffel Tower is in the city of") corrupt = model.to_tokens("The Colosseum is in the city of") ans = model.to_single_token(" Paris") _, clean_cache = model.run_with_cache(clean) def patch_layer(corrupt_act, hook, layer): # 把 clean 的残差流移植进 corrupt 的前向 corrupt_act[:] = clean_cache["resid_post", layer] return corrupt_act for L in range(model.cfg.n_layers): logits = model.run_with_hooks(corrupt, fwd_hooks=[ (f"blocks.{L}.hook_resid_post", lambda a,h: patch_layer(a,h,L))]) print(L, logits[0,-1,ans].item()) # 哪层一patch就让 "Paris" 概率飙升 = 关键层
探针就是给运行中的系统挂一个只读 APM probe:在模型某一层的激活上接一个 tap,训一个极轻量的线性分类器,问「这一层的内部状态里,有没有编码某个信息(词性?情感?真假?)」。探针不改模型、不参与训练,纯粹是事后抽头读总线——和你在内部消息总线上接 packet sniffer 看「这条数据流里有没有携带字段 X」一模一样。
SAE 和 circuit 都偏重、偏研究级;探针是最轻量、最早、最实用的可解释性工具,源头是 Alain & Bengio 的 Understanding intermediate layers using linear classifier probes(2016)。机制极简:冻结模型,取第 ℓ 层激活当特征,训一个线性分类器去预测你关心的属性。关键约束是「线性」:
Alain & Bengio 用线性探针扫 ResNet,发现线性可分度随层数单调上升——底层是边缘纹理,越往上越抽象。这给了「深度网络逐层提炼表示」一个可量化的证据。
from sklearn.linear_model import LogisticRegression import numpy as np # 收集每条句子在第 6 层、最后一个 token 的激活,配情感标签 X, y = [], [] for text, label in dataset: # label: 1=正面 0=负面 _, cache = model.run_with_cache(model.to_tokens(text)) X.append(cache["resid_post", 6][0, -1].numpy()) y.append(label) # 线性探针:能线性读出 = 该层已"现成"编码了情感 probe = LogisticRegression(max_iter=1000).fit(np.array(X), y) print("线性可分度(探针准确率):", probe.score(np.array(X), y)) # 在不同层重复 → 看情感信息在第几层"成型"