从原始文本到对齐模型:train-llm-from-scratch 的 LLM 基础原理与全流程实践指南
【免费下载链接】train-llm-from-scratchA straightforward method for training your LLM, from downloading data to generating text.项目地址: https://gitcode.com/GitHub_Trending/tr/train-llm-from-scratch
导读
本篇技术指南以 train-llm-from-scratch 仓库的docs/foundations/文档体系为骨架,系统讲解现代 decoder-only 大语言模型的六大核心概念:Tokenization 与数据形态、Decoder-only Transformer 架构、Causal Attention、目标函数与损失、优化与训练系统、生成与采样。它不是 PyTorch 入门教程,而是连接"仓库源码文件"与"大模型底层原理"的桥梁——读完你将能够把src/models/中的每个模块与论文中的每个公式一一对应,并理解 SFT、奖励模型、DPO、PPO、GRPO 等后训练阶段是如何在同一套骨干网络上通过"更换数据与损失"实现的。
一、整个训练故事:从原始文本到聊天模型
本仓库贯穿始终的核心思想可以用一条流水线概括:把文本变成数字,预测下一个 token,然后不断更换数据与损失,直到模型学会我们想要的行为。
仓库 README 中的images/00_pipeline.png给出了同一条完整路径的可视化版本(从 raw text 到 tokens、Transformer、next-token loss、base model,再到 SFT、Reward Model、PPO/DPO、GRPO 与 evaluation/chat)。
1.1 LLM 的本质:条件概率模型
在最底层,LLM 是一个条件概率模型,将整个 token 序列的联合概率分解为每个位置上"给定前文、预测下一个 token"的条件概率之积:
[ p_\theta(x_1, x_2, \ldots, x_T) = \prod_{t=1}^{T} p_\theta(x_t \mid x_{<t}) ]
理解这一点至关重要:Transformer 并不是直接学习"真理",它学习的只是一个"下一个 token"的概率分布。之所以这个任务能催生大量有用行为,是因为"预测下一个 token"这一压力会迫使模型把语法、事实、格式、风格甚至推理轨迹全部压缩进权重中——这是预训练阶段一切能力的来源。
二、每个概念在仓库中的落点
docs/foundations/README.md给出了一张"概念 → 代码"映射表,这是阅读本仓库的最高效索引。以下表格完整保留原文档内容,并补充了每个文件在仓库中的实际作用:
| 概念 | 为什么重要 | 仓库中的主要代码 |
|---|---|---|
| Tokenization | 文本必须先变成整数 id,模型才能训练 | scripts/prepare_pretrain_data.py、src/post_training/chat_template.py |
| 固定上下文窗口 | 训练样本是连续的 token 窗口 | data_loader/data_loader.py |
| Token 与位置嵌入 | 整数 id 变成向量,并携带位置信息 | src/models/transformer.py |
| Causal self-attention | 每个 token 只与前序 token 交换信息 | src/models/attention.py |
| Transformer block | Attention 加 MLP,pre-norm 残差结构 | src/models/transformer_block.py |
| Logits | 隐藏状态变成未归一化的词表得分 | src/models/transformer.py |
| Cross-entropy | 基础目标:奖励真实下一个 token 的概率 | src/models/transformer.py、src/post_training/sft.py |
| AdamW 与学习率调度 | 决定训练是否稳定的优化细节 | src/post_training/optim.py |
| 梯度累积 | 在显存限制下模拟更大的 batch | scripts/pretrain_base.py、scripts/train_sft.py |
| 生成 | 模型把采样出的 token 重新喂回自己 | src/models/transformer.py、src/post_training/inference.py |
可以看到,预训练与后训练共用同一套骨干代码,这正是本仓库最重要的设计思想:同一个 backbone 被反复复用(详见第五节"心智模型")。
三、推荐学习路径
原文档给出的阅读顺序非常讲究,它从"数据形态"出发,沿着前向传播的自然顺序展开:
- Tokenization 与数据形态 —— 文本如何变成 batch;
- Decoder-only Transformer —— 模型骨架;
- Attention、Mask 与多头 —— 核心算子;
- 目标、损失与困惑度 —— 模型被优化去做的事;
- 优化与训练系统 —— 训练循环如何保持稳定;
- 生成与采样 —— logits 如何变成文本。
读完这六篇基础文档后,再继续进入流水线各阶段页面:
- 数据处理
- 预训练
- SFT
- 奖励模型
- DPO / ORPO / KTO
- PPO
- GRPO / RLVR
四、六大基础概念深度展开
以下六节对应上述六个基础文档,是整篇指南的技术主体。每节都结合仓库源码给出可验证的实现证据。
4.1 Tokenization 与数据形态
Transformer 永远看不到字符或单词,它只看到整数 token id。Tokenizer 是语言与张量之间的边界。
本仓库使用 OpenAI 的r50k_basetokenizer(通过tiktoken),关键参数如下:
- 词表大小
50304; - 文本结束 token
<|endoftext|>,id 为50256(在 src/post_training/chat_template.py 中定义为EOT_ID = 50256,它同时是唯一的真 special token 和生成停止 token); - 聊天使用纯文本角色标记(
<|user|>、<|assistant|>等),因为该 tokenizer 没有自定义聊天 token。这些标记只是普通的多 token 字符串,模型在 SFT 阶段像学习普通文本一样学会它们。
为什么需要子词(subword)分词?词级词表面对罕见人名、拼写错误、代码标识符、URL 和新词时词表会爆炸;字符级词表虽能覆盖一切,但会让序列过长。子词分词是折中方案:高频词可以是一个 token,罕见词可以被拆解。原始 BPE 思想很简单:从小的单元开始,反复合并高频相邻对,最终得到一组固定大小、可复用的片段。本仓库不训练自己的 tokenizer,直接复用r50k_base。
预训练的数据形态:一条长 token 流。预训练时,文档被转换成一个扁平数组:
[ [d_1, \text{EOT}, d_2, \text{EOT}, \ldots, d_N, \text{EOT}] ]
scripts/prepare_pretrain_data.py 流式读取 Pile 分片,逐文档 tokenize、追加 EOT,并写入 HDF5:
for ids in enc.encode_ordinary_batch(docs): buf.extend(ids) buf.append(EOT_ID) if len(buf) >= WRITE_CHUNK: flush()训练加载器(data_loader/data_loader.py 的get_batch_iterator)从 HDF5 的tokens数据集中随机切出长度为context_length + 1的窗口:前context_length个 token 是输入,后context_length个是右移一位的目标:
[ x = [t_0, t_1, \ldots, t_{T-1}] ]
[ y = [t_1, t_2, \ldots, t_T] ]
这个"移位"就是整个 next-token prediction 任务的全部。
SFT 的数据形态:token 加损失掩码。SFT 样本是对话。我们希望模型学习回答,而不是死记 prompt。因此数据包含两个对齐的数组:tokens(token id)和loss_mask(assistant 回答 token 为1,prompt token 为0)。聊天模板是纯文本:
<|user|> {question}<|endoftext|><|assistant|> {answer}<|endoftext|>核心实现在 src/post_training/chat_template.py:
content_ids = _encode_ordinary(m["content"]) is_completion = role == "assistant" ids.extend(content_ids) mask.extend([1 if is_completion else 0] * len(content_ids)) ids.append(EOT_ID) mask.append(1 if is_completion else 0)掩码与 token id 逐位对齐的对应关系:
| 片段 | 示例 | Mask |
|---|---|---|
| user 标记 | <|user|> | 0 |
| user 问题 | What is 2+2? | 0 |
| assistant 标记 | <|assistant|> | 0 |
| assistant 回答 | <answer>4</answer> | 1 |
| assistant EOT | <|endoftext|> | 1 |
偏好学习的数据形态:prompt、chosen、rejected。偏好学习使用成对数据{"prompt": "...", "chosen": "...", "rejected": "..."}。两个回答共享同一个 prompt,这一点很关键——DPO 和奖励模型要比较的是回答质量而非 prompt 难度。批处理时,chosen 与 rejected 两侧被 padding 到相同长度,但仓库会记录真实序列长度(seq_lengths),以便奖励模型读取最后一个真实 token 而非 padding token。
RL prompt 的数据形态:prompt 加可验证的金标准答案。PPO 和 GRPO 需要生成后能自动打分的 prompt:{"prompt": "Jan has 3 apples...", "gold": "12"}。verifier(校验器)从生成结果中抽取最终答案并与gold比较。这种"可验证奖励"不需要人工标注,也不需要学习的奖励模型。
常见数据形态 bug 清单:
| Bug | 症状 | 预防 |
|---|---|---|
| 目标未移位 | 模型学会了复制当前 token | 总是用tokens[:, :-1]预测tokens[:, 1:] |
| SFT loss 包含 prompt token | 模型浪费容量预测用户输入 | 使用loss_mask,只对掩码位置求平均 |
| 缺少 EOT | 模型学不会何时停止 | 在文档与 assistant 消息后都加上 EOT |
| 偏好数据 prompt 不一致 | 奖励/DPO 比较了不同任务 | 统一为"共享 prompt + chosen/rejected 回答" |
| padding 被当作奖励位置 | 奖励模型在无意义的 pad 隐藏状态上训练 | 记录seq_lengths,取最后一个真实 token |
4.2 Decoder-only Transformer
本仓库实现的是 GPT 风格的 decoder-only Transformer。"Decoder-only"意味着三点:
- 模型只读入一条 token 序列;
- 每个位置只能关注之前的位置;
- 每个位置的输出都是对下一个 token 的分布。
这正是自回归语言建模的正确架构:(p_\theta(x_t \mid x_{<t}))。
前向传播。前向路径为:token ids (B, T) → token embedding (B, T, C),与位置 id 0..T-1 → position embedding (T, C) 相加 → N 个 Block → 最终 LayerNorm →lm_headLinear(C→V) → logits (B, T, V)。
实现位于 src/models/transformer.py 的Transformer类:
self.token_embed = nn.Embedding(vocab_size, n_embed) self.position_embed = nn.Embedding(context_length, n_embed) self.attn_blocks = nn.ModuleList([ Block(n_head, n_embed, context_length) for _ in range(N_BLOCKS) ]) self.layer_norm = nn.LayerNorm(n_embed) self.lm_head = nn.Linear(n_embed, vocab_size)文档中统一使用的形状符号:
| 符号 | 含义 |
|---|---|
B | batch size |
T | 序列长度 / 上下文长度 |
C | 嵌入宽度,即n_embed |
H | 注意力头数 |
D | 头宽度,通常C / H |
V | 词表大小 |
嵌入层。Token id 是类别型变量,嵌入表是一个可学习的查找表 (E_{\text{tok}} \in \mathbb{R}^{V \times C}),token id (x_t) 的向量为 (e_t = E_{\text{tok}}[x_t])。模型同时学习绝对位置嵌入 (E_{\text{pos}} \in \mathbb{R}^{T_{\max} \times C})。第一个 block 的输入是:
[ h_t^{(0)} = E_{\text{tok}}[x_t] + E_{\text{pos}}[t] ]
源码中由_pre_attn_pass完成:
tok_embedding = self.token_embed(idx) pos_embedding = self.position_embed(self.pos_idxs[:T]) return tok_embedding + pos_embedding位置嵌入之所以必要,是因为 attention 本身是排列等变的(permutation-equivariant):没有位置信息,模型无法区分一个 token 是出现在开头、末尾还是中间。
Transformer block。每个 block(src/models/transformer_block.py)使用 pre-norm 残差结构:
x = x + self.attn(self.ln1(x)) x = x + self.mlp(self.ln2(x))数学上:(u = x + \text{MHA}(\text{LN}(x))),(y = u + \text{MLP}(\text{LN}(u)))。每个 block 承担两件事:attention 在 token 位置间搬运信息,MLP 独立变换每个位置。
为什么残差连接重要?残差 block 学习的是增量而非完整替换:(y = x + f(x))。如果某层暂时没用,它可以学习一个很小的更新、让信息直接通过。这使深层堆叠可训练,因为梯度有一条穿过加法运算的直接回传路径。
为什么 LayerNorm 放在子层之前?LayerNorm 沿特征维归一化每个 token 向量:(\text{LN}(x) = \gamma \odot \frac{x - \mu}{\sqrt{\sigma^2 + \epsilon}} + \beta),其中 (\mu = \frac{1}{C}\sum_i x_i)、(\sigma^2 = \frac{1}{C}\sum_i (x_i-\mu)^2)。本仓库使用 pre-norm(LN -> sublayer -> residual)而非 post-norm(sublayer -> residual -> LN)。pre-norm 是 GPT 类模型的常见选择,因为它通常让更深的堆叠更容易优化。
MLP / 前馈网络。block 中的 MLP(src/models/mlp.py):
self.hidden = nn.Linear(n_embed, 4 * n_embed) self.relu = nn.ReLU() self.proj = nn.Linear(4 * n_embed, n_embed)即 (\text{MLP}(x) = W_2 , \text{ReLU}(W_1 x + b_1) + b_2),其中 (W_1) 从 (C) 扩到 (4C),(W_2) 从 (4C) 投影回 (C)。attention 让 token 通信,MLP 在通信之后给每个 token 向量做非线性计算。
Logits 与语言模型头。经过最后一个 block 和最终 norm 后:(z_t = W_{\text{lm}} h_t + b_{\text{lm}}),得到 (z_t \in \mathbb{R}^{V}),即词表中每个 token 一个未归一化得分。概率分布由 softmax 给出:
[ p_\theta(x_{t+1}=i \mid x_{\leq t}) = \frac{\exp(z_{t,i})}{\sum_{j=1}^{V}\exp(z_{t,j})} ]
参数量直觉。忽略 bias 和 norm,每个 block 的粗略参数:attention 约 (4C^2)(Q、K、V 和输出投影各为 (C \times C)),MLP 约 (8C^2)((C \to 4C) 与 (4C \to C))。因此每个 block 约 (12C^2)。嵌入和 LM head 额外增加 (VC + CV)。本仓库不绑定token embedding 与输出 embedding,所以输入嵌入和lm_head是独立的两套参数矩阵。
仓库特定的架构选择:
| 选择 | 仓库实现 | 后果 |
|---|---|---|
| 绝对可学习位置 | nn.Embedding(context_length, n_embed) | 简单易读;上下文长度固定 |
| Causal mask | 每个 head 内的下三角 buffer | 防止未来 token 泄漏 |
| MLP 激活 | ReLU | 教学上简单;很多生产 GPT 用 GELU/SwiGLU 变体 |
| Dropout | 基础模块中不出现 | 减少代码噪音;正则主要靠数据和优化器选择 |
| 权重绑定 | 不使用 | 更易读;参数比绑定嵌入更多 |
| 后训练头 | 使用forward_hidden | 奖励/价值头复用同一骨干 |
值得一提的实现细节:Transformer还提供了forward_hidden(返回最终 LayerNorm 之后的隐藏状态,正是lm_head消费的张量),PPO 的标量价值头与奖励模型的标量奖励头正是通过它复用骨干网络,而不需要复制 forward 逻辑(见 src/models/transformer.py 的forward_hidden注释)。
4.3 Attention、Mask 与多头
Self-attention 是让每个 token 决定哪些前序 token 重要的运算。在 decoder-only 模型中,位置 (t) 只能使用位置 (0..t) 的信息,不能使用 (t+1..T-1) 的信息——这一限制让 next-token 训练变得"诚实"。
Attention 方程。对输入张量 (X \in \mathbb{R}^{B \times T \times C}),一个注意力头学习三个线性投影:
[ Q = X W_Q,\quad K = X W_K,\quad V = X W_V ]
其中 (Q,K,V \in \mathbb{R}^{B \times T \times D})。缩放点积注意力为:
[ \text{Attention}(Q,K,V) = \text{softmax}\left(\frac{QK^T}{\sqrt{D}} + M\right)V ]
掩码 (M) 在允许的位置为0,在未来位置为 (-\infty)。
Q/K/V 的直觉。对每个 token:query 是"我在找什么";key 是"我包含什么信息";value 是"如果被选中,我应该传递什么内容"。点积 (q_t \cdot k_s) 衡量 token (t) 想从 token (s) 获取多少信息。
为什么除以 (\sqrt{D})?如果 query/key 各分量近似单位方差,它们的点积方差与 (D) 成正比。头越大,logits 越大,softmax 会过于尖锐、梯度变弱。缩放因子让 attention logits 保持在更稳定的区间。
Causal masking。对 (T=5),允许的注意力模式是下三角矩阵(对角线及以下为 1,以上为 0)。仓库将其存为下三角 buffer,并在 softmax 之前掩掉未来位置(见 src/models/attention.py 的Head):
self.register_buffer("tril", torch.tril(torch.ones(context_length, context_length))) ... attn_weights = q @ k.transpose(-2, -1) * scale_factor attn_weights = attn_weights.masked_fill(self.tril[:T, :T] == 0, float("-inf")) attn_weights = F.softmax(attn_weights, dim=-1) out = attn_weights @ v因为未来位置的 logits 变为 (-\infty),它们的 softmax 概率变为零。
多头注意力。一个头只有一种注意力模式;多个头让模型并行学习多种模式:语法依赖、重复名称/实体、局部短语结构、分隔符与格式跟踪、算术或类代码依赖等。仓库创建n_head个独立的Head模块(src/models/attention.py 的MultiHeadAttention):
self.heads = nn.ModuleList([ Head(n_embed // n_head, n_embed, context_length) for _ in range(n_head) ]) self.proj = nn.Linear(n_embed, n_embed)然后拼接各头输出并经输出投影混合:
x = torch.cat([h(x) for h in self.heads], dim=-1) x = self.proj(x)若 (H) 个头各自输出宽度 (D=C/H),拼接后回到宽度 (C):(\text{Concat}(\text{head}_1,\ldots,\text{head}_H) \in \mathbb{R}^{B \times T \times C})。
Attention 的代价。注意力得分矩阵的形状为 (B \times T \times T)(每头)。(H) 个头时核心得分存储约为 (O(BHT^2))。这就是上下文长度昂贵的原因:(T) 翻倍,attention 矩阵规模约翻四倍。仓库的教学实现有意保持可读,直接物化了这些矩阵。
Attention 能做什么、不能做什么。Attention 在位置之间混合信息,但它本身不会:产生词表上的概率分布、在无位置信息时知道 token 顺序、在加权平均之外做非线性变换。这些工作由位置嵌入、MLP、LayerNorm、残差路径和最终 LM head 承担。
心智调试清单。训练表现异常时逐项自检:
- mask 是否因果?token 能看到答案吗?
q、k、v是否从同一个归一化输入投影而来?head_size = n_embed // n_head是否为整数?- 拼接所有头后是否恰好返回
n_embed通道? - 序列长度
T是否小于等于context_length?
4.4 目标、损失与困惑度
架构决定模型能算什么,目标函数决定训练奖励什么行为。对 decoder-only 语言模型,基础目标就是 next-token prediction。
从 logits 到概率。位置 (t) 处模型输出 logits (z_t \in \mathbb{R}^{V}),softmax 得到分布,目标是整数 token id (y_t),该位置的交叉熵为:
[ \ell_t = -\log p_\theta(y_t \mid x_{\leq t}) ]
batch loss 是所有位置的平均:
[ \mathcal{L}{\text{LM}} = -\frac{1}{BT}\sum{b=1}^{B}\sum_{t=1}^{T} \log p_\theta(y_{b,t} \mid x_{b,\leq t}) ]
移位。模型收到 (x = [t_0,t_1,\ldots,t_{T-1}]),预测 (y = [t_1,t_2,\ldots,t_T])。基础forward路径在 src/models/transformer.py 中对所有位置计算交叉熵:
logits, loss = model(idx, targets) flat_logits = logits.view(B * T, C) targets = targets.view(B * T).long() loss = F.cross_entropy(flat_logits, targets)后训练 SFT 路径因为需要掩码而显式做移位(见 src/post_training/sft.py 中的损失实现):
logits = logits[:, :-1, :] targets = tokens[:, 1:] mask = loss_mask[:, 1:].to(logits.dtype)困惑度(Perplexity)。困惑度是交叉熵的指数:(\text{PPL} = \exp(\mathcal{L}))。解释口径:
- loss 接近 (\log(V)) 表示模型接近均匀随机猜测;
- loss 越低,模型越把概率集中在合理的下一个 token 上;
- 泛化判断上,验证集 loss 比训练 loss 更重要。
本仓库 (V = 50304),所以 (\log(V) \approx 10.83)——未训练模型通常从接近这个值起步。
SFT 掩码损失。SFT 仍用 next-token 交叉熵,但只有 assistant 回答 token 计入:
[ \mathcal{L}{\text{SFT}} = \frac{\sum{b,t} m_{b,t},\ell_{b,t}} {\sum_{b,t} m_{b,t}} ]
其中 (m_{b,t}=1) 表示 assistant token、(0) 表示 prompt token。实现:
ce = F.cross_entropy( logits.reshape(-1, V).float(), targets.reshape(-1).long(), reduction="none", ) ce = ce.view(targets.shape) * mask return ce.sum() / mask.sum().clamp(min=1.0)这个区别至关重要:模型应学会"如何回答 prompt",而不是"预测 prompt 本身"。
序列对数概率。偏好优化和 RL 需要整个回答的对数概率,而不仅是单个 token。对 prompt (p) 之后的回答 token (a_1,\ldots,a_L):
[ \log \pi_\theta(a \mid p) = \sum_{t=1}^{L} \log \pi_\theta(a_t \mid p, a_{<t}) ]
仓库在 src/post_training/rollout.py 中通过sequence_logprobs实现:应用 response mask 并对回答位置累加 token 对数概率。这一原语被以下模块复用:
- DPO、ORPO、KTO;
- PPO 的策略比值;
- GRPO 的策略比值;
- 与冻结参考模型之间的 KL 度量。
DPO 目标。DPO 使用偏好对:chosen 回答 (y_w) 与 rejected 回答 (y_l),并将策略与冻结的参考模型对比:
[ \Delta_\pi = \log \pi_\theta(y_w \mid x) - \log \pi_\theta(y_l \mid x) ]
[ \Delta_{\text{ref}} = \log \pi_{\text{ref}}(y_w \mid x) - \log \pi_{\text{ref}}(y_l \mid x) ]
[ \mathcal{L}{\text{DPO}} = -\log \sigma\left(\beta(\Delta\pi - \Delta_{\text{ref}})\right) ]
src/post_training/dpo.py 中的实现:
pi_logratios = policy_chosen_logps - policy_rejected_logps ref_logratios = ref_chosen_logps - ref_rejected_logps logits = pi_logratios - ref_logratios loss = -F.logsigmoid(beta * logits).mean()直觉:让 chosen 回答比 rejected 更可能,但相对参考模型衡量变化幅度,避免策略无约束漂移。
PPO 目标一图流。PPO 采样回答、打分,并用新旧动作概率之比更新策略:
[ r_t(\theta) = \frac{\pi_\theta(a_t \mid s_t)} {\pi_{\text{old}}(a_t \mid s_t)} = \exp(\log \pi_\theta - \log \pi_{\text{old}}) ]
裁剪后的策略目标:
[ \mathcal{L}_{\text{PPO}} = -\mathbb{E}_t \left[ \min \left( r_t(\theta) A_t, \text{clip}(r_t(\theta),1-\epsilon,1+\epsilon) A_t \right) \right] ]
src/post_training/ppo.py 中的实现:
ratio = torch.exp(new_logp - old_logp) surr1 = ratio * advantages surr2 = torch.clamp(ratio, 1.0 - clip, 1.0 + clip) * advantages loss = -masked_mean(torch.min(surr1, surr2), mask)裁剪防止单次更新离采样策略太远。
GRPO 目标一图流。GRPO 不学习价值函数。对每个 prompt,它采样一组回答并在组内归一化奖励:
[ A_i = \frac{r_i - \text{mean}(r_1,\ldots,r_G)} {\text{std}(r_1,\ldots,r_G)+\epsilon} ]
这个优势回答的问题是:"这个回答比同一 prompt 的兄弟回答更好还是更差?" src/post_training/grpo.py 的实现:
r = rewards.view(-1, group_size) adv = (r - r.mean(1, keepdim=True)) / (r.std(1, keepdim=True) + eps)它对可验证奖励的推理任务尤其有用,因为它省掉了 PPO 的价值头和 critic 训练循环。
目标对比总表:
| 阶段 | 数据 | 主要信号 | 学到什么 |
|---|---|---|---|
| 预训练 | 原始 token 流 | next-token CE | 语言建模 |
| SFT | prompt/answer 示例 | 掩码 next-token CE | 指令遵循格式 |
| 奖励模型 | chosen/rejected 对 | Bradley-Terry 偏好损失 | 标量偏好打分 |
| DPO | chosen/rejected 对 | 序列对数概率偏好损失 | 无需 RL 回滚的偏好对齐 |
| PPO | 采样回答 + 奖励 | 裁剪策略梯度 | KL 约束下的奖励追求行为 |
| GRPO | 成组采样回答 + verifier | 组相对裁剪策略梯度 | 无需 critic 的 verifier 驱动推理 |
4.5 优化与训练系统
损失定义好之后,训练就是工程问题:让参数朝正确方向移动,同时避免数值不稳定、显存爆炸和吞吐量崩溃。本仓库的主要配料:
- AdamW;
- 线性 warmup + 余弦学习率衰减;
- 梯度累积;
- 梯度裁剪;
- bf16 autocast;
- 多 GPU 训练用 DistributedDataParallel(DDP)。
训练步骤。scripts/pretrain_base.py 的预训练循环模式:
for micro in range(cfg.grad_accum): xb, yb = next(batch_iter) with amp_autocast(cfg.amp_dtype, ctx.device): _, loss = model(xb, yb) loss = loss / cfg.grad_accum loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), cfg.grad_clip) optimizer.step() optimizer.zero_grad(set_to_none=True)将 loss 除以grad_accum,可以保持梯度尺度与"完整有效 batch 装进显存"时一致。
AdamW。Adam 维护梯度与其平方的指数滑动平均:
[ 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 ]
偏差校正后参数更新近似为:
[ \theta_{t+1} = \theta_t - \eta \frac{\hat{m}_t}{\sqrt{\hat{v}_t}+\epsilon} ]
AdamW 将权重衰减与梯度更新解耦:
[ \theta_{t+1} = \theta_t - \eta \left( \frac{\hat{m}_t}{\sqrt{\hat{v}_t}+\epsilon}
- \lambda \theta_t \right) ]
仓库在 src/post_training/optim.py 的configure_optimizer中只对矩阵类参数施加权重衰减(标准 GPT 配方:衰减大权重矩阵,不衰减 bias、LayerNorm 缩放与一维参数):
if p.dim() >= 2: decay.append(p) else: no_decay.append(p)学习率 warmup 与余弦衰减。学习率在开始时很小、逐步爬升、再衰减。warmup 段:
[ \eta(s) = \eta_{\max}\frac{s+1}{S_{\text{warmup}}} \quad \text{if } s < S_{\text{warmup}} ]
之后:
[ \eta(s) = \eta_{\min}
- \frac{1}{2}(1+\cos(\pi p))(\eta_{\max}-\eta_{\min}) ]
其中 (p = \frac{s-S_{\text{warmup}}}{S_{\max}-S_{\text{warmup}}})。src/post_training/optim.py 的cosine_lr实现:
if step < warmup_steps: return lr * (step + 1) / max(1, warmup_steps) progress = (step - warmup_steps) / max(1, max_steps - warmup_steps) coeff = 0.5 * (1.0 + math.cos(math.pi * progress)) return min_lr + coeff * (lr - min_lr)warmup 防止权重尚未校准时的早期不稳定更新;余弦衰减在预算临近结束时逐步减小步长。
梯度累积。一个 batch 超出 GPU 显存时拆成 microbatch:
[ B_{\text{effective}} = B_{\text{micro}} \times N_{\text{accum}} \times N_{\text{gpus}} ]
示例:microbatch 8 × 累积 12 步 × 2 张 GPU = 有效 batch 192。所有 microbatch 贡献完梯度后优化器才 step 一次。
梯度裁剪。梯度裁剪限制全局范数:
[ g \leftarrow g \cdot \min\left(1, \frac{c}{|g|_2}\right) ]
梯度范数低于阈值 (c) 时不变,过大时整体缩放。这是稳定性护栏,在 RL 与长序列训练中尤其有用。
bf16 autocast。bf16 比 fp32 位数少,但保留了与 fp32 相同的 8 位指数,因此比 fp16 对深度学习训练宽容得多。仓库用 autocast 做前向计算:
with amp_autocast(cfg.amp_dtype, ctx.device): logits, _ = model(tokens) loss = sft_loss(logits, tokens, mask)模型参数通常保持 fp32,大量矩阵乘法以 bf16 运行,在支持的 GPU 上改善显存与吞吐。
DistributedDataParallel。DDP 每张 GPU 一个进程:各进程持有完整模型副本、读取不同数据分片、本地算梯度、在优化器 step 前同步梯度。配合梯度累积时,只需要在最后一个 microstep 同步——仓库用model.no_sync()跳过前面 microstep 的不必要通信。README 还提到,若大配置显存不足,预训练脚本提供--amp、--grad-checkpointing、--grad-accum等开关(见 README.md);Transformer也内置了可选的gradient_checkpointing支持(默认关闭,见 src/models/transformer.py)。
训练中应观察的指标:
| 指标 | 健康表现 | 问题信号 |
|---|---|---|
| train loss | 平稳下降 | 接近随机基线时停滞 |
| dev loss | 先降后稳 | 训练 loss 降、验证 loss 升 |
| grad norm | 有限、裁剪后有界 | NaN 或反复巨大尖峰 |
| tokens/sec | 相同配置下稳定 | 骤降或 dataloader 卡顿 |
| RL 阶段 KL | 有界 | 相对参考模型失控漂移 |
| RL 阶段 reward | 上升且有方差 | 多次迭代零信号 |
显存杠杆。配置放不下时按此顺序削减:先batch_size,再context_length,然后n_blocks、n_embed,最后才考虑n_head(且必须仍能整除n_embed)。上下文长度尤其昂贵,因为 attention 使用 (T \times T) 得分矩阵。
4.6 生成与采样
训练是对已知文本预测下一个 token;生成则是把模型自己采样出的 token 当作下一个输入。这个反馈回路正是"微小概率差异会带来截然不同补全"的原因。
自回归循环。简易实现位于 src/models/transformer.py 的generate:
for _ in range(max_new_tokens): idx_cond = idx[:, -self.context_length:] logits, _ = self(idx_cond) logits = logits[:, -1, :] probs = F.softmax(logits, dim=-1) idx_next = torch.multinomial(probs, num_samples=1) idx = torch.cat((idx, idx_next), dim=1)只取最后一个位置,因为该位置已经关注了整个当前上下文。
Greedy 解码 vs 采样。Greedy 取 (\arg\max_i p_i),确定性但常重复;采样按 (x \sim \text{Categorical}(p)) 抽取,随机但更多样。仓库基础generate直接在全 softmax 分布上采样;后训练推理工具(src/post_training/inference.py)提供更多控制。
Temperature。Temperature 在 softmax 之前重缩放 logits:
[ p_i = \frac{\exp(z_i / \tau)} {\sum_j \exp(z_j / \tau)} ]
效果:(\tau < 1) 分布更尖锐、更安全但多样性低;(\tau = 1) 不变;(\tau > 1) 分布更平、更多样但也更容易出错。注意 temperature 作用于logits而非概率。
Top-k 与 top-p。Top-k 只保留概率最高的 (k) 个 token;Top-p(nucleus sampling)保留累计概率至少为 (p) 的最小 token 集合。它们不属于模型架构,而是叠加在 logits 之上的解码策略。
上下文裁剪。模型有固定最大上下文长度(idx_cond = idx[:, -self.context_length:])。对话超出后,最旧的 token 被丢弃,模型无法再关注窗口外的文本——这是上下文长度作为产品约束而非单纯超参数的原因。
停止 token。tokenizer 的 EOT token 为EOT_ID = 50256。训练时 EOT 出现在文档之间与 assistant 消息之后;推理时聊天循环可在出现 EOT 或格式化答案完整时停止。如果模型从未被训练出清晰的停止 token 或回答分隔符,解码时就只能猜测何时停止。
为什么生成文本会漂移。教师强制(teacher-forcing)训练中,每个输入前缀都来自数据集;生成时前缀来自模型自身。若模型早期采样了坏 token,后续预测都以它为条件。这种分布偏移正是后训练的意义所在:SFT 教格式;奖励/偏好方法把模型推向更优补全;RLVR/GRPO 可以用外部 verifier 满足的最终答案作为奖励。
有用的生成诊断:
| 症状 | 可能原因 | 检查方法 |
|---|---|---|
| 无限重复 | 分布太尖锐或未学会停止行为 | 降低 max tokens、检查 EOT 处理、调整采样 |
| 无视指令 | base model 未充分 SFT | 测试 SFT 检查点 |
| 回答格式错误 | SFT 数据格式不匹配 | 检查聊天模板与掩码 |
| 文本像随机 | 模型欠训练或 temperature 过高 | 对比 train/dev loss、降低 temperature |
| 长 prompt 崩溃 | prompt 超出上下文或设备显存 | 裁剪上下文、检查context_length |
五、心智模型:同一骨干,反复复用
基础训练循环非常紧凑:
[ \text{text} \to \text{token ids} \to \text{embeddings} \to \text{Transformer blocks} \to \text{logits} \to \text{cross-entropy} \to \nabla_\theta ]
后训练阶段大部分只是更换数据和损失:
- SFT保持 next-token prediction,但把损失掩码到 assistant token;
- 奖励模型把输出从词表 logits 变成一个标量得分;
- DPO比较 chosen 与 rejected 回答的序列对数概率;
- PPO 和 GRPO采样补全、打分,并用带约束的 RL 目标更新策略。
这套循环的工程形态(前向 → 损失/梯度累积 → 反向 → 梯度裁剪 → 调度学习率 → AdamW step → 清零梯度)在 src/post_training/optim.py 与各训练脚本中反复出现。同一个 backbone 在所有阶段被复用,这是本仓库最重要的设计思想——理解了第四节的基础概念,SFT、奖励模型、DPO、PPO、GRPO 的实现都只是"数据形态 + 损失函数"的变体。
六、主要参考文献
docs/foundations/README.md为每个概念提供了对应的原始论文依据,是进一步深挖的起点:
- Attention Is All You Need(arXiv:1706.03762):提出 Transformer 与缩放点积注意力;
- The Pile(arXiv:2101.00027):描述本仓库预训练路径使用的 825 GiB 文本语料;
- Neural Machine Translation of Rare Words with Subword Units(arXiv:1508.07909):提出现代 tokenizer 依赖的 BPE 子词思想;
- Decoupled Weight Decay Regularization(arXiv:1711.05101):AdamW 的理论动机;
- Training language models to follow instructions with human feedback(arXiv:2203.02155):经典的 SFT → 奖励模型 → PPO RLHF 配方;
- Direct Preference Optimization(arXiv:2305.18290):DPO 页面背后的动机;
- DeepSeekMath(arXiv:2402.03300):在数学推理场景引入 GRPO。
结语
从docs/foundations/六个文档出发,我们已经把 train-llm-from-scratch 的完整基础原理链路走通:文本经r50k_base变为 token 流(tokenization.md),token 经嵌入与 N 个 pre-norm 残差 block 前向传播(transformer.md),attention 在下三角 mask 约束下混合信息(attention.md),交叉熵把 logits 变成学习信号(objectives.md),AdamW、warmup、梯度累积与 bf16 保证循环稳定(optimization.md),最终采样循环把 logits 变回文本(generation.md)。掌握了这条链路,你就可以沿着学习路径进入数据处理与预训练页面,进而理解 SFT、奖励模型与 RL 各阶段是如何在同一骨干上逐层叠加能力的。
【免费下载链接】train-llm-from-scratchA straightforward method for training your LLM, from downloading data to generating text.项目地址: https://gitcode.com/GitHub_Trending/tr/train-llm-from-scratch
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考