原文链接

如果你把 Transformer block 拆开来看,多数注意力都给了「attention」那部分——QKᵀ、softmax、多头、causal mask、KV cache,每一个都能讲一整篇。
但 block 里还有另外一半,叫「前馈网络(Feed-Forward Network,FFN)」,看起来就是一个最普通的两层 MLP(Multi-Layer Perceptron),先把维度从 d 升到 4d,过一个 ReLU,再压回 d。代码三行写完。
很多教程到这里就一句带过:「然后是一个 position-wise feed-forward。」
这是个很大的浪费。
第一,在标准 dense Transformer block 内部,FFN 大约占参数量的三分之二——它不是边角料,是大头。
第二,从 2021 年开始,一系列可解释性研究(Geva、Anthropic、Olah 等)把 FFN 重新解读成「键值记忆(Key-Value Memory)」——也就是说,attention 是「检索接口」,FFN 才是模型存储事实和模式的地方。
第三,现代 LLM(LLaMA、PaLM、Qwen、Mistral、DeepSeek)几乎全部把原版的 ReLU FFN 换成了 SwiGLU——这是 2017 年到现在 Transformer 主结构上变化最大的一处。
第四,主流混合专家(Mixture of Experts,MoE)做法,本质上就是把单个 FFN 换成 N 个 FFN 加路由,attention 基本保持原样。后面我们专门讲 MoE 的那一篇(44 篇),主战场也都在 FFN 这块。
第五,做推理工程的人都知道,量化时 attention 还好说,FFN 才是真正难处理的——4d 这一层的激活值分布往往非常厚尾,INT8 一不小心就把模型搞坏。
所以这一篇我想做的事情是:把 FFN 当成一个值得写一万字的对象,而不是一行注释。
读完之后,你应该能做到:
先把主公式贴出来,然后我们一节一节地拆它:
FFN(x) = max(0, x W₁ + b₁) W₂ + b₂
其中:
这是 2017 年原论文的写法。它有三个设计选择,每一个我们都要单独讲:两层而不是更多、4 倍扩张而不是其它倍数、逐位置(position-wise)而不是跨位置。
先回到上下文里。Transformer encoder 的一个 block 长这样(pre-LN 写法,现代主流):
x' = x + Attention(LN(x))
y = x' + FFN(LN(x'))
post-LN 写法(原论文):
x' = LN(x + Attention(x))
y = LN(x' + FFN(x'))
无论 LN 放哪儿,结构上有两块「子层」:先 attention,再 FFN,之间各夹一个残差连接(Residual Connection)和层归一化(Layer Normalization)。
如果你只看这两个子层做了什么事,会发现一个很清晰的分工:
写成大白话:attention 负责「token 之间通信」,FFN 负责「token 内部计算」。
这个分工乍一看好像 attention 是主力、FFN 是辅助,因为新闻里关注的都是 attention。但前面已经说过:FFN 占参数量的三分之二,attention 只占三分之一。从「容量」角度看,FFN 才是模型的主力。
这是个值得停下来想一想的问题。
理论上,self-attention 已经是一个「带参数的混合操作」——它有 W_Q、W_K、W_V、W_O 四个矩阵,足以做相当复杂的事情。为什么不直接堆叠 attention 层就行?为什么每层之后非要再夹一个 MLP?
答案藏在 attention 的结构里。
attention 的公式是 softmax(QKT/dk)Vtext{softmax}(QK^T/sqrt{d_k}) Vsoftmax(QKT/dk)V。这里 Q、K、V 都是输入 x 的线性投影。也就是说,attention 输出的每一个分量,都是输入 x 各位置的线性组合——加权系数虽然是非线性算出来的,但 V 进入输出的方式是纯线性。
如果你只堆 attention 层,多层之后当然不再是一个单纯的线性映射,因为 softmax 会让混合权重依赖输入;但这种非线性主要发生在「不同位置之间怎么混合信息」这件事上。
要让模型学到真正的非线性变换——比如「这个表示要先映射到一个高维空间,在那里某些方向被压缩、某些方向被放大、某些方向被砍掉,再压回来」——你需要一个纯粹做非线性的子层。这就是 FFN 的角色。
它升维(拉到 4d)、过非线性(ReLU 或后来的 Swish)、再降维(压回 d)。这个「升-非线性-降」的三明治,是经典 MLP 的标准动作,被各种深度学习模型反复证明有效。
换个角度说:attention 是按上下文做内容相关的跨位置混合,FFN 是在单个位置内部做高维非线性变换。两者结合,才能既看到上下文,又能做复杂的表示加工。
再强调一次这条性质,因为后面会反复用到:FFN 是逐位置(position-wise)的。
意思是:对 batch 里的每一个 token,FFN 都用同一组参数 W₁、W₂、b₁、b₂ 独立做一次计算。token A 的 FFN 计算和 token B 的 FFN 计算之间没有任何信息交换。
这不是巧合,是有意设计。
第一,它和 attention 的「跨位置混合」形成互补。
第二,它让 FFN 的计算可以完美并行——每个 token 一份独立的矩阵向量乘,一万个 token 就是一万次相同形状的乘法,GPU 很喜欢这种结构。
第三,参数量与序列长度无关——FFN 的参数 d×4d+4d×d=8d2d times 4d + 4d times d = 8d^2d×4d+4d×d=8d2,和 token 数 TTT 没关系。
第四,这种「位置不敏感」的属性,让 FFN 像一个「纯粹的查表函数」:你给它一个 d 维向量 x,它返回一个 d 维向量 y,不需要任何上下文。下面讲键值记忆视角时,这个属性是关键。
图里把整条流程画清楚了:输入 [B, T, d] → 升维到 [B, T, 4d] → ReLU → 降维到 [B, T, d]。每个位置独立处理,没有跨位置的算子。
这是 FFN 最常被略过、却最值得讨论的两个超参数。
为什么不是一层 MLP?因为一层带激活的 MLP 等价于「一个非线性核函数」,表达能力有限——具体说,一层的隐层维度即便很大,也仍然只是「线性 → 非线性 → 线性输出」三步合一,没有非线性的复合。
为什么不是三层、四层?因为再往里加层,会显著增加参数和训练复杂度,但收益并没有像「多堆几个 block」那样稳定。原论文直接采用了 2 层 FFN,后来 GPT-2、GPT-3、LLaMA 等主流模型也基本沿用了这个骨架。
更深的原因可能与残差连接(Residual Connection)有关:在残差网络里,每个 block 学的是一个「小修正」,不需要每个子层本身做特别复杂的变换,深度由层数堆叠提供,而不是由单个 FFN 内部提供。
如果 FFN 内部已经是 3 层,再加上残差和 LN,每个 block 就有 5-6 个非线性层,反而可能让训练变难。
「为什么 d_ff = 4d?」这是个面试常问题,但真正讲到位的答案不多。
原论文 Attention Is All You Need(Vaswani et al., 2017)在 §5.4 的消融表里,对 d_ff 做过几组对照:
也就是说,2 倍不够,8 倍边际收益太小,4 倍是性价比最高的点。
这个 4 倍扩张比的合理性,可以从另一个角度理解:FFN 第一层把 d 维向量映射到 4d 维空间。如果你把 4d 看成「特征字典」,每个字典条目对应一个被 ReLU 选择性激活的特征——4d 个条目大致够用,再多就稀疏到学不动。
但要注意:这个 4 倍是 2017 年在 d=512 的尺度上调出来的。后来不同尺度的模型,d_ff/d 这个比例其实有变动:
| 模型 | d_model | d_ff | 比值 |
|---|---|---|---|
| Transformer base (2017) | 512 | 2048 | 4.0 |
| Transformer big | 1024 | 4096 | 4.0 |
| BERT-base | 768 | 3072 | 4.0 |
| GPT-2 small | 768 | 3072 | 4.0 |
| GPT-3 175B | 12288 | 49152 | 4.0 |
| LLaMA-7B(SwiGLU) | 4096 | 11008 | 2.69(≈ 8/3) |
| LLaMA-65B(SwiGLU) | 8192 | 22016 | 2.69 |
| PaLM 540B(SwiGLU) | 18432 | 49152 | 2.67 |
ReLU FFN 一直是 4 倍。SwiGLU 改成 8/3 倍——但这不是「真的更窄」,而是因为 SwiGLU 多了一个矩阵,要把总参数量对齐到 8 d²。下面讲变体时会详细说。
最后一个值得强调的点:d_ff 不能孤立看,要和 d_model、num_layers、num_heads 一起看。
总参数量 ≈nlayers×(4d2(attention)+8d2(FFN))=12nlayersd2approx n_{text{layers}} times (4d^2 text{(attention)} + 8d^2 text{(FFN)}) = 12 n_{text{layers}} d^2≈nlayers×(4d2(attention)+8d2(FFN))=12nlayersd2。
这是一个非常好用的估计公式。比如:
记住这个 12 d² 的估计,对评估「这个模型有多大」「我能不能跑得起来」非常有用。
我们已经反复说 FFN 是 position-wise 的,现在把这件事的形式化结果提出来。
把 FFN 看成一个函数 f:Rd→Rdf: mathbb{R}^d to mathbb{R}^df:Rd→Rd,输入一个 d 维向量、返回一个 d 维向量。整个 FFN 子层做的事情是:
y[b, t, :] = f(x[b, t, :]) # 对每个 (b, t) 独立调用
注意 b 和 t 都不出现在 f 的定义里——f 只依赖参数 W₁、W₂、b₁、b₂,这些参数在所有位置共享。
这有几个有意思的推论:
推论 1:FFN 的输入分布是「每个 token 的隐状态」。
这点很容易被忽视。你训练 FFN 时,看到的不是「整句话」,而是大量被 attention 混合过的、独立的 d 维向量。两个不同句子在某个位置上的隐状态,对 FFN 来说是同一类输入——只要它们的 d 维表示落在相似的区域。
推论 2:FFN 学到的是「隐状态空间到隐状态空间」的映射。
这种映射的复杂度由 4d 这个隐藏层决定。如果 d=512、d_ff=2048,FFN 在内部有 2048 个「特征探测器」,每个探测器决定输入向量是否落在它响应的方向上。
推论 3:因为 FFN 不混 token,它对序列长度完全不敏感。
无论你输入 512 个 token 还是 32k 个 token,FFN 子层的参数和单 token 计算量完全一样。所以长序列推理的瓶颈通常不在 FFN,而在 attention(它的复杂度是 O(T²))。这是后面讲长上下文优化(41-43 篇)的一个关键背景。
推论 4:FFN 的计算可以无成本地并行。
GPU 可以把所有 (b, t) 的 FFN 计算合并成一次大的批量矩阵乘——[B⋅T,d]×[d,4d]→[B⋅T,4d][B cdot T, d] times [d, 4d] to [B cdot T, 4d][B⋅T,d]×[d,4d]→[B⋅T,4d],再 ×[4d,d]times [4d, d]×[4d,d]。这是为什么 FFN 在工程上特别友好的原因。
我喜欢把 FFN 想象成一台「词向量加工厂」。
attention 子层把「上下文」装进了当前 token 的隐状态——它现在不再只是 "cat" 这个词的初始嵌入,而是「这句话里、在这个位置上、被前后文影响过的 cat 表示」。
FFN 接过这个加工过的表示,做一次复杂的非线性变换:「让这个向量在隐空间里走一段路」。这一步可能是把一个表示「澄清」(让它更接近某个概念)、可能是「转换」(把动词形态改了)、也可能是「事实查询」(输入 "Paris is the capital of",输出方向上加上 "France" 的成分)。
具体它做了什么,要看下一节的「键值记忆」视角。
如果说前一节是「逐位置标量函数」的视角——把 FFN 当黑盒,问它的形状和性质——那么这一节要换一个完全不同的视角,把它打开看里面在干什么。
先做一点纯代数的重写。原公式:
FFN(x) = ReLU(x W₁ + b₁) W₂ + b₂
把 W₁ 看作 d_ff 个 d 维列向量(严格说是 W₁ 的列向量,或者把 W₁ 写成 [k₁ | k₂ | … | k_{d_ff}],每个 k_i 是 d 维):
W₁ = [k₁, k₂, ..., k_{d_ff}] 形状 d × d_ff
那么 xW1x W_1xW1 的第 iii 个分量就是 x⋅kix cdot k_ix⋅ki——也就是输入 xxx 与 kik_iki 的内积。
加上 ReLU 和偏置:
a_i = ReLU(x · k_i + b_{1,i})
a_i 是一个标量,可以理解成「k_i 这个方向,被 x 激活了多少」。
再看 W₂。把 W₂ 拆成 d_ff 个 d 维行向量(这次是行):
W₂ = [v₁; v₂; …; v_{d_ff}] 形状 d_ff × d
那么:
y = a · W₂ + b₂ = Σ_i a_i v_i + b₂
也就是说,FFN 的输出,是 d_ff 个向量 v_i 的加权和,权重是 a_i。
这个写法立刻让人联想到一个东西:注意力公式。
Attention(Q, K, V) = softmax(QKᵀ/√d_k) V
attention 也是「先用 Q 和每个 K 算相似度,得到权重,再用权重加权 V」。
唯一的区别是:
attention 的相似度用 softmax 归一化,FFN 用 ReLU 不归一化(多个激活可以同时打开)。
但形式上的对应关系非常清楚:FFN 是「带固定 K/V 的 attention」,或者反过来说,attention 是「带动态 K/V 的 FFN」。
这个视角不是凭空想出来的。Geva、Schuster、Berant、Levy 在 EMNLP 2021 的论文 Transformer Feed-Forward Layers Are Key-Value Memories 里,对 BERT 的每个 FFN 神经元做了系统的可视化研究。
他们做了什么呢?大致是:拿一个训练好的 BERT,把每一层 FFN 的 4d 个神经元逐个拿出来,看每个神经元在哪些输入下被强烈激活——也就是找出能把 a_i 激活到最大的那些训练样本。
结果非常震撼:
也就是说,FFN 的每个神经元,确实在做某种「检索」——它有一个偏好的输入模式(k_i 编码),当输入 x 的 d 维表示与 k_i 方向接近时,a_i 被打开,对应的 v_i 被加到输出里。
很多个用处。我挑几个最直接的。
第一,它解释了「事实回忆」是从哪儿来的。
一个语言模型能正确回答 "The capital of France is _" 是因为它在某个位置、某层 FFN 里,有一个神经元的 k 编码了「the capital of 」这个模式,对应的 v 在 vocab 投影后倾向 "Paris" 这个词的方向。Geva 的后续工作(Dissecting Recall of Factual Associations in Auto-Regressive Language Models,EMNLP 2023)甚至能定位到具体哪一层、哪些神经元负责一个特定事实,并通过修改这些神经元来改变模型记住的事实。
这是 ROME(Locating and Editing Factual Associations in GPT,Meng et al., NeurIPS 2022)和 MEMIT 等模型编辑工作的理论基础。
第二,它解释了为什么 MoE 有效。
如果 FFN 是关联记忆,那一个大 FFN 就像一个大字典。当你想让模型容量翻倍时,可以扩大 d_ff——但 d_ff 已经很大了(数万),每个 token 都要遍历所有 d_ff 个 key 浪费太多。
MoE(Mixture of Experts)的想法是:做几个独立的 FFN(专家),每次只激活最相关的几个。这样总参数量可以放大几十倍,但单次计算只动用一小部分。后面 44 篇会详细讲。
第三,它解释了为什么 attention 量化容易、FFN 量化难。
attention 的 K/V 是动态生成的,分布相对均匀;FFN 的 W₁ 行向量(也就是 k_i)有非常明显的「明星神经元」——少数 k_i 在大量样本上被强烈激活,对应的激活值有长尾。INT8 量化的难点就在这里:用 256 个量化级覆盖一个长尾分布会丢精度。后面讲量化(48-50 篇)时这点会具体展开。
图里把 W₁ 的列当成 keys、W₂ 的行当成 values 画出来,并对照了 attention 公式的各项。直观上很容易看出两者的同构关系。
需要泼一点冷水。
「FFN 是事实存储」是一个很流行的隐喻,但严格说,这个说法有它的边界。
第一,FFN 单个神经元不是「一个事实对应一个神经元」。Anthropic 在 Toy Models of Superposition(Elhage et al., 2022)中指出,神经网络往往把多个特征「叠加」(superposition)到同一个神经元上——尤其是当特征数量超过维度数时。所以你看一个神经元被「激活」,它可能同时编码三个不相关的概念。
第二,事实的回忆经常需要 attention 和 FFN 配合——attention 把相关上下文搬到当前位置,FFN 才能在该位置做 lookup。两者拆开看意义有限。
第三,模型的「事实」不是离散存储,而是分布式的——同一个事实可能被多个神经元、多层一起共同编码。改一个神经元只会让模型对这个事实的回忆稍稍偏移,不会完全忘掉。
但这些 caveat 不否定主结论:FFN 是模型存储「学到的内容」的地方,attention 是「调度逻辑」。
我们前面已经多次提到「FFN 占总参数 2/3」。这一节把它算清楚。
把一个 Transformer block 的可训练参数列出来(忽略 LN 和偏置,它们参数量极小):
Attention 子层:
FFN 子层:
单 block 合计:12 d²。其中 attention 占 4/12 = 33%,FFN 占 8/12 = 67%。
把 block 重复 n_layers 次,再加 embedding:
总参数 ≈ n_layers × 12 d² + V × d
其中 V 是词表大小,V × d 是 token embedding 的参数量(output 的 LM head 通常和 input embedding 共享权重)。
代入几个具体模型验算:
Transformer base (2017):n_layers=6, d=512, V=37000
GPT-2 small:n_layers=12, d=768, V=50257
LLaMA-7B:n_layers=32, d=4096, V=32000,但用 SwiGLU(d_ff=11008,3 个矩阵)
注意 LLaMA 因为用 SwiGLU,FFN 参数比例更夸张:4.3 / 6.6 ≈ 65%——和原版相当,但绝对值大得多。
图里把单 block 参数分布画成饼图:FFN 那一块是 attention 的两倍。
第一,模型瘦身的主要目标是 FFN。
剪枝(pruning)、低秩分解(low-rank factorization)、量化(quantization)、MoE,绝大多数是冲着 FFN 去的,因为它「肉最多」。
第二,FFN 也是显存大头。
训练时,激活值需要保存在显存里以便反传。FFN 的激活是 [B, T, 4d],比 attention 的 [B, T, d] 大 4 倍。所以 gradient checkpointing(重计算)经常优先重算 FFN。
第三,推理时 FFN 不能「缓存」。
KV cache 缓存的是 attention 的 K 和 V——它们随历史生成而累积,下一步只需要追加新 token 的 K/V,旧的复用。
但 FFN 是逐位置的,每生成一个新 token,都要把它过一遍完整的 FFN。在常见 dense LLM 的单 token decode 场景里,KV cache 已经把 attention 的历史部分复用掉了,而 FFN 仍然要逐层完整计算,所以它通常是每步延迟的重要来源之一。这也是为什么 MoE 在 decode 时格外有意义——它把 FFN 的「每步必算」成本砍下来了。
原版 FFN 用 ReLU,公式干净、计算便宜。但它有一些工程上的麻烦:
针对这些问题,2016 年前后出现了几个替代品:GELU(Gaussian Error Linear Unit)、Swish(也叫 SiLU)、Mish 等。它们都是「平滑版的 ReLU」,在大模型上常见到。
GELU 的形式(精确版):
GELU(x) = x · Φ(x)
其中 Φ 是标准正态的累积分布函数。直观上,它把 ReLU 的「0/1 硬开关」换成了「按 x 的标准正态尾概率加权」。BERT、GPT-2、GPT-3 都用 GELU。
Swish(SiLU):
Swish(x) = x · sigmoid(x)
形式更简单,性质和 GELU 几乎一样,LLaMA 系列用的就是这个。
但更大的变化是引入「门控」结构。
GLU(Gated Linear Unit)由 Dauphin 等人在 2017 年的 Language Modeling with Gated Convolutional Networks 中提出。后来 Shazeer 在 2020 年的短论文 GLU Variants Improve Transformer 中专门做了在 Transformer FFN 上的对照。
GLU 把 FFN 第一层从「一个矩阵乘 + 非线性」改成「两个矩阵乘相乘」:
FFN_GLU(x) = (x W₁ ⊙ σ(x V)) W₂
其中:
直觉上:x W₁ 是「候选信息」,σ(x V) 是「这个信息要打开多少」。两者相乘,得到「按门控加权的信息」。
这比 ReLU 灵活——它可以学到「打开 30%」「打开 80%」这种连续的门控,而不是「打开 / 不打开」二选一。
Shazeer 的论文里给出了一组变体:
实测上 SwiGLU 和 GeGLU 表现最好,比 ReLU FFN 在同等参数量下 PPL 略低。LLaMA、PaLM、Qwen、DeepSeek、Mistral 等主流大模型基本都选了 SwiGLU。
SwiGLU 公式:
FFN_SwiGLU(x) = (Swish(x W_gate) ⊙ x W_up) W_down
注意这里有三个矩阵:W_gate、W_up、W_down(在 LLaMA 实现里就是这个命名)。
这是个常被问到、答得到位的人不多的问题。
ReLU FFN 的参数量是 d×4d+4d×d=8d2d times 4d + 4d times d = 8d^2d×4d+4d×d=8d2。
SwiGLU 有三个矩阵,每个是 d×dffd times d_{text{ff}}d×dff(W_gate 和 W_up)或 dff×dd_{text{ff}} times ddff×d(W_down),合计 3×d×dff3 times d times d_{text{ff}}3×d×dff。
为了让 SwiGLU 和 ReLU FFN「同等参数量」做对比,需要 3×d×dff=8d23 times d times d_{text{ff}} = 8d^23×d×dff=8d2,解得:
d_ff = 8d / 3 ≈ 2.667 d
所以 LLaMA-7B 的 d=4096,d_ff = 4096 × 8/3 ≈ 10923——实际工程取了 11008(向上取到 256 的整数倍,便于 GPU 算子对齐)。
如果你在论文或博客里看到「LLaMA 的 FFN 比 4 倍小」,那是因为没意识到 SwiGLU 多了一个矩阵——参数总量是没变的。
Shazeer 的原文里给了几条小实验,结论是 SwiGLU 在多个任务上比 ReLU FFN 好一点点(PPL 低 0.01-0.1 量级)。后来大模型的实测也支持这个结论。
但 Shazeer 自己写了一句很诚实的话:「我们不能给出一个清晰的解释,为什么这些变体有效。或许是 divine benevolence。」(divine benevolence = 神的眷顾,他原话开玩笑)
也就是说,现在没有一个清楚的理论解释 SwiGLU 为什么更好——只是工程上稳定地有效。这种「不知道为什么但有效」在深度学习里很常见。
图里把三种变体的公式、参数量、d_ff 取值放在一起对比。要点是:同等参数量下,SwiGLU 比 ReLU FFN 好;但代价是多一个矩阵(实现复杂、算子要适配)。
混合专家(Mixture of Experts,MoE)整整一篇值得讲(44 篇会详讲),这里只点出它和 FFN 的关系。
标准 FFN:
y = FFN(x) # 单一参数共享给所有 token
MoE FFN:
gate_scores = router(x) # 给每个专家打分
top_k_experts = top_k(gate_scores) # 选出 k 个(通常 k=2)
y = Σ_{i in top_k} gate_scores[i] · FFN_i(x)
也就是说,MoE 用一个轻量的「路由网络」给每个 token 选 2 个(或 k 个)专家,只让这 2 个专家算 FFN,其它的不算。
如果有 8 个专家、k=2,那么 8 个专家的总参数量是单 FFN 的 8 倍,但每次推理只用其中 2/8 = 1/4 的计算(再加一点路由开销)。模型容量翻倍但计算不翻倍——这是 MoE 的核心卖点。
理论上你可以做 MoE-attention(确实有论文尝试过),但实际几乎所有 MoE 工作都只对 FFN 做切分。原因:
这些细节 44 篇会展开。这里只要记住:MoE = FFN 的稀疏化版本。它没改 attention,只改 FFN。
这一节把前面「键值记忆」的视角再往深推一层,进入可解释性研究的领域。
2018 年 Karpathy 的博客 The Unreasonable Effectiveness of Recurrent Neural Networks 里展示过:训练一个 LSTM 字符级语言模型,会有一个神经元专门追踪「我是不是在引号里」、另一个追踪「我是不是在 if 语句的条件部分」。这种「单个神经元学到一个具体功能」的现象,在 RNN 上就有人观察到了。
到了 Transformer 时代,OpenAI 在 2019 年的 GPT-2 微观分析(早期博客)和 Anthropic 后来的一系列工作(Olah、Elhage 等)把这件事推到了系统化研究。
但实际研究中,很快发现一个问题:很多神经元是「多义的」(polysemantic)——你看它在某些样本上激活,会同时响应几个不相关的概念。
比如在一个 GPT-2 模型里,你可能找到一个神经元,它同时对「关于猫的句子」「金融术语」「动词过去时」都强烈激活。
这违反了「一个神经元对应一个概念」的简单假设。
Anthropic 在 Toy Models of Superposition(Elhage et al., 2022)中给出了一个解释:当模型要表示的特征数量超过维度数时,它会把多个特征「叠加」到同一个方向上——不是 d_ff 个特征用 d_ff 个独立方向,而是用稀疏结构(只有少数特征同时激活),把更多特征压缩到 d_ff 维空间里。
这种「叠加」(superposition)让单个神经元变成多义的,但也让模型容量变大——它实际能编码的特征数远超 d_ff。
Anthropic 后来的 Towards Monosemanticity(Bricken et al., 2023)和 Scaling Monosemanticity(Templeton et al., 2024)用稀疏自编码器(Sparse Autoencoder, SAE)从叠加表示中「解出」单义特征。
做法大致是:训练一个超宽的自编码器(比如 d=512 解到 32k 或 256k 维),强制激活稀疏。每个解出的特征更接近「一个清晰的概念」。
在 Claude 3 Sonnet 上的实验里,他们解出了几百万个单义特征,包括「金门大桥」「Python 列表推导式」「关于死亡的诗歌」等具体得吓人的概念。
这些研究的结论是:FFN 内部确实在做某种「特征字典」的工作,但字典条目和神经元不是一一对应——神经元是叠加表示,需要专门的工具才能解出。
可解释性目前还没有直接落到工程实践(除了 ROME 这类编辑工作)。但有几个值得关注的方向:
原论文在两个位置加了 dropout(rate=0.1):
具体到 FFN:
h = ReLU(x W₁ + b₁)
h = dropout(h) # 注:这一步在很多实现里有
y = h W₂ + b₂
y = dropout(y) # 子层输出 dropout
return x + y
不同实现的 dropout 位置略有差异——有的在 ReLU 之后再 dropout,有的不。fairseq、Tensor2Tensor、HuggingFace 各有微小差异,但总体效果相近。
现代大模型(GPT-3、LLaMA)已经基本不用 dropout 了——因为数据足够大,过拟合不再是主要矛盾。
Xavier/Glorot 初始化是默认选择:W 用 N(0, 2/(fan_in + fan_out)) 或类似分布。但 W₂ 因为是输出回 d,常常额外乘一个 1/√(2 n_layers) 的缩放因子(来自 GPT-2 / LLaMA 的实现),目的是让深层残差累积时方差不爆炸。
坑 1:混合精度下的数值不稳。
FFN 的中间激活往往有明显长尾。fp16 更容易碰到溢出;bf16 虽然指数范围更宽,但尾部激活仍会放大舍入误差和层间方差漂移。所以现代实现通常会依赖 pre-norm、合适的残差缩放、稳定的 kernel 和混合精度策略,来控制这一段的数值行为。
坑 2:SwiGLU 的实现错误。
SwiGLU 公式是 Swish(xWgate)⊙xWuptext{Swish}(x W_{text{gate}}) odot x W_{text{up}}Swish(xWgate)⊙xWup,注意 Swish 只作用于 W_gate 那一支。一个常见 bug 是把 Swish 错放到 W_up 上、或者两支都过 Swish——结果模型能训但效果略差。
坑 3:在量化时 FFN 是瓶颈。
INT8 / INT4 量化通常先量化 attention,最后才动 FFN。FFN 的 W₁ 和 W₂ 都是大矩阵,权重分布有重尾,量化误差容易累积。一些方案(GPTQ、AWQ)专门针对 FFN 做了校准。
FFN 在推理阶段的特性,和训练时有些不一样,值得单独提。
KV cache 缓存的是 attention 的 K/V——它们一旦算出来,对未来步骤永远有效,可以累积存储,下一步只追加新 token 的 K/V、复用历史。
FFN 不一样。每生成一个新 token:
历史 token 的 FFN 计算结果对当前生成完全没用——它们已经写进了那些 token 的隐状态里,也已经传递过 attention,下一步 decode 不再需要它们的 FFN 输出。
所以 decode 时每一步都要完整跑一遍 FFN,没有 cache 可以复用。
decode 阶段(生成新 token),每步只处理 1 个 token,但要把 W₁ 和 W₂ 这两个矩阵从 HBM 读到 SRAM。这两个矩阵很大(每个 4d²,加起来 8d²),比单 token 的计算量大得多。
在常见 dense LLM 的单 token decode 场景里,FFN 往往更接近带宽瓶颈,而不是算力瓶颈。
这个观察对推理优化非常关键:
具体优化策略 50 篇之后再展开。
prefill 阶段(处理 prompt),一次性给一长串 token(比如 1k 个)。这时 FFN 的计算量是 1k × 8d²,已经足够大,算力变成瓶颈而不是带宽——因为权重只读一次,被 1k 个 token 共享。
所以 prefill 和 decode 在 FFN 上的瓶颈完全不同:prefill 看 GPU 的 FLOPS,decode 看 HBM 带宽。一个推理服务的延迟分布往往两者都要分别建模。
前面提了几次,这一节展开说。
INT8 量化的本质是把一个浮点张量映射到 256 个量化级。如果张量分布是 N(0, 1) 这种钟形,256 个级别覆盖均匀、误差很小。
但 FFN 第一层的输出(即 4d 维的中间激活)有几个特点:
如果你用一个简单的 max scaling 把 [-127, 127] 映射回浮点:
scale = max(|x|) / 127
x_int8 = round(x / scale)
那个 max 可能是 4d 维里某一个极端激活,导致大多数其它值在量化时只占用了几个量化级——精度损失很大。
SmoothQuant(Xiao et al., 2022):把激活的尺度压力部分吸收到权重上。如果激活的某个通道有重尾,就把这个通道的激活压小、同时按相反方向放大对应权重,乘积保持不变——但激活分布会更平,更适合量化。
GPTQ(Frantar et al., 2022):对权重做逐列量化,保持低误差。
AWQ(Lin et al., 2023):识别「关键权重通道」(对应那些激活重尾的通道),保留它们高精度。
FP8 训练(H100 之后):用 FP8 而不是 INT8——表示范围更宽,重尾问题轻一些。
这些细节 48-50 篇会展开。这里只要记住:FFN 在量化里特别难,因为它激活有重尾、第一层 ReLU 后分布偏斜。
很多现代实现把 FFN 第二层的偏置 b₂ 去掉了:
self.w1 = nn.Linear(d, 4*d, bias=True)
self.w2 = nn.Linear(4*d, d, bias=False) # 注意 bias=False
理由是后面紧跟着残差连接和 LN,b₂ 的作用会被 LN 的 β 参数吸收。去掉省一点参数,几乎不影响表现。LLaMA 系列把 W₁、W₂、W_gate、W_up 全部都去掉了 bias。
为了 GPU kernel 友好,d_ff 通常向上对齐到某个倍数(128、256、甚至 512)。例如 LLaMA-7B 的 d=4096,理论 d_ff=8d/3=10923,实际取 11008(=43 × 256)。这种对齐对训练速度有显著影响,对模型性能几乎无影响。
Chinchilla 的 scaling law 论文里提到一个细节:增加深度(n_layers)和增加宽度(d、d_ff)对模型表现的贡献有微妙差异。FFN 主要由 d 决定,但「足够深」也很关键——3-4 层的 Transformer 即使每层 FFN 很宽也学不动复杂任务。
有几篇论文尝试让多层共享同一组 FFN 参数(ALBERT 那一类)。结果:参数量降下来了,但表现也降下来。说明 FFN 在不同层学的东西确实不同——浅层 FFN 处理表层模式,深层 FFN 处理高级概念,硬强制它们用同一组参数会丢信息。
有研究发现,多语言模型(mBERT、XLM-R)的「语言识别」能力主要分布在 FFN 中——不同 FFN 神经元对不同语言敏感。这与「FFN 是关联记忆」的视角一致:每种语言的常用模式被存到了不同的 k_i 里。
我们已经从原理、变体、参数、可解释性的角度把 FFN 讲了一遍。这一节落到代码——把 FFN 写成 PyTorch 模块,逐行说明每一步在做什么。这个练习的价值是:你能验证前面所有定性叙述在数值上确实成立。
最直白的实现,对应原论文:
import torch
import torch.nn as nnclass FFN(nn.Module):
def __init__(self, d_model: int, d_ff: int, dropout: float = 0.1):
super().__init__()
self.w1 = nn.Linear(d_model, d_ff, bias=True)
self.w2 = nn.Linear(d_ff, d_model, bias=True)
self.dropout = nn.Dropout(dropout) def forward(self, x: torch.Tensor) -> torch.Tensor:
# x: [B, T, d_model]
h = self.w1(x) # [B, T, d_ff]
h = torch.relu(h) # [B, T, d_ff]
h = self.dropout(h) # 中间 dropout(部分实现没有这行)
y = self.w2(h) # [B, T, d_model]
y = self.dropout(y) # 子层输出 dropout
return y
读这段代码时,要注意几个看起来微小、其实重要的点。
第一,nn.Linear 的输入张量可以有任意前缀维度——它只在最后一维做矩阵乘。所以你不需要把 [B, T, d] 显式 reshape 成 [B*T, d] 再做矩阵乘,PyTorch 自动处理。这从工程上印证了「FFN 是 position-wise」——所有前缀维度都被当作独立样本。
第二,torch.relu 是一个完全没有参数的算子,逐元素操作。它对反向传播的影响是「输入小于 0 的位置,梯度直接置零」——这就是 dead ReLU 的来源。
第三,dropout 在两处都加了。如果你训练大模型,通常会把第一处去掉,只保留子层输出 dropout;如果做 fine-tune 大模型,dropout 可能整体设为 0。
第四,bias=True 是 PyTorch 默认值。LLaMA 系列把它改成 bias=False——前面提过,因为后面 LN 的 β 已经吸收了偏置作用。
class SwiGLU(nn.Module):
def __init__(self, d_model: int, d_ff: int):
super().__init__()
# d_ff 通常取 8*d_model/3 向上对齐到某个倍数
self.w_gate = nn.Linear(d_model, d_ff, bias=False)
self.w_up = nn.Linear(d_model, d_ff, bias=False)
self.w_down = nn.Linear(d_ff, d_model, bias=False) def forward(self, x: torch.Tensor) -> torch.Tensor:
gate = torch.nn.functional.silu(self.w_gate(x)) # Swish/SiLU
up = self.w_up(x)
h = gate * up # 逐元素门控
return self.w_down(h)
注意点:
w_gate、w_up、w_down。w_gate 上过 Swish/SiLU,w_up 直通;* 是逐元素相乘;把 d_model = 4、d_ff = 16,做一次小尺度的前向,看看每一步的形状和数值:
torch.manual_seed(42)
ffn = FFN(d_model=4, d_ff=16, dropout=0.0)
x = torch.randn(1, 3, 4) # batch=1, T=3, d=4
print("x:", x.shape, x)
print("w1:", ffn.w1.weight.shape) # [16, 4],注意 PyTorch Linear 是 [out, in]
print("w2:", ffn.w2.weight.shape) # [4, 16]h_pre = ffn.w1(x)
print("h_pre (before ReLU):", h_pre.shape) # [1, 3, 16]
h = torch.relu(h_pre)
print("h (after ReLU):", h.shape, "zeros:", (h == 0).float().mean().item())
y = ffn.w2(h)
print("y:", y.shape) # [1, 3, 4]
如果把 (h == 0) 那个比例打出来,在这种随机初始化的小实验里,通常会看到大概一半的 h_pre 元素被 ReLU 砍掉——这是对称分布输入下很自然的现象。
这个观察提供了一个直观印象:ReLU FFN 在单个样本上往往不会同时用满所有 d_ff 通道,所以「真正参与计算的通道数」通常小于 d_ff。本节只能帮助理解为什么 8d/3 的 SwiGLU 仍然能和 4d 的 ReLU FFN 竞争;它不是严格证明,因为真实模型的效果还取决于门控形式、优化过程和数据分布。
写一个小实验:构造两个不同的 batch、不同的位置,但中间某一个位置的 x 值完全相同,看 FFN 输出是不是相同。
ffn.eval()
x_a = torch.randn(1, 3, 4)
x_b = x_a.clone()
x_b[0, 0, :] = torch.randn(4) # 把第 0 个位置改了with torch.no_grad():
y_a = ffn(x_a)
y_b = ffn(x_b)# 第 1、2 个位置的输入相同 → 输出应该相同
diff_pos1 = (y_a[0, 1, :] - y_b[0, 1, :]).abs().max()
diff_pos2 = (y_a[0, 2, :] - y_b[0, 2, :]).abs().max()
diff_pos0 = (y_a[0, 0, :] - y_b[0, 0, :]).abs().max()print("diff at pos 1:", diff_pos1.item()) # 应该是 0
print("diff at pos 2:", diff_pos2.item()) # 应该是 0
print("diff at pos 0:", diff_pos0.item()) # 应该 > 0
跑一遍会发现 pos 1、pos 2 的输出完全相同(只受自己位置的输入影响),pos 0 因为输入变了所以不同。这就是「FFN 是 position-wise」的可执行验证。
如果把它换成 attention,结论就不对——attention 子层的输出在每个位置都依赖所有位置,改任何一个位置的输入,所有位置的输出都会变。
把 FFN 单独讲完之后,回过头看它和 attention 的关系,能得到一些更细的洞察。
最常见的隐喻是:attention 是「token 之间通信」,FFN 是「token 内部计算」。这个隐喻很好用,但需要小心一点——它给人一个错误印象,好像两者完全独立、可以拆开看。
实际上,attention 和 FFN 是紧密耦合的:
所以严格说,每一层 attention 和 FFN 形成一个「混合 → 加工 → 混合 → 加工 → …」的链条。多层下来,每个 token 的隐状态既反映上下文信息,也反映多次非线性变换的结果。
举一个具体例子。模型要预测 "The capital of France is _" 后面的词。
理想情况下,模型应该回忆起「France 的首都是 Paris」这条事实。这件事是怎么在网络里发生的?
按 Geva 等人的研究,大致是这样:
早期层:attention 把 "France" 这个 token 的信息搬到位置 5(也就是 "is" 后面这个待生成位置)周围。具体说,attention 头会让位置 5 的 Q 与「France」的 K 强匹配,从而把 France 的 V 加到位置 5 的隐状态里。
中后期层:位置 5 的隐状态现在已经携带了「country=France」「question_type=capital」这种语义。FFN 的 k_i(W₁ 的列向量)里,有一个或几个专门响应「the capital of 」这种模式——它的 k 与位置 5 的隐状态内积很高,对应的 v 在 vocab 投影后倾向 Paris、Lyon、Marseille 这些法国城市的方向。
最后一层:经过几次 attention + FFN 加工,位置 5 的隐状态在 unembedding 矩阵投影后,Paris 的 logit 最高。
也就是说,「事实回忆」是 attention 和 FFN 联合完成的——attention 负责把相关上下文搬到当前位置,FFN 负责在当前位置做 lookup。少了任何一个,事实都回忆不出来。
ROME(Meng et al., 2022)做模型编辑,就是利用这个机制:他们定位「the capital of France」对应的 FFN 神经元,然后修改它们的 v_i,让这个事实从 "Paris" 变成 "Beijing"——之后模型在回答这个问题时确实会输出错误答案。这反向验证了「FFN 存事实」的视角。
这是一个常被问、其实没标准答案的问题。
从消融实验看,把 attention 换成简单的卷积或 MLP-Mixer,模型表现会下降但不会完全垮——说明 attention 不是不可替代的。把 FFN 换成更窄的版本(比如 d_ff=d 而不是 4d),表现也会下降但能用——说明 FFN 也不是不可替代的。
但两者一起去掉,模型基本学不动。这说明 attention 和 FFN 是互补的,不是冗余的。
「重要性」这个问题更应该问:「在我的具体任务上,性能瓶颈在哪边?」
把整篇收束成三点就够了。
第一,FFN 不是 attention 旁边的配角,而是 block 内部的容量大头。 在标准 dense Transformer block 里,它通常吃掉大约三分之二的参数量,所以剪枝、量化、MoE 这些工作几乎都会优先盯着 FFN。
第二,FFN 最值得记住的视角不是「两层 MLP」,而是「逐位置的高维非线性映射」和「关联记忆」。 前者解释了它为什么和 attention 互补,后者解释了为什么大量事实、模式和可解释性现象都会落到这一层来讨论。
第三,FFN 的工程地位和它的数学地位一样高。 它没法像 KV 一样缓存,decode 时往往是每步延迟的重要来源;它的中间激活又是量化和混合精度里最难驯服的部分。所以你一旦进入 LLM 的训练、压缩或推理优化,几乎绕不开 FFN。
如果只记住一句话,那就是:attention 负责跨位置通信,FFN 负责单位置加工;attention 更像接口,FFN 更像仓库。
下一篇我们回到原论文的训练细节:8 张 P100、12 小时、warmup 4000 步、label smoothing 0.1,这些在 2017 年到底是怎么配出来的。
下面几条是我读论文、跟人讨论时反复见到的误解,逐一指出来。
误解一:FFN 只是辅助子层,attention 才是核心。
错。至少在标准 dense block 里,FFN 是参数大头,也是推理优化和模型压缩最常先动手的部分。把它看成配角,会误判很多工程问题的主战场。
误解二:4 倍扩张比是某个理论推出来的。
错。它首先是 2017 年原论文实验里跑出来的经验甜点,后来才变成社区的默认配置;它不是某条从理论直接推出来的定理。
误解三:SwiGLU 的 d_ff = 8d/3 比原版 4d 窄、参数量更少。
错。SwiGLU 少的是宽度,不是总参数,因为它多了一个门控矩阵。8d/3 这个数字本来就是为了把总参数量对齐到和 ReLU FFN 同一量级。
误解四:FFN 的每个神经元对应一个具体概念。
只对一半。确实存在很干净的单义神经元,但大量神经元是 polysemantic 的,真正可解释的对象更像是稀疏特征,而不一定是单个神经元。
误解五:MoE 是把 attention 切成多个专家。
错。主流 MoE 几乎都是把 FFN 切成多个专家,attention 基本保持原样;因为 FFN 参数多、逐位置易路由、也最适合做稀疏化。
下一篇 27|训练原论文 Transformer 把视角从「结构」切换到「训练过程」。我们会讲:
再往后:
下面按相关度排序,列出本篇直接引用与延伸阅读,每条附一句话提示其在本篇中的角色。
← 上一篇:25|Layer Normalization | 下一篇:27|训练原论文 Transformer →