Skip to content

约 13 分钟阅读

07. FFN:前馈网络做了什么 ​

前两篇讲的注意力负责在位置之间搬运信息:处理到“申请”时,它把“年假”“怎么”的信息按权重取过来,加进“申请”这个位置的向量。每层注意力之后紧跟着另一个模块,叫前馈网络(Feed-Forward Network,FFN,代码里常叫 MLP)。它不看别的位置,只对每个位置自己的向量做一次变换,却占了 Llama-3.1-8B 七成的参数。这一篇回答:FFN 具体算了什么;为什么它的参数这么多、每个 token 的计算里占多大比例;为什么现在几乎都用 SwiGLU,中间维度 14336 是怎么来的;“FFN 存储知识”这个说法有哪些证据、能信到什么程度;它和注意力怎样分工。读完你应该能从 config.json 手算任意模型的 FFN 参数,看懂 LlamaMLP 那三行代码,并对“知识存在哪一层”这类说法做出有依据的判断。

最朴素的版本:每个 token 各做一次两层全连接 ​

先看 2017 年原始 Transformer 的写法。注意力输出加回残差之后,每个位置得到一个向量 x。FFN 对它做两次矩阵乘法,中间夹一个激活函数:

text
FFN(x) = ReLU(x · W_1 + b_1) · W_2 + b_2

x:   一个位置的向量            [d]        原始 Transformer d = 512
W_1: 升维矩阵                  [d, d_ff]  d_ff = 2048,是 d 的 4 倍
ReLU:负数变 0,正数不变
W_2: 降维矩阵                  [d_ff, d]

用一组两维的小数字走一遍。设 d = 2、d_ff = 3,x = [1, 2],忽略偏置:

text
W_1 = [[1, 0,  1],      x · W_1 = [1×1+2×0, 1×0+2×1, 1×1+2×(-1)] = [1, 2, -1]
       [0, 1, -1]]      ReLU     → [1, 2, 0]           第 3 个中间单元被关掉

W_2 = [[1,  0],         [1, 2, 0] · W_2 = 1×[1,0] + 2×[0,1] + 0×[1,-1] = [1, 2]
       [0,  1],
       [1, -1]]

第 3 个中间单元的输入是 -1,被 ReLU 置零,它对应的 W_2 第 3 行 [1, -1] 就没有被加进输出。换一个 x,可能是另外几个单元被打开。FFN 的本质就是这样:先升到更宽的空间,用激活函数决定哪些中间单元放行,再把放行的单元对应的向量加起来降回原维度。

两个细节决定了它的性质。第一,激活函数不能省:没有它,x · W_1 · W_2 等于乘一个 d×d 的矩阵,两层就退化成一层线性变换,再宽也没用。第二,“前馈”指数据只往前走、不在位置之间交换。“年”“假”“怎么”“申请”“?”五个位置用同一套 W_1、W_2,各算各的,所以原始论文把它叫作 position-wise(逐位置)的前馈网络。

现代模型的 FFN 在这个骨架上做了两处改动:激活函数换成了带门控的 SwiGLU,矩阵从两个变成三个。Llama-3.1-8B 一层 FFN 的数据流和形状如下:

Llama-3.1-8B 一层 FFN:输入 [n,4096] 分别经 gate_proj 和 up_proj 升到 [n,14336],gate 一路过 SiLU 后与 up 一路逐元素相乘,再经 down_proj 降回 [n,4096];5 个 token 共用权重、各自独立计算

n 个位置共用同一套权重又互不依赖,正好可以把 n 个向量排成一个 [n, 4096] 的矩阵,一次大矩阵乘法算完。Prefill 时 n 是整段输入的长度,Decode 时 n 是 1(或者一批请求各 1 个),这也是后面计算账的基础。

从 ReLU、GELU 到 SwiGLU ​

ReLU 在 0 点有一个硬拐角,负数一侧梯度恒为 0。BERT 和 GPT-2 改用 GELU,它在负数一侧平滑地趋近 0,允许很小的负输出。Llama、Qwen、DeepSeek、Mistral 用的是 SwiGLU,Gemma 用它的近亲 GeGLU(hidden_activation: gelu_pytorch_tanh)。

SwiGLU 由两部分组成。SiLU(也叫 Swish)是激活函数本身:

