手把手带工程同学从零实现一个完整的 LLM(CS336)——Transformer 篇
读前速览
本文详细介绍了如何从零构建一个基于 Transformer 的语言模型,涵盖了从数据预处理、模型架构设计到训练优化的全过程,并提供了可执行的 Python 代码示例。
神经网络训练基础
通过线性回归实验,说明了模型参数的初始化、损失函数的计算以及梯度下降的优化过程,为理解更复杂的神经网络奠定了基础。
Transformer 架构详解
详细分析了 Transformer 模型的组成,包括自注意力机制(CausalSelfAttention)、前馈网络(MLP)和位置编码,并解释了这些组件如何协同工作来处理序列数据。
语言模型训练实践
介绍了如何将文本数据转换为模型可处理的格式,如何构造训练批次,以及如何使用交叉熵损失函数来优化模型,最终实现文字的自动生成。
完整摘要
文章首先通过一个简单的线性回归实验,说明了神经网络的训练原理,然后逐步深入到多层感知机(MLP)和语言模型(TinyGPT)的实现。重点讲解了 Transformer 架构中的关键组件,如自注意力机制、前馈网络和位置编码,并详细分析了模型的训练过程和参数优化策略。
目录 · 22 节

这是2026年的第 68 篇文章
( 本文阅读时间:约 30 分钟 )
01
前言
最近我在学习 CS336「Language Modeling from Scratch」,是斯坦福大学的大语言模型构建课程。这门课程在各种社交媒体(比如 X)中都很火爆,所以我想通过写系列文章的形式来分享这个学习过程。
虽然课程名字中叫「from Scratch」,但是它的含义是从零手搓,而不是从零教学,因此对于没有过神经网络/机器学习基础的同学来说,学习起来还是比较困难的,需要有较为完善的体系知识。它禁止我们使用 PyTorch 中的任何现有模块,全部需要从零开始构建(torch.nn.Parameter、torch.nn.Module、torch.optim.Optimizer 等除外)。
先叠个甲,我之前的背景是纯工程开发,大概在 25 年初开始转向 Agent 开发,此前也没有过系统的机器学习、神经网络学习经验,因此我学习 CS336 的过程也是逐渐查漏补缺,即用即学,如果专业的算法同学发现文章有不当之处,欢迎在评论区交流指正。
学习这门课程,最主要的就是完成它的 Assignment 作业,这一篇是这个系列中的第一篇文章,会跟随 CS336 Assignment1 的思路,完整介绍如何从零手搓一个 Transformer(注:非 Attention Is All You Need 原始论文版本,其中会引入 RoPE、SwiGLU 等变体,这也是 CS336 这门课程的优秀之处)。
Assignment1 的原始资料:stanford-cs336/assignment1-basics
如果你也想学习 LLM 的底层原理,强烈建议跟着本篇文章,独立认真完成 Assignment 1(注:CS336 的 Assignment 中都会附带一份 AGENTS.md,以防你的 coding agent 直接帮你一键完成作业)。
*为了先建立对于神经网络体感,文章开头会先从基础的 MLP 引入。
02
为什么要学习这个
相信很多 Agent 开发者(包括我在内),都只是把 LLM 当做一个魔法黑盒,我们只负责调用它的 API,它就可以实现一切。
但是如果不去了解 LLM 的底层运行原理,那就无法科学地回答:什么样的 prompt 是更好的?新模型发布后的 model card 里面写的参数都是什么意思?technical report 里面的各种架构变动又代表着什么?更具体地说,最近很火的 Jev,和我们传统认知的小模型在架构上的本质区别是什么?为什么它能这么快?而且随着 AI 对于传统软件架构的冲击,越来越多的 Agent 工程同学同时也要为运行效果负责,必须要去了解模型原理、推理、训练。
附一张 CS336 课程讲义中的截图:

03
MLP
在开始 Transformer 之前,需要先了解一个简单的神经网络是什么样的。这里从 MLP(Multi-Layer Perceptron,多层感知机)开始,它是一种最基础的前馈网络(Feed-Forward Network,FFN)。“前馈”指数据从输入出发,依次经过各层计算得到输出,网络中没有形成循环的连接。
我们先用它做一个小实验,看看数据怎么输入、模型怎么得到预测结果,以及训练到底是在做什么。
线性回归
假设我们拿到 (1, 3)、(2, 5)、(3, 7) 三组输入和答案,希望程序根据这些数据找到规律,再去预测新的输入。
我们先尝试用一条直线拟合这些数据,模型写成 y_pred = w * x + b,w(权重)和 b(偏置)是可以调整的参数。根据数据不断调整它们,让预测结果更接近答案,这个过程就叫训练。
上面这种用直线拟合数据、预测数值的做法,叫线性回归。PyTorch 提供了一个现成的 线性层nn.Linear,帮我们保存权重、偏置并完成计算。这里用 nn.Linear(1, 1),表示每个样本输入一个数、输出一个数,执行的就是 w * x + b。
下面代码里的 Tensor(张量),可以先理解为 PyTorch 用来存放数字、进行计算的多维数组。[[1.0], [2.0], [3.0]] 是一个 3 行 1 列的 Tensor,形状(shape)是 (3, 1):每行一个样本,每个样本只有一个输入值。
import torchfrom torch import nnx = torch.tensor([[1.0], [2.0], [3.0]]) # 已知的输入y = torch.tensor([[3.0], [5.0], [7.0]]) # 对应的答案model = nn.Linear(1, 1) # 创建模型,内部随机初始化 w 和 bpred = model(x) # Linear 内部用当前的 w、b 计算 w * x + bprint("训练前的预测:", pred)
运行到这里,我们只是用初始的 w、b 算了一次,模型还没有学习过,预测通常和 [3, 5, 7] 对不上。接下来就要比较预测和答案的差距,再调整参数。
输入有多个数时,Linear 会对它们加权求和,再加上偏置。后面讲 Transformer 时,我们会用矩阵乘法手搓这个过程。
什么是训练
刚创建的 w、b 是随机初始化的,预测通常不准。我们需要用一个数衡量预测和答案的差距,这个数叫 loss(损失)。这里用均方误差(MSE),把每个样本的误差平方,再取平均:
答案:[3, 5, 7]预测:[2, 4, 6]loss = ((2-3)² + (4-5)² + (6-7)²) / 3 = 1
预测全部正确时,loss 就是 0。我们训练模型,就是调整 w、b,让这个数尽量变小。
那怎么知道参数该往哪调?梯度可以帮我们判断。直观上就像下坡,每次朝着 loss 更小的方向调整一点,这叫梯度下降。学习率控制步子大小,步子太大也会走过头。
看下面的例子:黑点是已知答案,彩色直线是模型的预测。随着 w、b 不断调整,直线逐渐靠近这些点,下面记录的 loss 也随之降低。

后面的 MLP 也是这个过程,只是需要调整的参数更多。
非线性拟合
前面我们用一个 Linear 拟合了一条直线。但实际任务中,输入和输出之间经常是更复杂的非线性关系,比如一条弯曲的曲线,一个 Linear 就不够用了。那把多个 Linear 叠起来行不行?我们把两层展开看看:
h = w1 * x + b1y = w2 * h + b2= (w2 * w1) * x + (w2 * b1 + b2)
把上面的 w2 * w1 看成新的权重,w2 * b1 + b2 看成新的偏置,就又回到了 w * x + b。所以,多个 Linear 直接串起来,仍然等价于一个 Linear,这个结论对多个输入、输出也成立。这是线性代数里的基本性质:仿射变换复合后仍然是仿射变换(带偏置的 Linear 严格来说叫仿射变换)。
所以我们在 Linear 之间加入非线性的激活函数,让网络能拟合曲线。下面是几种常见激活函数的曲线,横轴是输入,纵轴是输出。我们这里先用 Tanh。

