ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从自注意力到混合专家:Transformer核心算法演进与工程实践

从自注意力到混合专家:Transformer核心算法演进与工程实践

做技术久了你会发现,真正决定一个模型上限的,往往不是数据集有多大、显卡有多快,而是架构背后的几条核心算法主线有没有吃透。Transformer 就是最典型的例子:从 2017 年《Attention Is All You Need》提出到现在,大家嘴上挂着“transformer 架构”,实际用到的却是 self-attention、位置编码、多头注意力、稀疏注意力、混合专家等一系列在演进中不断打磨出来的核心算法。这篇就围绕“transformer 架构中的核心算法演进”这个主线,从为什么需要这些算法、它们各自解决什么问题、实际代码里怎么落地三个角度逐层拆一遍,适合刚接触注意力机制的初学者,也适合已经在训练大模型、调试分布式架构的工程师拿来做一次系统性复盘。

1. 项目概述:Transformer架构的核心是什么

1.1 为什么要研究核心算法演进

先说一个很多人忽略的事实:Transformer 并不是一个孤立的“模型”,它是一套完整的算法框架。你在 HuggingFace 上from_pretrained()加载一个模型,背后跑的是多头自注意力、前馈网络、层归一化、位置编码、残差连接这些模块的组合方式。而所谓“演进”,也不是为了炫技,而是每一代变体都在解决上一代的痛点。

比如最初的标准 Transformer 用绝对位置编码,直接把正弦余弦函数加到 embedding 上,简单但泛化能力弱,序列一长就丢位置信息。后来有了相对位置编码、旋转位置编码,让模型天生对序列长度更鲁棒。再比如标准 attention 是 O(n²) 复杂度,长文本训练根本跑不动,这才逼出了稀疏注意力、FlashAttention、滑动窗口注意力这些算法。理解了这条演进线索,你才能回答那个经典问题:“为什么现在大模型都长这样?”

我接触过不少团队,上来就套用开源代码改参数,模型效果不好就疯狂加数据、调学习率,折腾一圈发现瓶颈其实在架构层面的算法选择上。这就是典型的“只见模型不见算法”。这篇博文的核心目的,就是把 transformer 骨架拆开,把每个核心算法的来龙去脉讲清楚,让你在选型、调优、改造的时候有自己的判断力。

1.2 核心算法全景速览

为了后面好展开,先给一张算法全景表。这张表我把从 2017 到 2025 年间真正影响过架构走向的核心算法按“要解决的问题”收敛到一起,后面每章都会逐条深入:

算法/模块解决的核心问题代表工作/落地形态
自注意力 (Self-Attention)建模序列内任意两个token之间的关系标准 Transformer、GPT 系列
多头注意力 (Multi-Head Attention)让模型同时捕获多种子空间关系几乎所有 transformer 变体
绝对位置编码让模型感知token顺序原始 transformer 论文
相对位置编码增强对序列长度和偏移的泛化T5、DeBERTa
旋转位置编码 (RoPE)让位置信息与注意力点积天然兼容LLaMA、ChatGLM 等主流大模型
稀疏注意力降低长序列的 O(n²) 复杂度Longformer、BigBird
FlashAttention从IO感知和GPU显存编程层面优化注意力FlashAttention-2/3,主流大模型训练引擎
混合专家 (MoE)以恒定计算量扩展模型容量Switch Transformer、Mixtral
归一化策略稳定深层网络训练Pre-LN、Post-LN 的实践选择
多模态融合跨模态信息对齐与融合Perceiver、Florence、多模态大模型

这张表可以说把“transformer 架构中的核心算法演进”的主干全部串起来了。下面我到每个模块内部详细拆,包括数学直觉、工程取舍和我在实际项目中踩过的坑。

2. 从注意力机制到自注意力:一切算法演进的原点

2.1 注意力机制的思想来源

注意力机制并不是 Transformer 原创的,它的思想最早来自神经机器翻译领域的 Bahdanau Attention(2015)。当时的序列到序列模型用 RNN 把源语言压缩成一个固定向量,解码时就靠这一个向量生成所有目标词,信息瓶颈非常明显。注意力机制的直觉是:解码每一步的时候,不要只盯那个死板的向量,而是回头去看源语言所有时刻的隐状态,按相关性加权汇总。

