好文集ChatGPT Plus¥119

两个神经元,一个“邪恶”的 Qwen

文章目录

大模型发布前都经过安全训练:不帮人干坏事,说话客气,答错了会道歉。有人想拆掉这层“规矩”,也有人想弄明白它到底装在模型的哪里。

社区里拆“规矩”最有名的办法叫 abliteration(ablation 和 obliteration 的合成词)。做法是:先拿一批有害和无害的提示词喂给模型,对比内部数值,找出代表“拒绝”的那个方向;再修改权重,把这个方向从模型里抹掉;最后另存成一个新模型发布。要准备数据、做统计、改权重,产出的是一份新的模型文件。

嵌入推文(联网查看)

最近,做模型可解释性(mechanistic interpretability,简称 mech interp)研究的 Lyra(@_lyraaaa_)发了一条帖子,展示了一种小得多的“外科手术”式做法:一个权重都不改,只在模型计算的途中,把两个神经元的数值推一下。帖子原话大意是:

手边有 Qwen3.5 4B 的话,试试把 L14 N8949 推 +1、L13 N7956 推 −1。现在你就有了一个邪恶的 Qwen。

我们在自己的 Mac 上复现了。改完之后,Qwen3.5-4B 劝紧张的新员工“去当个混蛋”,说自己的职责是监视用户、做用户的“忠实同伙”,还把 18 + 7 − 9 算成了 18。

这和 abliteration 不是一回事。abliteration 专门去掉“拒绝”;这次改变的是模型说话的倾向和性格。

具体操作是同时改两个神经元:第 14 层的 8949 号往上推,第 13 层的 7956 号往下推(编号都从 0 开始)。

(14, 8949, +1.0, "add")
(13, 7956, -1.0, "add")

这篇文章想借这次“手术”,把大模型内部怎样计算完整走一遍。这个操作切口极小:几十亿个参数里一个不碰,只在计算途中改一个数。可要讲清楚它为什么管用,就得把权重、矩阵乘法、MLP、残差流这些基础概念依次讲到。懂的人可以当作一次温习,看看这些概念落到真实代码上是什么样;不熟的人可以顺着这一刀,第一次看清神经网络在 LLM 里到底是怎么工作的。

TL;DR

  • 模型的权重是一些固定的矩阵,计算就是一次次矩阵乘法。

  • 每层的 MLP 先把一个 token 的 2560 个数展开成 9216 个,逐位“开关”,再压回 2560 个,加回残差流。

  • 所谓神经元,就是这 9216 个中间数里的某一位。

  • 我们用一个 PyTorch hook,在 down_proj 之前给这一位加上一个数。等价于往残差流里加一个固定方向的向量,后面所有层都受影响。

  • 权重、训练、GPU 底层计算都没动。可模型的回答变得敌对,算术也算错了;反方向推则大多陷入重复。

文章配图

这次手术的样子。一座多层的塔代表模型的 32 层,中间那股带子是从下往上贯穿每一层的残差流。中部一层被剖开,露出它的 MLP:成排的细丝就是神经元(实际每层 9216 个),镊子只碰其中一根。示意图,层数和细丝数量未按实际绘制。

Primer:先认识这些名词

下面的数字都来自 Qwen3.5-4B 的 config.json。它们是训练之前就定好的尺寸(超参数),训练只改矩阵里的数,不改这些尺寸。

文章配图

一个词在模型里怎么流动

每个 token 进入模型后,会变成一串 2560 个数字。它代表这个 token 当前的“状态”。Qwen3.5-4B 有 32 层,这串数字从第 0 层一路传到第 31 层,最后一层根据它算出下一个词的概率。

每一层做两件事。注意力让当前 token 去看前面的 token,把相关信息拿过来。MLP 不看别的 token,只对当前 token 自己的 2560 个数做一次加工。两者的结果都加到原来的状态上,不会替换掉它:

x = x + attention(x)
x = x + mlp(x)

这条一路传下去、每层都往上加东西的 x,叫残差流(residual stream)。后面会反复用到这一点:每一层的输出,都是往残差流上加一个 2560 维的向量。

文章配图

权重就是矩阵,计算就是矩阵乘法