# Sequential 按顺序执行,上一层的输出交给下一层model = nn.Sequential(nn.Linear(1, 64), # 一个输入,算出 64 个中间值nn.Tanh(), # 对每个值做非线性变换nn.Linear(64, 64), # 对这些值加权求和,加上偏置,得到 64 个输出nn.Tanh(),nn.Linear(64, 1), # 最后得到一个预测值)
这就是我们这里的 MLP:三个 Linear,中间加了两个 Tanh。Linear(1, 64) 可以理解为同时计算 64 个不同的 w * x + b,每个都有自己的参数;Tanh 本身没有需要训练的参数。
输入和最终输出之间的中间层叫作隐藏层。上面的网络从 1 个输入值出发,经过两层各有 64 个数的中间表示,最后得到 1 个预测值,所以它有两个隐藏层,每层的宽度都是 64。这里的“隐藏”指这些数由网络内部计算出来,训练数据只提供输入和目标答案,并不直接指定这些中间值。
接下来我们用 y = sin(x) + 0.3 * cos(3x) 加一点随机噪声生成数据,看看这个 MLP 能不能学出曲线的规律。
一个完整的 MLP 可以参考下面这段代码,已经写了详细的注释,如果有不懂的地方可以问问自己的 AI。
from __future__ import annotationsimport mathimport randomimport torchfrom torch import nndef make_dataset(n: int = 2048) -> tuple[torch.Tensor, torch.Tensor]:# x 原本是一维向量,unsqueeze(1) 把它变成 (n, 1)。# 神经网络通常按二维 batch 输入理解数据:每一行是一个样本,每一列是一个特征。# Java 类比:原来像 double[n],unsqueeze 后更像 double[n][1]。x = torch.linspace(-2 * math.pi, 2 * math.pi, n).unsqueeze(1)# randn_like(x) 生成和 x 形状相同的随机噪声。# 这里加噪声是为了让任务更像真实数据:真实数据通常不会完美落在一条函数曲线上。noise = 0.05 * torch.randn_like(x)# 这里造一个有规律但不完全干净的函数,让模型学 sin/cos 的组合。# x 是输入,y 是标准答案。训练时模型只看到 x,loss 会拿预测值和 y 比较。y = torch.sin(x) + 0.3 * torch.cos(3 * x) + noisereturn x, yclass TinyMLP(nn.Module):def __init__(self) -> None:# nn.Module 是所有 PyTorch 模型/层的基类。# super().__init__() 会初始化父类里负责登记参数、子模块等基础设施。super().__init__()# nn.Sequential 会按顺序执行这些层:# Linear 做仿射变换,Tanh 提供非线性,否则模型只能学直线。# Linear(1, 64):每个样本输入 1 个数字,输出 64 维隐藏表示。# Linear(64, 1):最后把 64 维隐藏表示压回 1 个预测值。self.net = nn.Sequential(nn.Linear(1, 64),nn.Tanh(),nn.Linear(64, 64),nn.Tanh(),nn.Linear(64, 1),)def forward(self, x: torch.Tensor) -> torch.Tensor:# 在 PyTorch 里,调用 model(x) 时,实际会转到 forward(x)。# 这有点像 Java 里某个框架约定你实现 handle/request 方法,然后框架负责调用。return self.net(x)def main() -> None:# 固定随机种子,方便你多次运行时看到接近的结果。torch.manual_seed(42)random.seed(42)x, y = make_dataset()# 简单切分:前 80% 做训练集,后 20% 做验证集。split = int(0.8 * len(x))x_train, y_train = x[:split], y[:split]x_val, y_val = x[split:], y[split:]model = TinyMLP()# optimizer 负责根据梯度更新模型参数;lr 是每次更新的步子大小。# model.parameters() 来自 nn.Module,会递归收集 self.net 里 Linear 的 weight/bias。optimizer = torch.optim.AdamW(model.parameters(), lr=3e-3)# MSELoss 衡量预测值和真实值的平方误差,适合这个回归任务。loss_fn = nn.MSELoss()print("shape check")print(f"x_train: {tuple(x_train.shape)}")print(f"y_train: {tuple(y_train.shape)}")print(f"prediction: {tuple(model(x_train[:8]).shape)}")print()batch_size = 128for step in range(1, 801):# 真实训练通常不会每一步都用完整训练集,而是抽一个 mini-batch。# 好处:计算更快,也让每步梯度带一点随机性,常常更容易训练。# 随机抽一批样本。idx 的 shape 是 (batch_size,)。idx = torch.randint(0, len(x_train), (batch_size,))# 前向传播:输入 x,得到预测 pred。# x_train[idx] 的 shape 是 (batch_size, 1),对应一批样本。pred = model(x_train[idx])loss = loss_fn(pred, y_train[idx])# PyTorch 默认会累积梯度,所以每一步训练前要先清空旧梯度。optimizer.zero_grad()# 反向传播:从 loss 出发,计算每个参数的梯度。loss.backward()# 根据梯度真正更新参数。optimizer.step()if step == 1 or step % 100 == 0:# 验证时不需要梯度;no_grad 会省内存,也避免误把验证计算放进计算图。# 注意:验证集不参与 optimizer.step,只用于观察模型有没有泛化。with torch.no_grad():val_loss = loss_fn(model(x_val), y_val)print(f"step {step:04d} | "f"train_loss={loss.item():.5f} | "f"val_loss={val_loss.item():.5f}")with torch.no_grad():# 拿几个没放进 batch 的点,看模型现在会输出什么。sample_x = torch.tensor([[-3.0], [0.0], [3.0]])sample_y = model(sample_x)print()print("sample predictions")for value, pred in zip(sample_x.squeeze().tolist(), sample_y.squeeze().tolist()):print(f"x={value:+.1f} -> y_hat={pred:+.4f}")if __name__ == "__main__":main()
04
语言模型基础-TinyGPT
前面我们用 MLP,根据输入的 x 预测一个数。接下来换个任务:给模型一段文字,让它预测下一个字。比如输入「今天天气」,模型接着生成「很」,再把「今天天气很」作为输入,继续预测。这样反复进行,就能逐步生成一段文字。
这里我们用一个极简的小 demo——TinyGPT 来演示这个过程,下面是完整代码,已经写了详细的注释,可以自己本地跑一跑,如果有不懂的地方可以问问自己的 AI。
这个小 demo 的目的是为了构建对于语言模型的体感,后续会逐章拆解其中的所有模块
from __future__ import annotationsimport mathimport torchfrom torch import nnimport torch.nn.functional as FTEXT = ("agents plan actions observe results and update context. ""language models predict tokens from previous tokens. ""attention lets each token read useful earlier tokens. ") * 40class CausalSelfAttention(nn.Module):def __init__(self, d_model: int, num_heads: int, block_size: int) -> None:super().__init__()# 多头 attention 会把 d_model 平均分给每个 head。# d_model 是每个 token 的向量维度;block_size 才是最大上下文长度。assert d_model % num_heads == 0self.num_heads = num_headsself.head_dim = d_model // num_heads# qkv 一次性算出 query/key/value,减少三次线性层的样板代码。# 输入和输出 shape 都围绕 (batch_size, seq_len, d_model) 展开。self.qkv = nn.Linear(d_model, 3 * d_model)self.proj = nn.Linear(d_model, d_model)# register_buffer 注册的是“不是参数、但要跟着模型移动/保存”的 tensor。# mask 不需要训练,所以不用 nn.Parameter。self.register_buffer("mask", torch.tril(torch.ones(block_size, block_size)))def forward(self, x: torch.Tensor) -> torch.Tensor:# x: (batch_size, seq_len, d_model)batch_size, seq_len, d_model = x.shapeq, k, v = self.qkv(x).chunk(3, dim=-1)# 多头 attention 标准 shape: (batch_size, num_heads, seq_len, head_dim)。# 多头不是多跑几个模型,而是把 d_model 切成几份,让不同 head 学不同关系。q = q.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)k = k.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)v = v.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)# q @ k^T 得到 token 之间的相似度分数。scores = q @ k.transpose(-2, -1) / math.sqrt(self.head_dim)# causal mask 保证当前位置不能看未来 token。scores = scores.masked_fill(self.mask[:seq_len, :seq_len] == 0, float("-inf"))weights = F.softmax(scores, dim=-1)out = weights @ v# 把 heads 维度拼回 d_model,恢复 (batch_size, seq_len, d_model)。out = out.transpose(1, 2).contiguous().view(batch_size, seq_len, d_model)return self.proj(out)class Block(nn.Module):def __init__(self, d_model: int, num_heads: int, block_size: int) -> None:super().__init__()# 一个 GPT block 通常由两块组成:# attention 负责 token 之间交流,MLP 负责每个 token 自己的非线性加工。self.ln1 = nn.LayerNorm(d_model)self.attn = CausalSelfAttention(d_model, num_heads, block_size)self.ln2 = nn.LayerNorm(d_model)self.mlp = nn.Sequential(# 常见设计会先把 hidden 扩到 4 倍,再压回原维度。# 这不是改变序列长度,而是改变每个 token 向量内部的维度。nn.Linear(d_model, 4 * d_model),nn.GELU(),nn.Linear(4 * d_model, d_model),)def forward(self, x: torch.Tensor) -> torch.Tensor:# Pre-LN Transformer block:先 LayerNorm,再 attention/MLP。# 残差连接 x + f(x) 保持 shape 不变,也让梯度更容易流动。# 第一行:每个 token 先从上下文里读信息。x = x + self.attn(self.ln1(x))# 第二行:每个 token 再独立过一段 MLP 做加工。x = x + self.mlp(self.ln2(x))return xclass TinyGPT(nn.Module):def __init__(self, vocab_size: int, block_size: int) -> None:super().__init__()d_model = 64self.block_size = block_size# token_embedding 负责“这个 token 是什么”。# 输入 idx 是整数 id;embedding 输出连续向量。神经网络只能处理数值张量。self.token_embedding = nn.Embedding(vocab_size, d_model)# position_embedding 负责“这个 token 在第几个位置”。# 如果没有位置信息,attention 本身不天然知道顺序。self.position_embedding = nn.Embedding(block_size, d_model)self.blocks = nn.Sequential(Block(d_model, num_heads=4, block_size=block_size),Block(d_model, num_heads=4, block_size=block_size),)self.ln = nn.LayerNorm(d_model)self.lm_head = nn.Linear(d_model, vocab_size)def forward(self, idx: torch.Tensor, targets: torch.Tensor | None = None):# idx: (batch_size, seq_len),里面是 token ids。batch_size, seq_len = idx.shape# positions 是 0..seq_len-1,对应序列里的每个位置。# shape 是 (seq_len,),加到 token_embedding 时会自动 broadcast 到 batch 维度。positions = torch.arange(seq_len, device=idx.device)# token 信息和位置信息相加,得到每个位置的初始表示。x = self.token_embedding(idx) + self.position_embedding(positions)x = self.blocks(x)x = self.ln(x)# logits: (batch_size, seq_len, vocab_size),每个位置预测下一个 token。# 对某个位置来说,vocab_size 个数字就是“下一个 token 是词表中每个 id 的分数”。logits = self.lm_head(x)loss = Noneif targets is not None:# cross_entropy 要求 (N, classes) 和 (N,),所以合并 batch_size/seq_len。loss = F.cross_entropy(logits.view(batch_size * seq_len, -1), targets.view(batch_size * seq_len))return logits, loss@torch.no_grad()def generate(self, idx: torch.Tensor, steps: int) -> torch.Tensor:# generate 是推理阶段:不再给 targets,也不算 loss,只反复预测下一个 token。for _ in range(steps):# 如果序列超过 block_size,只保留最后 block_size 个 token 作为上下文。context = idx[:, -self.block_size :]logits, _ = self(context)# 只取最后一个位置的 logits,因为生成时只需要预测“下一个 token”。probs = F.softmax(logits[:, -1, :], dim=-1)next_id = torch.multinomial(probs, num_samples=1)idx = torch.cat([idx, next_id], dim=1)return idxdef make_data():# 字符级 tokenizer:简单但足够演示语言模型训练。# 后面如果换成 BPE tokenizer,这里 data 的构造方式会变,但模型仍然吃 token ids。chars = sorted(set(TEXT))stoi = {ch: i for i, ch in enumerate(chars)}itos = {i: ch for ch, i in stoi.items()}data = torch.tensor([stoi[ch] for ch in TEXT], dtype=torch.long)return data, stoi, itosdef get_batch(data: torch.Tensor, batch_size: int, block_size: int):# 每个样本是一段连续 token ids。x 是输入上下文,y 是每个位置的下一个 token。starts = torch.randint(0, len(data) - block_size - 1, (batch_size,))x = torch.stack([data[i : i + block_size] for i in starts])# y 是 x 的下一个字符序列,用来做 next-token prediction。y = torch.stack([data[i + 1 : i + block_size + 1] for i in starts])return x, ydef decode(ids: torch.Tensor, itos: dict[int, str]) -> str:return "".join(itos[i] for i in ids.tolist())def main() -> None:torch.manual_seed(123)# block_size 是模型一次最多能看的上下文长度。block_size = 32data, stoi, itos = make_data()model = TinyGPT(vocab_size=len(stoi), block_size=block_size)optimizer = torch.optim.AdamW(model.parameters(), lr=3e-3)x, y = get_batch(data, batch_size=4, block_size=block_size)logits, loss = model(x, y)assert loss is not Noneprint("shape check")print(f"x: {tuple(x.shape)}")print(f"logits: {tuple(logits.shape)}")print(f"loss: {loss.item():.4f}")print()for step in range(1, 301):x, y = get_batch(data, batch_size=32, block_size=block_size)_, loss = model(x, y)assert loss is not None# 标准训练三步:清梯度 -> 反向传播 -> 更新参数。optimizer.zero_grad()loss.backward()optimizer.step()if step == 1 or step % 50 == 0:print(f"step {step:04d} | loss={loss.item():.4f}")start = torch.tensor([[stoi["a"]]], dtype=torch.long)sample = model.generate(start, steps=180)[0]print()print(decode(sample, itos))if __name__ == "__main__":main()
预测下一个字
神经网络无法直接对文字做计算,所以我们先给字符编号,把一段文字转换成一串数字。上面代码中的 make_data() 就是在执行这一过程,比如:
字符:a g e n t s编号:2 8 6 14 19 18"agents" → [2, 8, 6, 14, 19, 18]
这份字符和编号的对应表叫词表。这里按字符编号,相当于一个简化的 Tokenizer(分词器),每个字符就是一个 token。实际语言模型常用 BPE 等分词方式,一个 token 可以包含多个字符,后面会详细介绍。
前面的 MLP 要预测一个具体数值,输出的就是数值。这里要从词表里选择下一个字符,属于分类任务。同一段文字后面也可以有不同的接法,所以我们用概率表示各个字符出现的可能性。
模型先给词表里的每个字符输出一个分数,这组分数叫 logits。再通过 softmax,把它们转换成总和为 1 的概率。softmax 的做法是先对每个分数取指数,再除以这些指数值的总和,原来的分数越高,得到的概率就越大。
这里 是第 个字符的分数, 是它对应的概率,分母是所有字符的分数取指数后的总和。
上面代码中的 generate() 就是在执行这一过程。比如输入 agent 后,各个字符的概率可以是这样(仅作示意):
|
下一个字符 |
概率 |
|
s |
60% |
|
空格 |
25% |
|
其余字符合计 |
15% |
模型认为 s 更有可能接在后面,拼成 agents。文字生成时,我们就可以根据这些概率选出下一个字符。
完整过程可以先记成:
文字 → 字符编号 → TinyGPT → 每个字符的分数 → 概率 → 选出下一个字符
在 TinyGPT.forward() 里,token_embedding 会根据编号查出一组可以训练的数,这一步叫 Embedding。然后用 Transformer 处理前面的文字,最后通过 lm_head 这个 Linear,输出词表中每个字符的分数。这里又用到了前面见过的线性层,只是输出从一个数变成了一组数。
Attention
输入 agent,预测后面的字符时,模型需要结合前面的 a、g、e、n、t。这些字符的信息怎么汇集到一起?这里就要用到 Attention(注意力机制)。
前面每个字符已经通过 Embedding 转成了一组数。Attention 会为当前位置能看到的各个位置计算权重,再按权重把它们的信息加起来。权重越大,对当前位置的输出贡献就越大。上面代码中的 CausalSelfAttention 就是在执行这一过程。
比如 t 所在的位置可以读取 a、g、e、n、t 的信息,用来预测下一个字符;a 所在的位置只能读取自己。这个限制叫因果遮罩(causal mask),避免模型在训练时提前看到后面的答案。
前面我们介绍过,MLP 是一种前馈网络(FFN)。这里的 TinyGPT 使用 Linear → GELU → Linear 组成的 MLP 作为前馈模块,对应代码中的 self.mlp。它先把每个 token 的向量扩展到原来的 4 倍大小,经过 GELU 激活后,再变回原来的大小。
在 Block.forward() 中,数据先经过 Attention 汇集上下文信息,再经过这个前馈模块做非线性处理:
x = x + self.attn(self.ln1(x))x = x + self.mlp(self.ln2(x))
这里的 MLP 对每个位置分别计算,位置之间的信息交流由 Attention 完成。
这里的“位置”就是字符在序列里的下标。比如 agent 中,a 在位置 0,g 在位置 1,依次类推。TinyGPT 除了查字符的 Embedding,还会用这个下标查 position_embedding,把两个向量相加后送进 Transformer。同一个字符出现在不同位置时,就会得到不同的初始表示。
具体的 attention 机制会在后面详细介绍。
训练
模型刚创建时参数是随机的,自然不知道 agent 后面应该接什么。训练需要给它输入和对应的答案,但文字里的答案从哪里来?
原文的下一个字符就是答案。 比如训练文本开头的 agents,就可以构造这些训练目标:
看到 a → 预测 g看到 ag → 预测 e看到 age → 预测 n看到 agen → 预测 t看到 agent → 预测 s
get_batch() 把输入和答案错开一个位置,就能同时准备好这些目标。把编号还原成字符看,就是:
输入 x:a g e n t答案 y:g e n t s
训练时,模型会在每个位置预测下一个字符。前面介绍的因果遮罩保证每个位置只能利用自己和前面的内容。
前面的 MLP 用均方误差衡量预测值和答案差多少。这里模型输出的是一组字符分数,我们用交叉熵损失(cross-entropy loss)衡量它预测得好不好。
比如输入 agent,原文的下一个字符是 s。模型给 s 的概率只有 10% 时,说明它不太看好这个正确答案,loss 就比较大;如果给到 90%,loss 就比较小。训练会根据这个误差调整参数,让模型给正确答案更高的概率。
上面代码中的 F.cross_entropy 就是在执行这一过程,直接传入 logits 和答案编号即可,它内部会处理从分数到概率的计算。接着回到 main() 的训练循环,反向传播、更新参数。只不过这次训练的是 TinyGPT 里面的参数,包括 Embedding、Attention 和 MLP 等模块。
开头的 TEXT 用下面三句英文重复 40 遍作为训练文本:
agents plan actions observe results and update context.language models predict tokens from previous tokens.attention lets each token read useful earlier tokens.
模型有两层 Transformer Block,一次最多看 32 个字符,每次训练抽取 32 段文本。我们可以运行一下观察日志:
step 0001 | loss=3.3545step 0050 | loss=0.9013step 0100 | loss=0.1608step 0300 | loss=0.1020
loss 整体降下来了,说明模型在这些训练文本上,预测下一个字符的能力变得更强了。
推理与生成
训练完成后,我们用模型预测新的输入,这个过程就是推理。每轮推理都根据当前文本预测下一个 token,再通过采样选出一个 token,追加到文本末尾。generate() 把这个过程循环执行,就能逐步生成一段文字。

