从零学大模型:Attention 之后为什么还需要 MLP?
上一篇《Self-Attention —— 多头注意力》介绍了如何将 12 个 Attention Head 的结果重新组合为 A_l [T,768]。A_l 再通过第一条 Residual Connection 与 Block 的输入 X_l 相加,得到已经包含上下文信息的 Hidden State H_l [T,768]。
但在 GPT-2 Block 中,Self-Attention 后面还有一个 MLP(多层感知机)子层。既然 Self-Attention 已经让每个位置读取了上下文,为什么还需要这个 MLP?
Attention 与 MLP 的分工
Attention 和 MLP 都会更新 Hidden State,但它们处理信息的方式不同。
Self-Attention 的关键作用是让每个 Token 位置读取可见上下文。对于第 i 个位置,Self-Attention 先计算 Q_i 与各位置 Key 的匹配程度,再对位置 0 ... i 的 Value 加权求和。因此,该位置的输出会直接依赖多个可见位置。
MLP 不再读取其他位置,而是对每个位置已经包含上下文信息的 Hidden State 进行非线性特征变换。
因此,二者的侧重点可以概括为:Attention 主要在不同 Token 位置之间混合信息;MLP 主要在每个 Token 位置内部变换特征。
GPT-2 MLP 的整体结构
MLP 通常由输入层、一个或多个隐藏层和输出层组成。GPT-2 每个 Block 中的 MLP 采用一个隐藏层和一个输出层;按不计输入层的常见约定,这称为“两层 MLP”。
下面以 GPT-2 Small 为例。图中,T 表示 Token 数量,D = 768 表示 Hidden State 的维度,M = 3072 = 4D 表示 MLP 隐藏层的宽度。
第 l 个 Block 中,H_l 先经过 LayerNorm 得到 MLP 输入 Z_l。隐藏层通过 Up Projection(升维投影)生成激活前的值 U_l,再经 GELU 得到输出 G_l。GELU 是逐元素激活函数,用于引入非线性。
输出层通过 Down Projection(降维投影)将 G_l 组合为 MLP 更新 M_l,该更新随后通过第二次 Residual Add 与 H_l 相加。这里,M_l 表示更新,与表示隐藏层宽度的 M 不同。
以上是 GPT-2 的具体结构。其他模型的前馈网络可以采用不同的激活函数或门控结构,并不都遵循“Up Projection → GELU → Down Projection”的串行流程。
为什么采用“扩展 → 非线性 → 再组合”
前面提到的 Up Projection 和 Down Projection 都是线性投影:把输入按固定权重加权求和,再加上偏置。
但模型需要学习的关系可能更复杂。例如,假设某个输出分量只应在两条线索同时出现时明显增大,其他情况下保持接近零。固定的加权相加无法准确表达这种关系。Up Projection 先组合输入,GELU 再对组合结果进行非线性变换,使 MLP 能够学习这类依赖特征组合的关系。
即使隐藏层保持 768 维,GELU 也能引入非线性。扩展到 3072 维,则提供了更多隐藏单元。每个单元使用各自的权重和偏置组合输入,再经过 GELU 产生一个数值。这样的扩展不是把输入复制四遍,也不意味着保存了四倍“知识”。
这些额外的非线性计算结果可供 Down Projection 组合,使 MLP 能表达更复杂的输入与输出关系。不过,加宽隐藏层也会增加参数量和计算量。
下图以单个 Token 位置为例,两列 3072 维向量分别表示隐藏层激活前后的数值。圆点表示向量分量,数量和连线仅作示意;颜色变化用于示意 GELU 前后各分量数值的变化。
从 3072 维降回 768 维,并不会让前面的扩展失去意义。每个输出分量都可以综合多个非线性中间结果,因此输出虽然仍是 768 个数,却能表达更复杂的输入与输出关系。
单独看 Down Projection,它无法无损保留任意 3072 维输入。但 MLP 的目标是利用这些中间结果生成所需的更新,而非保存全部中间值。Down Projection 因此是在组合前面的计算结果,并不是把 Up Projection 的操作逆转回去。
隐藏层会学到什么?
Geva 等人的研究发现,一些隐藏单元(神经元)会对特定的文本模式产生较强响应,即遇到这类输入时,激活后的输出值较大。
对应到本文的 GPT-2 流程,隐藏单元的激活值就是 GELU 后的向量分量。可以把这类单元直观地看作训练得到的“模式探测器”,但这不意味着每个单元都对应一个固定概念;同一个单元也可能对多种文本模式产生响应。
Position-wise:逐位置计算,共享参数
GPT-2 的 MLP 也常被称为 FFN(Feed-Forward Network,前馈网络)或 Position-wise FFN。Position-wise 表示:在同一 Block 内,每个 Token 位置独立完成 MLP 变换,所有位置共享同一组参数。
把 H_l 中第 i 个位置的向量记为 h_(l,i) [D],这个位置经过 LayerNorm 和 MLP 后得到更新 m_(l,i) [D]:
1 | m_(l,i) = MLP_l(LN_ffn_l(h_(l,i))) [D] |
m_(l,i) 的计算只读取当前位置的 h_(l,i),而其中已包含 Attention 引入的上下文信息。因此,逐位置计算并不意味着忽略上下文。
实现中,T 个位置可以组织成矩阵批量计算,这不改变逐位置计算的含义。不同 Block 则使用各自独立的参数。
GPT-2 MLP 的三步计算
下面依次展开 Up Projection、GELU 和 Down Projection,公式省略权重和 bias 的层下标 l。
Up Projection:从 768 维扩展到 3072 维
第一层线性投影使用 W_up [768,3072] 和 b_up [3072]:
1 | U_l = Z_l W_up + b_up |
W_up 有 3072 列,每一列把 768 个输入值加权组合成一个中间值。因此,每个 Token 位置会得到 3072 个输出,组成 U_l [T,3072]。
为什么中间维度是 3072?
GPT-2 技术报告表 2 给出的 117M 模型(通常称为 GPT-2 Small)隐藏维度为
D = 768;在 OpenAI 官方实现中,每个 Block 都以nx * 4作为 MLP 的中间维度。因此,GPT-2 Small 中有M = 4D = 3072。四倍宽度并非 GPT-2 独有:《Attention Is All You Need》第 3.3 节的逐位置前馈网络采用
512 → 2048 → 512。它是一种常见的架构配置,并非所有 Transformer 都必须遵循,也不表示四倍维度在理论上最优。
GELU:在线性投影之间加入非线性
Up Projection 之后,GPT-2 对每个位置的 3072 个中间值逐元素应用 GELU(Gaussian Error Linear Unit)。GELU 原论文将其定义为 GELU(x) = xΦ(x),其中 Φ(x) 是标准高斯分布的累积分布函数。与 ReLU 根据输入正负进行硬截断不同,GELU 会根据输入值连续调节输出。
如果 t 表示 Token 位置、j 表示隐藏单元编号,可以写成:
1 | G_l[t,j] = GELU(U_l[t,j]) |
GELU 不改变 Tensor shape,输入和输出都是 [T,3072];它只以非线性方式改变各元素的取值。
为什么两个线性层之间需要这样的非线性?如果去掉 GELU,那么 MLP 只剩两次连续的仿射变换:
1 | (x W_up + b_up) W_down + b_down |
即使中间维度扩展到 3072,两次投影仍可合并为一次从 768 维到 768 维的仿射变换。GELU 在两者之间引入非线性,使 MLP 能表达更复杂的映射。
为什么选择 GELU?
MLP 需要在线性投影之间加入非线性,但并不限定必须使用 GELU。原始 Transformer 使用 ReLU;GPT-2 则选择 GELU。
相比 ReLU,GELU 在零附近的变化更平滑;原论文也通过实验比较了 GELU、ReLU 和 ELU。因此,GELU 是一种有实验支持的架构选择,并非理论上唯一或必然最优的答案。
其他模型也可以采用不同的激活函数,或使用带 gate 的 FFN;后者还会改变内部结构。
Down Projection:回到 768 维
第二层线性投影使用 W_down [3072,768] 和 b_down [768]:
1 | M_l = G_l W_down + b_down |
Down Projection 将 3072 维中间结果重新组合成 768 维 MLP 更新 M_l。它必须回到 Hidden size D,因为接下来要通过第二次 Residual Add 与原来的 H_l [T,D] 逐元素相加:
1 | X_(l + 1) = H_l + M_l [T,D] |
整个过程中 Token 数量始终为 T,只有特征维在 MLP 内部暂时从 D 扩展到 M,再回到 D。
llama.cpp 实战
下面使用 llama.cpp b10435 和 GPT-2 Q8_0 模型,从 GGUF 参数、运行时 Tensor 和源码三个角度验证 MLP。基础环境和模型准备见《LLM 如何逐个生成 Token?》。
进入 llama.cpp 目录,设置模型路径,并构建调试程序:
1 | cd llama.cpp |
核对 FFN 参数
先从 GGUF 中读取 Hidden size、FFN 中间维度,以及 Block 0 的 LayerNorm、Up Projection 和 Down Projection 参数:
1 | .venv/bin/gguf-dump "$GPT2_MODEL" --json | |
当前模型的输出为:
1 | { |
输出确认了 D = 768、M = 3072,两组投影权重对应 768 → 3072 → 768。LayerNorm 不改变 Hidden size,其 weight 和 bias 均为 768 维;两次投影的 bias 则分别匹配各自的输出维度。
跟踪 MLP 的运行时 shape
以 How are 为输入运行调试程序,筛选 Block 0 的 MLP 相关输出:
1 | ./build/bin/llama-eval-callback \ |
筛选后的关键输出如下:
1 | common_debug_cb_eval: ffn_inp-0 = (f32) ADD(node_24{768, 2, 1, 1}, inpL{768, 2, 1, 1}}) = {768, 2, 1, 1} |
llama.cpp / GGML 按 {特征维, Token 维, ...} 显示运行时 Tensor,与前文使用的 [T,特征维] 顺序相反:
| 运行时节点 | 运行时 shape | 逻辑含义 |
|---|---|---|
ffn_inp-0 |
{768,2} |
MLP 输入 H_0 [2,768] |
ffn_norm-0 |
{768,2} |
LayerNorm 输出 Z_0 [2,768] |
ffn_up-0 |
{3072,2} |
Up Projection 矩阵乘法结果,尚未加 b_up |
ffn_up_b-0 |
{3072,2} |
加上 b_up 后的完整输出 U_0 [2,3072] |
ffn_gelu-0 |
{3072,2} |
逐元素 GELU 输出 G_0 [2,3072] |
ffn_down-0 |
{768,2} |
Down Projection 矩阵乘法结果,尚未加 b_down |
ffn_out-0 |
{768,2} |
加上 b_down 后的 MLP 更新 M_0 [2,768] |
l_out-0 |
{768,2} |
Residual Add 输出 X_1 [2,768] |
How are 对应两个 Token,因此从 ffn_inp-0 到 l_out-0,Tensor 的第二维始终为 2。在每次 MUL_MAT 中,同一张权重矩阵分别作用于两个位置的向量,只改变特征维,不混合不同位置的信息。这正是 Position-wise 的运行时表现。
阅读这段输出时,重点关注等号左侧的节点名、操作类型和最右侧的 shape;中间的上游 Tensor 名称及自动编号可能随计算图变化。
对照 GPT-2 建图代码
GPT-2 在 llama_model_gpt2::graph::graph() 中先完成第一次 Residual Add,得到 MLP 输入 ffn_inp,随后依次执行 MLP 前的 LayerNorm、build_ffn() 和第二次 Residual Add:
1 | // add the input |
按通用 build_ffn() 的参数顺序,关键实参如下:
build_ffn() 形参 |
GPT-2 传入的实参 |
|---|---|
up / up_b |
ffn_up / ffn_up_b |
gate / gate_b / gate_s |
NULL / NULL / NULL |
down / down_b |
ffn_down / ffn_down_b |
type_op |
LLM_FFN_GELU |
type_gate |
LLM_FFN_SEQ |
up 和 down 对应两次线性投影,type_op 选择 GELU。LLM_FFN_SEQ 只是 gate 的连接类型,本身不代表“无 gate”;真正的依据是 gate、gate_b 和 gate_s 均为 NULL。因此,GPT-2 走的是无 gate 的 up → GELU → down 路径。
在通用的 build_ffn() 中,源码先构造 Up Projection 和 bias 节点:
1 | ggml_tensor * tmp = up ? build_lora_mm(up, cur) : cur; |
GPT-2 未传入 gate,因此 cur 直接接收 Up Projection 的结果 tmp,随后进入 GELU。
LLM_FFN_GELU 分支随后构造 ggml_gelu() 节点:
1 | case LLM_FFN_GELU: |
最后再完成 Down Projection 和 bias:
1 | if (down) { |
原理与源码可以汇总为:
| 原理概念 | GPT-2 Small 中的形式 | llama.cpp 中的入口 |
|---|---|---|
| MLP Pre-Norm | [T,768] → [T,768] |
build_norm(..., LLM_NORM, ...) |
| Up Projection | [T,768] → [T,3072] |
build_ffn() 中 build_lora_mm(up, cur) + bias |
| GELU | [T,3072] → [T,3072] |
ggml_gelu() |
| 无 gate | 不存在额外 gate Tensor | GPT-2 调用中的 gate/gate_b/gate_s == NULL |
| Down Projection | [T,3072] → [T,768] |
build_lora_mm(down, cur) + bias |
| 第二次 Residual Add | H_l + M_l |
ggml_add(ctx0, cur, ffn_inp) |
这些 C++ 调用用于构建 GGML 计算图,定义节点、依赖关系和 Tensor shape,并不立即执行数值计算。前面的回调输出才是计算图执行后的结果。
小结
本文介绍了 GPT-2 中 MLP 的作用与计算过程:
- Attention 在不同 Token 位置之间混合信息;MLP 使用共享参数,逐位置对包含上下文信息的 Hidden State 进行非线性变换。
- GPT-2 Small 使用无 gate 的
768 → 3072 → GELU → 768MLP。中间的 3072 维提供临时的计算工作区,GELU 在两次线性投影之间引入非线性。 - Down Projection 将非线性中间响应组合成与输入维度相同的 MLP 更新
M_l [T,768]。