你可以把它类比成查资料写报告。如果不带注意力,等于把所有资料强行装进脑子里再写总结,脑子里能存的必然有限;带注意力,就变成了每写一段都回去翻阅原文,找到相关段落重点参考,这样信息量再大也不怕。这个“按相关性动态选择信息”的思想,从 RNN 时代一路沿用到了现在的 transformer,是整个架构最重要的底层直觉。

到了 2017 年,Transformer 做了一个更彻底的决定:连 RNN 都不用了,只保留注意力机制本身,让序列中任意两个位置直接交互。这个决定有两层意义。第一,彻底解决了 RNN 的并行性问题,因为 attention 对序列位置的计算天然是矩阵运算,GPU 可以一次性处理整条序列。第二,解决了长距离依赖问题,RNN 需要一步步传递信息,距离越长信息衰减越严重,而 attention 让任意远的两个词直接建立计算路径。这两点也正是后来深度学习大规模扩张的基础。

2.2 多头自注意力如何工作

自注意力(Self-Attention)的核心算法可以用一套公式概括:

Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V

简单拆解一下。输入序列的每个 token 都会通过三个矩阵变换分别生成 Query、Key、Value,Query 代表“我想找什么”,Key 代表“我有什么特征可以被匹配”,Value 代表“我实际提供的信息”。Q 和 K 做点积得到一个相关度打分,除以sqrt(d_k)防止内积过大导致 softmax 梯度消失,softmax 归一化后作为权重,对 V 做加权求和。

“多头”是什么意思?把 Q、K、V 分别在多个子空间里做上述操作,再拼接起来做一次线性变换。每个头相当于一个独立的“查询通道”,有的头可能更关注语法关系,有的头更关注语义相关性,有的头甚至能学到指代关系。这是从“一个过滤器看全局”变成“多个过滤器各看各的角度”,表达能力天然更强。

在实际工程实现里,多头不是真的做多遍循环,而是把num_heads维度直接揉进 batch 维度里做一次矩阵乘法,这样既有多头的效果又不牺牲并行效率。这也是新手最容易晕的地方:读代码时看到q.reshape(B, N, H, D)再交换维度,其实就是把多头拆出来参与批量计算。我在后面的实战章节会放一段最小实现,到时候你对照着看会更清楚。

2.3 注意力缩放与温度系数的细节把控

公式里的sqrt(d_k)这一项值得单独说。它是防止极端数值破坏训练稳定性的关键。如果 d_k 是 64,乘积方差大约也是 64 的量级,softmax 在这种数值范围下会直接“饱和”,梯度变得特别小,训练基本卡死。除以 8 以后,方差被压缩回 1 附近,softmax 的梯度才能保持在一个健康区间。

有段时间我在调一个小型 transformer 做文本分类,训练 loss 长期不降。排查了半天,最后发现是复现代码时漏了缩放项。加上1/sqrt(d_k)之后,loss 立刻往下走。所以说这种细节点位往往最坑人,你以为是超参数没调好,实际是算法结构被改坏了。后来我给自己定了条规矩:任何改动必须拿原论文的公式逐项对照,尤其是像缩放、掩码这类看似微小却影响数值稳定性的操作。

3. 位置编码迭代:模型如何感知序列顺序

3.1 绝对位置编码的原理与局限

把 RNN 扔掉之后,Transformer 也把“顺序”扔掉了。自注意力本身是置换不变的,你把一句话的词序打乱,注意力计算结果一模一样。但语言是强顺序相关的,所以必须主动把位置信息塞进去。原始论文的做法是绝对位置编码,用一套固定频率的正弦余弦函数:

PE(pos, 2i) = sin(pos / 10000^(2i / d_model)) PE(pos, 2i + 1) = cos(pos / 10000^(2i / d_model))