main() 最后从字符 a 开始,调用 model.generate() 连续生成 180 个字符,再由 decode() 把编号还原成文字,运行后可以得到结果:
anguage models predich tokens from previous tokens. attention lets each token read usefulearlier tokens. agents plan actions observe results and update context. language models pre
已经能看到训练文本里的句子了,但也有 predich 这样的拼写错误。这个模型只见过几句反复出现的文字,训练 loss 降低还不足以说明它能处理其他文本。通过这个示例,我们可以看到语言模型如何从文字中获得训练目标,又如何通过反复预测生成文字。
接下来就沿着刚才的数据流,从 Tokenizer 开始,把各个模块拆开实现。这个 demo 用了 PyTorch 自带的 Linear、Embedding、LayerNorm 等组件,正式进入 Assignment 1 后,我们会手写相应组件,并用上 RMSNorm、RoPE 和 SwiGLU。
05
拆解 Transformer
这一节我会用我自己的 CS336 Assignment1 的实现来对照着逐一拆解 Transformer 的模块,我的实现在这里:cs336-assignment。后面的代码片段全都出自这个仓库。
如果你也想尝试一下 Assignment1,强烈建议停止阅读,去 clone 原始仓库:stanford-cs336/assignment1-basics,自己独立完成。如果你只是想了解一下 Transformer 的各个模块,那就让我们继续吧!
5.1 Architecture
前面我们已经跑通了一个小型语言模型,接下来开始拆解它的内部结构。先看一下《Attention Is All You Need》论文中最初的 Transformer 架构:

图片来源:Attention Is All You Need,Figure 1。
图中左边是 Encoder(编码器),右边是 Decoder(解码器)。原论文主要用它做机器翻译。Encoder 先处理原文,Decoder 再结合 Encoder 的输出和已经生成的译文,逐个预测后面的词。
现在常见的 GPT、Llama 这类生成式大语言模型采用的是 decoder-only 架构。已有文本放在同一条序列里,每个位置只能读取自己和前面的内容,再预测下一个 token。它省去了独立的 Encoder,以及 Decoder 中读取 Encoder 输出的那层 Attention。前面的 TinyGPT 也是这个结构。
我们在 CS336 Assignment 1 中要实现的,可以理解为一个 Llama 风格的简化语言模型,采用 decoder-only 结构,并使用 RMSNorm、RoPE 和 SwiGLU。这些也是 Llama 架构 中的设计,后面会逐个介绍。下面是作业给出的架构图:

图片来源:CS336 Assignment 1,Figure 1、Figure 2。
沿着左图从下往上看,token 编号先经过 Embedding 转成向量,再经过多层 Transformer Block,最后通过 Norm 和 Linear 得到词表中每个 token 的分数。用 softmax 把分数转成概率,就接上了前面讲的预测过程。
右图把一个 Block 展开了。其中 Attention 让各个位置读取上下文,Feed-Forward 是对每个位置单独计算的前馈网络,这里使用 SwiGLU。图中的 Norm 使用 RMSNorm,放在这两个模块之前,所以叫 pre-norm;绕过模块、连到 Add 的线表示把模块的输入加回输出,这叫残差连接。
接下来就按这张图拆开实现。先从模型输入之前的文本编码与 Tokenizer 开始。
5.2 Tokenizer 与 Embedding
前面的 TinyGPT 给每个字符编了一个号。这里我们换成 Assignment 1 要求的 byte-level BPE。它会先把文字变成字节,再把经常一起出现的字节组合成一个 token。
字符、Unicode 与 UTF-8
先来复习一下字符编码。我们看到的 A、中 都是字符,Unicode 给这些字符分配了统一的编号,这个编号叫作码点。比如 A 的编号是 65,中 的编号是 20013。
但文字在文件里存储、在网络上传输时,用的是字节。一个字节有 8 位,能表示的整数范围是 0~255,像 20013 这样的编号,一个字节就放不下了。UTF-8 规定了怎么把一个 Unicode 码点编码成字节,根据码点的不同,会用到 1~4 个字节。
|
字符 |
Unicode 码点(十进制) |
UTF-8 字节(十进制) |
字节数 |
|
|
65 |
|
1 |
|
|
20013 |
|
3 |
所以,一个字符不一定只占一个字节。A中 只有两个字符,经过 UTF-8 编码后却有四个字节。Python 中的 encode() 和 decode() 就能完成这两个方向的转换。
>>> text = "A中">>> data = text.encode("utf-8")>>> list(data)[65, 228, 184, 173]>>> data.decode("utf-8")'A中'
把文字转成 UTF-8 字节后,每个字节都只会落在 0~255 之间。因此,初始词表只要为这 256 种字节各准备一个 token,就能表示任意有效的 UTF-8 文本,也能处理训练时没见过的字符。
以完整的字符串 我爱AI! 为例,逐个字符转成 UTF-8 字节,会得到下面的结果。
|
字符 |
UTF-8 字节 |
|
我 |
|
|
爱 |
|
|
A |
|
|
I |
|
|
! |
|
"我爱AI!"→ [230, 136, 145, 231, 136, 177, 65, 73, 239, 188, 129]
原来的 5 个字符变成了 11 个 token。只按字节切分,序列会比较长。接下来,BPE 会把经常相邻出现的 token 合并成更长的字节片段,让同一段文字可以用更少的 token 表示。
BPE
BPE 每轮统计相邻 token 对出现的次数,把最高频的一对合成一个新 token。反复执行,直到词表达到指定大小,或者已经没有可以合并的相邻对。
假设训练文本里有四种单词,low 出现 5 次,lower 出现 2 次,widest 出现 3 次,newest 出现 6 次。这些英文字母在 UTF-8 中都只占一个字节,所以初始时每个字母就是一个 token,比如 newest 会拆成 n e w e s t。
widest 和 newest 都有相邻的 s t,加起来出现了 3 + 6 = 9 次。它是出现次数最多的组合之一,我们的实现会在同频时选择字典序更大的那一对,因此第一轮选中 s t,把它合成一个新 token st。此时 newest 变成 n e w e st。重新统计后,下一轮再把相邻的 e st 合成 est,于是变成 n e w est,从最初的 6 个 token 缩短到 4 个。
每个方框表示一个 token,下面这张图展示了前两轮的频次统计和合并结果。

