资讯动态

从零手写Self-Attention:原理、代码与踩坑指南

发布时间:2026/10/13 13:58:02 来源:尧图企业网站定制
1. 追本溯源把论文公式翻译成代码前的必备认知1.1 从“加权求和”的直觉到缩放点积公式很多朋友第一次接触 Self-Attention看到的核心公式就是那个经典的缩放点积形式[ Attention(Q, K, V) softmax(\frac{QK^T}{\sqrt{d_k}})V ]对公式本身我见过不少人表示“能看懂”。懂到什么程度知道 Q 是查询、K 是键、V 是值然后 softmax 求相似度权重再对 V 做加权求和。听起来确实很顺但你让他把这三行代码写出来就卡住了。问题的根源在于直觉归直觉代码是对每一维张量做形状变换的真实操作你必须在每一步都知道“现在这个矩阵的行是什么、列是什么、乘完之后每一维代表什么含义”。我自己的理解路径比较笨我先把整个流程拆成本质来看。Self-Attention 到底在做一个什么样的运算抛开矩阵包装它做的是“根据一个查询向量去一堆候选键里面找和它最像的然后按照像的程度去取对应的值”。像的程度就是注意力权重取值的过程就是加权求和。这有点像你去菜市场买菜你心里想着“我要一把新鲜的芹菜”这就是 Q每个摊位上都竖着一排标签写明自己卖什么这就是 K键代表物品的身份/特征你扫了一圈用目光在一排标签中找到最匹配“新鲜芹菜”的那一个Q 和 K 做相似度计算最后从那个摊主手里接过芹菜取对应的 VV 代表商品的实际内容。这里需要明确一个关键认知Q 和 K 的身份是不同的。K 是外部事物的“关键特征描述”V 是外部事物的“实际内容”。Q 是你内部的“查询意图”。三者维度可以相同但在语义定位上完全是三个角色。1.2 为什么是三份不同的向量而不是直接用输入本身很多新手会有一个灵魂拷问既然输入序列里的每个词都有一份 embedding 向量为什么不能直接拿这些向量自己和自己做点积非得再经过三个 Linear 层变换出 Q、K、V好问题。如果直接用输入向量算相似度有两个明显问题第一表达能力受限。输入向量本身承载的是“这个词在整个词表中的身份信息”它是一个综合向量。直接拿它代表“我要查什么”和拿它代表“我对外展示什么特征”、再拿它代表“我实际输出的内容”三个角色混在一起完全没法独立优化。第二维度固定死板。如果直接用输入向量做任意两两相似度你只能在这个原始向量空间里进行内积运算。一旦经过三个可学习的线形投影模型可以自由地把“查询意图”投射到适合做匹配的特征空间里把“对外特征”投射到另一个匹配空间里把“输出内容”投射到第三个空间里。这三个空间可以是不同的线性子空间特征提取的灵活度一下子拉开了。为了印证这一点你可以做一个简单的实验随机初始化一组输入序列直接算任意两两点的内积再去看看经过学习后的 QK 点积权重分布。你会发现前者基本看不出什么语义规律而经过训练后的模型注意力矩阵往往能清晰展现出句法依赖、指代关系等结构信息。这个差距就是三份投影的功劳。所以代码实现的第一步必然是self.w_q nn.Linear(d_model, d_k) self.w_k nn.Linear(d_model, d_k) self.w_v nn.Linear(d_model, d_v)这里先不急着上完整代码后面我会把这几个 Linear 层的维度逻辑掰开揉碎讲清楚。1.3 一个容易被忽略的细节为什么点积要除以根号 d_k公式里除以 (\sqrt{d_k}) 这个操作很多新手会觉的是“顺手除了一下”其实这是整个 attention 数值稳定性的生死线。点积的本质是对 (d_k) 维向量做逐维度相乘再求和。当 (d_k) 变大时每个维度上的数值就算分布在 0 均值附近累加之后的方差也会线性增长。假设 (q_i, k_i) 都是均值为 0、方差为 1 的独立随机变量那么点积的方差近似等于 (d_k)。方差大了会导致什么softmax 输入值的绝对值会被推到很大的区间。比如 (d_k64) 时你可以大概估算出点积结果的标准差接近 8。硬生生的把 8 个标准差的尺度喂给 softmaxsoftmax 会给出什么基本上注意力权重会极度接近 one-hot几乎全部集中在少数一两个位置上其余位置趋近于 0。这不仅让注意力分布丧失信息量还会造成接近饱和区的梯度消失问题——反向传播时梯度过小训练寸步难行。除以 (\sqrt{d_k}) 之后把方差拉回到 1 这个量级softmax 的输入落在合理区间注意力分布就能保持均匀且有分辨力的状态。这也是为什么代码里你总会看到scores torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(d_k)注意是除以 math.sqrt(d_k)不是 d_k。有人图省事直接除 d_k结果发现注意力权重整体偏平、难以区分原因就是缩放过头了。2. 从零手写 Self-Attention最朴素实现的逐行拆解2.1 先搞定单头版本把公式还原成可运行的 PyTorch 代码为了不让人一开始就被多头拆分的维度变换吓到我建议先写一个单头 Self-Attention。它虽然在实际项目中不常用但作为理解工具极其好用——你只需要盯着一个注意力矩阵看不需要处理 head 维度。下面是完整的基础版代码可以拿去直接跑import torch import torch.nn as nn import math class SelfAttention(nn.Module): def __init__(self, d_model: int, d_k: int, d_v: int): super().__init__() self.d_model d_model self.d_k d_k self.d_v d_v self.w_q nn.Linear(d_model, d_k) self.w_k nn.Linear(d_model, d_k) self.w_v nn.Linear(d_model, d_v) # 输出投影见 2.4 节详述 self.out_proj nn.Linear(d_v, d_model) def forward(self, x, maskNone): # x 的形状: [batch_size, seq_len, d_model] Q self.w_q(x) K self.w_k(x) V self.w_v(x) # scores 形状: [batch_size, seq_len, seq_len] scores torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k) if mask is not None: scores scores.masked_fill(mask 0, -1e9) attn_weights torch.softmax(scores, dim-1) context torch.matmul(attn_weights, V) # [batch_size, seq_len, d_v] output self.out_proj(context) return output, attn_weights依次拆解这段代码的每个环节(x) 的形状是 ([batch_size, seq_len, d_model])。它可以是 embedding 层的输出也可以是上一层 Transformer Block 的输出。三个 Linear 层分别对 (x) 做线性变换。注意 (w_q) 和 (w_k) 都把输入压缩到 (d_k) 维而 (w_v) 映射到 (d_v) 维。(QK^T) 计算(K) 需要通过transpose(-2, -1)交换最后两个维度才能让“查询的行”和“键的行”对齐。transpose(-2, -1)表示交换倒数第二维和倒数第一维对于三维张量来说就是交换 (seq_len) 和 (d_k)。想搞清楚为什么是-2, -1而不是1, 2核心在于你的代码是否兼容了 batch 维度。这里用负数索引是为了不管前面是否还有额外维度都通用。除以 (\sqrt{d_k}) 完成缩放。softmax 作用在dim-1上也就是作用在“每个 query 对所有 key 的分数”这个维度上让每个 query 的注意力权重加起来等于 1。将注意力权重与 (V) 相乘得到加权汇总的向量。2.2 千万注意矩阵乘法的顺序和维度形状这里有一个非常关键的细节我见过很多人在这一步翻车——torch.matmul(attn_weights, V)这行代码为什么不是V放在前面我们逐步推。(attn_weights) 的形状是 ([batch, seq_len, seq_len])。拆开来看对于 batch 中某一个样本(attn_weights[i]) 是一个 (seq_len \times seq_len) 的矩阵。假设 (seq_len3)那么第 (i) 个 query 的权重向量就是这矩阵的第 (i) 行它由三个数组成(w_{i0}, w_{i1}, w_{i2})分别表示第 (i) 个 query 对第 0、第 1、第 2 个 key 的关注度。(V) 的形状是 ([batch, seq_len, d_v])(V[i]) 的每一行是“第 (j) 个词的内容向量”。现在我们想得到的结果是第 (i) 个 query 的输出 (w_{i0} \times V[0] w_{i1} \times V[1] w_{i2} \times V[2])。这一计算用矩阵乘法表示就是 ([seq_len \times seq_len] [seq_len \times d_v])即 (attn_weights) 在前、(V) 在后。因此torch.matmul(attn_weights, V)是对的。如果你手滑交换了顺序(V attn_weights) 的形状逻辑就变了——它会让每个词去加权汇总所有 query语义完全错乱。再补充一个新手常见的矩阵乘法困惑Q K.transpose(-2, -1)这一步为什么不是Q.transpose(-2, -1) K这里也是同理。我们需要得到每个 query 和每个 key 的相似度矩阵。相似度矩阵的第 (i) 行代表第 (i) 个 query 对所有 key 的分数第 (j) 列代表所有 query 对第 (j) 个 key 的分数。所以形状必须是 ([seq_len, seq_len])。(Q) 的形状是 ([..., seq_len, d_k])要让矩阵乘法结果的第一个维度对应seq_len就必须让它作为左矩阵的首维(K^T) 的形状变成 ([..., d_k, seq_len])作为右矩阵这样点积结果就是 ([seq_len, seq_len])。其实顺手验证一下结果形状永远是排查维度问题最粗暴有效的方法。2.3 打印中间张量验证每个步骤的形状你可以在 forward 里面临时加几行打印语句比如print(Q shape:, Q.shape) print(K shape:, K.shape) print(V shape:, V.shape) print(scores shape:, scores.shape) print(attn_weights shape:, attn_weights.shape) print(context shape:, context.shape)拿一个实例验证。假设batch_size2, seq_len4, d_model8, d_k4, d_v4那么你会依次看到变量期望形状解释Q(2, 4, 4)每个样本的每个 token 生成 4 维查询K(2, 4, 4)每个 token 生成 4 维键V(2, 4, 4)每个 token 生成 4 维值scores(2, 4, 4)每个样本内 token 两两之间的相似度attn_weights(2, 4, 4)归一化之后的注意力权重每行和为 1context(2, 4, 4)每个 token 汇聚上下文后的结果向量一眼扫过去所有中间状态都清清楚楚。这也印证了一个原则写 attention 代码时不要让任何一个中间张量的形状在你脑子里保持模糊状态。一旦模糊后面 debug 的时间会翻倍。2.4 输出投影为什么 d_v 要再映射回 d_model上面的代码最后有一个out_proj层。有些人会问既然 context 已经是加权汇总后的向量为什么还要接一个 Linear 层把它变回 d_model核心原因有两种一种来自信息理论一种来自工程实践。信息理论层面的原因是d_model是当前层特征表示的“语言”。后续残差连接要执行 (x \text{Attention}(x)) 的加法操作如果 attention 子层输出维度不是 (d_model)加法直接做不了。所以靠out_proj把 (d_v) 维的信息再揉进 (d_model) 维空间。工程实践层面的原因是每个头各自产出的 (d_v) 维向量在拼接之后是一个比较高维的向量。经过一个可学习矩阵投影回 (d_model)模型可以自主决定到底从拼接过的高维信息中筛选哪些、压缩哪些。没有这个投影后续层面对多头信息的利用就只能是一个硬拼接缺少了一个跨 head 融合的机会。换句话说out_proj不是可有可无它是多头注意力“多路信息提取后统一汇总”的必经之路。很多简化教程把它省掉了但从论文原模型到 huggingface 的实现这个投影都是标配。3. 多头注意力的张量细节head 维度到底藏在哪里3.1 为什么切多头让每个头看不同的特征子空间单头注意力的表达能力有限整个模型只有一份 Q、K、V 投影它只能学习一种“相似度度量”。多头的思路则是把维度切成若干份让每份在不同的子空间上分别做注意力运算然后合并结果。想象成开会讨论问题一个组里全是同一领域的人大家可能达成一致但都是同一视角分成不同小组从不同角度各自琢磨最后汇总出来的方案更能覆盖问题全貌。在 Transformer 原论文中(d_{model}512)head 数为 8那么每个头对应的 (d_kd_v64)。每个头都有一组独立的 (W_q, W_k, W_v) 参数。这意味着每个头可以学习不同的“对齐偏好”比如有的头关注词的相邻依赖有的头关注长距离指代有的头关注语法角色。这种分工是天然涌现的因为你只是给了模型多个平行的学习通道。3.2 一次加载完所有 head把 QKV 拆成多头视角在实际代码里为了向量化效率我们不会真的写 8 个独立的 SelfAttention 模块循环调用。高效的做法是先让一个大的 Linear 层一次性算好所有头的 Q、K、V然后通过 reshape 拆出头维度。class MultiHeadAttention(nn.Module): def __init__(self, d_model, n_head, d_k, d_v): super().__init__() self.n_head n_head self.d_k d_k self.d_v d_v self.w_q nn.Linear(d_model, n_head * d_k) self.w_k nn.Linear(d_model, n_head * d_k) self.w_v nn.Linear(d_model, n_head * d_v) self.out_proj nn.Linear(n_head * d_v, d_model) def forward(self, x, maskNone): batch_size, seq_len, _ x.size() Q self.w_q(x).view(batch_size, seq_len, self.n_head, self.d_k) K self.w_k(x).view(batch_size, seq_len, self.n_head, self.d_k) V self.w_v(x).view(batch_size, seq_len, self.n_head, self.d_v) Q Q.transpose(1, 2) # [batch, n_head, seq_len, d_k] K K.transpose(1, 2) V V.transpose(1, 2) scores torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k) if mask is not None: scores scores.masked_fill(mask 0, -1e9) attn_weights torch.softmax(scores, dim-1) context torch.matmul(attn_weights, V) # 还原形状 context context.transpose(1, 2).contiguous().view(batch_size, seq_len, -1) output self.out_proj(context) return output, attn_weights这上面的代码里有个很容易让新手崩溃的地方到底什么时候该view什么时候该transpose为什么 transpose 之后还要来一个.contiguous()先拆解Q的变换过程。原始 (x) 形状是 ([batch, seq, d_model])。经过w_q后形状变成 ([batch, seq, n_head \times d_k])。这时数据从头到尾是连续排列的第 0 个 seq 位置上的前d_k个数是 head 0 的 Q接着d_k个数是 head 1 的 Q以此类推。所以执行.view(batch_size, seq_len, self.n_head, self.d_k)是非常自然的一步切分——直接在最后一个维度上按 head 切块数据顺序完全对应。此时张量形状是 ([batch, seq, n_head, d_k])但 attention 计算要把 seq 和 head 分开处理。每个 head 要独立获得一个 ([seq, seq]) 的分数矩阵所以最好让 head 维度靠近 batch变成 ([batch, n_head, seq, d_k])。这一步必须用transpose(1, 2)交换维度而不能用view强行重新解释——因为 view 只适用于连续内存上的形状重解释而 transposition 之后数据内存布局已经变了在新的内存排列下你无法通过简单的 view 得到想要的多头矩阵。如果你尝试在transpose(1, 2)之后直接view(batch, n_head, seq, d_k)会报错或者得到语义错乱的数据。强制连续化就是.contiguous()它会在背后重新整理内存保证后续 view 安全。原则是transpose 之后想 reshape先.contiguous()。3.3 多头拼接还原的秘密为什么要用 view(batch, seq, -1) 而不是 reshape 的其他写法来看context.transpose(1, 2).contiguous().view(batch_size, seq_len, -1)这一步。context 在多头注意力里面形状是 ([batch, n_head, seq, d_v])。我们要拼回原来的 (seq \times (n_head \times d_v)) 形状。这里涉及一个顺序问题拼回的时候同一个 seq 位置上的所有 head 结果要并排排列而不是把 head 放在最外面。原始的计算逻辑里对于某个 seq 位置的 token它的 output 是多个 head 各自输出的拼接shape 是 ((n_head \times d_v))。所以我们要把 context 从 ([batch, n_head, seq, d_v]) 变回 ([batch, seq, n_head, d_v])再展平最后两维。如果你直接对 ([batch, n_head, seq, d_v]) 做view(batch, seq, -1)那就相当于把“每个 head 内部的所有 seq 输出排在前面然后再排下一个 head”而不是把同一个 seq 的多头结果聚在一起。拼接顺序错了后续 out_proj 学到的特征映射也就完全错位。实际调试中这个 bug 极其隐蔽因为网络不会报错训练也可能往下降但最终效果明显低于预期。我的建议是在多头实现中每次维度变换之后都打印一下实际形状并且可以用一个很小的随机矩阵手动验证拼接语义。4. 两种 Mask 的代码实现padding mask 和 causal mask4.1 padding mask处理变长序列的三件套实际训练里一个 batch 内的句子长度通常不一致。我们会把它们 pad 到同一个长度。但 padding 位置是无效的不应该参与注意力计算否则模型会去“关注”这些填充符。解决方法就是 padding mask。构造 padding mask 常见的做法是def get_padding_mask(x, padding_idx0): # x: [batch, seq_len] return (x ! padding_idx).unsqueeze(1).unsqueeze(2)这里假设输入 token id 里padding 的 id 是 0那么x ! 0的位置都是有效 tokenmask 值为 Truepadding 位置是 False。unsqueeze 两次把形状变成 ([batch, 1, 1, seq])是为了广播到注意力分数矩阵 ([batch, head, seq, seq])。在 forward 里if mask is not None: scores scores.masked_fill(mask 0, -1e9)注意这里条件是mask 0因为上面 mask 的值 True/False 就对应 1/0。也可以直接构造 0/1 矩阵然后用mask 0找到 padding 位置。把这些位置的 score 填一个极大负数softmax 之后这个位置的概率就会趋近于 0。这里有一个非常高频的疑问为什么 padding 位置不直接把 scores 改成 0 或一个很小的数因为在 softmax 中即使很小的正数也会分摊概率而你是希望这个位置完全不影响其他词的注意力分布所以必须用一个绝对值极大的负数把它彻底“沉底”。4.2 causal mask语言模型里不能偷看未来在自回归语言模型如 GPT 结构里注意力必须是因果的第 (i) 个 token 只能 attend 到它自己和它之前的 token不能看到未来的 token。实现方式是在分数矩阵上加一个上三角 mask。构造方式很经典def get_causal_mask(seq_len): # [seq_len, seq_len]下三角为 True mask torch.tril(torch.ones(seq_len, seq_len)).bool() return mask如果原始 scores 形状是 ([batch, head, seq, seq])那么 causal mask 也要扩展维度mask mask.unsqueeze(0).unsqueeze(0) # [1, 1, seq, seq] scores scores.masked_fill(mask 0, -1e9)torch.tril生成的是下三角矩阵下三角位置为 True、上三角为 False。mask 0定位到上三角的未来位置把这些位置掩成负无穷softmax 之后未来信息就被完全阻断了。一个常被忽略的点causal mask 和 padding mask 在 Transformer decoder 里是同时使用的两者是叠加而非二选一。常见做法是先把两个 mask 都用逻辑与合成一个再统一填到 scorescombined causal_mask padding_mask_broadcast scores scores.masked_fill(combined 0, -1e9)4.3 用 -1e9 还是 float(-inf)有什么区别我习惯用-1e9而不是torch.finfo(dtype).min或者float(-inf)原因是工程上的保险无穷大在反向传播时如果参与梯度计算容易产生 NaN。虽然 softmax 里被掩蔽的位置梯度通常为 0但在某些混合精度训练场景下inf可能导致数值问题。也有人用-1e9会担心一个问题如果注意力分数的量级是 10 万那么 -1e9 还不够“极小”。但实际场景中经过缩放的注意力分数基本都在个位数区间-1e9 已经足够让 softmax 的结果在 float32 精度下趋近于 0。在 float16 训练下烂位置大概变成1e-10量级对结果的影响微乎其微。如果你极度需求精确性可以用masked_scores scores.masked_fill(mask_m 0, torch.finfo(scores.dtype).min)这是最严谨的写法。但说实话日常实验里-1e9简洁又够用我很多项目直接沿用这个值训练稳定没出过问题。5. 踩坑复盘从实现错误到数值问题的完整排查链路5.1 经典错误一缩放因子被遗忘训练曲线变成心跳图先说一个我真实踩过的坑。有一段时间训练 Transformer 模型loss 曲线看起来很诡异——整体缓慢下降但每个 step 都在剧烈震荡像是心脏监测仪的波形。我当时第一反应是学习率太大调了半天没有根本性好转。后来把 attention 打分打印出来发现 scores 的数值动辄是几十上百进到 softmax 后基本变成 one-hot 分布。问题就出现在我自己写代码时漏了/ math.sqrt(self.d_k)这一步。由于维度是 64点积方差在 64 量级标准差 8 左右使得 softmax 输入被推到饱和区梯度几乎传不动。训练曲线震荡是因为偶尔某个 batch 的梯度侥幸回传模型在即将崩坏和勉强下降之间反复。这个排查过程给我的教训很深遇到训练不稳定先检查注意力分数分布看看是否因为缩放缺失导致饱和。打印scores.mean()和scores.std()如果能稳定在学生时代熟悉的那几个量级均值接近 0、标准差接近 1缩放基本就没问题。5.2 经典错误二softmax 之后再对 mask 区域填零这个问题在各类代码 review 里反复出现。有人为了“让被 mask 的位置不参与输出”写下了这种代码attn_weights torch.softmax(scores, dim-1) attn_weights attn_weights.masked_fill(mask 0, 0)看起来好像逻辑正确mask 掉的位置权重变为 0加权求和时这些 token 不参与。但坑在于softmax 已经把所有位置的分母算过了你事后把某些位置归零其他位置的权重并不会重新归一化。举个例子假设有效位置有 3 个权重本来是 [0.2, 0.3, 0.2]无效位置一个权重是 0.3因为忘记在 softmax 前 mask 了。你事后把 0.3 置 0剩下的 [0.2, 0.3, 0.2] 加起来只有 0.7并不是一个合法的概率分布。这会导致输出向量的尺度整体变小语义表达受影响并且反向传播时对这些位置的梯度处理也会变得很奇怪。正确的做法永远只有一种让 mask 作用在 softmax 之前用负极大数填充让 softmax 自己输出合法概率。我还见过一种更隐蔽的变体有人把 attention 权重的分母手动修正强行重新归一化。这种做法的代价是你需要多算一次 sum 操作还会破坏梯度传播的简洁性。完全没必要不如直接用masked_fill(scores, -1e9)干净。5.3 经典错误三维度变换打乱了“哪个维度是哪个词”这个坑最容易在多头实现中出现。举个真实发生的例子我在某个项目里把context.transpose(1, 2)这段代码注释掉直接从[batch, n_head, seq, d_v]做.view(batch, seq, -1)想当然地认为“反正迟早要 reshape 到目标形状”。结果就是上面分析过的“拼接顺序混乱”——同一个 token 的多头结果被拆散到不同的行里。模型虽然能跑通训练也不报错但最终 BLEU 分数比基准实现低了近 2 个点。排查这个问题有一个非常实用的技巧。拿一个 batch_size1、seq_len3、n_head2、d_v2 的小例子手算一遍期望拼接后每个位置的值然后打印代码实际输出的矩阵。直接用眼睛对比。torch.manual_seed(0) x torch.randn(1, 3, 4) model MultiHeadAttention(d_model4, n_head2, d_k2, d_v2) out, _ model(x) print(out.shape) print(out)一旦你发现输出矩阵第一行的某些数字其实应该属于第三行就能立刻定位到 transpose/view 顺序的错误。5.4 对付 NaN 的突击策略一步步定位是哪里炸的attention 输出 NaN 的排查链通常很固定。顺着我的经验走基本能找到根因。第一步看 scores 是否有 NaN。如果 scores 有 NaN大概率是输入里混入了 NaN比如 embedding 查表越界、数据清洗遗漏或者d_k为 0/数据本身是 inf。再检查 mask 和缩放因子。第二步如果 scores 正常、softmax 输出正常但 V 矩阵有 NaN那问题多半出现在前面层如 LayerNorm 的 epsilon 设置太小、或者残差连接的初始化不当。第三步如果前向输出正常但反向传播后出现 NaN大概率分布在 softmax 的梯度未 mask 的饱和区或者在极其深层的梯度爆炸。这时不如换个思路用梯度裁剪先撑住训练再逐步缩小问题范围。我不能说这套方法能解决所有 NaN但在我排查过的 attention 相关 NaN 里大约七成绕不开以上步骤。把这些排查顺序牢记能省大量时间。6. 工程化进阶从手写版到内置 API 与性能优化6.1 熟悉之后直接用 PyTorch 内置的官方实现当你把上面的手写代码完整跑通之后建议再看一眼 PyTorch 官方封装好的函数torch.nn.functional.scaled_dot_product_attention它是一个高度优化的融合算子背后的实现可能切到 FlashAttention 或 memory-efficient attention 的 kernel注意力计算的内存占用和速度都比手写版好很多。调用方式import torch.nn.functional as F attn_output F.scaled_dot_product_attention( query, key, value, attn_maskNone, dropout_p0.1, is_causalFalse, )这个 API 直接接受[batch, ..., seq_len, d_model]形状的输入。当is_causalTrue时它内部自动生成因果 mask当需要自定义 padding mask 时把它传到attn_mask参数即可。但我不建议新手直接跳进这个 API 而不手写一遍。两个原因一是没有自己写过你对 mask 的拼接顺序、维度语义的理解始终隔着一层遇到自定义情况容易无从下手二是内置 API 的参数抽象程度高它的 mask 机制和论文里的原始描述有细微出入不理解底层的话你连接口文档都未必看得精确。6.2 什么时候必须手写什么时候可以依赖内置实现我的经验是分场景看教学/原理验证场景必须手写并且要完整走一遍中间张量的形状验证。常规 Transformer 训练直接用F.scaled_dot_product_attention省显存而且快。需要自定义注意力结构如相对位置编码、稀疏注意力、局部注意力以手写为基础进行修改因为内置 API 封装死了点积注意力的流程很难插入自定义逻辑。推理服务部署优先考虑 FlashAttention 的 kernel如果手写实现则要特别注意内存优化和算子融合。实际项目中最常用的路线是开发阶段用手写实现验证思路稳定后切换官方融合算子提性能。两条路都走过一遍你才算真正吃透了 attention。6.3 给动手实践者的三个建议最后给想动手实现一遍 Self-Attention 的朋友三个建议都是吃过亏之后的体会。第一个建议先别着急写多头把单头实现从上到下跑通打印所有中间张量。90% 的问题都出在形状逻辑不清。第二个建议构造极小样例去验证比如 seq_len3、d_model4、n_head2。小到你能随手算大到才能保证信息流转不出错。第三个建议写一个梯度检查。用torch.autograd.gradcheck或者手动数值差分验证前向和反向是否一致。大部分 bug 在反向传播阶段才会暴露而数值梯度检查能在早期抓住它们。这三个建议看上去简单真正坚持下来的人不多但能做到的话你会在注意力机制的理解深度上甩开大多数同行。代码敲一遍、坑踩一遍、修复一遍比看十篇博客都有用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