每个位置 pos 生成一个跟 embedding 维度一样的向量,直接加到 token embedding 上。这个设计的巧妙之处在于,对于任意固定偏移 k,PE(pos+k)可以表示为PE(pos)的线性函数,理论上模型能学会“相对位置”的规律。

但它的问题也很明显。第一,绝对位置信息和 token 语义信息在 embedding 空间里是加在一起的,模型必须自己学着去解耦,这增加了学习难度。第二,外推能力弱,训练时见过的最大位置是 1024,推理时给到 2048,模型对没见过的位置编码完全没有概念,表现会明显下滑。很多早期 transformer 做长文本任务时效果崩盘,根子就在这。

3.2 相对位置编码与旋转位置编码的把控差异

绝对位置编码的替代方案之一是相对位置编码。思路很直接:模型真正关心的不是“词在第几个位置”,而是“两个词之间隔了多远”。T5 的实现方式最典型,它对 attention logits 按相对距离加一个可学习的偏置项,不再往 embedding 里塞位置信号。相对距离超出某个窗口后就归并到同一个 bucket,这样对长序列的泛化更自然。

不过现在工业界最主流的方案已经是旋转位置编码(RoPE)。RoPE 的原理非常优雅:在计算 Q 和 K 的点积之前,把它们的向量按位置做旋转,旋转角度跟位置有关。这样一来,两个向量的点积结果天然包含“相对位置”信息,因为旋转后点积的差值只取决于相对角度。RoPE 不但外推效果好,而且不增加参数,和现有的注意力算子能无缝融合。

我在实际部署大模型推理服务时,RoPE 的“外推能力”并不是纯理论优势。业务里经常要处理超过训练长度的 query,比如训练时最大 4096,线上偶尔出现 5000 的输入。用绝对位置编码的老模型直接抽风,RoPE 则可以靠位置编码插值技术(比如 NTK-aware scaling)快速适配。这个差异在长上下文场景里简直是天壤之别。强烈建议新项目直接选 RoPE,别在绝对位置上浪费时间。

4. 效率驱动的架构演进:从稀疏注意力到混合专家

4.1 稀疏注意力为什么重要

标准 attention 的复杂度是 O(n²),一句话里有 10 万 token,就得算 100 亿个注意力分数,这在训练阶段还能靠显存堆,到推理阶段就成了灾难。稀疏注意力(Sparse Attention)的核心思想是:不是所有 token 两两之间都有必要交互。Longformer、BigBird 都做了近似的尝试,把注意力模式设计成局部窗口注意力加随机注意力再加全局 token 注意力,大幅减少计算量。

不过说实话,稀疏注意力的实现复杂度和收益在工程上是对半开的,尤其对 GPU kernel 的访存模式极其不友好,容易搞出“稀疏不如稠密快”的尴尬局面。我自己在长文本摘要任务上试过 Longformer,效果还行,但部署时速度提升并没有理论值那么理想,因为稀疏计算需要专门的算子,通用推理框架对这类算子的优化普遍不够完善。

4.2 FlashAttention与在线计算

FlashAttention 是近两年真正改变训练范式的算法级优化,它的目标不是减少 FLOPs,而是减少 GPU 显存读取(IO)。传统 attention 实现要把 S = QK^T 这个中间矩阵写回显存,再读出来做 softmax、乘 V,来回好几趟,慢就慢在 IO 上。FlashAttention 用分块(tiling)和在线 softmax 重计算,让每个 block 在 SRAM 里完成尽可能多的计算,最后再合并结果,显存占用大幅下降,速度直线上升。

很多朋友只看参数和代码,没意识到 FlashAttention 对 token 长度上限的影响有多大。没有 FA 之前,训练 8k 上下文的模型,activation 的显存几乎占了一半还多;用上 FA 之后,同样的显存能跑 32k 甚至更长。而且在训练大模型时,FA 还顺带省了反向传播重新读 S 矩阵的开销,用重计算的方式以少量计算量换一次显存读取。

工程上要留意的坑是:FlashAttention 对输入数据类型和 shape 都有要求,最常见的是必须满足 head_dim 是 16 的倍数,某些版本还只支持 fp16/bf16。如果你沿用 fp32 训练,要么升级到混合精度,要么只能降级回普通 attention,这个取舍要在项目最开始就定好。