前面的例子里,我们直接拿单词来统计。实际输入是一整段文字,需要先按规则切成若干片段,再分别做 BPE,这一步叫预分词。比如,我们的正则规则会把 Hello world! 切成下面这样。
"Hello world!" → ["Hello", " world", "!"]
其中 " world" 保留了开头的空格。每个片段随后转成字节,BPE 的频次统计和合并都只在片段内部进行。比如 Hello 里的 l 和 o 可以合并,但末尾的 o 不能和下一个片段开头的空格合并。这样就给合并划定了边界,避免跨片段组合不断进入词表。
预分词得到的片段还会继续拆分、合并,最终一个片段可以对应一个或多个 token。代码里的 PATTERN 和 regex.findall() 就是在执行预分词。<|endoftext|> 这样的特殊 token 会提前单独切出,作为一个完整 token 保留,不参与普通文本的合并。
下面是完整实现,前面讲的内容就是 train_bpe() ,入参是训练文本的文件路径、目标词表大小和特殊 token 列表,出参是 vocab 和 merges。
vocab 负责保存 token 编号与字节串的对应关系,merges 保存有顺序的合并规则。
import regexfrom collections import CounterPATTERN = r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""def train_bpe(input_path, vocab_size, special_tokens):# 以 bytes 读取语料,之后显式按 UTF-8 解码,避免默认编码影响结果。with open(input_path, "rb") as f:corpus = f.read()text = corpus.decode("utf-8")# special token 是文档边界:不把它的内部字符交给预分词或 BPE 统计。# 这里先按照 special 把原始文本分开成 spilt listspilt_list = []if len(special_tokens) == 0:spilt_list.append(text)else:special_token_pattern = "|".join([regex.escape(special) for special in special_tokens])spilt_list = regex.split(special_token_pattern, text)# 再对拆开来的每一项,按照作业里面的预分词器(实际上就是一段正则)进行预分词# 为什么要预分词,可以看这个:https://zhuanlan.zhihu.com/p/692508797all_pretokens = []for spilt in spilt_list:all_pretokens.extend(regex.findall(PATTERN, spilt))# 先统计下预分词后,每个词的频率,例如 "low" -> 5。pretoken_text_counts = Counter(all_pretokens)# 将每个 pre-token 从字符串转为 utf-8 的 bytes,例如 "low"-> b"low"# 然后再拆成独立的# token_sequence_counts 里面的内容:(b"l", b"o", b"w") -> 5token_sequence_counts = {}for key, value in pretoken_text_counts.items():raw_bytes = key.encode("utf-8")bytes_tokens = []# bytes 实际上是 int 的数组,所以这里取出来的是 int,还需要手动创建一个 bytesfor byte_int in raw_bytes:bytes_tokens.append(bytes([byte_int]))token_sequence_counts[tuple(bytes_tokens)] = value# 初始词表包含全部 256 个可能的单 byte 值。# special token 作为一个完整 token 加入 vocab,不拆成内部的 byte token。merges = []vocab = {}for token_id in range(256):vocab[token_id] = bytes([token_id])for token_id, special_token in enumerate(special_tokens, start=256):vocab[token_id] = special_token.encode("utf-8")# 每轮只学习一种最高频 pair;新 token 会让 vocab 的大小增加 1。current_vocab_size = len(vocab)while current_vocab_size < vocab_size:# 基于「当前」token 序列统计相邻 pair 的加权次数。# 例如某序列出现 5 次,其中的每个相邻 pair 都贡献 5 次,而不是 1 次。pair_counts = Counter()for seq, count in token_sequence_counts.items():for pair in zip(seq, seq[1:]):pair_counts[pair] += count# 没有相邻 pair 时,说明无法继续学习新的 merge。if not pair_counts:break# 找最高频 pair;同频时由 find_max 按字典序选择更大的 pair。pair, count = find_max(pair_counts)# 两个 bytes token 拼接成一个新 bytes token,例如 b"l" + b"o" -> b"lo"。target = pair[0] + pair[1]vocab[current_vocab_size] = target# merges 必须保留创建顺序;编码时会按这个顺序应用规则。merges.append(pair)# 不能边遍历边修改旧表:读取旧 token_sequence_counts,# 将 merge 后的结果写入新表,全部完成后再整体替换。new_token_sequence_counts = {}for seq, count in token_sequence_counts.items():new_seq = []index = 0while index < len(seq):if index + 1 < len(seq) and (seq[index], seq[index + 1]) == pair:# 命中时消费两个旧 token,写入一个新 token;因此不会重叠合并。new_seq.append(target)index += 2else:# 未命中时保留当前 token,继续检查下一个位置。new_seq.append(seq[index])index += 1new_token_sequence_counts[tuple(new_seq)] = counttoken_sequence_counts = new_token_sequence_counts# 本轮新增了一个 vocab token,下一轮将基于更新后的序列重新统计 pair。current_vocab_size += 1return vocab, mergesdef find_max(pair_counts):return max(pair_counts.items(), key=lambda item: (item[1], item[0]))
Tokenizer
前面通过 BPE 得到了词表和合并规则。接下来把它们封装成一个 Tokenizer,提供 encode() 和 decode() 两个方法,分别把文字转换成 token 编号,以及把编号还原成文字。
import regexfrom collections.abc import Iterable, IteratorPATTERN = r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""class Tokenizer:def __init__(self,vocab: dict[int, bytes],merges: list[tuple[bytes, bytes]],special_tokens: list[str] | None = None):if special_tokens is None:special_tokens = []self.vocab = vocabself.merges = mergesself.special_tokens = special_tokens# special token 最好校验一下,如果不在 vocab 里面,我们就给他手动加一下然后分配一个 idfor sp_token in special_tokens:sp_token_bytes = sp_token.encode("utf-8")if(sp_token_bytes not in self.vocab.values()):self.vocab[max(self.vocab.keys()) + 1] = sp_token_bytes# 一个字典推导式,实际上就是把 vocab 的 kv 调转一下,因为它是 int2bytes 的self.vocab_b2i = {value: key for key, value in self.vocab.items()}def encode(self, text: str) -> list[int]:token_int_list = []# 和 train_bpe 一样,还是先按照 special tokens 切分一下if len(self.special_tokens) == 0:split_list = [text]else:special_token_pattern = "|".join(regex.escape(special)for special in sorted(self.special_tokens, key=len, reverse=True))split_list = regex.split(f"({special_token_pattern})", text)# 切分后的 split_list: ["Hi", "<|endoftext|>", " there"]# 对齐进行遍历,对每一个单独的结果进行 encodefor part in split_list:if(part == ""):continue# 如果是 special token,就直接从 vocab 里面找到 id,然后 append 到最终结果里面if(part in self.special_tokens):token_int_list.append(self.vocab_b2i[part.encode("utf-8")])else:# 否则就是普通的正常文本# 先预分词,Tokenizer 的 encode 也是不能跨 pre-token 的# pre_tokens_bytes_list 的内容:[[b"a", b"b", b"d"],[b"a", b"b", b"d"],....]pre_tokens = regex.findall(PATTERN, part)pre_tokens_bytes_list = []for pre_token in pre_tokens:raw_bytes = pre_token.encode("utf-8")pre_tokens_bytes = []for pre_token_byte_int in raw_bytes:pre_tokens_bytes.append(bytes([pre_token_byte_int]))pre_tokens_bytes_list.append(pre_tokens_bytes)# 这里三层循环current_list_index = 0while current_list_index < len(pre_tokens_bytes_list):# 1. 先依次取出预分词的结果,取出来的内容 pre_token_bytes:[b"a", b"b", b"d"]pre_token_bytes = pre_tokens_bytes_list[current_list_index]for merge in self.merges:# 2. 遍历 merges,一条 merge 规则的输入是一条 token 序列,输出也是一条新的 token 序列new_bytes = []i = 0while i < len(pre_token_bytes) - 1:# 3. 从左到右扫描最新 token 序列,根据 merge 关系,拼装新的 token 序列if merge == (pre_token_bytes[i], pre_token_bytes[i+1]):new_bytes.append(pre_token_bytes[i]+pre_token_bytes[i+1])i += 2else:new_bytes.append(pre_token_bytes[i])i += 1# 从左到右扫描 完 token 序列后,边界要处理下# 例如 pre_token_bytes = [b"a", b"b", b"c"] merge = (b"a", b"b")# merge 完了之后还剩最后一个 b"c" 就退出了,要手动添加到最新的 token 序列里面if(i == len(pre_token_bytes) - 1):new_bytes.append(pre_token_bytes[i])# 新的 token 序列需要赋值,进入下一个 merge 规则的循环pre_token_bytes = new_bytes# 2、3循环跑完后,pre_token_bytes 就是一个已经完整应用过所有 merges 的新的 token 序列了,此时就可以查词表转为 int 了for pre_token in pre_token_bytes:token_int_list.append(self.vocab_b2i[pre_token])current_list_index += 1return token_int_listdef encode_iterable(self, iterable: Iterable[str]) -> Iterator[int]:for part in iterable:for token_id in self.encode(part):yield token_iddef decode(self, ids: list[int]) -> str:bytes_list = []for token_id in ids:bytes_list.append(self.vocab[token_id])return b"".join(bytes_list).decode("utf-8", errors = "replace")
Tokenizer 在词表大小和序列长度之间做取舍。合并常见组合可以缩短序列,但词表越大,模型的存储和输出计算开销也越大。
BPE 学出词表和合并规则后,编码就按这套规则执行。训练和推理要使用同一套 Tokenizer,保证同一个编号始终对应相同的内容。
推荐一个网站:Tiktokenizer,可以直观地体验分词结果,这里我们演示 gpt2 的 tokenizer:
Embedding
Tokenizer 把文字转换成了 token 编号,但编号本身不能表达语义关系。假设「国王」和「皇后」各是一个 token,编号分别是 42 和 87,这两个数的大小和差距,并不能告诉模型它们的含义有多接近。
Embedding 把每个 token 编号转换成一个可学习的向量。 通过训练,「国王」和「皇后」这样含义相关的 token,可以在向量空间中更接近。这样,模型就能通过向量之间的关系来表示语义上的相似性,并将这些向量交给后面的 Attention 和前馈网络处理。
实现上,我们把所有 token 的向量放在一张表里。词表里有 vocab_size 个 token,每个向量有 d_model 个数,表的形状就是 (vocab_size, d_model)。像 vocab_size 和 d_model 这种需要在创建模型时就确定的参数就叫超参数。
实际处理时,我们通常会把多段文本组成一批(batch),一起交给模型。比如一批有 2 段文本,每段有 3 个 token,编号就可以排成一个 2 行、3 列的数组,形状记作 (2, 3)。用 B 表示一批的文本条数、S 表示每段的 token 数量,就写成 (B, S)。
经过 Embedding,每个编号都被替换成一个向量。如果每个向量有 4 个数,输出形状就变成 (2, 3, 4),表示 2 段文本,每段 3 个 token,每个 token 用 4 个数表示。一般写成 (B, S, D),其中 D 就是向量的维度。
在拆解 Transformer 的过程中,我们要始终留意张量的 shape。看懂每个维度代表什么,以及数据经过各个模块后如何变化,就更容易理解模型内部的计算过程。

