Transformer Block 入门
块,是所有零件最终碰面的地方:attention 和前馈网络作为同一个函数的两个子层,一个残差连接让深度不至于把梯度抹掉,一个 norm放在刚好能让那条残差连接名副其实的位置,再把整个东西复制 N 份、各自独立地叠成一个模型。这一页里的每一个数字,都是从旁边那张图上读出来的,全都能从这页收尾那六行伪代码推出来。
两件事,一个形状
attention 在 token 之间搬信息;FFN 一点也不搬。一个 Transformer 块把两件事依次跑一遍,宽度全程不变。
这页讲的一切,都发生在同一个反复出现的单元里:块。一个现代语言模型,就是几十份一模一样的块前后摞起来,每一份都接一串向量、再吐出一串形状完全相同的向量。
块里面,两个很不一样的操作依次跑:attention,它让每个位置都能读到别的所有位置;FFN,它压根不让一个位置看见邻居。切换一下,看看都有谁在喂给 token 2 的输出:
注意切到 FFN 之后,那一把汇入的线收成了一条竖线:只有位置 2 喂给位置 2。这一张图就是两个子层的全部区别 —— 信息只在 attention 这一步跨位置流动,而且它跑在前面,所以后面的 FFN 手上永远已经是 attention 收集好的上下文。
跟着一个 token 的向量走完这一遍:它进来,attention 更新它一次,FFN 再更新它一次 —— 拖过这三步,盯着 d_model 本身看,别只看里面的数值:
看宽度,别看数值:进来六根柱子,过完 attention 还是六根,过完 FFN 还是六根。块里每个操作都是照着「原封不动保住 d_model」这条设计的,因为下一个操作 —— 还有下一个块 —— 跟它之间唯一的约定就是「还我一个跟你接手时一样的形状」。
这份约定不挑宽度。滑过六个真实模型,从 一路滑到 ,看这三个检查点始终锁在一起:
这就是把 attention 和 FFN 叫作一个东西的两个「子层」、而不是两个各自独立的阶段的全部理由:它们共享同一个输入空间和输出空间,所以一个块就是一个从 R^d_model 到 R^d_model的单一函数 —— 这一页后面全部内容都靠这条性质,因为正是它让 N 个块能够直接叠起来,谁都不用在接缝处对付形状不匹配。
把尺度看住
放任不管,激活值会随深度飘出好几个数量级。归一化就是解法 —— 而 LayerNorm 和 RMSNorm 就差一步。
一个块对流过它的数值没有天生的下限或上限。每个子层都会对拿到的向量乘、加、重新加权一遍,没有任何东西把结果拽回一个正常范围。层数一多,不管这事儿,就会越滚越大。
把每一层想成把向量的量级乘上一个固定因子,24 层原样叠起来、没人纠正它:把这个因子往 1 上下拖一点点,看 24 层之后落到哪儿:
看 1.0 两边留的空间有多小:每层因子 1.4,到第 24 层能到起始量级的三千多倍;每层因子 0.6,能掉到不足百万分之五。而一个真实的块每层要跑几十个操作,随便哪个都能悄悄把这个因子从 1 上推开,没人盯着。
LayerNorm 就是解法,在一个 8 维向量上正好两步:先减掉它自己的均值,再除以它自己的标准差 —— 一步步走,读它真正算出来的数:
注意这两个算出来的数只属于这一个向量:μ 和 σ 对每个 token、每个位置、每一次都是独立重算的。这里没有任何东西是跨序列共享的 —— 这正是 LayerNorm 能安全用在长度不一的一批输入上的原因,不像需要整批数据的 BatchNorm。
RMSNorm 只留第二步。在同一个向量上切换这两种,读出各自留下的均值:
因为 RMSNorm 从不减均值,它的输出保留着输入原本的均值 —— 虚线离零线有段距离。LayerNorm 的重新居中,实际效果没看起来那么大:两种方式训出来的模型,loss 差不多,而 RMSNorm 用的操作数更少。
操作数少多少,说具体的:LayerNorm 要算均值、方差、再缩放;RMSNorm 直接跳过均值这一步。拖过六个真实宽度,直接比这两个操作数:
在 GPT-3 的宽度上,这个差距是每个向量几万次逐元素运算 ——,每一个,每个 token,每一次前向和反向都要算。这就是「RMSNorm 整体大约快 7–10%」这句常被引用的话背后的算术:不是理论上活儿少,是每个调用点实打实地少干了这么多。
恒等是免费的
把输入加回到输出上,块能做到的最简单行为就变成了什么都不做。就这一次加法,深堆栈才训得起来。
拿任意一个把向量 x 映成某个 f(x) 的子层,把它往后传的东西改成 f(x) + x。块不再需要从头学完整的输出 —— 它只需要在已经拿到的东西上学一个修正量。
把残差连接关掉再打开,看块往后传的是什么:
注意残差打开时,一个输出全零的 f 会让整个块变成恒等映射 ——y = x,分毫不差。这样的块摞起来,整体上什么都不做,这比一个必须先学会「原样复制输入」才能开始学别的东西的堆栈,起点友好得多。
恒等是默认值只是一半。对 y = f(x) + x 用链式法则得到dy/dx = df/dx + 1,这个+1就是残差路径的全部贡献 —— 把每一层自己的梯度想成一个收缩因子,拖动梯度要穿过的深度:
看这两条曲线没几层就分开了,不是慢慢拉开的:过了,裸链条已经丢掉了残差链条还完整保留着的绝大部分。一个到第 1 层几乎是零强度的梯度,没法去更新第 1 层的权重 —— 这一层留在模型里,却缺席了训练。
这个 +1 是真的第二条路,不是同一条路上的亮度旋钮:驱动一摞块,把skip 连接在「在」和「结构性拿掉」之间切换:
因为那条轨道要么画出来、要么没画,这里没什么可以被误读成「弱了一点」的空间 —— 拿掉它,不管你已经堆到多深,到达第一块的梯度就正好是裸链条的那个数。
这不是 Transformer 独有的故事。比比残差连接出现前后,一个裸卷积堆栈实际能训到多深:
2015 年之前,给一个裸卷积堆栈往上加层,超过大约 20 层,训练反而更差,不是更好 —— 更深的网络连一个更浅的都比不过,更别提超过了。第二年 ResNet-152 不用任何特殊技巧就训出来了。 Transformer 继承的正是这条性质:GPT-3 的 96 层、Llama 70B 的 80 层,都是因为每一层都是残差块才训得出来。
norm 放在哪儿
现代的块都把 norm 放在子层之前,不是之后。这个差别听着像装修问题。它决定的是深度能不能训得起来。
2017 年的原论文把 norm 放在残差求和之后 ——post-norm。2020 年之后建的模型,几乎全都把 norm 挪到了子层之前 ——pre-norm。还是那两样东西,换了个顺序 —— 这个顺序改变的是残差连接实际护着的是什么。
让同一个 token 并排走过这两种布局:
看最后一步落在哪儿:pre-norm 的残差求和结果就是块的原始输出,完全没被任何 norm 碰过;post-norm 的求和结果,紧接着就地被归一化了。两边的skip 连接一模一样 —— 不一样的是,它和块的出口之间有没有隔着东西。
就这一步之差要紧,是因为反向传播是把块倒着跑一遍:把每个 norm的反向传播想成「让通过它的梯度只剩一个分数 c」,随深度增长比较 pre-norm 和 post-norm:
因为 post-norm 的 +1 是待在 norm 里面的,每一层都要被 c 乘一遍 —— 把 c 调小,post-norm 那条曲线,连同它的残差连接,就会折回裸链条一直就有的那种消失形状。pre-norm 的 +1 从不待在任何东西里面,不管堆多深、c 多小,它都恒等于 1。
pre-norm 还欠一笔账:块里没有任何东西会去重新缩放残差流本身。看它自己的量级一块一块地涨,没有任何纠正在望:
因为 norm 在进子层的路上,读到的永远只是 x 的一份拷贝,它从不碰直通路径上的 x —— 这正是每个 pre-norm 模型都要在最后一个块之后再加一次 norm 的原因,赶在下游任何东西直接读这条流之前。
这就回答了为什么一个块两样都要:归一化控制流过它的东西的尺度,残差连接控制这股流能不能一路到达第 1 层。把 norm 放到求和的错误一侧,它会在做好第一件事的同时悄悄搭上第二件事 —— 没人会注意到,直到模型深到这事儿真的要命的那一天。
完整的块,一次看完
把这些部件按顺序摆好 —— norm、attention、相加、norm、FFN、相加 —— 结果就是现代每个 decoder 实际在跑的 pre-norm 块。
两个子层、两个残差连接、两次 norm,各走一遍,顺序固定 —— 前三节一直在铺垫的那种组合,如今在一个块里用了两遍。
一步步走过这六个阶段,看一个 token 的向量x 被读、被更新、再被读一次:
注意 x 是被复用的,不是被替换的:同一个变量被连续归一化、变换、再加回去两次。从残差连接的角度看,attention 和 FFN各自只是往一个累计总量上贡献一个小修正 —— 谁都不是从头产出块的输出。
这两个修正花的钱差得很远。拖过六个真实宽度,比一比每个块里attention 花的和 FFN 花的:
注意 FFN 那根柱子,任何宽度下都正好是 attention 那根的两倍:四个 d_model×d_model 矩阵对上两个 d_model×4d_model矩阵,就是 4d² 对 8d²,这个比值把 d整个约掉了。FFN 不是碰巧大一点 —— 它是按一个精确的、跟宽度无关的倍数,固定大一倍。
拉远到整个模型,同样的花样在另一个尺度上重演。比一比 embedding 表和所有块加起来:
在一个让人直觉上以为「attention 就是模型」的 Transformer 里,实际上是 FFN 占了一个块大部分的参数、干了大部分 token 级的计算 —— attention 花它的预算决定信息该往哪儿送,FFN 花的预算多得多,用来决定信息到了之后该做什么。这个分法也是研究者越来越倾向于认为模型的事实性记忆住在 FFN 里的原因,attention 只是决定对某个 token 该激活哪部分记忆的那一层。
组合发生在深度里
把 N 个这样的块叠起来,单看任何一个都没变。变的是同一个变换跟自己复合了多少次。
每个块都有自己的权重 —— attention 模式、FFN、norm 参数各自独立 —— 但都读写 R^d_model,这正是 §01 形状约定要紧的原因。一块一块地把堆栈搭起来,一路搭到真实模型的深度:
注意随着 N 变大,图里没有任何东西改变形状 —— 变的只是剪影个数:,。深度和宽度都能把模型做大 —— 拖动一个共享倍数,比一比只扩深度和只扩d_model:
看那条直线和那条曲线怎么越拉越开:深度翻两番,参数正好涨 4 倍;宽度翻两番,参数正好涨 16 倍,因为块里每个矩阵不是 d × d就是 d × 4d —— 宽度是平方级的花钱方式,深度是线性级的。
这意味着同一份参数预算,花在哪儿买到的东西完全不同。在一个又深又窄的堆栈和一个又浅又宽的堆栈之间拖动,总量大致保持不变:
看同样的预算,窄堆栈能多买出多少块 —— 宽度的平方级花费,正是深度是「买更多顺序组合」的便宜方式、宽度是「在一个深度上买更多容量」的贵方式的原因。
「更多组合」不只是个比喻。把 N 个残差块展开,就是对信号可能走过的每一个块子集求和 —— 拖动 N,看隐含路径数涨得有多快:
到—— 已经是 1,680 万条长短不一的独立路径,全都藏在一摞权重再普通不过的块里(Veit、Wilber & Belongie,2016 年)。宽度从来变不出这种东西:把一层的神经元数翻倍,只是把这一个深度上的容量翻倍。把层数翻倍,则是把这些层能组合出来的方式数乘起来。
六行代码,以及它们变成的模型
这一页的每个论点,最后都落回两行代码,重复 N 次,外面再包四行。
把这层包装一步步搭起来,从块本身搭到一个完整模型:
一共六行:那两行块,就是 §05 一步步走过的,在刚搭好的堆栈里重复 N 次 ——
x = x + attn(norm(x)) x = x + ffn(norm(x))
外面再包四行 —— 一端一个 embedding,另一端一次 norm 加 head —— 就是一个完整的 decoder-only 语言模型:
x = embed(ids) for blk in blocks: x = blk(x) x = final_norm(x) logits = head(x)
head 是一个单独的 d_model × vocab_size 线性层 —— 常跟 embedding 表共用同一个矩阵,省下一份参数。
块里还有一块留给了自注意力 primer:看看因果 mask 到底让一个位置能读到哪些位置:
注意一旦到场就能读全部六个,却只能读自己。推理时,每个已经可见位置背后的 K、V 向量都会被缓存、不用重算 —— 这就是生成的回复越长续写越慢的原因,也是 MQA、GQA 和 sliding-window attention 这些手法共同瞄准的目标。