系列文章导航:AI系列文章导航目录-持续更新中
第02课:Transformer架构核心原理
📝 本文摘要:本文从RNN的局限性出发,详解Transformer的核心架构:输入表示层(Token Embedding + 位置编码)、Transformer Block(多头自注意力 + FFN + 残差连接 + 层归一化)、输出层;重点拆解了Self-Attention计算过程、多头注意力、FFN的"升维-激活-降维"本质(模式匹配+知识检索)、MoE演进、以及Encoder-only vs Decoder-only的分野和KV Cache加速原理。
你不需要能手推公式,但你必须能回答:Transformer为什么能工作?注意力机制在干什么?为什么它能取代RNN?如果面试官问你,你能讲清楚。这一课会把Transformer从头到脚拆开给你看。
一、Transformer之前的世界:为什么需要革命
1.1 RNN(Recurrent Neural Network,循环神经网络)
输入: x₁ → x₂ → x₃ → ... → xₙ
↓ ↓ ↓ ↓
隐状态: h₁ → h₂ → h₃ → ... → hₙ
怎么工作的:RNN像一个"有记忆的管道"------每读入一个词,它就把当前词和上一步的"记忆"(隐状态)融合,产生新的记忆,再传给下一步。
核心问题:
- 串行计算:h₂依赖h₁,h₃依赖h₂......一步接一步,无法并行,训练慢
- 长距离遗忘:信息从x₁传到hₙ要经过n次变换,每变换一次信息就"稀释"一点,长序列中早期信息几乎丢失
打个比方:就像传话游戏,第1个人说的话传到第20个人,信息早就走样了。
1.2 LSTM(Long Short-Term Memory,长短期记忆网络)/ GRU(Gated Recurrent Unit,门控循环单元)
对RNN的改进,通过"门控"机制(可以理解为"阀门"------决定什么信息该记住、什么该忘记)缓解长距离遗忘,但串行计算的根本问题没解决。
演进逻辑:RNN → LSTM/GRU,是在"串行处理"框架内做改良,但改良有上限。
1.3 Transformer的颠覆(2017)
核心创新:彻底抛弃递归结构(不再一步一步传信息),用**自注意力(Self-Attention,自注意力)**直接建模任意两个位置之间的关系------不管隔多远,一步直达。
- ✅ 完全并行计算 → GPU(Graphics Processing Unit,图形处理单元/显卡)友好 → 可大规模训练
- ✅ 任意距离直接关联 → 没有长距离遗忘
- ✅ 计算复杂度与序列长度的关系从O(n)串行变为O(n²)并行(代价是注意力计算本身是O(n²),但可并行)
演进逻辑:RNN的串行传话 → LSTM的"选择性传话" → Transformer的"不需要传话,大家直接对话"。
二、Transformer架构全景:先看森林,再看树木
在拆解每个组件之前,你需要先理解Transformer的整体设计哲学:
2.1 核心设计哲学
Transformer的本质 = 信息重组机器
输入:一串Token(词/子词)
处理:让每个Token和所有其他Token"对话",汇聚信息,再独立加工
输出:每个Token的新表示,包含了全局上下文信息
为什么要这样设计:因为语言的本质是"关系"------"苹果"这个词的意义取决于它周围的词(是水果还是手机?)。注意力机制就是让每个词去"看"周围所有的词,决定谁对自己最重要。
2.2 整体架构一览
输入 Token序列
↓
┌─────────────────────────────────────────┐
│ 1. 输入表示层 │
│ ├─ Token Embedding(词嵌入) │ Token → 向量
│ └─ Positional Encoding(位置编码) │ 注入位置信息
│ │ ⭐ 为什么需要:自注意力本身 │
│ │ 没有顺序概念,必须显式告知 │
└──────────────────┬──────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 2. Transformer Block ×N(堆叠N层) │
│ ┌─────────────────────────────────────┐│
│ │ Multi-Head Self-Attention ││ ⭐ 核心:全局关系建模
│ │ (多头自注意力) ││ 让每个Token与所有Token对话 │
│ └───────┬─────────────────────────────┘│
│ ↓ Add & Norm(残差连接+归一化) │ 稳定训练,保留原始信息 │
│ ┌─────────────────────────────────────┐│
│ │ Feed-Forward Network (FFN) ││ ⭐ 逐位置非线性变换 │
│ │ (前馈网络/前馈层) ││ 对汇聚来的信息做加工 │
│ └───────┬─────────────────────────────┘│
│ ↓ Add & Norm(残差连接+归一化) │ 稳定训练,保留原始信息 │
└──────────────────┬──────────────────────┘
↓ (重复N次)
┌─────────────────────────────────────────┐
│ 3. 输出层 │
│ ├─ Decoder: 自回归生成下一个Token │
│ └─ Encoder: 取[CLS]或平均做分类 │
└─────────────────────────────────────────┘
三层结构的类比:
- 输入表示层 = 翻译官:把文字翻译成模型能理解的"数字语言",并标注"这是第几个词"
- Transformer Block = 信息交流会+加工厂:先让所有信息交流(注意力),再各自加工(FFN)
- 输出层 = 决策者:根据加工后的信息做最终输出
三、核心组件逐个拆解
3.1 Token Embedding(词嵌入/Token嵌入)
做什么:把离散的Token映射为连续的向量。
"hello" → [0.23, -0.15, 0.87, ...] (维度d_model,如4096)
为什么:神经网络只能处理数值向量,不能处理文本。Embedding就像是给每个词一个"身份证号"------不过这个号不是一个数字,而是一个高维向量,相似的词向量也相近("猫"和"狗"的向量比"猫"和"汽车"更近)。
3.2 Positional Encoding(位置编码)
问题:Self-Attention本身没有顺序概念------"猫吃鱼"和"鱼吃猫"在注意力看来是一样的(因为词和词之间的关系权重可能恰好相同)。模型必须知道每个词在句子中的位置。
解法:给每个位置加一个独特的位置向量,让模型知道"这个词在第几个位置"。
原始Transformer用正弦/余弦函数(Sinusoidal Positional Encoding):
PE(pos, 2i) = sin(pos / 10000^(2i/d_model))
PE(pos, 2i+1) = cos(pos / 10000^(2i/d_model))
pos = 位置序号(第几个Token)
i = 维度序号(向量的第几个分量)
d_model = 嵌入维度
现代模型常用:
- 可学习的位置编码(Learned Positional Encoding):直接让模型学位置向量
- RoPE(Rotary Position Embedding,旋转位置编码):目前主流,通过旋转矩阵编码相对位置
- ALiBi(Attention with Linear Biases,线性偏置注意力):给注意力分数加距离惩罚
演进逻辑:正弦余弦(固定公式)→ 可学习(更灵活)→ RoPE(相对位置,更优雅)→ ALiBi(更简单,外推性好)
3.3 Self-Attention(自注意力机制)⭐核心中的核心
直觉:每个Token都要"看"序列中所有其他Token,决定自己应该关注谁、关注多少。
类比:想象你在一个聚会里,你想知道谁跟你最相关。你会扫一眼所有人,根据他们的话题、表情、和你关系的远近,分配你的注意力------最相关的多看几眼,不相关的忽略。Self-Attention就是让每个Token做这件事。
计算过程:
1. 对每个Token的向量x,通过三个不同的权重矩阵生成三个向量:
Q (Query,查询) = X · W_Q → "我在找什么"(我在聚会里找什么话题)
K (Key,键) = X · W_K → "我能提供什么"(我能聊什么话题)
V (Value,值) = X · W_V → "我实际的内容"(我真正能说的内容)
2. 计算注意力分数(Attention Score,注意力得分):
对每个Query,和所有Key做点积,衡量"匹配程度"
score(i,j) = Q_i · K_j (第i个Token的Query与第j个Token的Key的点积)
3. 归一化得到注意力权重(Attention Weight):
Attention(Q,K,V) = softmax(Q·Kᵀ / √d_k) · V
─────────────────────
│ │
"谁和我相关" "取他们的内容"
softmax把分数变成概率分布(所有权重之和=1)
除以√d_k是防止点积值过大导致softmax梯度消失
4. 用权重对Value加权求和,得到最终输出:
output_i = Σ_j weight(i,j) × V_j
具体例子:
句子:"猫 坐 在 垫子 上"
当计算"垫子"的注意力时:
Q_垫子 分别与 K_猫、K_坐、K_在、K_垫子、K_上 做点积
→ 得到注意力权重,比如:猫0.1, 坐0.5, 在0.1, 垫子0.2, 上0.1
→ 最终"垫子"的表示 = 0.1×V_猫 + 0.5×V_坐 + 0.1×V_在 + 0.2×V_垫子 + 0.1×V_上
模型自己学会了:"垫子"和"坐"关系最密切(因为坐在垫子上是最常见的搭配)
为什么除以√d_k:当Q和K的维度d_k很大时,点积的值可能非常大(因为每个分量相乘再求和),这会导致softmax函数的梯度消失(softmax对极端大的值不敏感,输出接近one-hot)。除以√d_k相当于对点积做缩放,让softmax的输入值域合理,梯度正常。
3.4 Multi-Head Attention(多头注意力)
思想:一个注意力头只能关注一种关系模式,多头让模型同时关注多种不同的关系。
类比:就像你用不同的"视角"看同一场聚会------
-
视角1(语法视角):关注主谓关系
-
视角2(语义视角):关注同义词
-
视角3(指代视角):关注代词和它指代的人/物
-
更多功能由更多的头承担
原始向量 X
├─ Head 1: Q₁=X·W_Q1, K₁=X·W_K1, V₁=X·W_V1 → Attention → output₁
├─ Head 2: Q₂=X·W_Q2, K₂=X·W_K2, V₂=X·W_V2 → Attention → output₂
├─ Head 3: Q₃=X·W_Q3, K₃=X·W_K3, V₃=X·W_V3 → Attention → output₃
└─ ...最终拼接(Concatenate)所有头的输出,再做一次线性变换(Linear Projection)得到最终输出。
为什么不直接用一个大的注意力:多头提供了多样性------每个头的权重矩阵不同,学到的关系模式不同。实验证明多头比单头效果好,尤其在长序列和复杂语义任务上。
3.5 Feed-Forward Network(FFN,前馈网络/前馈层)⭐让小白也能理解
先说人话:如果说注意力层是"信息交流会"------让每个Token去听其他Token说什么;那么FFN就是"独立加工车间"------每个Token拿到交流来的信息后,关起门来自己消化、加工、提炼。
FFN到底在做什么:
FFN(x) = activation(x · W₁ + b₁) · W₂ + b₂
步骤1: x · W₁ + b₁ → 把向量从d_model维映射到d_hidden维(通常d_hidden = 4 × d_model)
步骤2: activation() → 非线性激活函数(ReLU或GELU)
步骤3: · W₂ + b₂ → 再从d_hidden维映射回d_model维
"升维再降维"到底有什么意义? 用一个生活类比来理解:
想象你是一个学生(维度d_model = 你的知识面)
步骤1: 升维(d_model → 4×d_model)
相当于:你进入图书馆,把知识展开到更大的空间
原本你知道"猫"是一个概念,现在展开为:猫的种类、猫的习性、猫的历史...
在高维空间中,原本"挤在一起"的概念被拉开了,更容易区分
步骤2: 非线性激活(GELU/ReLU)
相当于:你选择了性地关注某些展开的知识,忽略不相关的
GELU函数的特点:接近0的值被严重压制,大值几乎保留
→ 这是一种"筛选",只有显著的特征能通过
步骤3: 降维(4×d_model → d_model)
相当于:你从图书馆回来,把展开的知识重新压缩回你的知识面
但这次压缩后的知识已经不同了------因为你在高维空间做了筛选和组合
更深的理解:FFN是模型的"知识存储器"
近年来的研究表明(如Anthropic的字典学习/Dictionary Learning工作),FFN的真正角色可能是一个Key-Value记忆系统:
FFN第一层(W₁): 学习Key------"这是什么类型的信息?"
FFN第二层(W₂): 学习Value------"对应的输出应该是什么?"
举个例子:
输入包含"法国"的概念
→ W₁匹配到"国家/首都"这个Key
→ W₂输出"巴黎"这个Value
所以FFN本质上是在做:模式匹配 → 知识检索
现代FFN的演进:
原始FFN: 线性→激活→线性(2层,简单但容量有限)
↓
GLU变体(Gated Linear Unit,门控线性单元): 加入门控机制,效果更好
SwiGLU: activation((x·W₁) ⊙ (x·W_G)) · W₂ (⊙是逐元素乘法)
↓
MoE(Mixture of Experts,混合专家): 把一个大FFN拆成多个小FFN(专家)
每次只激活部分专家,总参数量大但推理成本低
代表:Mixtral 8x7B、DeepSeek-V3(256路由专家+1共享专家)
一句话总结FFN:注意力层负责"问对人",FFN负责"想明白"。
3.6 残差连接(Residual Connection)& 层归一化(Layer Normalization)
output = LayerNorm(x + Sublayer(x))
───────── ────────────────
归一化 残差连接:原始输入直接"跳连"到输出
残差连接:让信息可以"跳过"某些层直接传递。为什么需要?深层网络容易出现梯度消失(梯度在层层传递中越来越小,最终无法更新底层参数),残差连接提供了一个"快捷通道",确保梯度能回流到网络底层。
类比:就像高速公路旁边的应急通道------即使主路堵了(梯度消失),信息还能通过应急通道传递。
层归一化:对每个样本的特征做标准化(减均值、除标准差),稳定训练过程,加速收敛。与Batch Normalization(批归一化)不同,Layer Normalization不依赖batch内的其他样本,更适合变长序列和单条推理。
Pre-Norm vs Post-Norm:
- Post-Norm(原始Transformer):LayerNorm(x + Sublayer(x)),先加残差再归一化。训练不稳定,需要学习率warmup(预热)
- Pre-Norm(现代主流):x + LayerNorm(Sublayer(x)),先归一化再加残差。训练更稳定,不需要warmup,目前几乎所有大模型都用Pre-Norm
演进逻辑:Post-Norm → Pre-Norm,是从"理论上更优雅"到"实践中更稳定"的调整。
四、Encoder vs Decoder:两条路线的分野
原始Transformer(2017)是Encoder-Decoder结构(用于机器翻译),后来分化为两条路线。理解这个分野,才能理解今天大模型的格局。
4.1 Encoder-only(BERT路线,2018-)
输入 → [Encoder Blocks] → 输出
双向注意力(每个Token看前后所有Token)
擅长 :理解任务------分类、检索、匹配、语义相似度
不适合 :生成任务(因为没有学过"如何一个接一个地生成")
代表:BERT(Bidirectional Encoder Representations from Transformers,来自Transformers的双向编码器表示)
为什么BERT火了一阵又不火了:因为"理解"最终要服务于"生成"。能理解一句话但不能生成回答,应用场景受限。
4.2 Decoder-only(GPT路线,2018-)⭐当前绝对主流
输入 → [Decoder Blocks] → 输出
因果注意力(Causal Attention,每个Token只能看之前的Token,不能看未来)
擅长 :生成任务------对话、续写、推理、代码生成、工具调用
关键:自回归生成(Autoregressive Generation)------每次只预测下一个Token
输入: "今天天气"
预测: "很"
输入: "今天天气很"
预测: "好"
输入: "今天天气很好"
预测: "。"
为什么Decoder-only成为主流:
- 生成是更通用的能力------理解可以看作"生成答案"的特例(给你一段文本,让你分类,本质上也是在"生成"分类标签)
- 训练更简单------不需要Encoder-Decoder之间的对齐,只需预测下一个Token
- 规模化效果更好------涌现能力(Emergent Abilities)主要在Decoder-only模型上观察到,因为生成任务迫使模型学会更深层的规律
代表:GPT系列(GPT-1/2/3/4/4o)、LLaMA系列、DeepSeek系列、Qwen系列、Mistral/Mixtral系列、Claude系列------几乎所有你能叫出名字的大模型都是Decoder-only。
4.3 Encoder-Decoder(T5/BART路线)
现在主要用于特定任务(翻译、摘要),不是主流大模型架构。T5(Text-to-Text Transfer Transformer,文本到文本迁移Transformer)把所有NLP任务统一为"文本到文本"格式。
演进逻辑:Encoder-Decoder(翻译专用)→ Encoder-only(理解任务)→ Decoder-only(通用生成+理解)→ 一统天下
五、从Transformer到大模型的关键技术
5.1 Scaling Laws(缩放定律/规模定律)
Kaplan et al. (2020) 发现:
模型性能 ≈ f(参数量N, 数据量D, 计算量C)
关键结论:
1. 更大的模型 + 更多的数据 = 更好的性能(近似幂律关系,Power Law)
2. 三个因素中,计算量是根本约束(你的GPU算力是有限的)
3. 最优方案:在固定计算预算下,训练更大的模型,但只走较少的步数
Chinchilla定律(Hoffmann et al., 2022)修正:之前大家倾向于"大模型+少数据"(比如GPT-3用300B token训练175B参数模型),实际上"适度模型+足够数据"更优------Chinchilla用70B参数+1.4T token训练,效果超过GPT-3。这就是为什么Llama用更多数据训练更小模型效果反而好。
对开发者的意义:选模型不是"越大越好",要根据你的计算预算和任务需求选最合适的规模。
5.2 MoE(Mixture of Experts,混合专家模型)
传统Transformer: 每个Token过同一个FFN
MoE Transformer: 每个Token只激活部分FFN(专家)
Router(路由器,决定Token走哪些专家)
/ | \
Expert1 Expert2 Expert3 Expert4
↑ ↑
Token A只走 Token B只走
Expert1,Expert3 Expert2,Expert4
优势 :总参数量大(能力强),但每次推理只激活一部分(速度快、成本低)。
代表:
- Mixtral 8x7B:8个专家,每次激活2个
- DeepSeek-V3:256个路由专家+1个共享专家,每次激活8个(总参数671B,激活37B)
演进逻辑:Dense FFN(所有Token过同一个FFN,简单但贵)→ MoE(稀疏激活,大模型低成本的关键技术)
六、推理过程详解(你的代码实际在做什么)
当你在代码里调用model.generate()或者client.chat.completions.create()时,底层发生了什么?
6.1 完整推理流程
用户输入: "1+1等于几?"
↓
┌─ Step 1: Tokenization(分词)─────────────────────────────┐
│ 文本 → Token ID序列 │
│ "1+1等于几?" → [16, 62, 16, 118, 72, ...] │
│ │
│ ⭐ 为什么要分词:模型不认识"文字",只认识"数字ID" │
│ ⭐ 分词方式影响效率:BPE(Byte-Pair Encoding,字节对编码) │
│ 是最常用的子词分词方法,能把罕见词拆成常见子词 │
└────────────────────────────────────────────────────────────┘
↓
┌─ Step 2: Embedding(嵌入)────────────────────────────────┐
│ 每个Token ID → d_model维向量 │
│ 加上位置编码(Positional Encoding) │
│ │
│ ⭐ 此时的向量:包含"这个词是什么"和"它在第几个位置"两重信息 │
└────────────────────────────────────────────────────────────┘
↓
┌─ Step 3: Transformer Layers ×N ──────────────────────────┐
│ 每一层做三件事: │
│ 1. Causal Self-Attention(因果自注意力) │
│ 每个Token只能看到自己和之前的Token │
│ 用注意力权重从前文"汇聚"相关信息 │
│ 2. FFN(前馈网络) │
│ 对汇聚来的信息做非线性加工 │
│ 本质:模式匹配 + 知识检索 │
│ 3. 残差连接 + 层归一化 │
│ 稳定训练,保留原始信息 │
│ │
│ ⭐ 每经过一层,Token的表示就更"丰富"一层 │
│ 浅层学到语法,深层学到语义,更深层学到任务逻辑 │
└────────────────────────────────────────────────────────────┘
↓
┌─ Step 4: Output Head(输出头)────────────────────────────┐
│ 最后一层向量 → 词表大小的logits(未归一化的概率分数) │
│ logits → softmax → 概率分布 │
│ 采样策略(Sampling Strategy): │
│ - Greedy(贪心):直接选概率最大的Token │
│ - Top-k:从概率最高的k个Token中按概率采样 │
│ - Top-p(Nucleus Sampling,核采样):选累积概率不超过p │
│ 的最小Token集合,从中采样 │
│ - Temperature:控制分布的"尖锐程度" │
│ T↓更确定(趋向贪心),T↑更多样(趋向均匀) │
│ │
│ → "2"(或"等于"、"二"等,取决于概率最高的Token) │
└────────────────────────────────────────────────────────────┘
↓
┌─ Step 5: 自回归重复 ─────────────────────────────────────┐
│ 把预测出的Token加入输入序列,继续预测下一个Token │
│ 直到遇到<EOS>(End of Sentence,句末结束符) │
│ 或达到最大长度(max_tokens) │
│ │
│ "1+1等于几?" → "2" → "<EOS>" → 停止 │
│ "请写一首诗" → "春" → "风" → "吹" → ... → "<EOS>" → 停止 │
└────────────────────────────────────────────────────────────┘
6.2 KV Cache:推理加速的关键
自回归生成的特点:每一步预测新的Token时,之前所有Token的Embedding、Attention计算结果其实不变------但朴素实现会重新计算,非常浪费。
KV Cache的思路:
第1步: 输入 [T1] → 计算 T1的K和V → 缓存K1,V1 → 预测 T2
第2步: 输入 [T1,T2] → T1的K,V不变,只需算T2的K,V → 缓存K2,V2 → 预测 T3
...
第n步: 输入 [T1,...,Tn] → 只需算Tn的K,V → 预测 Tn+1
没有KV Cache: 每步 O(n²) 的注意力计算量
有KV Cache: 每步只有 O(n) 的新计算量(算新Token的K,V + 和所有缓存的K做注意力)
这就是为什么大模型推理的主要瓶颈是显存(存放KV Cache)而不是算力。长上下文(128K/1M token)需要极大的KV Cache,这也是各种压缩KV Cache的技术(如PagedAttention、MQA/GQA)被研发的原因。
七、面试高频问题速答
Q: Transformer为什么比RNN好?
A: 两个核心优势------并行计算和无长距离遗忘。RNN串行处理限制训练速度(无法利用GPU并行),长序列信息衰减严重(传话游戏效应);Transformer用自注意力直接建模任意距离关系(一步直达,不需逐步传递),且完全可并行(每个位置的注意力计算独立)。
Q: 注意力机制的复杂度?
A: 时间复杂度O(n²d),n是序列长度,d是维度。每个Token要和所有Token做点积。这是Transformer的主要瓶颈,也是长上下文研究的核心问题。各种线性注意力(Linear Attention,用核函数近似,降到O(nd))、稀疏注意力(Sparse Attention,只计算部分位置对,如Longformer的滑动窗口+全局Token)方案在尝试解决。
Q: 为什么Decoder-only是主流?
A: 三个原因------生成能力更通用(理解是生成的特例)、训练更简单(只需预测下一个Token,不需Encoder-Decoder对齐)、规模化效果更好(涌现能力主要在Decoder-only上观察到)。
Q: KV Cache是什么?为什么重要?
A: 自回归生成时,已生成Token的Key和Value向量不变,可以缓存下来避免重复计算。这是推理加速的关键技术。没有KV Cache,每次生成都要重新计算所有历史Token的注意力,复杂度从O(n)变成O(n²)。KV Cache的代价是占用显存,所以推理的主要瓶颈是显存而非算力。
Q: FFN在Transformer中起什么作用?
A: 注意力层负责"信息汇聚"(每个Token从前文收集相关信息),FFN负责"信息加工"(对汇聚来的信息做非线性变换,提取更高级特征)。近年研究发现FFN本质上是一个Key-Value记忆系统------第一层做模式匹配(这是什么类型的信息?),第二层做知识检索(对应的输出应该是什么?)。这也是MoE技术的基础:把一个大FFN拆成多个专家,每次只激活部分。
📝 作业
作业1:用代码实现一个简单的自注意力
用Python/NumPy实现Scaled Dot-Product Attention(缩放点积注意力),输入3×4的矩阵,输出注意力结果。
参考答案:
python
import numpy as np
def scaled_dot_product_attention(Q, K, V):
"""
Scaled Dot-Product Attention(缩放点积注意力)
Q: (seq_len_q, d_k) --- Query矩阵,seq_len_q个查询,每个d_k维
K: (seq_len_k, d_k) --- Key矩阵,seq_len_k个键,每个d_k维
V: (seq_len_k, d_v) --- Value矩阵,seq_len_k个值,每个d_v维
返回: (seq_len_q, d_v) --- 注意力输出
(seq_len_q, seq_len_k) --- 注意力权重
"""
d_k = Q.shape[-1] # Key的维度
# Step 1: 计算注意力分数(Q和K的点积)
# 每个Query和每个Key做点积,衡量"匹配程度"
scores = Q @ K.T # (seq_len_q, seq_len_k)
# Step 2: 缩放(除以√d_k)
# 防止点积值过大导致softmax梯度消失
scores = scores / np.sqrt(d_k)
# Step 3: softmax归一化
# 把分数变成概率分布(每一行的权重之和为1)
def softmax(x):
# 减去最大值防止数值溢出
exp_x = np.exp(x - np.max(x, axis=-1, keepdims=True))
return exp_x / exp_x.sum(axis=-1, keepdims=True)
attention_weights = softmax(scores) # (seq_len_q, seq_len_k)
# Step 4: 用权重对Value加权求和
# 每个输出位置 = 所有Value的加权平均
output = attention_weights @ V # (seq_len_q, d_v)
return output, attention_weights
# 测试
np.random.seed(42)
Q = np.random.randn(3, 4) # 3个Token的Query,维度4
K = np.random.randn(3, 4) # 3个Token的Key,维度4
V = np.random.randn(3, 6) # 3个Token的Value,维度6(d_v可以≠d_k)
output, weights = scaled_dot_product_attention(Q, K, V)
print("注意力权重:\n", weights)
print("输出:\n", output)
# 验证:每一行的权重之和应该为1
print("每行权重和:", weights.sum(axis=1))
作业2:画图理解因果注意力掩码
在纸上画一个5×5矩阵,用因果掩码(Causal Mask,下三角为0,上三角为-∞)标注,解释为什么Decoder不能看到未来Token。
参考答案:
因果掩码矩阵(5个Token的注意力掩码):
Token1 Token2 Token3 Token4 Token5
Token1 [ 0, -∞, -∞, -∞, -∞ ]
Token2 [ 0, 0, -∞, -∞, -∞ ]
Token3 [ 0, 0, 0, -∞, -∞ ]
Token4 [ 0, 0, 0, 0, -∞ ]
Token5 [ 0, 0, 0, 0, 0 ]
0 表示可以关注(softmax后为正值)
-∞ 表示不可关注(softmax后为0,即权重为0)
效果:
- Token1只能看自己
- Token2可以看Token1和自己
- Token3可以看Token1、Token2和自己
- Token5可以看所有之前的Token和自己
- 没有任何Token能看到"未来"的Token
这就是"因果"(Causal)的含义:生成第i个Token时,只能基于前i-1个Token的信息,
不能"偷看"第i个之后的信息(因为那些还没生成)。
在代码中实现:
mask = np.triu(np.ones((5, 5)), k=1) * (-np.inf) # 上三角设为-∞
attention_scores = softmax(Q @ K.T / sqrt(d_k) + mask) # 加上掩码
作业3:理解FFN的"升维-激活-降维"
用NumPy实现一个简单的FFN,观察升维和降维对数据的影响。
参考答案:
python
import numpy as np
def ffn(x, W1, b1, W2, b2):
"""
Feed-Forward Network(前馈网络)
x: (batch, d_model) --- 输入
W1: (d_model, d_hidden) --- 第一层权重,d_hidden通常=4×d_model
b1: (d_hidden,) --- 第一层偏置
W2: (d_hidden, d_model) --- 第二层权重
b2: (d_model,) --- 第二层偏置
"""
# Step 1: 升维 + 非线性激活
# 把d_model维向量映射到d_hidden维高维空间
# GELU激活函数:接近0的值被压制,大值保留------起到"筛选"作用
hidden = gelu(x @ W1 + b1) # (batch, d_hidden)
# Step 2: 降维回来
# 从d_hidden维映射回d_model维
output = hidden @ W2 + b2 # (batch, d_model)
return output
def gelu(x):
"""
GELU(Gaussian Error Linear Unit,高斯误差线性单元)
公式: x * Φ(x),其中Φ是标准正态分布的累积分布函数
特点:
- x > 0 时,输出接近 x(保留)
- x < 0 时,输出接近 0(压制)
- x ≈ 0 时,输出很小(压制)
相比ReLU(负数直接变0),GELU更平滑,训练更稳定。
"""
return x * 0.5 * (1 + np.vectorize(math.erf)(x / np.sqrt(2)))
import math
# 测试
np.random.seed(42)
d_model = 4
d_hidden = 16 # 4×d_model
x = np.random.randn(1, d_model) # 一个输入向量
W1 = np.random.randn(d_model, d_hidden)
b1 = np.random.randn(d_hidden)
W2 = np.random.randn(d_hidden, d_model)
b2 = np.random.randn(d_model)
result = ffn(x, W1, b1, W2, b2)
print("输入:", x)
print("FFN输出:", result)
# 观察升维后的中间结果
hidden = gelu(x @ W1 + b1)
print("升维后(d_hidden={}维):".format(d_hidden), hidden)
print("升维后非零元素数:", np.count_nonzero(hidden))
下一篇文章见:AI系列文章导航目录-持续更新中