text
SiLU(z) = z · sigmoid(z) = z / (1 + e^(-z))
SiLU(1) = 0.731   SiLU(0) = 0   SiLU(-1) = -0.269   最小值约 -0.28(z ≈ -1.28 处)

门控线性单元(Gated Linear Unit,GLU)是结构:输入分两路各乘一个矩阵,一路过激活当作“门”,另一路保持线性当作“值”,两路逐元素相乘:

text
SwiGLU-FFN(x) = ( SiLU(x · W_g) ⊙ (x · W_u) ) · W_d

W_g:gate_proj  [4096, 14336]    门:决定每个中间单元开多大
W_u:up_proj    [4096, 14336]    值:每个中间单元的内容
W_d:down_proj  [14336, 4096]    降回 4096 维
⊙:  逐元素相乘

还是用上面的小数字。W_g 沿用刚才的 W_1,W_d 沿用 W_2,再加一个 W_u:

text
W_u = [[1, 0.5, 0],     x · W_u = [3, 0.5, 4]
       [1, 0,   2]]

gate = x · W_g = [1, 2, -1]       SiLU(gate) = [0.731, 1.762, -0.269]
h = SiLU(gate) ⊙ up = [0.731×3, 1.762×0.5, -0.269×4] = [2.193, 0.881, -1.076]
y = h · W_d = 2.193×[1,0] + 0.881×[0,1] + (-1.076)×[1,-1] = [1.117, 1.957]

对比 ReLU 版本的 [1, 2]:第 2 个中间单元门开得很大(1.762),但值只有 0.5,最终贡献 0.881;第 3 个中间单元的门是负的小数,ReLU 会把它完全关掉,SiLU 留下了 -0.269,乘上值 4 之后贡献 -1.076,方向还反了过来。每个中间单元的输出变成“门的开合程度 × 值的大小”,两者都随输入变化,这比 ReLU 的“开或关”多了一层乘法交互。

左:ReLU、GELU、SiLU 在 -4 到 4 之间的形状;右:d=2、d_ff=3 的 SwiGLU 手算过程

为什么门控更好,目前主要是实验结论。Shazeer 2020 年的论文《GLU Variants Improve Transformer》在 T5 上比较了 ReLU、GELU 和几种 GLU 变体,在参数和计算相同的条件下,GEGLU、SwiGLU 的困惑度最低,下游任务也更好;论文本身没有给出为什么有效的理论解释。后来 PaLM、LLaMA 都采用了 SwiGLU,它成为默认选择是因为一再被大规模训练验证,而不是因为有推导证明它最优。

对应的代码就三行。Transformers 的 LlamaMLP(modeling_llama.py):

python
class LlamaMLP(nn.Module):
    def __init__(self, config):
        self.gate_proj = nn.Linear(4096, 14336, bias=False)   # bias 由 config.mlp_bias 控制,Llama 为 false
        self.up_proj   = nn.Linear(4096, 14336, bias=False)
        self.down_proj = nn.Linear(14336, 4096, bias=False)
        self.act_fn = SiLU()                                   # config.hidden_act = "silu"

    def forward(self, x):                                      # x: [n, 4096]
        return self.down_proj(self.act_fn(self.gate_proj(x)) * self.up_proj(x))

Qwen2/Qwen3、Mistral、DeepSeek 的稠密 FFN 几乎一字不差,差别只在维度。

每层 1.76 亿参数:FFN 占七成 ​

Llama-3.1-8B 每层 FFN 三个矩阵,每个 4096×14336:

text
每层 FFN = 3 × 4096 × 14336 = 176,160,768        (没有偏置)
32 层   = 5,637,144,576 ≈ 56.4 亿
全模型  = 8,030,261,248                          FFN 占 5,637,144,576 / 8,030,261,248 = 70.2%

同一层的注意力只有 4096×4096(q)+ 4096×1024(k)+ 4096×1024(v)+ 4096×4096(o)= 41,943,040 个参数,是 FFN 的 1/4.2。造成这个差距的有两点:GQA 把 k、v 缩到了 1024 维(上一篇);FFN 中间维度是隐藏维度的 3.5 倍,又有三个矩阵。原始 Transformer 用 MHA 和两矩阵、4 倍宽的 FFN,注意力 4d²、FFN 8d²,FFN 约占两层合计的 2/3,这是“FFN 占三分之二参数”这个说法的来源;到 Llama 3,比例升到了七成。