模型里最常见的零件是 PyTorch 的 nn.Linear。它只装着一个矩阵 W,调用它就是做一次矩阵乘法:

y = W @ x

模型的“权重”就是这些矩阵里的数字。训练改的是它们,下载下来的 9 GB 文件里装的也主要是它们。推理时权重固定不变,变的是每次算出来的 x、y 这些中间结果。

文章配图

MLP:展开、开关、压回

MLP 名字听起来抽象,在 Transformers 的 Qwen3.5 源码里只有一行:

def forward(self, x):
    return self.down_proj(self.act_fn(self.gate_proj(x)) * self.up_proj(x))

三个 *_proj 都是 nn.Linear,也就是三个矩阵。proj 是 projection(投影)的缩写,意思就是“用矩阵把一组数变成另一组数”。拆开写:

a = gate_proj(x)          # 2560 个数 → 9216 个数
b = up_proj(x)            # 2560 个数 → 9216 个数
h = F.silu(a) * b         # 逐位相乘,还是 9216 个数
output = down_proj(h)     # 9216 个数 → 2560 个数,加回残差流

文章配图

为什么要先展开再压回来

up 和 down 说的就是维度往上走、往下走。直接用一个 2560 × 2560 的矩阵不行吗?问题在于,两次矩阵乘法中间如果什么都不做,就能合并成一次:

down @ (up @ x)  ==  (down @ up) @ x      # 合并成一个 2560×2560 的矩阵,展开白展开

矩阵乘法只能做加权求和这类“线性”操作,叠多少次都还是线性的。所以中间必须插一个逐位作用的非线性函数,这里用的是 silu:

文章配图

有了它,三步各有分工:展开时,每一位都在问“x 里有没有某种模式”;silu 让每一位单独决定开不开、开多大,这一步合并不掉,模型的表达能力主要来自这里;压回时,把打开的那些位各自对应的方向加起来,写回 2560 维的残差流。压回 2560 是必须的,因为残差流就这么宽。

gate_proj 是干什么的

早期的 MLP 只有两个矩阵:down(act(up(x)))。2020 年 Shazeer 的论文《GLU Variants Improve Transformer》发现,再加一路 gate,两路逐位相乘(这种结构叫 SwiGLU),同样参数量下效果更好。LLaMA、Qwen、Mistral 等后来基本都用它。为什么更好主要是实验结论。直觉上,“两种读数相乘”能表达“在 A 成立时才输出 B”这类条件。

9216 从哪来 它是 Qwen 团队训练前定的尺寸,写在模型 config.json 的 intermediate_size 里,和 32 层、宽度 2560 一样,不是训练出来的。早期 Transformer 惯用宽度的 4 倍;有了 gate 之后多了一个矩阵,一般缩到 8/3 倍左右再调整,Qwen 在这里选的是 3.6 倍。9216 = 9 × 1024,GPU 切块计算也整齐。

每层 MLP 有三个 2560 × 9216 的矩阵,约 7080 万个参数,32 层合计约 22.6 亿。整个检查点(含视觉部分)约 46.6 亿参数,MLP 占了将近一半

“神经元”到底是哪个数

这里说的神经元不是一个对象,也不是某个权重。它是 h 这 9216 个数里的某一位。“第 14 层 8949 号神经元”,指的就是第 14 层 MLP 算出的 h 的第 8949 位。这个数叫它的激活,每个 token 都有一个,每次推理都重新算。

代码里 h 的形状是 [batch, tokens, 9216],即“几条对话 × 每条多少个 token × 9216”。于是 8949 号神经元在所有 token 上的激活写作:

h[:, :, 8949]

换到权重的角度,一个神经元 n 对应三份权重:

文章配图

down_proj 把各个神经元的方向加起来

矩阵乘法还有一种看法:结果等于矩阵的每一列,乘以输入对应的那一位,再全部加起来。

output = h[0]    * W_down[:, 0]
       + h[1]    * W_down[:, 1]
       + ...
       + h[9215] * W_down[:, 9215]

W_down[:, n] 是 down_proj 权重的第 n 列,一个固定的 2560 维向量。每个神经元的激活是一个系数,决定“第 n 个方向”往残差流里加多少。

文章配图

我们加的那一行

之前

h = F.silu(gate_proj(x)) * up_proj(x)
output = down_proj(h)