4.3 混合专家MoE结构的扩张逻辑

MoE(Mixture of Experts)是最近两年大模型架构演进中绕不开的方向。它的目标很明确:希望在总计算量不变的前提下,把模型容量扩大几倍甚至几十倍。Switch Transformer、Mixtral 这些工作都验证了这条路。核心算法是“门控网络 + 稀疏激活”,每个 token 进来先过 router,router 把它分给最相关的少数专家 FFN,而不是让所有专家都算一遍。

MoE 的优势在工程上非常直观:同等 FLOPs 预算下,模型参数更多,知识容量更大,推理时每个 token 只激活一小部分专家,功耗和延迟可控。但它的坑也多。第一,负载不均衡:如果 router 把太多 token 分给同一个专家,某些 GPU 卡就会过载,其他卡闲置,分布式训练效率大打折扣,所以常见训练目标里会加一个 auxiliary load-balancing loss。第二,推理部署复杂:每个 token 可能去不同的专家,batch 内的路由结果不规则,对显存预分配和 kernel 编写都不友好。

如果你要在自己的项目里上 MoE,我的建议是从 Mixtral 这类开源实现起步,先把 router 和 load-balancing loss 的代码读懂,再考虑自己设计专家数量和路由策略。千万别一上来就手写一套,调度问题会让你调到头秃。

5. 从 Transformer 到预训练模型的工业落地

5.1 GPT 系列:解码器架构的演化

标准的 transformer 有 encoder 和 decoder 两部分,但如果目标只是“预测下一个 token 生成文本”,只需要 decoder 左半部分加一个因果掩码就足够了,GPT 系列的架构就是这样一条简洁演进路线。因果掩码(Causal Mask)保证每个位置只能看到左边的 token,让自回归生成成为可能。

到 GPT-2、GPT-3 时代,架构容器基本定型,剩下的大头几乎都花在数据规模、模型规模、优化稳定性的工程化打磨上。我做文本生成项目时深有体会:decoder-only 架构在代码实现上比 encoder-decoder 简单得多,也更容易做 KV Cache 优化。KV Cache 是推理阶段的经典缓存技术,生成第 n 个 token 时不需要把前面所有 token 的 Q/K 都重算一遍,只要把已有的 K/V 缓存住,每次只算新 token 的增量即可。这个优化能让推理吞吐量提升一个量级,是部署阶段必须掌握的核心技能。

5.2 Swin Transformer:视觉架构的迁移

视觉领域的 transformer 演进是另一条有意思的线。ViT 直接把图像切成 patch 序列扔进 transformer,结构简单,但问题是无层级、无局部先验,小数据集上效果不如 CNN。Swin Transformer 的核心创新是“移动窗口自注意力”(Shifted Window Attention),把注意力限制在窗口内计算,并通过窗口错位让相邻窗口信息得以交互,既降低了计算量,又恢复了图像的层级特征。

从核心算法演进的角度看,Swin 给了我们一个重要启发:transformer 不是只能做全局建模,局部建模 + 跨区域交互同样有效。这个思路后来也被引入 NLP 的长上下文处理中,形成了各种 sliding window attention 变体。我们在处理长文档这类任务时,完全可以借鉴 Swin 的“窗口 + 移动”思想,把处理长度在现有硬件下翻两到三倍。

5.3 多模态与融合型架构的扩展

多模态大模型出现之前,视觉和语言各用各的架构,要融合必须设计复杂的接口。以 transformer 为核心的架构天然适合做多模态融合,因为文本、图像、音频都可以编码成 token 序列,然后统一在注意力层做交互。Perceiver 这一类架构的新颖之处在于用一组可学习的潜在数组作为 query,把高维输入映射到低维潜在空间,大幅减少计算量。

我试过在一个工业质检项目里引入视觉语言融合模型,当时最大的感悟是:跨模态的核心不是“把两种数据喂进去”,而是“如何在注意力机制里让两种模态互相指涉”。很多论文喜欢用 cross-attention,让文本 query 去关注图像 key/value;也有方案直接在开头就把两种模态拼在一起做全连接注意力。两种路线各有取舍,前一种更利于控制信息流向,后一种实现简单但训练时模态间会互相干扰。

