大模型是怎么炼成的?LLM 训练全流程数学拆解(公式 + 演算 + 避坑)
- 约 24 分钟读完 - 全文 9425 字大模型是怎么炼成的?LLM 训练全流程数学拆解
目标读者:学过函数、导数、向量/矩阵基础,想搞清楚 LLM 训练本质的同学。
本文目标:把“炼丹”过程拆成可计算的公式和可复现的例子。
写在前面:LLM 训练其实就是“猜下一个词”
大语言模型(Large Language Model,LLM)看起来很神秘,但核心任务可以概括为一句话:
给定前面的词,预测下一个词。
整个训练流程可以拆成 5 大块:
- 数据预处理:把互联网上的文本变成模型能吃的“数字串”。
- Tokenization:把句子切分成 token,再映射成向量。
- Transformer 前向传播:用 Attention 机制捕捉上下文关系。
- 计算损失:模型猜的下一个词和真实下一个词差多少。
- 反向传播 + 优化器:用梯度调整权重,让模型越猜越准。
下面每一节都会先讲直觉,再给公式、例子、图解和踩坑经验。
一、训练全景图:从原始文本到可用模型
1.1 一句话理解
LLM 的训练就像教一个小孩学说话:先给他看大量书(预训练),再教他怎么礼貌回答(SFT),最后告诉他哪些回答更好(RLHF)。
1.2 三阶段训练流程
flowchart LR
subgraph 数据
D1["原始文本(网页、书籍、代码)"]
D2["清洗 / 去重 / 过滤"]
D3["Token 序列"]
end
subgraph 训练阶段
P["① 预训练 Pre-training"]
S["② 监督微调 SFT"]
R["③ 强化学习 RLHF / DPO"]
end
subgraph 输出
M["基础大模型"]
C["对话模型"]
A["对齐模型"]
end
D1 --> D2 --> D3 --> P --> M
M --> S --> C
C --> R --> A
1.3 每个阶段在优化什么?
| 阶段 | 输入 | 目标 | 损失函数 |
|---|---|---|---|
| 预训练 | 海量无标注文本 | 学会语言结构和知识 | 下一个 token 的交叉熵 |
| SFT | ( prompt, answer )配对 | 学会按人类习惯回答问题 | 监督式交叉熵 |
| RLHF | prompt + 多条回答 + 人类排序 | 学会生成更受欢迎的回答 | PPO / DPO 奖励模型 |
二、数据预处理与 Tokenization
2.1 一句话理解
模型看不懂汉字或英文,只能看数字。Tokenization 就是把文本切成最小单元,再查表换成整数 ID。
2.2 BPE 编码例子
BPE(Byte Pair Encoding)是 LLM 最常用的分词方法,核心思想:把出现频率高的子词合并成一个 token。
假设语料里经常出现:
low, lower, lowest, high, higher, highest
初始把每个字符当做一个 token:
l, o, w, e, r, h, i, g
发现 er 出现很多次,合并成新 token er;发现 est 出现很多次,合并成 est。最后得到词表:
low, lower, lowest, high, higher, highest, er, est
2.3 从文本到 ID 的例子
假设词表(vocabulary)有 10000 个 token,其中:
| 文本 | Token ID |
|---|---|
| 北京 | 1024 |
| 是 | 7 |
| 中国 | 2048 |
| 的 | 3 |
| 首都 | 4096 |
句子:
北京 是 中国 的 首都
变成 token ID 序列:
$$ \mathbf{x} = [1024,\ 7,\ 2048,\ 3,\ 4096] $$
2.4 Embedding:把 ID 变成向量
每个 token ID 会查一张 Embedding 表,得到长度为 $d$ 的向量。假设 $d=4$,词表大小 $V=10000$:
$$ \mathbf{E} \in \mathbb{R}^{10000 \times 4} $$
对 token ID $1024$,取第 $1024$ 行:
$$ \mathbf{e}_{1024} = \mathbf{E}[1024] \in \mathbb{R}^4 $$
整个句子就变成矩阵:
$$ \mathbf{X} = \begin{bmatrix} \mathbf{e}_{1024}^\top \\ \mathbf{e}_{7}^\top \\ \mathbf{e}_{2048}^\top \\ \mathbf{e}_{3}^\top \\ \mathbf{e}_{4096}^\top \end{bmatrix} \in \mathbb{R}^{5 \times 4} $$
实际 LLM 中 $d$ 通常是 768、1024、4096、8192 等。
2.5 流程图
flowchart LR
T["原始文本"] --> B["BPE / SentencePiece 分词"]
B --> I["Token ID 序列"]
I --> E["Embedding 查表"]
E --> X["输入向量矩阵 X ∈ R^(T×d)"]
2.6 使用注意点 / 避坑
| 坑 | 现象 | 处理 |
|---|---|---|
| 词表太小 | 很多词被切成很多 token,序列变长、效率低 | 词表一般设 32k-200k |
| 词表太大 | 稀疏 token 训练不充分,Embedding 质量差 | 平衡词表大小和数据量 |
| 中英文混合 | 中文常按字切,导致同样内容 token 数比英文多 | 训练中文专用 BPE |
| 特殊符号处理 | 数字、代码、表情被切得很碎 | 预处理时统一格式,保留代码结构 |
2.7 异常场景处理
- OOV(Out of Vocabulary):现代 BPE 会把未知字符拆成
<unk>或字节级子词,基本不会出现完全无法表示的情况。出现<unk>过多时,检查清洗流程。 - 序列超长: truncation 截断或滑动窗口切分,不要硬塞。
- 标签泄漏:预训练数据里混入了测试集内容,会导致评估指标虚高。要严格去重、划分数据。
三、Transformer 架构:Attention + FFN
3.1 一句话理解
Transformer 就像一个“全连接阅读器”:每个词都能直接看到其他所有词,并通过 Attention 机制决定该关注谁。
3.2 结构图
flowchart TD
subgraph TransformerBlock["Transformer Block"]
X["输入 X"] --> LN1["LayerNorm"]
LN1 --> A["Multi-Head Self-Attention"]
A --> Add1["X + Attention(X)"]
Add1 --> LN2["LayerNorm"]
LN2 --> F["Feed Forward Network"]
F --> Add2["+ 残差连接"]
end
Add2 --> OUT["输出"]
堆叠很多个这样的 Block,再接一个输出层,就是 GPT 系列的结构。
3.3 Attention 的数学公式
对于某个输入序列,先通过三个权重矩阵算出 Query、Key、Value:
$$ \begin{aligned} \mathbf{Q} &= \mathbf{X} \mathbf{W}^Q \\ \mathbf{K} &= \mathbf{X} \mathbf{W}^K \\ \mathbf{V} &= \mathbf{X} \mathbf{W}^V \end{aligned} $$
然后计算 Scaled Dot-Product Attention:
$$ \text{Attention}(\mathbf{Q}, \mathbf{K}, \mathbf{V}) = \text{softmax}\left( \frac{\mathbf{Q}\mathbf{K}^\top}{\sqrt{d_k}} \right) \mathbf{V} $$
其中:
- $d_k$:每个 head 的 Key/Query 维度。
- 除以 $\sqrt{d_k}$ 是为了防止点积结果太大,导致 softmax 梯度消失。
- softmax 把分数变成概率分布(每行之和为 1)。
3.4 计算例子:Attention 到底在算什么?
假设输入序列有 3 个 token,每个 token 的向量维度 $d=4$。为了演示,简化为 $d_k = 2$。
输入矩阵:
$$ \mathbf{X} = \begin{bmatrix} 1 & 0 & 1 & 0 \\ 0 & 1 & 0 & 1 \\ 1 & 1 & 0 & 0 \end{bmatrix} $$
假设已经学过权重(实际训练中得到):
$$ \mathbf{W}^Q = \begin{bmatrix} 1 & 0 \\ 0 & 1 \\ 0 & 0 \\ 0 & 0 \end{bmatrix}, \quad \mathbf{W}^K = \begin{bmatrix} 0 & 1 \\ 1 & 0 \\ 0 & 0 \\ 0 & 0 \end{bmatrix} $$
计算 Q 和 K:
$$ \mathbf{Q} = \mathbf{X}\mathbf{W}^Q = \begin{bmatrix} 1 & 0 \\ 0 & 1 \\ 1 & 1 \end{bmatrix}, \quad \mathbf{K} = \mathbf{X}\mathbf{W}^K = \begin{bmatrix} 0 & 1 \\ 1 & 0 \\ 1 & 1 \end{bmatrix} $$
计算 $\mathbf{Q}\mathbf{K}^\top$:
$$ \begin{aligned} \mathbf{Q}\mathbf{K}^\top &= \begin{bmatrix} 1 & 0 \\ 0 & 1 \\ 1 & 1 \end{bmatrix} \begin{bmatrix} 0 & 1 & 1 \\ 1 & 0 & 1 \end{bmatrix} \\ &= \begin{bmatrix} 0 & 1 & 1 \\ 1 & 0 & 1 \\ 1 & 1 & 2 \end{bmatrix} \end{aligned} $$
除以 $\sqrt{d_k} = \sqrt{2} \approx 1.414$:
$$ \frac{\mathbf{Q}\mathbf{K}^\top}{\sqrt{2}} \approx \begin{bmatrix} 0 & 0.707 & 0.707 \\ 0.707 & 0 & 0.707 \\ 0.707 & 0.707 & 1.414 \end{bmatrix} $$
对每一行做 softmax(以第一行为例):
$$ \text{softmax}([0,\ 0.707,\ 0.707]) = \left[ \frac{e^0}{Z},\ \frac{e^{0.707}}{Z},\ \frac{e^{0.707}}{Z} \right] $$
其中 $Z = e^0 + e^{0.707} + e^{0.707} \approx 1 + 2.028 + 2.028 = 5.056$。
所以第一行:
$$ [0.198,\ 0.401,\ 0.401] $$
含义:第一个 token 更关注第二个和第三个 token。
3.5 为什么需要 Masked Attention?
训练语言模型时,模型只能看到当前位置及之前的 token,不能偷看答案。所以要用一个上三角为 $-\infty$ 的 Mask:
0 -∞ -∞
0 0 -∞
0 0 0
这样 softmax 之后,当前位置看不到后面的词。
3.6 使用注意点 / 避坑
| 坑 | 现象 | 处理 |
|---|---|---|
| $d_k$ 算错 | Attention 分数尺度异常,训练不稳定 | 检查 head 维度 = 总维度 / head 数 |
| 忘记 Mask | 模型作弊,直接看到未来 token,loss 特别低但推理崩溃 | 训练时加 causal mask |
| Softmax 数值溢出 | $e^{1000}$ 变成 Inf | 实现时先做 x - max(x) 再 exp |
| 注意力权重全一样 | Q/K 初始化或学习率问题 | 检查初始化、LayerNorm 位置 |
3.7 异常场景处理
- Attention 权重集中在某一个位置:可能是某个 token 的 Key 向量特别大,检查是否没做 scale,或 LayerNorm 失效。
- Loss 很低但生成质量差:可能是 mask 写错,训练时模型偷看了未来词,推理时却没有未来词可用。
- Multi-Head Attention 输出震荡:不同 head 学到的模式冲突,适当增加 warmup 轮数。
四、前向传播:从输入到预测概率
4.1 一句话理解
前向传播就是数据从输入层一直流到输出层,最后得到“下一个词是词表里每个词的概率”。
4.2 计算图
flowchart LR
X["输入 Token IDs"] --> E["Embedding"]
E --> PE["+ 位置编码 Positional Encoding"]
PE --> TB["Transformer Blocks × N"]
TB --> LN["Final LayerNorm"]
LN --> LM["线性层 Linear"]
LM --> Soft["Softmax"]
Soft --> P["概率分布 P(next token | previous)"]
4.3 输出层公式
Transformer 最后一层的输出 $\mathbf{H} \in \mathbb{R}^{T \times d}$,乘以输出权重矩阵 $\mathbf{W}_{\text{out}} \in \mathbb{R}^{d \times V}$,得到 logits:
$$ \mathbf{Z} = \mathbf{H} \mathbf{W}_{\text{out}} + \mathbf{b}_{\text{out}} \in \mathbb{R}^{T \times V} $$
对 logits 做 softmax,得到概率:
$$ P(w_t = j \mid w_{<t}) = \frac{e^{z_{t,j}}}{\sum_{k=1}^{V} e^{z_{t,k}}} $$
4.4 计算例子
假设词表大小 $V=5$,某一步的 logits:
$$ \mathbf{z} = [2.0,\ 1.0,\ 0.5,\ 0.1,\ -1.0] $$
先求 $e^{z_i}$:
$$ e^{2.0} \approx 7.389,\ e^{1.0} \approx 2.718,\ e^{0.5} \approx 1.649,\ e^{0.1} \approx 1.105,\ e^{-1.0} \approx 0.368 $$
求和:
$$ Z = 7.389 + 2.718 + 1.649 + 1.105 + 0.368 = 13.229 $$
概率分布:
$$ \mathbf{p} = [0.558,\ 0.205,\ 0.125,\ 0.084,\ 0.028] $$
模型认为第一个词概率最大,为 55.8%。
4.5 使用注意点 / 避坑
- logits 尺度影响 softmax:如果 logits 都很大,softmax 会接近 one-hot,梯度变小;温度参数 $T$ 可以调整:
$$ P_i = \frac{e^{z_i / T}}{\sum_j e^{z_j / T}} $$
- 输出层权重共享(Weight Tying):很多 LLM 把 Embedding 矩阵和输出层矩阵共享,减少参数并提升效果。
- 推理时不要复用训练时的 dropout:切换到 eval 模式,否则结果不稳定。
4.6 异常场景处理
- Softmax 输出全 0 或 NaN:检查 logits 是否 overflow,实现时减去最大值。
- 生成结果重复同一个 token:可能是温度太低、top-k/top-p 设置不当,或训练数据里该模式重复过多。
- 概率分布太平:模型不自信,增加训练或检查标签是否正确。
五、损失函数:Cross Entropy Loss
5.1 一句话理解
Cross Entropy 衡量“模型预测的分布”和“真实分布”之间的距离。预测越准,loss 越小。
5.2 数学公式
对于下一个 token 的真实标签是第 $c$ 个词(one-hot 向量 $\mathbf{y}$),模型预测概率为 $\mathbf{p}$:
$$ \mathcal{L} = -\sum_{j=1}^{V} y_j \log(p_j) = -\log(p_c) $$
因为只有真实位置 $c$ 的 $y_c=1$,其他为 0,所以 loss 简化为 负对数似然。
5.3 计算例子
真实下一个词是第 1 个(从 0 开始数也是第 1 个),模型预测概率:
$$ \mathbf{p} = [0.558,\ 0.205,\ 0.125,\ 0.084,\ 0.028] $$
真实标签 $c=1$,所以 $p_c = 0.205$:
$$ \mathcal{L} = -\log(0.205) \approx 1.585 $$
如果模型完全猜对,$p_c = 0.999$:
$$ \mathcal{L} = -\log(0.999) \approx 0.001 $$
如果完全猜错,$p_c = 0.001$:
$$ \mathcal{L} = -\log(0.001) \approx 6.908 $$
loss 越小,模型对真实下一个词的预测概率越高。
5.4 一个 batch 的平均 loss
训练时一个 batch 有 $B$ 个样本,每个样本有 $T$ 个 token 位置参与预测,平均 loss:
$$ \mathcal{L}_{\text{batch}} = -\frac{1}{B \cdot T} \sum_{b=1}^{B} \sum_{t=1}^{T} \log p\left(w_t^{(b)} \mid w_{<t}^{(b)}\right) $$
5.5 使用注意点 / 避坑
| 坑 | 现象 | 处理 |
|---|---|---|
| 标签 smoothing 过度 | 模型变得不自信,输出模糊 | label smoothing 一般 0.1 左右 |
| 忽略 padding | padding 位置也参与 loss 计算,污染梯度 | 用 mask 屏蔽 padding |
| loss 为负 | 对数定义域问题或实现 bug | 检查 softmax 前是否减了最大值 |
| z-loss 没加 | MoE 模型路由不稳定 | 加 auxiliary loss 约束专家负载 |
5.6 异常场景处理
- Loss 一开始很大然后骤降:正常现象,说明模型快速学习简单模式。
- Loss 一直不下降:检查学习率、数据质量、标签是否正确。
- Loss 变成 NaN:通常是学习率过大、梯度爆炸或出现了 $\log(0)$,加 epsilon 或做梯度裁剪。
六、反向传播:从损失回到每个权重
6.1 一句话理解
反向传播就是用链式法则,从输出层的 loss 倒推,算出每个权重对 loss 的贡献。
6.2 链式法则回顾
复合函数求导:
$$ \frac{\partial \mathcal{L}}{\partial w} = \frac{\partial \mathcal{L}}{\partial a} \cdot \frac{\partial a}{\partial z} \cdot \frac{\partial z}{\partial w} $$
神经网络就是很多层复合函数套在一起,反向传播只是系统化地算一遍。
6.3 计算例子:一个简单的输出层权重
假设模型最后一层只有一个神经元,简化为:
$$ \begin{aligned} z &= w \cdot h + b \\ p &= \sigma(z) \\ \mathcal{L} &= -y \log(p) - (1-y)\log(1-p) \end{aligned} $$
其中 $h$ 是 Transformer 最后一层的某个隐藏状态,$y$ 是真实标签(0 或 1)。
已知:$h=2.0$,$w=0.5$,$b=-1.0$,$y=1$。
前向:
$$ \begin{aligned} z &= 0.5 \times 2.0 + (-1.0) = 0 \\ p &= \sigma(0) = 0.5 \\ \mathcal{L} &= -\log(0.5) \approx 0.693 \end{aligned} $$
反向:
$$ \frac{\partial \mathcal{L}}{\partial z} = p - y = 0.5 - 1 = -0.5 $$
然后:
$$ \begin{aligned} \frac{\partial \mathcal{L}}{\partial w} &= \frac{\partial \mathcal{L}}{\partial z} \cdot \frac{\partial z}{\partial w} = -0.5 \times 2.0 = -1.0 \\ \frac{\partial \mathcal{L}}{\partial b} &= \frac{\partial \mathcal{L}}{\partial z} \cdot \frac{\partial z}{\partial b} = -0.5 \times 1 = -0.5 \end{aligned} $$
更新(学习率 $\eta = 0.1$):
$$ \begin{aligned} w_{\text{new}} &= 0.5 - 0.1 \times (-1.0) = 0.6 \\ b_{\text{new}} &= -1.0 - 0.1 \times (-0.5) = -0.95 \end{aligned} $$
再算一次前向,$z = 0.6 \times 2.0 - 0.95 = 0.25$,$p = \sigma(0.25) \approx 0.562$,loss 变小。
6.4 Transformer 里的反向传播
flowchart TD
L["Loss"] --> O["输出层梯度"]
O --> TB["Transformer Block N"]
TB --> TB2["Transformer Block N-1"]
TB2 --> DOTS["..."]
DOTS --> E["Embedding 层"]
E --> G["所有权重梯度"]
6.5 使用注意点 / 避坑
| 坑 | 现象 | 处理 |
|---|---|---|
| 梯度消失 | 深层网络前面层梯度接近 0,学不动 | 残差连接、LayerNorm、更好的初始化 |
| 梯度爆炸 | loss 突然变成 NaN,权重更新巨大 | 梯度裁剪、减小学习率、混合精度检查 |
| 激活函数死区 | ReLU 神经元永久输出 0 | 用 GeLU/SiLU,或用 SwiGLU |
| checkpoint 丢失梯度 | 梯度检查点实现错误 | 验证 checkpoint 前后梯度一致 |
6.6 异常场景处理
- 某些层梯度全 0:检查激活函数是否全部死亡,或该层是否被 freeze。
- 不同层梯度差 1000 倍:深层网络不稳定,加梯度裁剪或 LayerNorm。
- Loss 反升:学习率过大或数据里有异常样本,先降学习率再查数据。
七、优化器:AdamW + 学习率调度
7.1 一句话理解
AdamW 是 Adam 的改进版,它在更新权重的同时做真正的权重衰减(Weight Decay),让模型参数不要太大,防止过拟合。
7.2 Adam 更新公式
对于参数 $\theta$ 和梯度 $g_t$:
$$ \begin{aligned} m_t &= \beta_1 m_{t-1} + (1 - \beta_1) g_t \\ v_t &= \beta_2 v_{t-1} + (1 - \beta_2) g_t^2 \\ \hat{m}_t &= \frac{m_t}{1 - \beta_1^t} \\ \hat{v}_t &= \frac{v_t}{1 - \beta_2^t} \\ \theta_t &= \theta_{t-1} - \eta \cdot \frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon} \end{aligned} $$
- $m_t$:一阶动量(梯度的指数移动平均)。
- $v_t$:二阶动量(梯度平方的指数移动平均)。
- $\beta_1=0.9$,$\beta_2=0.999$,$\epsilon=10^{-8}$ 是常用默认值。
7.3 AdamW 的权重衰减
AdamW 把权重衰减放在梯度更新之外:
$$ \theta_t = \theta_{t-1} - \eta \cdot \frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon} - \eta \lambda \theta_{t-1} $$
其中 $\lambda$ 是 weight decay 系数,常见值 $0.01$。
7.4 计算例子
假设参数 $\theta_{t-1} = 1.0$,梯度 $g_t = -2.0$,学习率 $\eta = 0.1$,$\lambda = 0.01$。
为了简化,假设 $m_t \approx g_t = -2.0$,$v_t \approx g_t^2 = 4.0$(已经稳定)。
Adam 部分:
$$ \Delta \theta_{\text{Adam}} = -\eta \cdot \frac{g_t}{\sqrt{g_t^2} + \epsilon} = -0.1 \cdot \frac{-2.0}{2.0 + 10^{-8}} \approx 0.1 $$
Weight Decay 部分:
$$ \Delta \theta_{\text{WD}} = -\eta \lambda \theta_{t-1} = -0.1 \times 0.01 \times 1.0 = -0.001 $$
最终更新:
$$ \theta_t = 1.0 + 0.1 - 0.001 = 1.099 $$
没有 weight decay 时 $\theta_t = 1.1$,AdamW 让它稍微小一点。
7.5 学习率调度
LLM 训练通常用 warmup + cosine decay:
flowchart LR
A["lr = 0"] --> B["Warmup 线性上升"]
B --> C["Cosine Decay 下降"]
C --> D["最小学习率保持"]
公式:
$$ \eta_t = \begin{cases} \eta_{\max} \cdot \frac{t}{t_{\text{warmup}}}, & t < t_{\text{warmup}} \\ \eta_{\min} + \frac{1}{2}(\eta_{\max} - \eta_{\min}) \left(1 + \cos\left(\frac{t - t_{\text{warmup}}}{t_{\max} - t_{\text{warmup}}} \pi\right)\right), & t \ge t_{\text{warmup}} \end{cases} $$
7.6 使用注意点 / 避坑
| 坑 | 现象 | 处理 |
|---|---|---|
| Weight decay 太大 | 参数被压得太小,欠拟合 | 常见 0.01,小模型可 0.1 |
| Warmup 太短 | 早期梯度爆炸,loss 变成 NaN | warmup 步数设总步数的 1%-3% |
| 学习率衰减太快 | 还没收敛就降到 0 | 调整 cosine 周期或最小学习率 |
| AdamW β 参数写错 | 训练异常 | β1=0.9,β2=0.999,不要混淆 |
7.7 异常场景处理
- Loss 在 warmup 阶段就 NaN:说明峰值学习率太高,或 warmup 太短,降低 $\eta_{\max}$。
- Loss 降到一定程度后停滞:可能是学习率太小,检查 cosine 是否已经进入尾部。
- 验证 loss 比训练 loss 高很多:过拟合,增大 weight decay 或加 dropout。
八、从预训练到 RLHF:三阶段训练
8.1 预训练:让模型“博览群书”
目标函数:
$$ \mathcal{L}_{\text{PT}} = -\mathbb{E}_{x \sim D_{\text{pretrain}}} \left[ \sum_t \log P(x_t \mid x_{<t}; \theta) \right] $$
数据量通常是万亿级 token,算力消耗最大。
8.2 SFT:监督微调
用人工标注的 (prompt, answer) 对训练:
$$ \mathcal{L}_{\text{SFT}} = -\mathbb{E}_{(x,y) \sim D_{\text{sft}}} \left[ \sum_t \log P(y_t \mid x, y_{<t}; \theta) \right] $$
注意:通常只计算 answer 部分的 loss,prompt 部分用 mask 屏蔽。
8.3 RLHF:人类反馈强化学习
flowchart TD
P["Prompt"] --> RM["奖励模型 Reward Model"]
RM --> R["奖励分数 r"]
R --> PPO["PPO 优化策略模型"]
PPO --> M["更新后的模型"]
奖励模型给回答打分,PPO 用策略梯度优化:
$$ \nabla_\theta J = \mathbb{E}_{x,y} \left[ r(x,y) \cdot \nabla_\theta \log P(y \mid x; \theta) \right] - \beta \cdot \text{KL}(P_\theta | P_{\text{ref}}) $$
第二项是 KL 散度约束,防止模型跑得太偏。
8.4 DPO:更简单的对齐方法
DPO(Direct Preference Optimization)不用训练奖励模型,直接用偏好数据优化:
$$ \mathcal{L}_{\text{DPO}} = -\mathbb{E}_{(x, y_w, y_l)} \left[ \log \sigma\left( \beta \log \frac{P(y_w \mid x)}{P_{\text{ref}}(y_w \mid x)} - \beta \log \frac{P(y_l \mid x)}{P_{\text{ref}}(y_l \mid x)} \right) \right] $$
其中 $y_w$ 是人类更喜欢的回答,$y_l$ 是较差的回答。
8.5 使用注意点 / 避坑
| 坑 | 现象 | 处理 |
|---|---|---|
| SFT 数据太少 | 模型学会模板但泛化差 | SFT 数据通常 10k-1M 条 |
| SFT 只训 answer | prompt 也参与 loss 会干扰 | 用 label mask 只保留 answer |
| RLHF 奖励黑客 | 模型钻奖励模型漏洞,生成无意义高分回答 | 加 KL 约束、人工抽检 |
| DPO 偏好数据噪声 | 模型对齐方向乱 | 严格清洗偏好对 |
8.6 异常场景处理
- SFT 后模型不会遵循指令:检查 prompt 格式是否和预训练一致,增加多轮对话样本。
- RLHF 后模型过度讨好:奖励模型可能偏向“长回答”或“道歉”,调整奖励函数。
- 对齐后模型能力下降:KL 系数太大,模型被锁死;太小,模型跑偏。需要调 $\beta$。
九、PyTorch 实战:一个可运行的 MiniGPT Demo
9.1 一句话理解
前面几节的公式,在 PyTorch 里其实只有几行代码:前向传播是 model(x),交叉熵是 F.cross_entropy,反向传播是 loss.backward(),AdamW 更新是 optimizer.step()。下面这个约 70 行的脚本可以在 CPU 上直接跑通完整训练流程。
9.2 完整代码(CPU 可运行)
"""MiniGPT:用 PyTorch 跑通 LLM 训练全流程"""
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
torch.manual_seed(42)
# ===== 0. 复现 3.4 节的 Attention 手算例子 =====
X = torch.tensor([[1, 0, 1, 0], [0, 1, 0, 1], [1, 1, 0, 0]], dtype=torch.float32)
WQ = torch.tensor([[1, 0], [0, 1], [0, 0], [0, 0]], dtype=torch.float32)
WK = torch.tensor([[0, 1], [1, 0], [0, 0], [0, 0]], dtype=torch.float32)
Q, K = X @ WQ, X @ WK
attn = torch.softmax(Q @ K.T / math.sqrt(2), dim=-1)
print("Attention 第一行:", attn[0].tolist()) # ≈ [0.198, 0.401, 0.401],与手算一致
# ===== 1. 数据预处理与 Tokenization(对应第二节)=====
# 玩具语料:字符级分词,真实 LLM 用的是 BPE
corpus = "北京是中国的首都。北京有天安门。中国的首都是北京。" * 20
chars = sorted(set(corpus))
stoi = {ch: i for i, ch in enumerate(chars)}
itos = {i: ch for ch, i in stoi.items()}
V = len(chars)
data = torch.tensor([stoi[ch] for ch in corpus], dtype=torch.long)
# ===== 2. 模型:Embedding + Transformer Block + 输出层(第三、四节)=====
class MiniGPT(nn.Module):
def __init__(self, vocab_size, d_model=64, n_head=4, n_layer=2, max_len=32):
super().__init__()
self.max_len = max_len
self.tok_emb = nn.Embedding(vocab_size, d_model)
self.pos_emb = nn.Embedding(max_len, d_model)
layer = nn.TransformerEncoderLayer(d_model, n_head, dim_feedforward=4 * d_model,
batch_first=True, norm_first=True)
self.blocks = nn.TransformerEncoder(layer, num_layers=n_layer)
self.ln_f = nn.LayerNorm(d_model)
self.head = nn.Linear(d_model, vocab_size, bias=False)
self.head.weight = self.tok_emb.weight # Weight Tying(4.5 节)
def forward(self, idx):
T = idx.size(1)
x = self.tok_emb(idx) + self.pos_emb(torch.arange(T, device=idx.device))
# causal mask:上三角为 -inf,防止偷看未来 token(3.5 节)
mask = torch.triu(torch.full((T, T), float("-inf"), device=idx.device), diagonal=1)
h = self.ln_f(self.blocks(x, mask=mask))
return self.head(h) # logits: (B, T, V)
model = MiniGPT(V)
# ===== 3. 取 batch:输入和标签错开一位(预测下一个 token)=====
B, T = 16, 32
def get_batch():
ix = torch.randint(0, len(data) - T - 1, (B,))
x = torch.stack([data[i:i + T] for i in ix])
y = torch.stack([data[i + 1:i + T + 1] for i in ix]) # 标签 = 输入右移一位
return x, y
# ===== 4. 优化器与学习率调度:AdamW + warmup + cosine(第七节)=====
max_steps, warmup_steps, lr_max, lr_min = 300, 10, 3e-3, 3e-4
optimizer = torch.optim.AdamW(model.parameters(), lr=lr_max, weight_decay=0.01)
def lr_lambda(step):
if step < warmup_steps: # warmup 线性上升
return step / warmup_steps
p = (step - warmup_steps) / (max_steps - warmup_steps)
return lr_min / lr_max + 0.5 * (1 - lr_min / lr_max) * (1 + math.cos(math.pi * p))
scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)
# ===== 5. 训练循环:前向 → CE loss → 反向传播 → 更新(第四、五、六节)=====
for step in range(max_steps):
x, y = get_batch()
logits = model(x) # 前向传播
loss = F.cross_entropy(logits.view(-1, V), y.view(-1)) # 交叉熵(5.2 节)
optimizer.zero_grad()
loss.backward() # 反向传播(链式法则)
grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) # 梯度裁剪(6.5 节)
optimizer.step() # AdamW 更新权重
scheduler.step()
if step % 30 == 0 or step == max_steps - 1:
lr = scheduler.get_last_lr()[0]
ppl = math.exp(loss.item()) # perplexity(第十节第 5 条)
print(f"step {step:4d} | loss {loss.item():.4f} | ppl {ppl:7.2f} | lr {lr:.5f} | grad {grad_norm:.2f}")
# ===== 6. 生成:贪心解码 =====
model.eval()
idx = torch.tensor([[stoi["北"]]], dtype=torch.long)
for _ in range(15):
logits = model(idx[:, -model.max_len:])
nxt = logits[0, -1].argmax() # 贪心:取概率最大的 token
idx = torch.cat([idx, nxt.view(1, 1)], dim=1)
print("生成:", "".join(itos[i] for i in idx[0].tolist()))运行方式:
pip install torch # CPU 版即可
python demo.py9.3 实际运行输出
在 CPU(torch 2.5.1)上跑一遍,输出如下:
Attention 第一行: [0.1978, 0.4011, 0.4011]
词表大小 V=13, 总 token 数=500
参数量: 102976
step 0 | loss 41.3162 | ppl 8.8e+17 | lr 0.00030 | grad 14.51
step 30 | loss 0.6630 | ppl 1.94 | lr 0.00297 | grad 1.07
step 60 | loss 0.2738 | ppl 1.32 | lr 0.00280 | grad 1.08
step 90 | loss 0.0683 | ppl 1.07 | lr 0.00251 | grad 0.28
step 120 | loss 0.0576 | ppl 1.06 | lr 0.00214 | grad 0.24
step 150 | loss 0.0315 | ppl 1.03 | lr 0.00171 | grad 0.20
step 180 | loss 0.0456 | ppl 1.05 | lr 0.00127 | grad 0.21
step 210 | loss 0.0325 | ppl 1.03 | lr 0.00088 | grad 0.18
step 240 | loss 0.0330 | ppl 1.03 | lr 0.00057 | grad 0.20
step 270 | loss 0.0369 | ppl 1.04 | lr 0.00037 | grad 0.11
step 299 | loss 0.0458 | ppl 1.05 | lr 0.00030 | grad 0.20
生成: 北京有天安门。中国的首都是北京。
几个值得注意的点:
- Attention 第一行
[0.198, 0.401, 0.401]和 3.4 节手算结果完全一致。 - step 0 的 loss 高达 41,远高于随机猜测的理论值 $\ln 13 \approx 2.56$——这是 Weight Tying 下随机初始化的 Embedding 同时当输出层,产生了极端 logits。30 步内就降到 2.56 以下,说明 AdamW + warmup 很快把它拉回正常区间。
- 最终 loss 降到 0.03 左右(PPL ≈ 1.03),模型基本“背”下了玩具语料,所以贪心生成能接出通顺的句子。
9.4 代码与前文章节的对应关系
| 代码片段 | 对应章节 | 关键点 |
|---|---|---|
stoi / itos 字符级分词 |
第二节 | 真实场景换成 BPE(如 tiktoken) |
nn.Embedding |
2.4 节 | 就是查表 $\mathbf{E}[id]$ |
Q @ K.T / math.sqrt(2) |
3.3 节 | Scaled Dot-Product Attention |
torch.triu(..., "-inf", diagonal=1) |
3.5 节 | Causal mask,忘了它模型就作弊 |
self.head.weight = self.tok_emb.weight |
4.5 节 | Weight Tying |
F.cross_entropy(logits, y) |
第五节 | 内部已做 softmax + 负对数似然,数值稳定 |
loss.backward() |
第六节 | 自动完成整条链式法则 |
clip_grad_norm_(..., 1.0) |
6.5 节 | 梯度裁剪防 NaN |
torch.optim.AdamW |
7.2、7.3 节 | $\beta_1=0.9, \beta_2=0.999$ 是默认值 |
LambdaLR + lr_lambda |
7.5 节 | warmup + cosine decay |
math.exp(loss.item()) |
第十节第 5 条 | PPL $= e^{\mathcal{L}}$ |
9.5 使用注意点 / 避坑
| 坑 | 现象 | 处理 |
|---|---|---|
忘记 optimizer.zero_grad() |
梯度跨 step 累积,loss 曲线异常 | 每个 step 先清零梯度 |
| 训练/推理忘了切模式 | dropout 在推理时还生效,结果不稳定 | 训练 model.train(),生成 model.eval() |
cross_entropy 前手动 softmax |
数值溢出、loss 变 NaN | 直接传 logits,PyTorch 内部已做稳定化 |
| 标签没右移 | 模型在学“复制当前 token”,loss 极低但没学会预测 | y = x 右移一位 |
| mask 写成下三角 -inf | 每个 token 只能看自己,模型学不到上下文 | 上三角才是屏蔽未来 |
| 玩具语料 loss 降不到 0 就慌 | 属正常,随机性 + 容量限制 | 先固定种子,确认能过拟合单 batch |
9.6 动手练习建议
- 把
lr_max从3e-3改成3e-2,观察 loss 是否 NaN(体会 warmup 和梯度裁剪的作用)。 - 注释掉 causal mask,看 loss 是不是“假低”——训练 loss 很低,但生成崩了。
- 把
n_layer从 2 加到 6,观察梯度范数变化和训练速度。 - 把玩具语料换成一段真实文本(如《论语》),词表改为字级,看 PPL 能降到多少。
- 用
torch.autograd.grad手动验证 6.3 节手算的梯度 $\partial \mathcal{L} / \partial w = -1.0$。
十、通用训练避坑指南
下面这 12 条适用于 LLM 全阶段:
- 数据质量 > 数据数量:脏数据会让模型学会错误模式,先清洗再训练。
- 固定随机种子:方便复现和对比实验。
- 从小规模开始验证:先用 1B 参数或少量数据跑通 pipeline。
- 监控 loss 曲线:训练 loss、验证 loss、perplexity 一起画。
- perplexity 是核心指标:
$$ \text{PPL} = e^{\mathcal{L}} $$
PPL 越低越好,一般预训练 PPL 在 2-10 之间。
- 检查梯度范数:突然变大说明要梯度裁剪。
- 学习率是最重要的超参数:先按论文默认值,再按 10 倍上下调。
- Batch size 影响稳定性:大 batch 需要更大学习率,小 batch 噪声大但泛化好。
- 不要用测试集调参:会过拟合测试集。
- 注意数据泄漏:训练集和测试集要去重。
- 混合比例要平衡:代码、中文、英文、数学数据比例影响模型能力。
- 保存多个 checkpoint:不要只存最后一个,中间可能有更好的模型。
十一、异常场景与业务异常处理
11.1 训练阶段异常
| 异常 | 可能原因 | 处理 |
|---|---|---|
| Loss = NaN / Inf | 学习率过大、梯度爆炸、log(0) | 降学习率、梯度裁剪、检查 softmax 实现 |
| Loss 完全不降 | 学习率太小、数据损坏、标签错误 | 调大 lr、抽查数据、检查 mask |
| 训练 loss 降,验证 loss 升 | 过拟合 | 早停、加 dropout、增数据、加正则 |
| 训练/验证 loss 都高 | 欠拟合 | 加模型容量、训练更久、减正则 |
| 显存 OOM | batch 太大、序列太长、模型太大 | 降 batch、用 gradient accumulation、激活检查点 |
| 分布式训练卡住 | 通信问题、某些 GPU 慢 | 检查 NCCL、负载均衡、all-reduce 同步 |
| 某些 token 生成重复 | 训练数据偏差或解码参数问题 | 调 temperature、top-p、加重复惩罚 |
11.2 部署/业务阶段异常
| 业务异常 | 表现 | 处理 |
|---|---|---|
| 概念漂移 | 线上数据分布变了,效果下降 | 监控输入分布、定期重训、在线学习 |
| 输入越界 | 用户输入超长、乱码、恶意 prompt | 长度截断、输入校验、内容安全过滤 |
| 模型幻觉 | 生成看似合理但错误的内容 | RAG 检索增强、增加事实性数据、后验校验 |
| 输出有害内容 | 生成歧视、违法、不当内容 | 安全对齐、输出拦截、人工审核 |
| 推理成本过高 | 模型太大,延迟/费用超标 | 量化、蒸馏、剪枝、换小模型 |
| 长尾问题答不好 | 冷门领域准确率低 | 领域 SFT、RAG、专家模型路由 |
11.3 一个实用的排查 checklist
□ 输入 token 范围对吗?(padding 是否被 mask?)
□ 标签 mask 对吗?(SFT 是否只算 answer loss?)
□ 损失函数和输出层匹配吗?(CE for classification)
□ 学习率调度正常吗?(warmup + decay)
□ 权重还有在更新吗?(打印梯度范数)
□ 单条数据能过拟合吗?(Sanity Check)
□ 分布式通信正常吗?(loss 同步一致)
□ 显存占用符合预期吗?(避免 OOM)
十二、总结
| 环节 | 核心作用 | 一句话口诀 |
|---|---|---|
| Tokenization | 文本 → 数字 | 高频子词合并,查表得向量 |
| Embedding | ID → 向量 | 每个 token 对应一行稠密向量 |
| Transformer | 提取上下文关系 | QK 点积算 attention,再乘 V |
| 输出层 + Softmax | 生成概率分布 | 猜下一个词是哪个 |
| Cross Entropy | 衡量预测好坏 | 真实词概率越大,loss 越小 |
| 反向传播 | 计算每个权重梯度 | 链式法则从后往前传 |
| AdamW | 更新权重 | 动量 + 二阶矩 + 真正 weight decay |
| LR 调度 | 控制学习节奏 | 先 warmup,再 cosine 下降 |
| SFT / RLHF | 对齐人类偏好 | 先模仿人类回答,再优化偏好 |
如果你是第一次接触 LLM 训练,建议按这个顺序动手:
- 先用一个小型 Transformer(如 nanoGPT)跑通“下一个 token 预测”。
- 改改学习率、batch size、warmup,观察 loss 曲线变化。
- 打印 attention 权重,看看模型在关注哪些 token。
- 用少量 SFT 数据微调,体会 mask answer 的重要性。
- 最后尝试 DPO,理解偏好对齐的数学本质。
本文档持续维护中。如果有哪一步的公式或例子让你卡住,欢迎在评论区指出,我会继续用更直观的方式补充图解和代码。