下面的公式表示,取出第 b 段文本中第 s 个 token 的编号,再去表 E 中查出对应向量。
完整的Embedding 实现如下。
import torchimport mathclass Embedding(torch.nn.Module):def __init__(self, vocab_size: int, d_model: int, device=None, dtype=None):super().__init__()# vocab_size 词表大小# d_model 每个 token 的维度self.embedding = torch.nn.Parameter(torch.empty((vocab_size, d_model),device = device,dtype = dtype))# 用 assignment1 里面给出的初始化公式进行初始化# W ~ 𝒩(μ = 0, σ² = 1,截断到 [-3, 3]torch.nn.init.trunc_normal_(self.embedding,mean = 0,std = 1,a = -3,b = 3)# embedding 实际上就是一个查表操作# forward 的输入 tensor shape 为 (batch, seq_len)# 里面执行的逻辑就是对每个 batch 的每个 seq 元素,执行一下查表操作# 怎么理解这个 []?# 实际上 python 中 A[B] 等价于 A.__getitem__(B)def forward(self, token_ids: torch.Tensor) -> torch.Tensor:return self.embedding[token_ids]
关于 Embedding 同样也推荐一个网站:Token3D,它将模型的 Embedding 表中的每个 token 向量从高维度映射到低维度,可以直观的看到 Embedding 是什么样的,我们同样选择 gpt2,图中有 50257 个点:

扩展:Embedding 层与 Embedding 模型
相信很多人之前在做语义搜索、RAG 时用过 Embedding 模型。它和这里的 Embedding 层都把输入转换成向量,但作用和处理过程不同。
这里的 Embedding 层是模型中的一个组件,按 token 编号查表,为每个 token 提供初始向量。同一个 token 出现在不同句子里,查到的向量相同,这一步还没有结合上下文。
常见的文本 Embedding 模型则接收一整段文字,经过包括 Attention 在内的多层计算,再汇总成一个表示整段文本的向量,用来比较文本的语义相似度。它内部通常也有 Embedding 层,但我们拿来做检索的向量,是整个模型处理后的结果。
5.3 Linear 与 SwiGLU
Linear
经过 Embedding,每个 token 已经有了一个向量。Linear 做的就是把一个向量通过线性计算,转换成另一个向量。 具体来说,它用一组可学习的权重,对输入的各个分量做加权求和,组成新的向量。后面 Attention 中的 Q、K、V,以及 SwiGLU 中的向量变换,都会用到它。
先看前面提到的线性回归的例子。那时一个输入对应一个权重,算的是 y = wx + b。现在输入有多个数,每个数都有自己的权重,把它们分别相乘再加起来,就得到一个输出。
按照 Assignment1 的要求,作业采用了 PaLM、LLaMA 等模型中省略线性层偏置的设计,因此我们实现的是 y = Wx,省略了偏置 b。
假设一个 token 的向量是 [1, 2, 3, 4],我们希望输出 3 个数,就需要 3 组权重,每组都有 4 个数。把它们排在一起,得到一张 3 × 4 的权重矩阵 W。图中第一组权重是 [1, 0, 1, 0],算出的第一个输出就是 1×1 + 0×2 + 1×3 + 0×4 = 4。另外两组分别算出 6 和 -1,于是输出向量变成 [4, 6, -1]。

这里的整数权重是为了方便看清计算过程。实际使用时,权重先随机初始化,再通过训练更新,模型会学到怎样组合输入更有利于预测。in_features 决定每组权重有多少个数,out_features 决定有多少组权重,也就决定了输出向量的长度。即使输入输出长度相同,Linear 也能改变向量的内容。
如果把单个输入向量写成一列,上面的计算就是:
代码里,每个 token 的向量放在张量的最后一维,相当于横着排。因此计算写成 X @ W.T,W.T 就是对 W 进行转置操作。原本 W 的每一行是一组权重,转置后每一列是一组权重,刚好能与输入向量相乘求和。
对于前面的 batch 示例,输入 shape 是 (2, 3, 4),表示 2 段文本、每段 3 个 token、每个 token 有 4 个数。乘上 shape 为 (4, 3) 的 W.T 后,输出就变成 (2, 3, 3)。6 个 token 都分别使用同一个 W 做变换,文本条数和 token 数量保持不变,只有最后一维从 4 变成了 3。 这一步只处理每个 token 自身的向量,不会读取其他位置的 token。
下面是完整的 Linear 实现。按照 Assignment1 的要求,权重的初始化采用截断正态分布,均值为 0,标准差为
,取值限制在正负三倍标准差内。importtorchimport mathclass Linear(torch.nn.Module):def__init__(self, in_features, out_features, device=None, dtype=None):super().__init__()self.in_features = in_featuresself.out_features = out_features# 用 troch empty 申请出一个 tensor,devie、dtype 直接透传 # in_features 输入维度 # out_features 输出维度 self.W = torch.nn.Parameter( torch.empty( (out_features, in_features), device = device, dtype = dtype ) ) # 用 assignment1 里面给出的初始化公式进行初始化 # W ~ 𝒩(μ = 0, σ² = 2 / (d_in + d_out)),截断到 [-3σ, 3σ] # 先计算标准差 std = (2 / (in_features+out_features)) ** 0.5 torch.nn.init.trunc_normal_( self.W, mean = 0, std = std, a = -3 * std, b = 3 * std ) # linear 实际上就是一个简单的线性变换,在 pytorch 中表达为 y=x@W.T,「.T」 表示转置 def forward(self, x: torch.Tensor) -> torch.Tensor: return x @ self.W.T
SwiGLU
前面 TinyGPT 的前馈模块用的是 Linear → GELU → Linear 这样的普通 MLP。Assignment1 里面要求的是另一种 MLP:SwiGLU。
它让输入走两条分支,一条计算特征,另一条计算一组系数,然后把对应位置的数相乘。比如某个特征的值是 4,乘上 0.5 就变成 2,乘上 0 就被抑制了。这种用一条分支的输出调节另一条分支的计算方式,叫作门控。
这些系数会随输入变化,让网络能灵活调节各个特征的强弱。GLU Variants Improve Transformer 的 T5 预训练实验表明,在相近的参数量和计算量下,SwiGLU 比使用 ReLU、GELU 的普通前馈层取得了更低的困惑度。
先看它用到的激活函数 SiLU:
下面这张作业原图比较了 SiLU 和 ReLU。SiLU 在 0 附近是平滑的,也允许一部分负值通过。

SwiGLU 用到了三个不带偏置的 Linear,它们各自的权重矩阵记作 W1、W2、W3,都会随训练更新。输入 x 同时经过两个 Linear。权重为 W3 的一层生成特征;权重为 W1 的一层,其输出经过 SiLU 得到门控系数。两路结果逐元素相乘后,再经过权重为 W2 的 Linear 得到输出。

图中假设门控分支输出 [0, 0.5, 2, -0.2],特征分支输出 [2, 4, 3, 2]。对应位置相乘后,得到 [0, 2, 6, -0.4]。第一个分量被抑制为 0,第二个缩小一半;后两个分别被放大和改变符号。SiLU 的输出可以为负,也可以大于 1,所以这里的门控能做连续的调节。
两路都先把向量从 D 扩到 d_ff,相乘后再通过 W2 变回 D。整个过程对每个 token 分别进行,batch 和序列长度保持不变。写成公式就是
其中 表示逐元素相乘。
作业建议 d_ff 取接近 的 64 的倍数。这样三个权重矩阵的参数量,接近传统隐藏宽度为 4D 的两层前馈网络。
完整实现包含 silu() 和 SwiGLU,三个投影都复用前面写好的 Linear。
import torchimport mathdef silu(in_features: torch.Tensor):return in_features * torch.sigmoid(in_features)class SwiGLU(torch.nn.Module):def __init__(self, d_model: int, d_ff: int, device=None, dtype=None):# d_ff:隐藏层的维度super().__init__()self.d_model = d_modelself.d_ff = d_ff# w1、w3 负责升维self.w1 = Linear(d_model,d_ff,device,dtype)self.w3 = Linear(d_model,d_ff,device,dtype)# w2 负责降维self.w2 = Linear(d_ff,d_model,device,dtype)def forward(self, x: torch.Tensor):a = silu(self.w1(x))b = self.w3(x)# 注意这里是是逐元素相乘return self.w2(a * b)
5.4 Attention
“我吃了一个苹果”和“这款新手机来自苹果”,两句话里的“苹果”分别指水果和公司。假设“苹果”是一个 token,它在两句话中查到的 Embedding 向量相同,单靠这个向量无法区分当前说的是哪种含义,还需要结合“吃了”“新手机”等上下文。
前面的 Linear 和 SwiGLU 都分别处理每个 token 的向量。Attention 让不同 token 的信息联系起来:为可以读取的各个 token 计算权重,再按权重汇总它们的信息。这样,同一个 token 在不同语境中就可以得到不同的向量表示。