部分参数占比
FFN(32 层)5,637,144,57670.2%
注意力(32 层)1,342,177,28016.7%
词嵌入 + 输出层1,050,673,15213.1%
RMSNorm(65 个)266,2400.003%
合计8,030,261,248100%

计算量跟着参数走。矩阵乘法里每个参数对每个 token 做一次乘加,记 2 次浮点运算(FLOPs),所以每层 FFN 每 token 是 2 × 176,160,768 ≈ 3.5 亿 FLOPs,32 层合计约 11.3 GFLOPs。常说的“每 token 约 2 × 参数量 ≈ 16 GFLOPs”把词嵌入也算了进去,而词嵌入只是查表;只计矩阵乘法是 15.0 GFLOPs,FFN 占 75%。

这个比例会随上下文变化。注意力里还有一部分不对应参数:新 token 的 Q 要和 n 个历史 K 点积、再对 n 个 V 加权求和,每层每 token 约 4 × n × 4096 FLOPs,随上下文长度线性增长,FFN 的计算则与上下文无关:

Llama-3.1-8B 的参数构成与每 token 计算构成:参数里 FFN 占 70.2%;上下文为 1 时 FFN 占计算的 75.1%,8192 时 58.4%,32768 时 35.0%,131072 时 13.5%

普通对话几千个 token 时,FFN 仍是计算的大头;上下文超过约 2.15 万(4 × n × 4096 × 32 与 FFN 的 11.3 GFLOPs 相等处),注意力分数那部分就超过 FFN;到 12.8 万,FFN 只占 13.5%。所以“优化 FFN 就是优化推理”只在中短上下文成立,长上下文的成本账在第 12 篇。

推理时还有一笔更直接的账:Decode 每生成一步,32 层 FFN 的 56.4 亿个参数(BF16 约 11.3 GB)都要从显存读一遍,一批请求共享这一次读取。权重量化和 MoE 首先盯上 FFN,就是因为它占了大部分要搬的字节(量化见推理系列第 14 篇)。

中间维度为什么是 14336 ​

原始 Transformer 的 FFN 宽度是 4d。SwiGLU 有三个矩阵,如果还用 4d,参数就是两矩阵版本的 1.5 倍。为了在同样参数下比较,Shazeer 的论文把中间维度缩到 2/3:

text
两矩阵、4d:  2 × d × 4d       = 8d²
三矩阵、x·d: 3 × d × x·d      = 8d²   →  x = 8/3 ≈ 2.67
d = 4096:    8/3 × 4096 = 10922.7

LLaMA 1 论文明确写了采用 2/3·4d。实际维度还要取整到某个数的倍数,方便矩阵按 GPU 计算单元的块大小切分,也方便张量并行时平分到每张卡。Llama 2 7B 取 256 的倍数,10922 → 11008。

14336 不是这个规则直接给出的。Meta 的参考实现(meta-llama/llama3 仓库的 llama/model.py,FeedForward 类)在 2/3 之后多乘了一个系数:

python
hidden_dim = 4 * dim                                   # 16384
hidden_dim = int(2 * hidden_dim / 3)                   # 10922
if ffn_dim_multiplier is not None:
    hidden_dim = int(ffn_dim_multiplier * hidden_dim)  # × 1.3 → 14198