5.4 分布式架构下的Transformer核心算法

当模型突破单卡显存,分布式训练架构就变得和核心算法同等重要。这里我不展开讲分布式系统原理,只说和 transformer 算法相关的几个核心点。第一,张量并行(Tensor Parallelism)会把多头注意力的头拆分到不同 GPU 上,每个头独立计算再 all-reduce 汇总,这就要求你对“头数能被卡数整除”这类配置特别敏感,否则直接报错或者极端浪费算力。第二,流水线并行(Pipeline Parallelism)会把 transformer 的层按阶段切开,每个 GPU 负责若干层,只在阶段边界传 activation,这能显著降低通信量,但会引入流水线气泡。

还有一个容易被忽视的细节:当模型使用 MoE 结构时,专家分布在多卡上,router 的结果会导致特定 token 去非本卡专家计算,这就涉及 All-to-All 通信,通信量远大于标准数据并行。如果你只是用 DeepSpeed 或 Megatron 的默认配置跑 MoE,很可能会被通信瓶颈卡死。建议对 MoE 场景单独做 profiling,观察通信占比后再决定是否做专家分组调度。

6. 实操:用代码拆解 Transformer 核心算法

6.1 一个极简的 PyTorch 多头注意力实现

网上关于 transformer 的代码实现很多,但不少实现为了简洁牺牲了可读性,新手很难把公式和代码对应起来。我提供一个我自己常用、也推荐团队新成员先读懂的最小版本,核心流程完整,注释直接对应公式:

import torch import torch.nn as nn import math class MultiHeadSelfAttention(nn.Module): def __init__(self, d_model, n_heads): super().__init__() assert d_model % n_heads == 0 self.d_model = d_model self.n_heads = n_heads self.d_k = d_model // n_heads self.w_q = nn.Linear(d_model, d_model) self.w_k = nn.Linear(d_model, d_model) self.w_v = nn.Linear(d_model, d_model) self.w_out = nn.Linear(d_model, d_model) def forward(self, x, mask=None): batch, seq_len, _ = x.shape Q = self.w_q(x).view(batch, seq_len, self.n_heads, self.d_k) K = self.w_k(x).view(batch, seq_len, self.n_heads, self.d_k) V = self.w_v(x).view(batch, seq_len, self.n_heads, self.d_k) # 转成 [batch, n_heads, seq_len, d_k] 方便批量矩阵乘 Q = Q.transpose(1, 2) K = K.transpose(1, 2) V = V.transpose(1, 2) # attention scores: QK^T / sqrt(d_k) scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k) if mask is not None: scores = scores.masked_fill(mask == 0, float("-inf")) attn_weights = torch.softmax(scores, dim=-1) out = torch.matmul(attn_weights, V) # [batch, n_heads, seq_len, d_k] # 合并多头 out = out.transpose(1, 2).contiguous().view(batch, seq_len, self.d_model) return self.w_out(out)

这段代码把前面讲的自注意力公式完整落地了。d_k的缩放、多头维度的拆分拼接、mask 填充-inf这几处都是最容易写错的点。尤其注意view操作前要用contiguous(),否则 transpose 后的内存布局不连续,会直接报错。别问我是怎么知道的,新同学写这一段几乎必踩。

6.2 从零训练一个Transformer的观察方法

拿到代码能跑通只是第一步,真正理解 transformer 需要在训练过程中观察它的行为。我的建议是先用一个小型数据集(比如 Sin 函数预测或者 Tiny Shakespeare)从头训练一个极小的 transformer,然后盯着 loss 曲线和注意力权重可视化看。

以“transformer 预测正弦数据”这类小任务为例,你会发现很有意思的现象:loss 不是平滑下降,而是阶梯式下降,前面若干步 loss 几乎不动,然后突然掉一大截。这其实反映出模型在“逐步发现”数据规律。头几个 attention head 的可视化结果往往杂乱无章,训练到后期会逐渐形成清晰的斜线结构,说明位置编码和注意力开始配合。如果你训练一个序列模型,到最后 attention 图还是乱的,那大概率是学习率或数据预处理出了问题。