接下来就用“这款新手机来自苹果”拆开看。为了方便演示,假设它被切成 这款 / 新 / 手机 / 来自 / 苹果 五个 token。我们先看“苹果”怎样读取前文的信息,得到结合了当前语境的表示。
如果觉得这块内容比较抽象,推荐看一下 3Blue1Brown 的视频,动画演示会清晰很多:https://www.bilibili.com/video/BV1TZ421j7Ke
Q、K、V
在“这款新手机来自苹果”里,我们看到“手机”,就容易判断“苹果”指的是公司。我们希望模型也能利用这样的线索:处理“苹果”时,多关注能帮助理解它的词,把这些词的信息结合进来。
那模型怎么知道该关注谁,又该从对方那里拿到什么信息?Attention 为每个 token 算出三个向量,叫作 q、k、v。先用下面的说法理解它们:
|
向量 |
可以怎样理解 |
放到这个例子里 |
|
q(Query,查询) |
我需要什么线索? |
“苹果”需要能帮助判断当前含义的上下文线索 |
|
k(Key,键) |
我有什么可供匹配的线索? |
可以把“手机”的 k 想成带有“电子产品”这类标签,供“苹果”的 q 来匹配 |
|
v(Value,值) |
我能传过去什么信息? |
匹配后,“手机”通过 v 把自身的信息传给“苹果” |
这些说法是帮助理解的比喻。q、k、v 实际上都是向量,由每个 token 的输入分别经过三个 Linear 算出来。
q 表示“想找什么”,k 表示“有什么可供匹配”。 两个向量做点积,可以把它们的匹配程度变成一个分数。比如“苹果”的 q 与“手机”的 k 匹配分数较高,经过缩放和 softmax 后,“手机”就会分到较大的权重。点积得到的是分数,经过缩放和 softmax 才是权重。
v 是实际传递的信息。 “手机”的权重越大,它的 v 对最终结果的贡献就越大。我们用 q、k 算“关注谁、关注多少”,用 v 提供“拿到什么信息”。

图中的缩放因子通常取 ,其中 d_k 是 q、k 向量的分量个数。本例各有两个分量,所以缩放因子是 。向量越长,点积累加的项越多,分数的波动通常也越大;缩放可以避免 softmax 过早把权重集中到少数 token 上,让训练更稳定。
图中“手机”的权重约为 0.485,就把它的 v 中每个数乘以 0.485。其他 token 同样处理,再把五个向量相加,就得到一个汇总了上下文信息的向量,这就是这个注意力头的输出。
这个向量用来更新“苹果”原来的表示,让它带上“手机”等词提供的线索。后续网络就能利用这些线索理解当前语义,并最终预测下一个 token。图中也接上了这条后续路径,具体结构会在后面展开。
三个 Linear 的权重 W_Q、W_K、W_V 通过训练学习;q、k、v 和注意力权重则根据当前输入计算。比如 q = x @ W_Q.T,k、v 同理。
刚才只算了“苹果”。其他 token 也用自己的 q 做同样的计算。把所有 q 按行排成矩阵 Q,k、v 也分别排成 K、V,就能用矩阵乘法一起算。
沿用上面的示例,5 个 token 的 q、k、v 都用两个分量表示,因此 Q、K、V 的 shape 都是 (5, 2)。K 转置后,每一列就是一个 token 的 k。图中突出“苹果”这一行,其他行也按同样的方式计算。

分数表的每一行代表谁在读取信息,每一列代表从谁那里读取。 比如第 5 行就是“苹果”对五个 token 的匹配分数。这样,公式里的每一步就能和刚才的计算对应起来:
因果遮罩
模型生成文字时,只能根据已经出现的内容预测下一个 token。比如输入“这款 / 新”,接下来要预测“手机”,此时模型还看不到“手机 / 来自 / 苹果”。
训练时,我们已经有完整的句子。为了同时计算多个位置的预测,会把“这款 / 新 / 手机 / 来自 / 苹果”一起送进模型:“新”所在位置预测“手机”,“手机”所在位置预测“来自”。如果 Attention 能读取后面的 token,“新”在预测“手机”时就能直接看到“手机”,相当于提前看到了答案。 这样训练出来的模型就可能依赖生成时根本拿不到的信息。
因果遮罩就是把每个位置后面的 token 遮住。 这样既能让整句话并行训练,又能保证每个位置都只根据自己和前面的内容预测,与实际生成时的条件一致。
具体做法是:在 softmax 之前,把未来位置的分数设为负无穷。经过 softmax,这些位置的权重就变成 0,其余权重在每行内归一化。

例如“手机”这一行,只能读取“这款”“新”“手机”,“来自”和“苹果”的权重为 0。最后一行的“苹果”可以读取全部五个 token,因此仍是前面算出的那组权重。
多头注意力
上面一个注意力头,为每个 token 算出了一组权重。多头 Attention 让模型同时计算多组权重,分别汇总信息。 各个头使用不同的投影参数,可以学到不同的匹配关系,最后把各自的结果合在一起。
比如处理“这款新手机来自苹果”中的“苹果”时,可以设想一个头更关注“手机”,提取电子产品相关的信息;另一个头更关注“来自”,提取来源关系的信息。两个头各自算权重、汇总向量,再把结果拼接起来,交给一个 Linear 混合。这样,“苹果”的表示就能同时结合不同的线索。这里的分工只是帮助理解,实际每个头关注什么由训练学到。

图里的 B、S、D 分别表示文本数量、序列长度和向量长度。拆成 H 个头后,每个头处理 D / H 个分量,独立计算一张 S × S 的注意力权重表;最后把各头的结果拼接,再经过输出 Linear,恢复成 (B, S, D)。
比如输入是 (2, 5, 64),分成 4 个头后,Q、K、V 各自变成 (2, 4, 5, 16)。每个头汇总出 16 个分量,4 份拼回 64 个分量,最终输出仍是 (2, 5, 64)。
把图里的过程写成公式,就是:
其中 是分给第 个头的那部分向量;每个头都按前面介绍的方法计算 Attention,并使用因果遮罩。 把各个头的输出沿最后一维拼接, 对应图中最后的输出 Linear。
每段文本各自计算 Attention,batch 中不同文本之间不会互相读取。最后的 out_proj 沿用前面讲过的 Linear,对拼接后的向量进行变换。整个模块的输入、输出都是 (B, S, D),但输出的每个 token 向量已经汇集了它能读取的上下文。
下面是 softmax()、注意力计算和多头 Attention 的完整代码,复用前面实现的 Linear。
import torchimport mathdef softmax(x: torch.Tensor, dim: int):# 因为 exp 是指数函数,如果 x 过大可能会导致溢出,而 softmax 实际上只关心每个 x 的差值,不关心具体的绝对值,所以先全都减掉 x 的最大值x = x - x.max(dim = dim, keepdim = True).valuesexp_x = torch.exp(x)return exp_x / exp_x.sum(dim = dim,keepdim = True)def scaled_dot_product_attention(Q: torch.Tensor,K: torch.Tensor,V: torch.Tensor,mask: torch.Tensor | None = None,) -> torch.Tensor:"""参数:Q: [..., n, d_k] (n 为 Query 序列长度)K: [..., m, d_k] (m 为 Key 序列长度)V: [..., m, d_v]mask: [n, m] 布尔矩阵,True 为保留,False 为屏蔽返回:[..., n, d_v] 与输入 batch 维一致的张量"""# 先获取 K 向量维度d_k = K.size(-1)# 计算 Q K 向量的分数,并除以 √d_k 进行缩放socres = (Q @ K.mT) / math.sqrt(d_k)"""Masked Scores = [[10, -inf, -inf],[ 5, 10, -inf],[ 2, 8, 10]]以下权重只示意遮罩后的形状,并非上面分数的实际 softmax 结果:[[1.0, 0.0, 0.0],[0.3, 0.7, 0.0],[0.1, 0.4, 0.5]]"""if mask is not None:# mask 为 flase的地方设为负无穷socres = socres.masked_fill(mask == False, float('-inf'))socres = softmax(socres, dim = -1)return socres @ Vclass MultiheadSelfAttention(torch.nn.Module):def __init__(self, d_model: int, num_heads: int, device=None, dtype=None):super().__init__()# 要用 head 数量均分 d_model,所以需要校验能否整除assert d_model % num_heads == 0self.d_model = d_modelself.num_heads = num_headsself.head_dim = d_model // num_heads# 先定义获取 QKV 向量的 Linearself.W_Q = Linear(in_features=d_model, out_features=d_model, device=device, dtype=dtype)self.W_K = Linear(in_features=d_model, out_features=d_model, device=device, dtype=dtype)self.W_V = Linear(in_features=d_model, out_features=d_model, device=device, dtype=dtype)# 输出经过 QKV 运算后需要再过一次 Linearself.out_proj = Linear(in_features=d_model, out_features=d_model, device=device, dtype=dtype)def forward(self, x: torch.Tensor):# 先计算出完整的 QKV,再按照 head 拆分q = self.W_Q(x)k = self.W_K(x)v = self.W_V(x)# 拆分后的多头 QKV 的 shape:(batch_size, num_heads, seq_len, head_dim)# 这里 view 拆分需要先拆 qkv 的最后维度,所以前两维 shape 要展示保持 batch_size, seq_len,通过 transpose 处理成最终的 shapebatch_size, seq_len, d_model = x.shapeq = q.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)k = k.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)v = v.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)# 这里需要一个下三角矩阵"""True False False FalseTrue True False FalseTrue True True FalseTrue True True True"""mask = torch.tril(torch.ones(seq_len, seq_len, device=x.device, dtype=torch.bool))# 计算注意力out = scaled_dot_product_attention(Q=q,K=k,V=v,mask=mask)# 把 heads 维度拼回 d_model,恢复 (batch_size, seq_len, d_model)。out = out.transpose(1, 2).contiguous().view(batch_size, seq_len, self.d_model)return self.out_proj(out)
5.5 RoPE
上一节的基础 Attention 根据 q、k 的匹配程度分配权重。因果遮罩限制了读取范围;我们还希望匹配分数能利用 token 之间相隔多远的信息。
TinyGPT 的做法是把位置 Embedding 加到 token Embedding 上,再计算 q、k、v。这里改用 RoPE(旋转位置编码),在 q、k 已经计算出来之后,根据各自的 token 位置旋转它们,再计算注意力分数。
RoPE 的具体做法是:把 q、k 的高维向量按分量两两配对,每一对当成一个二维向量,根据 token 的位置分别旋转,再按原顺序拼回去。 比如 拆成 和 ,旋转后得到 和 ,拼起来仍然是一个四维向量。 要理解这种做法为什么成立,我们先来复习一下高中就学过的二维旋转矩阵:
它把二维向量逆时针旋转 ,长度保持不变。比如 旋转 ,就变成 。 回到刚才的四维例子,对 和 分别旋转,等价于让整个向量乘下面这个矩阵:
左上角只旋转 ,右下角只旋转 ,这种结构叫分块对角矩阵。每一对旋转后长度不变,所以整个向量的长度也不变;知道旋转角度后,反向旋转就能还原,信息没有被丢掉。
更高维的向量也可以这样处理,只需沿对角线放入更多二维旋转矩阵。因此这里要求向量长度为偶数。配对按固定的坐标顺序进行;计算 q、k 的点积时,各对分量的点积相加,就得到完整向量的点积。
接下来确定每一对转多少。这里 i 表示 token 在序列中的位置,从 0 开始;k 表示向量中第几对分量,从 1 开始。比如四维向量 (a,b,c,d) 中,k=1 对应 (a,b),k=2 对应 (c,d)。位置 i 的第 k 对分量,旋转角度为:
是控制旋转频率的基数。比如取 、 ,向量拆成两对后,第一对每前进一个位置旋转 1 弧度,第二对旋转 0.1 弧度。下面固定向量的内容,看看它放在不同位置时会怎样旋转:

我们可以把不同分量对想成钟表上的秒针和时针:秒针转得快,方便看几秒钟的变化;时针转得慢,方便看几小时的变化。 RoPE 给不同分量对设置不同频率,也是为了提供不同距离尺度的位置线索。
-
高频分量像秒针,对较近的位置变化更敏感。 上面第一对每前进一个 token 就转 1 弧度,约 57°,相邻位置的方向差别已经很明显。但它转得快,绕过一圈后又会接近原来的方向,单看这一对,较远的位置也可能看起来很接近。
-
低频分量像时针,在更长的位置范围内缓慢变化。 第二对每前进一个 token 只转 0.1 弧度,约 5.7°,前进 10 个位置才转 1 弧度。它转一圈需要跨过更长的距离,能补充较远距离的位置线索;但如果只用很慢的频率,相邻位置的角度差又会太小。
所以我们把快慢不同的“指针”放在一起:快的帮助区分附近的位置,慢的补充更长范围的变化。这些分量具体承载什么内容,由模型训练学到。

点积等于两个向量的长度相乘,再乘以夹角的余弦。 只看图中这一对二维分量:原来从 q 到 k 的角度差是 ,按位置分别旋转 、 后,角度差就变成 :
这里 是这对分量的旋转频率。向量长度不变,位置差 通过角度差影响点积分数。完整向量的点积,就是把各对分量的结果相加。
旋转保持 q、k 的模长和 shape 不变,v 不旋转。 后续仍按原来的方式计算注意力权重,再对 v 加权求和。相比把位置向量加到 token Embedding 上,RoPE 的优势是让匹配分数直接依赖相对距离。
下面是完整的 RoPE 以及集成了 RoPE 的多头注意力的实现:
import torchimport mathclass RotaryPositionalEmbedding(torch.nn.Module):def __init__(self, theta: float, d_k: int, max_seq_len: int, device=None):# theta:RoPE 的频率基数# d_k:q、k 向量维度# max_seq_len:最大 token 长度super().__init__()self.d_k = d_k# 先计算每个位置需要旋转的角度# 公式:f_k = 1 / theta^((2k - 2) / d_k)# arange(0, d_k, 2) 产生 [0, 2, 4, ..., d_k-2],对应公式中的2k-2(k从1开始)# 注意这里 f_k 的 shape 是 (d_k / 2,)f_k = 1 / (theta ** (torch.arange(0, d_k, 2, device=device).float() / d_k))# 这里要用外积,外积的结果是前行后列,angle 是一个矩阵# angle[n, k] = n × f_k = n / theta^((2k - 2) / d_k)# angle 的shape:(max_seq_len, d_k / 2)angle = torch.outer(torch.arange(max_seq_len, device=device).float(), f_k)# cos_table、sin_table shape 都是 (max_seq_len, d_k / 2)self.register_buffer("cos_table", angle.cos())self.register_buffer("sin_table", angle.sin())def forward(self, x: torch.Tensor, token_positions: torch.Tensor) -> torch.Tensor:# x:shape 为 (..., seq_len, d_k) 的 q 或 k 向量# token_positions:shape 为 (..., seq_len) 的整数位置下标;每个位置对应 x 中一个 q/k 向量所在的 token 位置。# 这里 cos 和 sin 的 shape 都是 token_positions.shape + (d_k // 2,),得用 repeat_interleave 复制一下元素cos_value = self.cos_table[token_positions].repeat_interleave(2, dim=-1).to(x.dtype)sin_value = self.sin_table[token_positions].repeat_interleave(2, dim=-1).to(x.dtype)# 公式:rotated_q = q ⊙ cos_value + rotate_half(q) ⊙ sin_value# rotate_half:[x0, x1, x2, x3, ...] → [-x1, x0, -x3, x2, ...]"""最后一维两两拆分,例如:原来 (3, 6):[[ 1, 2, 3, 4, 5, 6],[ 7, 8, 9, 10, 11, 12],[13, 14, 15, 16, 17, 18],]现在 (3, 3, 2):[[ [1, 2], [3, 4], [5, 6] ], ← 第 0 个 token,3 组,每组 2 个[ [7, 8], [9,10], [11,12] ], ← 第 1 个 token[ [13,14], [15,16], [17,18] ], ← 第 2 个 token]"""x_unflatten = x.unflatten(-1,(-1,2))"""按照奇偶拆分开even = 每组的第 0 个元素:[[ 1, 3, 5],[ 7, 9, 11],[13, 15, 17],]odd = 每组的第 1 个元素:[[ 2, 4, 6],[ 8, 10, 12],[14, 16, 18],]"""even, odd = x_unflatten.unbind(-1)"""先奇偶互换,然后压缩最后两维stack 后的结果:[[ [-2, 1], [-4, 3], [ -6, 5] ], ← 第 0 个 token[ [-8, 7], [-10, 9], [-12, 11] ], ← 第 1 个 token[ [-14,13], [-16,15], [-18, 17] ], ← 第 2 个 token]flatten 后:[[ -2, 1, -4, 3, -6, 5],[ -8, 7, -10, 9, -12, 11],[-14, 13, -16, 15, -18, 17],]"""x_rotate_half = torch.stack((-odd, even), dim = -1).flatten(-2)rotated_x = x * cos_value + x_rotate_half * sin_valuereturn rotated_xclass MultiheadSelfAttention(torch.nn.Module):def __init__(self, d_model: int, num_heads: int, theta=None, max_seq_len=None, device=None, dtype=None):super().__init__()# 要用 head 数量均分 d_model,所以需要校验能否整除assert d_model % num_heads == 0self.d_model = d_modelself.num_heads = num_headsself.head_dim = d_model // num_heads# 先定义获取 QKV 向量的 Linearself.W_Q = Linear(in_features=d_model, out_features=d_model, device=device, dtype=dtype)self.W_K = Linear(in_features=d_model, out_features=d_model, device=device, dtype=dtype)self.W_V = Linear(in_features=d_model, out_features=d_model, device=device, dtype=dtype)# 输出经过 QKV 运算后需要再过一次 Linearself.out_proj = Linear(in_features=d_model, out_features=d_model, device=device, dtype=dtype)# 如果传入了 theta 和 max_seq_len,则处理 rope 的逻辑if theta is not None and max_seq_len is not None:self.rope = RotaryPositionalEmbedding(theta=theta, d_k=self.head_dim, max_seq_len=max_seq_len, device=device)else:self.rope = Nonedef forward(self, x: torch.Tensor, token_positions=None):# 先计算出完整的 QKV,再按照 head 拆分q = self.W_Q(x)k = self.W_K(x)v = self.W_V(x)# 拆分后的多头 QKV 的 shape:(batch_size, num_heads, seq_len, head_dim)# 这里 view 拆分需要先拆 qkv 的最后维度,所以前两维 shape 要展示保持 batch_size, seq_len,通过 transpose 处理成最终的 shapebatch_size, seq_len, d_model = x.shapeq = q.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)k = k.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)v = v.view(batch_size, seq_len, self.num_heads, self.head_dim).transpose(1, 2)# 如果 rope 不为 null 就对 q、k 进行 rope 计算if self.rope is not None:# token_positions shape:(batch_size, seq_len)if token_positions is None:token_positions = torch.arange(seq_len, device=x.device).expand(batch_size, seq_len)# 每条文本的位置沿 head 轴广播;一维位置可由所有文本共用。if token_positions.ndim == 2:token_positions = token_positions.unsqueeze(1)q=self.rope(q, token_positions)k=self.rope(k, token_positions)# 这里需要一个下三角矩阵"""True False False FalseTrue True False FalseTrue True True FalseTrue True True True"""mask = torch.tril(torch.ones(seq_len, seq_len, device=x.device, dtype=torch.bool))# 计算注意力out = scaled_dot_product_attention(Q=q,K=k,V=v,mask=mask)# 把 heads 维度拼回 d_model,恢复 (batch_size, seq_len, d_model)。out = out.transpose(1, 2).contiguous().view(batch_size, seq_len, self.d_model)return self.out_proj(out)
5.6 RMSNorm
向量经过多层计算,数值的大小会变化。我们希望送入下一步计算时,它们的整体尺度比较稳定。RMSNorm 就像调节音量:先根据向量自身的大小做一次缩放,再让模型学习各个分量需要多大的幅度。
每个 token 都有自己的 RMS。 一个 token 的向量里有 个数,就用这 个数计算它的 RMS,再用这个 RMS 归一化该向量。不同 token 分别计算,可以并行完成。
先算 RMS(Root Mean Square,均方根)。 设这个向量为 ,把所有分量分别平方、求平均,再开根号,就得到衡量整体大小的一个数。为避免后面除以零,根号内还加上小常数 : 是分量个数, 遍历这些分量; 这里取 ,避免下一步除以零。 再做归一化和缩放。 每个分量 都除以上面算出的同一个 RMS,再乘自己的缩放系数 :
表示当前分量。 初始化为 1,随后通过训练学习,让模型调整这个分量的幅度。同一层的所有 token 共用这组 ,输入输出形状不变。 举个例子,令 、 。忽略很小的 :
于是输出约为 。如果输入整体放大成 ,RMS 也会放大十倍,归一化后仍得到相同的结果。
输入整体放大成 (20,40),RMS 也会放大十倍,归一化后仍得到相同的结果。
和 LayerNorm 相比,RMSNorm 省去了减均值这一步。 LayerNorm 先减均值,再除以标准差;RMSNorm 直接除以均方根,计算步骤更少,也不要求归一化后的均值为 0。
下面是完整的 RMSNorm 实现。
import torchimport mathclass RMSNorm(torch.nn.Module):def __init__(self, d_model: int, eps: float = 1e-5, device=None, dtype=None):super().__init__()# d_model 模型 token 维度;weight 用于最后做缩放,维度要和 d_model 一致# eps 用于防止分母为 0self.weight = torch.nn.Parameter(torch.ones(d_model,device = device,dtype = dtype))self.eps = epspassdef forward(self, x: torch.Tensor) -> torch.Tensor:# 先转成 float32,降低平方时溢出的风险in_dtype = x.dtypex_float32 = x.to(torch.float32)# 应用公式# 注意 mean 只需要对最后维度求平均,keepdim 表示需要保留维度,这里 mean 后的 shape 就是 (batch, seq_len, 1)rms = torch.sqrt(torch.mean(torch.square(x_float32),dim = -1,keepdim = True) + self.eps)# 注意这里是 * weight 而不是 @,因为是逐元素相乘缩放# 这里计算需要注意一下 shape# x_floagt32(batch, seq_len, d_model),rms(batch, seq_len, 1)# x_floagt32/rms 这里会用到 pytorch 里面的广播机制# 广播:两个 shape 不完全一样的 tensor 做逐元素运算时,PyTorch 会在尺寸为 1 的维度上,自动“重复使用”那个值,让 shape 对齐。result = (x_float32 / rms) * self.weight# 返回前转回原来的 dtypereturn result.to(in_dtype)
5.7 组装 Transformer
前面的模块已经齐了,现在回过头来看 Architecture 中的那两张图,我们接下来只要按照架构去组装TransformerBlock 和 TransformerLM 就可以了:
import torchimport mathclass TransformerBlock(torch.nn.Module):def __init__(self, d_model: int, num_heads: int, d_ff: int, theta=None, max_seq_len=None, device=None, dtype=None):super().__init__()# 初始化 MHAself.attention = MultiheadSelfAttention(d_model=d_model,num_heads=num_heads,theta=theta,max_seq_len=max_seq_len,device=device,dtype=dtype)# 初始化 FFself.ffn = SwiGLU(d_model=d_model, d_ff=d_ff, device=device, dtype=dtype)# 初始化两个 RMSNorm,分别用于 MHA、FFself.norm1 = RMSNorm(d_model=d_model, device=device, dtype=dtype)self.norm2 = RMSNorm(d_model=d_model, device=device, dtype=dtype)def forward(self, x: torch.Tensor, token_positions: torch.Tensor = None):# 先进行 MHA 操作x = x + self.attention(x=self.norm1(x), token_positions=token_positions)# 再进行 FF 操作x = x + self.ffn(x=self.norm2(x))return xclass TransformerLM(torch.nn.Module):def __init__(self, vocab_size: int, nums_layer: int, d_model: int, num_heads: int, d_ff: int,theta=None, max_seq_len=None, device=None, dtype=None):super().__init__()self.token_embedding = Embedding(vocab_size=vocab_size, d_model=d_model, device=device, dtype=dtype)self.layers = torch.nn.ModuleList([TransformerBlock(d_model=d_model,num_heads=num_heads,d_ff=d_ff,theta=theta,max_seq_len=max_seq_len,device=device,dtype=dtype)for _ in range(nums_layer)])self.norm = RMSNorm(d_model=d_model,device=device,dtype=dtype)# 注意最后是把向量映射回词表self.linear = Linear(in_features=d_model, out_features=vocab_size, device=device, dtype=dtype)def forward(self, token_ids: torch.Tensor):batch_size, seq_len = token_ids.shape# 计算后 x.shape: (batch, size, d_model)x = self.token_embedding(token_ids)# 需要构建一个 token positions,传递到 transformer block 里面给 rope 使用# 比如# batch_size = 2# seq_len = 4# [# [0, 1, 2, 3],# [0, 1, 2, 3],# ]token_positions = torch.arange(seq_len, device=token_ids.device).unsqueeze(0).expand(batch_size, seq_len)# 计算后 x.shape: (batch, size, d_model)for layer in self.layers:x = layer(x, token_positions)# 计算后 x.shape: (batch, size, d_model)x = self.norm(x)# 这里要把向量通过 lm header 计算为词表分数# 计算后 x.shape: (batch, size, vocab_size)x = self.linear(x)return x
TransformerBlock 的 forward():
是输入, 是中间结果, 是输出。这里的 + 就是残差连接,它把子层的计算结果加回输入,在原有表示上做更新。它也为梯度提供了一条直接传回前面各层的路径,让深层网络更容易训练。 TransformerLM 的 forward():
是 Block 的数量, 是第 个 Block 的输出。LM head 将每个 token 的向量映射为词表分数 logits,尚未经过 softmax。
参数量和计算量
最后来看一个 Assignment1 里的资源核算题,把前面各个模块的参数量和计算量串起来:
Problem (transformer_accounting): Transformer LM resource accounting (5 points)
(a) Consider a GPT-2 XL-sized model using our assignment architecture, which has the following configuration:
- vocab_size:
50,257
- context_length:
1,024
- num_layers:
48
- d_model:
1,600
- num_heads:
25
- d_ff:
4,288 (the nearest multiple of 64 to )
Suppose we constructed our model using this configuration. How many trainable parameters would our model have? Assuming each parameter is represented using single-precision floating point, how much memory is required to just load this model?
Deliverable: A one-to-two sentence response.
(b) Identify the matrix multiplies required to complete a forward pass of our GPT-2 XL-shaped model. How many FLOPs do these matrix multiplies require in total? Assume that our input sequence has context_length tokens.
Deliverable: A list of matrix multiplies (with descriptions), and the total number of FLOPs required.
(c) Based on your analysis above, which parts of the model require the most FLOPs?
Deliverable: A one-to-two sentence response.
(d) Repeat your analysis with GPT-2 small (12 layers, 768 d_model, 12 heads), GPT-2 medium (24 layers, 1024 d_model, 16 heads), and GPT-2 large (36 layers, 1280 d_model, 20 heads). As the model size increases, which parts of the Transformer LM take up proportionally more or less of the total FLOPs?
Deliverable: For each model, provide a breakdown of model components and its associated FLOPs (as a proportion of the total FLOPs required for a forward pass). In addition, provide a one-to-two sentence description of how varying the model size changes the proportional FLOPs of each component.
(e) Take GPT-2 XL and increase the context length to 16,384. How does the total FLOPs for one forward pass change? How does the relative contribution of FLOPs of the model components change?
Deliverable: A one-to-two sentence response.
我们就以 GPT-2 XL 的规模来计算一下这道题:
1. 参数量和内存:按给定配置,模型有多少可训练参数?用 float32 存储这些参数需要多少内存?
我们把各个模块的参数加起来。这里所有 Linear 都不带偏置,Embedding 和 LM head 不共享权重:
○ Embedding 和 LM head 各有一张权重表,共 个参数。
○ 每个 Block 中,Attention 的 Q、K、V 和输出投影共 个参数;SwiGLU 的三个 Linear 共 个参数;两个 RMSNorm 共 个参数。
○ 最后一个 RMSNorm 还有 个参数,RoPE 没有可训练参数。
把 48 个 Block 加上输入、输出部分,总参数量就是:
约 16.40 亿参数。float32 每个参数占 4 字节,所以仅存储参数需要 字节,约 6.56 GB(6.11 GiB)。
2. 一次前向传播的计算量: 输入 1,024 个 token 时,需要做哪些矩阵乘法?总共需要多少 FLOPs?
这里按一条序列计算,只统计矩阵乘法。形状为 和 的两个矩阵相乘,按 次浮点运算估算。比如一个 Q 投影,就是 乘 ,需要 FLOPs。
Attention 中还有 和注意力权重乘 两次矩阵乘法。每个头的向量长度是 ,合计 25 个头,两次乘法共需 FLOPs。其余 Linear 也按同样的方法计算:

相加得到约 3.52 万亿 FLOPs。Embedding 是查表,RMSNorm、RoPE、softmax 等操作不计入这道题的矩阵乘法统计。
3. 计算量主要花在哪里: 哪些模块消耗的 FLOPs 最多?用每一项的 FLOPs 除以总量,就得到占比。SwiGLU 占约 57.5%,消耗最多;Attention 的四个投影占 28.6%,两次注意力矩阵乘法占 9.2%,LM head 占 4.7%。在这个配置下,大部分计算花在 Linear 上。
4. 模型变大后的变化: 换成 small、medium、large 规模,分别计算各模块的 FLOPs 占比,比较哪些占比上升、哪些下降。
继续用上面的算法,替换层数和 d_model,词表大小和序列长度保持不变。d_ff 按 取最近的 64 的倍数,三个模型分别取 2048、2752、3392。算出各项 FLOPs 后,再除以各自的总量:
|
模型 |
总 FLOPs(万亿次) |
Q、K、V 和输出投影 |
两次注意力矩阵乘法 |
SwiGLU |
LM head |
|
small |
0.292 |
19.9% |
13.3% |
39.8% |
27.1% |
|
medium |
0.830 |
24.8% |
12.4% |
50.1% |
12.7% |
|
large |
1.769 |
27.3% |
10.9% |
54.3% |
7.4% |
|
XL |
3.517 |
28.6% |
9.2% |
57.5% |
4.7% |
可以看到,模型变大后,投影和 SwiGLU 的占比上升,另外两项的占比下降。因为 d_ff 随 d_model 增长,Block 中这些 Linear 的计算量大致随 d_model 的平方增长;两次注意力矩阵乘法只随它线性增长,LM head 还不随 Block 数量增加。
5. 序列变长后的变化: 把 XL 规模模型的上下文长度从 1,024 增加到 16,384,总 FLOPs 和各模块的占比如何变化?
序列长度变成原来的 16 倍,所有 Linear 的计算量也变成 16 倍;两次注意力矩阵乘法包含序列长度的平方,所以变成 16^2 = 256 倍。沿用上面的结果:
总 万亿次
总计算量约为原来的 38 倍。这时,两次注意力矩阵乘法的占比升到 61.7% ,成为最大的计算开销;SwiGLU 占 24.2%,四个投影占 12.1%,LM head 占 2.0%。
06
后记
写到这里,Transformer 的各个模块就都实现了。跟着课程做完这一部分,我们对模型的理解也会提高很多,不过现在我们只是搭好了模型的架子,距离实现一个完整的 LLM 还有一些工作要做,下一篇会继续写训练、推理相关的内容。
参考资料:
CS336: Language Modeling from Scratch
Assignment 1: Basics
https://github.com/stanford-cs336/assignment1-basics/tree/main
cs336-assignment(A1 实现)
https://github.com/fengye404/cs336-assignment
Transformer 原论文:Attention Is All You Need
https://arxiv.org/abs/1706.03762
RoPE 原论文:RoFormer: Enhanced Transformer with Rotary Position Embedding
https://arxiv.org/abs/2104.09864
RoPE 推导(苏剑林):Transformer升级之路:2、博采众长的旋转式位置编码
https://spaces.ac.cn/archives/8265
RMSNorm 原论文:Root Mean Square Layer Normalization
https://arxiv.org/abs/1910.07467
SwiGLU 出处:GLU Variants Improve Transformer
https://arxiv.org/abs/2002.05202