hidden_dim = multiple_of * ((hidden_dim + multiple_of - 1) // multiple_of)   # 向上取 1024 的倍数 → 14336

llama-models 仓库的 sku_list.py 记录了 Llama 3 和 3.1 的 8B 为 ffn_dim_multiplier 1.3、multiple_of 1024;70B 为 1.3 和 4096,同样算出 32768 → 21845 → 28398 → 28672,和 Llama-3.3-70B 的 intermediate_size 一致。所以 Llama 3 的 FFN 是隐藏维度的 3.5 倍,比“等参数”的 8/3 大了约 31%。Meta 的论文和代码只给出了这个系数,没有公开选择过程;能确定的是,它把更多参数放进了 FFN。

其他模型的选择各不相同,没有统一规则:

模型隐藏维度中间维度比值说明
Llama-2-7B4096110082.698/3 后取 256 的倍数
Llama-3.1-8B4096143363.58/3 × 1.3 后取 1024 的倍数
Mistral-7B4096143363.5与 Llama 3 相同
Qwen3-8B4096122883.0
Qwen2.5-0.5B89648645.43小模型 FFN 相对更宽
Gemma-2-9B3584143364.0GeGLU
DeepSeek-V3 稠密层7168184322.57只有前 3 层
DeepSeek-V3 每个专家716820480.29MoE,每 token 激活 8 个路由专家 + 1 个共享专家

数据来自各模型 2026 年 10 月时 Hugging Face 上的 config.json。判断一个模型的 FFN 时,先看 hidden_act(silu 基本对应 SwiGLU 三矩阵),再用 3 × hidden_size × intermediate_size 算每层参数;MoE 模型要看 moe_intermediate_size 和专家数。

键值记忆:FFN 里可能存着什么 ​

参数占七成,FFN 里到底装了什么?一种有影响力的读法是把它看成一张键值表。把矩阵乘法按中间单元拆开写:

text
FFN(x) = Σ_i  m_i · v_i            i = 1 … 14336

m_i = SiLU(x · g_i) × (x · u_i)    g_i、u_i:W_g、W_u 的第 i 列(4096 维),当作“键”
v_i:W_d 的第 i 行(4096 维),当作“值”

(PyTorch 的 nn.Linear 把权重转置存放,所以在 gate_proj.weight 里 g_i 是第 i 行。)每个中间单元做一件事:用键和输入比相似度,得到系数 m_i,然后把自己的值向量按 m_i 加进输出。前面手算的例子就是 3 对键值,其中两对系数大、决定了结果。

FFN 的键值记忆读法:输入与每个键比相似度得到系数,系数大的键对应的值向量按系数加权求和,作为 FFN 的输出加回残差流(数值为示意)

这个读法有几项研究支持,证据强度不同:

  • 键对应输入模式,值推动输出词。Geva 等(2021)在一个 16 层、用 WikiText-103 训练的语言模型里,找出让每个键响应最强的训练句子,人工标注发现低层的键多对应表层模式(如以某个词结尾),高层的键多对应语义模式(如“在讲电视节目”);高层的值向量投到词表上,排在前面的词常常就是这些句子的下一个词。这是“FFN 是键值记忆”这一说法的出处,模型比今天的大模型小得多。
  • 知识神经元。Dai 等(2022)在 BERT 上用梯度归因找出与某条事实(如某国首都)相关的少数 FFN 中间单元,抑制它们会降低模型答对这条事实的概率,增强则相反。
  • 因果追踪与 ROME。Meng 等(2022)在 GPT-2 XL 和 GPT-J 上逐个位置、逐层恢复被破坏的激活,发现主语最后一个 token 处的中层 MLP 对事实回忆影响最大;据此对一个 MLP 做秩一修改,就能把“某某在哪座城市”改成另一个答案。

但同样有研究指出这幅图过于整齐:

  • Hase 等(2023)发现,因果追踪定位出的层与编辑效果最好的层几乎无关,在别的层编辑同样有效,“定位到”不等于“存在那里”。
  • Niu 等(2024)复查知识神经元,认为这些单元更像是在表达词语共现的模式,把它们称为“知识”是过度简化。
  • 编辑一条事实后,相关的推论(例如改了某人的国籍,他的母语、所在城市)往往不同步更新(Cohen 等,2023)。
  • 单个中间单元常同时响应多种互不相关的模式(多义性),一条信息也常分散在许多单元和多层里(Elhage 等,2022 的叠加现象研究)。
  • 事实回忆不只靠 FFN:Geva 等(2023)发现,注意力负责把主语位置上已经富集的属性提取到最后一个位置。

可以在小模型上自己看一次。用 Qwen2.5-0.5B-Instruct(24 层,本机 CPU、FP32,Transformers 5.21)续写“法国的首都是巴黎,中国的首都是”,正常情况下下一个 token 是“北京”的概率为 0.777。逐层把 FFN 的输出清零再算一次:清零第 0 层、第 2 层或最后的第 23 层,“北京”的概率降到 0.01 以下,输出直接乱掉;清零中间第 11、12、18 层,概率降到 0.16—0.40,但“北京”多数仍排第一;清零其余大多数层,概率在 0.45—0.95 之间波动,不少层清零后反而略高于不清零。换成“日本的首都是东京,中国的首都是”趋势相同。

这组结果说明两件事:首尾几层 FFN 承担了模型离不开的基础变换,删掉哪个事实都会坏;中间层没有哪一层单独“存着北京”,删一层只是削弱,信息有冗余、分散在多层。它只是一个模型、两个提示的观察,清零也会把激活推到训练时从未见过的状态,不能当作定位知识的证据。逐层拆开同一个模型、看每层往残差流里加了多少,见实践篇。

对“某层 FFN 存了某知识”这类说法,比较可靠的检验是:做因果干预而不只是看相关性;换多种问法和语言看是否仍成立;看修改后相关事实是否一起变化。

FFN 和注意力的分工 ​

两者的输入输出形状一样,都是读一份归一化后的 [n, 4096],输出 [n, 4096] 加回残差流(下一篇讲),区别在于信息从哪来:

  • 注意力在位置之间搬运。权重是根据当前内容临时算出来的 softmax 分布,同一个参数矩阵面对不同上下文会看不同的地方。它是模型里唯一让“申请”知道前面有“年假”的部件。
  • FFN 在每个位置内部加工。权重是训练好的固定参数,对同一个输入向量总给出同一个输出。经过注意力之后,“申请”位置已经混入了“年假”“怎么”的信息,FFN 再把这种组合映射成新的特征,比如更接近“请假流程”“您可以”这些续写方向的成分(这是示意,单个 FFN 的具体作用很难这样逐条读出来)。

层层交替之后,模型既能看上下文,又能对看到的内容做非线性加工。只靠注意力,每层除了 softmax 基本是线性的;只靠 FFN,每个 token 永远不知道别的 token 在说什么。

这种分工也划出了 FFN 的边界。FFN 里的参数在训练结束时就固定了,它能装下的是预训练语料里反复出现的通用知识和语言规律,比如“年假”是一种带薪休假。资料助手要回答的是这家公司的年假政策:入职满几年有几天、在哪个系统提交,这些内容既不在预训练数据里,也会随制度修订变化。指望 FFN 记住它们,答案就是模型编出来的。正确的做法是把员工手册的相关段落检索出来放进上下文,由注意力读取(检索增强与工具调用)。微调可以往参数里写入一些固定的知识,但不适合频繁变化、需要出处的规定。

MoE:把一个大 FFN 拆成多个专家 ​

FFN 逐位置独立、又占了大部分参数,这让它成为扩容的首选位置。混合专家(Mixture of Experts,MoE)把一层的一个大 FFN 换成许多个小 FFN(专家),再加一个路由器,每个 token 只选其中几个来算。注意力层不变。

DeepSeek-V3 的前 3 层是普通稠密 FFN(中间维度 18432),其余 58 层每层有 256 个路由专家和 1 个共享专家,每个专家是中间维度 2048 的 SwiGLU,每个 token 选 8 个路由专家再加共享专家:

text
每个专家:           3 × 7168 × 2048 = 44,040,192 个参数
每 token 激活:      (8 + 1) × 44,040,192 = 396,361,728   与稠密层 3 × 7168 × 18432 完全相同
每层专家总参数:     (256 + 1) × 44,040,192 ≈ 113 亿

每个 token 走过的 FFN 计算量和一个稠密层一样,但每层可供挑选的专家参数是稠密层的约 28.6 倍。这就是总参数 6710 亿、每 token 只激活 370 亿的来源:MoE 改的就是 FFN 这一块。路由怎样选、怎样防止所有 token 挤到少数专家上,见第 11 篇。

FFN 是每层里逐位置独立的那一半:先升维、用激活函数挑出要放行的中间单元、再降维加回去;SwiGLU 用一路线性结果当门去调节另一路,它胜出靠的是大规模实验。Llama-3.1-8B 每层 FFN 3 × 4096 × 14336 = 1.76 亿参数,合计占全模型 70.2%,上下文在 8K 以内时也占每 token 矩阵乘计算的 58%—75%;14336 来自 8/3 × 4096 再乘 1.3、取 1024 的倍数。把 FFN 读成键值记忆有实验支持,但知识分散、冗余,单个神经元或单层定位都靠不住,会变的业务知识应该放进上下文而不是指望参数。注意力和 FFN 的结果都要加回同一条主干,加法和归一化怎样让 32 层叠得起来,是下一篇的内容:08. 残差连接与归一化。

别急,先让缓存热一下。