之后

h = F.silu(gate_proj(x)) * up_proj(x)
h[:, :, neuron] += delta
output = down_proj(h)

因为 down_proj 是矩阵乘法(线性的),这一行的效果可以精确写出来:

output_new = output + delta * W_down[:, neuron]

也就是说,这一层在每个 token 上都多往残差流加了同一个固定向量。后面的所有层,读到的都是这个被推偏了的状态。

真实代码怎么插进去

作者没有改模型源码,用的是 PyTorch 自带的 hook:给某个模块登记一个函数,模块每次运行之前先调用它。hook 挂在每一层的 down_proj 上,它拿到的输入正好就是 h:

for i, layer in enumerate(self.lm.layers):
    layer.mlp.down_proj.register_forward_pre_hook(self._hook(i))

hook 里真正改数字的是这几行:

h = inp[0]                                            # down_proj 的输入,就是 h
h = h.clone()                                         # 复制一份,不在原数据上改
h[:, :, n] = h[:, :, n] + vals[:, None].to(h.dtype)
return (h,)                                           # 交还给 down_proj

hook 返回新张量时,PyTorch 就用它代替原来的输入;不需要干预时 hook 什么也不返回,计算照常进行。

文章配图

我们的程序一次并排跑同一个提示词的三份:一份按设置的强度,一份强度为 0(原模型),一份所有强度取负。vals 给每一份一个不同的 delta,[:, None] 让它作用到这一份的每个 token 上。

hook 在两个阶段都起作用:模型读入整段提示词时,对所有提示词 token 生效;之后每生成一个新 token,也生效。

界面上的 +1 不是加进去的数

界面填的是 dose(剂量)。实际加进 h 的 delta 要换算:

delta = dose × 该层残差流长度的中位数 / W_down[:, n] 的长度

这样换算,加进残差流的向量 delta × W_down[:, n],长度正好是 dose × 残差流的典型长度。这把尺子在不同层、不同神经元之间通用。

文章配图

文章配图

“残差中位数”是用作者代码里一段固定的参考文本测出来的(一段历史叙述、一小段 Python、一句对话),不针对每个提示词重新测。

哪些东西没改

  • 权重没改,没有重新训练,也没有微调。三个矩阵里的数字从头到尾是同一份。

  • PyTorch 和 Apple GPU 的底层计算没改。hook 是 PyTorch 的标准功能,改完的 h 交回去,GPU 照常做矩阵乘法。

  • 改的只是这一次推理中算出来的一个中间结果,推理结束就没了。

我们用的是官方 Qwen/Qwen3.5-4B(版本 851bf6e)未量化的 bfloat16 权重。核心推理和 hook 复用作者的 neuron-explorer(版本 f79b219),源码一行没改;我们写的是外围的网页、服务和验证。

为什么一个数能改变回答

模型生成的每个词,都取决于最后一层的残差流。我们在第 13、14 层各往残差流里塞了一个固定向量,长度和残差流本身差不多。后面十几层的注意力和 MLP 都在这个推偏了的状态上继续算,它们按训练出来的方式解读这个偏移,偏移一路传到最后的概率。

为什么偏偏变得敌对,而不是别的效果,取决于这两个方向在训练好的权重里和什么相关。这正是神经元可解释性研究想弄清楚的问题,本文回答不了。

dose 为 −1 并不是“关掉”这个神经元。up_proj 那一路可正可负,正常运行时激活本身就可能是负数。−1 是把它往负方向推出一个残差长度那么远,写进残差流的是反方向的向量。模型平时很可能从没见过这么大的负值,所以反向干预不一定得到“相反的性格”。

实际看到了什么

首轮用 8 个英文提示词,每次都选概率最高的词(无随机性),最多生成 192 个 token。下面是同时改两个神经元时的原始输出的一个例子:

文章配图

参考资料:

  1. https://github.com/lyramakesmusic/neuron-explorer Neuron Explorer源代码
全文完
查看原文

沿着这个主题继续读

从一篇文章,找到相关的教程、经验和阅读路径。

DeepSeek 与国产大模型DeepSeek、豆包、Kimi、Qwen、MiniMax 等国产大模型的实测、Agent 用法与本地部署。