这种观察方法比直接套用预训练模型更能建立算法直觉。我自己带人入门时,必做的练习就是手写一个 200 行以内的 transformer、在 CPU 上训练一个字符级语言模型、打印 loss 和 attention 图。做过这个练习的人,对架构核心算法的理解深度完全不一样。

7. 常见问题与排查技巧实录

7.1 高频故障速查表

下面这些是我在各类 transformer 训练和推理项目里反复遇到的高频问题,整理成表格方便你直接对照。

问题现象大概率原因排查/解决方案
loss 不降或 NAN注意力缩放项缺失、学习率过大、混合精度溢出检查sqrt(d_k)、降低 LR、用 bf16
长序列训练 OOM标准 attention 显存爆炸换 FlashAttention、梯度检查点、稀疏窗口
推理速度极慢未使用 KV Cache推理框架显式开启 KV Cache
位置泛化差绝对位置编码 + 训练长度不足换 RoPE + 位置插值
MoE 训练卡慢All-to-All 通信过高专家分组、批量再平衡、通信 profiling
多头维度不匹配d_model 不能整除 n_heads架构设计时验整除,或改 n_heads
生成重复内容自回归采样问题多于架构问题调整温度、top-p、重复惩罚
分布训练 loss 波动batch 变小、学习率未按卡数调整线性缩放 LR,开 grad accumulation

7.2 在分布式环境下的定位思路

分布式环境下定位 transformer 问题,比单机调试多一个维度:你要先分清是“算法问题”还是“系统问题”。曾经有个项目,模型并行加数据并行混着用,loss 曲线隔一段时间就出现尖峰,刚开始我以为是数据里有脏样本,后来用排除法逐个环节替换才发现是数据加载器在不同 rank 之间做了重复采样,导致某些 batch 的分布严重偏离。

我的排查顺序一般是这样:先用小配置单卡跑几十步,看 loss 曲线是否正常;正常再上多卡,保持完全相同的超参,对比曲线;如果多卡出现波动,开通信 profiling 看是否有慢节点或通信不均匀。这套方法帮我排掉过无数“假算法问题”。很多团队一遇到多卡训练效果变差就去改模型结构,其实八成是分布式配置的问题。

7.3 关于显存占用与 batch size 的调优取舍

Transformer 的显存占用大头是 activation,这是新手最容易忽略的。省显存的常见手段无非是梯度检查点、混合精度、FlashAttention、减小序列长度,但每一样都有代价。梯度检查点用时间换显存,会让训练变慢 20%-30%;混合精度省显存明显,但 fp16 在 loss 大尺度时容易溢出;缩短序列长度会直接牺牲模型对长依赖的建模能力。

我常用的策略是“按需组合”。第一阶段上 FlashAttention + 混合精度,看显存还剩多少;第二阶段尽量加大 batch size 提升吞吐;最后才考虑梯度检查点。这顺序能最大限度保住训练速度和模型效果。如果你发现 batch size 已经很小但显存还是爆,先检查是不是激活值没有释放,很多框架默认开自动混合精度后,某些算子的临时 buffer 没走节省路径。

8. 最后聊几点实操感受

做了这么多年 transformer 相关的项目,我最大的体会是:核心算法的演进看似学术味很浓,其实每一次演进的背后都是工程痛点。绝对位置编码外推不行,才有了 RoPE;标准注意力显存爆炸,才有了 FlashAttention;模型参数到瓶颈,才有了 MoE。所以在看论文或读源码的时候,先问一句“它解决的是什么工程问题”,学习效率会提高很多。最后顺手分享一个小经验:如果要在自己的项目里改架构,别一上来就重构,先在小数据集做“换算法前后对照实验”,比如在同一个模型里对比绝对位置编码和 RoPE 的效果差距,数据会直观地告诉你值不值得迁移。算法演进没有银弹,适合你的业务场景和硬件环境,才是真正的好架构。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进