从原始文本到对齐模型:train-llm-from-scratch 的 LLM 基础原理与全流程实践指南
2026/9/15 21:55:35 网站建设 项目流程

从原始文本到对齐模型: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 blockAttention 加 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
梯度累积在显存限制下模拟更大的 batchscripts/pretrain_base.py、scripts/train_sft.py
生成模型把采样出的 token 重新喂回自己src/models/transformer.py、src/post_training/inference.py

可以看到,预训练与后训练共用同一套骨干代码,这正是本仓库最重要的设计思想:同一个 backbone 被反复复用(详见第五节"心智模型")。

三、推荐学习路径

原文档给出的阅读顺序非常讲究,它从"数据形态"出发,沿着前向传播的自然顺序展开:

  1. Tokenization 与数据形态 —— 文本如何变成 batch;
  2. Decoder-only Transformer —— 模型骨架;
  3. Attention、Mask 与多头 —— 核心算子;
  4. 目标、损失与困惑度 —— 模型被优化去做的事;
  5. 优化与训练系统 —— 训练循环如何保持稳定;
  6. 生成与采样 —— 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)

文档中统一使用的形状符号:

符号含义
Bbatch 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 承担。

心智调试清单。训练表现异常时逐项自检:

  1. mask 是否因果?token 能看到答案吗?
  2. qkv是否从同一个归一化输入投影而来?
  3. head_size = n_embed // n_head是否为整数?
  4. 拼接所有头后是否恰好返回n_embed通道?
  5. 序列长度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语言建模
SFTprompt/answer 示例掩码 next-token CE指令遵循格式
奖励模型chosen/rejected 对Bradley-Terry 偏好损失标量偏好打分
DPOchosen/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_blocksn_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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询