大模型预训练:从原理到实战

📝 课程定位

本教程是大模型技术全栈课程的第8讲,聚焦于**预训练(Pre-training)**阶段。我们将以 NanoQwen 项目为载体,从理论原理到代码实现,完整走通大模型预训练的全流程。


一、大模型技术全栈概览

1.1 什么是大模型技术全栈?

大模型技术全栈是指从数据准备到模型部署的完整技术链路,涵盖以下核心阶段:

阶段核心任务关键技术
预训练从海量语料中学习语言知识自回归建模、Transformer架构
微调(SFT)让模型学会遵循指令监督微调、LoRA
对齐(RLHF/DPO)让模型输出符合人类偏好强化学习、偏好优化
推理部署高效地提供模型服务vLLM、量化、KV-Cache

1.2 为什么需要理解全栈?

在实际工程中,预训练决定了模型的"知识上限",微调决定了模型的"行为方式",对齐决定了模型的"输出质量"。只有理解全栈,才能在出现问题时定位到正确的阶段进行修复。

1.3 本讲聚焦:预训练

预训练是整个流程的起点。本讲将以 NanoQwen(一个约26M参数的轻量模型)为例,完整讲解预训练的每一个环节。


二、预训练原理

2.1 自回归语言建模

是什么?

自回归语言建模(Autoregressive Language Modeling)是一种让模型学习"根据前文预测下一个词"的训练方式。给定一个文本序列 $x_1, x_2, …, x_n$,模型学习的是条件概率分布:

$$P(x_t | x_1, x_2, ..., x_{t-1})$$

为什么?

这种方式天然适合文本生成任务。模型一旦学会了"给定前文,下一个词最可能是什么",就可以通过逐词生成来完成续写、对话、翻译等任务。GPT系列、Qwen系列、Llama系列都采用这种方式。

怎么做?

在NanoQwen的数据集代码(lm_dataset.py)中,自回归建模的实现非常直观:

1
2
3
4
# 原始 token 序列: [101, 7592, 2088, 102, 2023]
# 拆分为输入和目标:
X = torch.tensor(input_ids[:-1], dtype=torch.long)  # [101, 7592, 2088, 102]
Y = torch.tensor(input_ids[1:], dtype=torch.long)    # [7592, 2088, 102, 2023]

对应关系如下:

位置输入 X目标 Y含义
01017592给定 token 101,预测 7592
175922088给定 101+7592,预测 2088
22088102给定 101+7592+2088,预测 102
31022023给定 101+7592+2088+102,预测 2023
💡 关键理解

一条长度为 N 的序列,会产生 N-1 个训练信号。模型通过一次前向传播同时计算所有位置的预测,然后一次性计算 N-1 个位置的交叉熵损失。


2.2 Teacher Forcing

是什么?

Teacher Forcing 是训练时使用的一种策略:在预测第 $t$ 个词时,不使用模型自己在第 $t-1$ 步的预测结果作为输入,而是使用真实的第 $t-1$ 个词作为输入。

为什么?

如果训练时用模型自己的预测结果作为下一步输入,一旦某一步预测错误,后续所有步骤的输入都会偏离正确轨道,导致训练不稳定。Teacher Forcing 通过提供真实的历史序列作为输入,保证了训练过程的稳定性和收敛速度。

怎么做?

在NanoQwen中,Teacher Forcing 体现在训练数据的构造方式上:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# 训练时:并行计算所有位置(使用真实历史)
X = [101, 7592, 2088, 102]     # 真实的前 N-1 个 token
Y = [7592, 2088, 102, 2023]    # 真实的后 N-1 个 token
logits = model(X)               # 一次前向传播,得到所有位置的预测
loss = CrossEntropyLoss(logits, Y)  # 一次性计算损失

# 推理时:逐个生成(使用模型自己的预测)
tokens = [101]
for i in range(max_len):
    next_token = model(tokens).argmax()  # 模型自己预测下一个
    tokens.append(next_token)
⚠️ 训练与推理的区别

训练时使用 Teacher Forcing,模型输入的是真实序列,可以并行计算所有位置;推理时模型必须自回归地逐个生成 token,因为没有"未来"的真实 token 可用。


三、预训练数据构建

3.1 数据来源与格式

是什么?

预训练数据是指用于训练大模型的大规模文本语料。NanoQwen 使用的数据来自多个开源数据集的整合,最终以 JSONL 格式存储:

1
{"text": "<|im_start|>我知道底边长度和高,分别是5cm和8cm,请告诉我计算出来的面积是多少。三角形的面积为20平方厘米。<|im_end|> <|im_start|>为什么理智告诉我们不要跟陌生人接触?因为陌生人可能会对我们的个人安全造成威胁。<|im_end|>"}

为什么?

JSONL(JSON Lines)格式每行一个 JSON 对象,适合大规模数据的流式读取,无需将整个文件加载到内存。

数据来源

NanoQwen 的训练语料整合自以下数据集:

  • BelleGroup/train_3.5M_CN
  • LinkSoul/instruction_merge_set
  • stingning/ultrachat
  • BAAI/COIG-PC-core
  • shibing624/sharegpt_gpt4
  • shareAI/ShareGPT-Chinese-English-90k
  • Tiger Research
  • BelleGroup/school_math_0.25M
  • YeungNLP/moss-003-sft-data

最终清洗后形成约 1.6GB 的高质量中文语料。


3.2 数据清洗

是什么?

数据清洗是指对原始爬取或收集到的文本进行过滤和处理,去除低质量、有害或重复的内容。

为什么?

原始网络数据中包含大量噪声:广告文本、乱码、HTML标签、过短或过长的片段等。如果不清洗,模型会学到这些噪声模式,导致生成质量下降。

怎么做?

NanoQwen 的数据清洗流程包括:

  1. 提取中文部分:从多语言数据集中筛选中文文本
  2. 长度过滤:筛掉超过 512 字的文本片段,控制单条数据的长度
  3. 格式整理:用 <|im_start|><|im_end|> 特殊标记包裹每段文本
  4. 拼接输出:将清洗后的文本拼接为 pretrain_hq.jsonl 文件

3.3 PII 检测与去除

是什么?

PII(Personally Identifiable Information,个人可识别信息)检测是在训练数据中识别并去除或脱敏个人隐私信息的过程。

为什么?

训练数据如果包含真实的姓名、电话、邮箱、身份证号、地址等,模型可能在生成时泄露这些信息,造成隐私风险和法律问题。

怎么做?

实际操作中通常采用规则+模型联合的方案:

方法一:基于规则(正则表达式)

适用于格式固定的 PII:

PII 类型正则表达式示例
邮箱[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}
电话号码1[3-9]\d{9}
身份证号\d{17}[\dXx]

方法二:基于模型(NER)

使用命名实体识别模型识别人名、地名、组织等语义相关的 PII:

  • spaCy 预训练 NER 模型
  • BERT + CRF 序列标注模型
  • 自定义微调模型
PII 检测示例

原始文本你好,我是张伟,邮箱是 zhangwei@email.com,电话是 138-1234-5678。

检测结果

  • 人名:“张伟”(NER模型识别)
  • 邮箱:zhangwei@email.com(正则匹配)
  • 电话:138-1234-5678(正则匹配)

脱敏后你好,我是[PERSON],邮箱是 [EMAIL],电话是 [PHONE]。

方法依赖格式理解语义适用场景
规则法邮箱、电话、身份证等格式固定的信息
模型法人名、地名等需要上下文理解的信息
混合策略生产环境推荐方案

3.4 数据去重

是什么?

数据去重是在预训练语料中去除重复或近似重复的文本片段。

为什么?

重复数据会导致模型对特定文本过拟合,降低泛化能力。研究表明,去重后的数据训练出的模型在下游任务上表现更好。

怎么做?

常用的去重方法包括:

  1. 精确去重:基于哈希(如 MD5/SHA256)检测完全相同的文本
  2. 模糊去重:使用 MinHash + LSH(局部敏感哈希)检测近似重复的文本
  3. N-gram 去重:计算文本的 n-gram 重叠率,超过阈值则判定为重复

四、分词器训练

4.1 BPE 算法

是什么?

BPE(Byte Pair Encoding,字节对编码)是一种子词分词算法。它从单个字符开始,不断合并出现频率最高的相邻字符对,逐步构建词表。

为什么?

BPE 解决了"词级分词"和"字符级分词"之间的平衡问题:

  • 词级分词词表太大,且无法处理未登录词(OOV)
  • 字符级分词序列太长,信息密度低
  • BPE 通过子词单元,既控制了词表大小,又保留了大部分高频词的完整表示

怎么做?

NanoQwen 使用 tokenizers 库训练 BPE 分词器,代码位于 scripts/train_tokenizer.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from tokenizers import models, pre_tokenizers, trainers, Tokenizer

# 1. 初始化 BPE tokenizer
tokenizer = Tokenizer(models.BPE())
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)

# 2. 定义特殊 token
special_tokens = ["<|endoftext|>", "<|im_start|>", "<|im_end|>"]

# 3. 配置训练器
trainer = trainers.BpeTrainer(
    vocab_size=6400,                                    # 词表大小
    special_tokens=special_tokens,                      # 特殊 token
    show_progress=True,
    initial_alphabet=pre_tokenizers.ByteLevel.alphabet() # 初始字母表
)

# 4. 从语料中训练
texts = read_texts_from_jsonl(data_path)
tokenizer.train_from_iterator(texts, trainer=trainer)

# 5. 设置解码器
tokenizer.decoder = decoders.ByteLevel()
💡 词表大小的选择

NanoQwen 将词表设为 6400,远小于 Qwen(152K)或 GPT-4(100K+)。对于轻量模型,小词表能有效控制 Embedding 层的参数量,避免"头重脚轻"。代价是压缩率较低,同一段文本需要更多 token 来表示。


4.2 特殊 Token

是什么?

特殊 Token 是分词器中具有特定语义功能的标记,不对应实际的文字内容。

为什么?

模型需要这些标记来区分不同的文本段落、标识序列的开始和结束、以及在对话场景中区分角色。

NanoQwen 的特殊 Token

TokenID用途
<|endoftext|>0文本结束标记 / 填充标记(pad_token)
<|im_start|>1对话轮次开始标记(bos_token)
<|im_end|>2对话轮次结束标记(eos_token)

4.3 Tokenizer 配置

是什么?

Tokenizer 配置文件(tokenizer_config.json)定义了分词器在使用时的行为,包括特殊 token 的映射关系和对话模板。

怎么做?

NanoQwen 的配置中包含一个关键的 chat_template,它使用 Jinja2 模板语法定义了对话格式:

1
2
3
4
5
6
<|im_start|>system
You are a helpful assistant<|im_end|>
<|im_start|>user
你来自哪里?<|im_end|>
<|im_start|>assistant
我来自地球<|im_end|>

通过 tokenizer.apply_chat_template(messages) 可以自动将消息列表格式化为上述格式。


五、模型架构详解

NanoQwen 的架构遵循现代 Decoder-only Transformer 的设计范式,与 Qwen、Llama 等主流模型一脉相承。

5.1 整体架构

NanoQwen-Small 的配置:

参数
隐藏层数(num_hidden_layers)8
隐藏维度(hidden_size)512
注意力头数(num_attention_heads)8
KV 头数(num_key_value_heads)2
词表大小(vocab_size)6400
最大序列长度512(训练时)
总参数量约 26M

数据流如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
input_ids
    |
    v
[Embedding 层] --> token 嵌入向量 (batch, seq_len, 512)
    |
    v
[NanoQwenBlock x 8] --> 8 层 Transformer Block
    |   每层包含:
    |   - RMSNorm
    |   - GQA 注意力(含 RoPE)
    |   - 残差连接
    |   - RMSNorm
    |   - FeedForward(SwiGLU)/ MOE
    |   - 残差连接
    |
    v
[RMSNorm] --> 最终归一化
    |
    v
[LM Head] --> logits (batch, seq_len, 6400)

5.2 RMSNorm(均方根归一化)

是什么?

RMSNorm(Root Mean Square Layer Normalization)是 LayerNorm 的一种简化变体,由 Llama 等模型推广使用。

为什么?

与标准 LayerNorm 相比,RMSNorm 去掉了"减均值"的步骤,只进行缩放归一化。实验表明,这种简化在大模型上的效果与 LayerNorm 相当,但计算开销更低。

数学公式

给定输入向量 $x \in \mathbb{R}^d$:

$$\text{RMSNorm}(x) = \frac{x}{\text{RMS}(x)} \cdot \gamma$$

其中:

$$\text{RMS}(x) = \sqrt{\frac{1}{d} \sum_{i=1}^{d} x_i^2 + \epsilon}$$
  • $\gamma$:可学习的缩放参数(维度为 d)
  • $\epsilon$:防止除零的小常数(默认 1e-5)

与 LayerNorm 的对比

特性LayerNormRMSNorm
减均值
除标准差是(方差归一化)是(RMS归一化)
偏置项有 $\beta$
计算量较大较小
效果相当或更好

NanoQwen 中的实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class RMSNorm(torch.nn.Module):
    def __init__(self, dim: int, eps: float = 1e-5):
        super().__init__()
        self.eps = eps
        self.weight = nn.Parameter(torch.ones(dim))  # 可学习缩放参数

    def _norm(self, x):
        # rsqrt = 1 / sqrt(x),即计算 RMS 的倒数再乘以 x
        return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)

    def forward(self, x):
        return self.weight * self._norm(x.float()).type_as(x)
📝 代码细节

x.pow(2).mean(-1, keepdim=True) 计算的正是 $\frac{1}{d}\sum x_i^2$。torch.rsqrt 计算平方根的倒数,即 $1/\sqrt{\cdot}$。self.weight 初始化为全 1,但作为 nn.Parameter 会在训练中学习到每个维度的最佳缩放值。


5.3 RoPE 旋转位置编码

是什么?

RoPE(Rotary Position Embedding,旋转位置编码)是一种将位置信息融入注意力机制的方法。它通过对 Query 和 Key 向量施加与位置相关的旋转变换来编码位置信息。

为什么?

传统的绝对位置编码(如正弦位置编码)将位置信息直接加到词嵌入上,无法很好地捕捉相对位置关系。RoPE 的核心优势在于:

  1. 天然的相对位置编码:两个位置 $m$ 和 $n$ 的 Query 和 Key 做点积时,结果只依赖于相对位置 $m - n$
  2. 支持外推:可以处理比训练时更长的序列
  3. 不增加参数:旋转矩阵完全由位置决定,无需额外参数

数学原理

对于位置 $m$ 处的查询向量 $q$ 的第 $i$ 对维度 $(q_{2i}, q_{2i+1})$,RoPE 施加的旋转为:

$$\begin{pmatrix} q_{2i}' \\ q_{2i+1}' \end{pmatrix} = \begin{pmatrix} \cos(m\theta_i) & -\sin(m\theta_i) \\ \sin(m\theta_i) & \cos(m\theta_i) \end{pmatrix} \begin{pmatrix} q_{2i} \\ q_{2i+1} \end{pmatrix}$$

其中频率 $\theta_i = \frac{1}{\text{base}^{2i/d}}$,NanoQwen 中 base = 1,000,000。

NanoQwen 中的实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
def precompute_freqs_cis(dim: int, end: int = int(32 * 1024), theta: float = 1e6):
    # 计算每对维度的频率:theta_i = 1 / (base^(2i/d))
    freqs = 1.0 / (theta ** (torch.arange(0, dim, 2)[: (dim // 2)].float() / dim))
    # 生成位置序列 [0, 1, 2, ..., end-1]
    t = torch.arange(end, device=freqs.device)
    # 外积:每个位置 x 每个频率 -> 角度矩阵
    freqs = torch.outer(t, freqs).float()
    # 计算 cos 和 sin,并拼接(对应向量前半和后半)
    freqs_cos = torch.cat([torch.cos(freqs), torch.cos(freqs)], dim=-1)
    freqs_sin = torch.cat([torch.sin(freqs), torch.sin(freqs)], dim=-1)
    return freqs_cos, freqs_sin

def apply_rotary_pos_emb(q, k, cos, sin, position_ids=None, unsqueeze_dim=1):
    def rotate_half(x):
        # 将后半部分取反并移到前面:[x1,x2,x3,x4] -> [-x3,-x4,x1,x2]
        return torch.cat((-x[..., x.shape[-1] // 2:], x[..., : x.shape[-1] // 2]), dim=-1)

    # 旋转公式:q' = q * cos + rotate_half(q) * sin
    q_embed = (q * cos.unsqueeze(unsqueeze_dim)) + (rotate_half(q) * sin.unsqueeze(unsqueeze_dim))
    k_embed = (k * cos.unsqueeze(unsqueeze_dim)) + (rotate_half(k) * sin.unsqueeze(unsqueeze_dim))
    return q_embed, k_embed
💡 关键设计

RoPE 的 cos/sin 矩阵在模型初始化时通过 register_buffer 预计算并缓存,不是可训练参数。推理时直接按位置索引取出,无需重复计算。


5.4 GQA 分组查询注意力

是什么?

GQA(Grouped-Query Attention,分组查询注意力)是标准多头注意力(MHA)和多查询注意力(MQA)之间的折中方案。在 GQA 中,多个 Query 头共享同一组 Key 和 Value 头。

为什么?

标准 MHA 中每个 Query 头都有独立的 Key 和 Value 头,KV 缓存的内存占用与头数成正比。当模型很大、序列很长时,KV 缓存会成为瓶颈。GQA 通过减少 KV 头的数量来降低内存和计算开销,同时保持接近 MHA 的性能。

NanoQwen 的配置

1
2
3
num_attention_heads = 8    # Query 头数
num_key_value_heads = 2    # KV 头数
n_rep = 8 / 2 = 4          # 每组 KV 头被 4 个 Query 头共享

这意味着:

  • 8 个 Query 头,每个头的维度为 512/8 = 64
  • 2 个 Key 头和 2 个 Value 头,每个头的维度为 64
  • 每 4 个 Query 头共享 1 个 Key 和 1 个 Value 头

怎么做?

核心在于 repeat_kv 函数,它将 KV 头复制 n_rep 次以匹配 Query 头的数量:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def repeat_kv(x: torch.Tensor, n_rep: int) -> torch.Tensor:
    bs, slen, num_key_value_heads, head_dim = x.shape
    if n_rep == 1:
        return x  # MHA 的情况,无需复制
    return (
        x[:, :, :, None, :]
        .expand(bs, slen, num_key_value_heads, n_rep, head_dim)
        .reshape(bs, slen, num_key_value_heads * n_rep, head_dim)
    )
# 输入形状: (batch, seq_len, 2, 64)  -- 2个KV头
# 输出形状: (batch, seq_len, 8, 64)  -- 扩展为8个头,与Q匹配
注意力类型Query头数KV头数KV缓存大小性能
MHANN最优
GQANG (1<G<N)中等接近MHA
MQAN1最小略有下降

5.5 SwiGLU 激活函数

是什么?

SwiGLU 是一种结合了 Swish(SiLU)激活函数和门控线性单元(GLU)的前馈网络结构,广泛用于 Llama、Qwen、Gemma 等现代大模型。

为什么?

相比传统的 ReLU 前馈网络 FFN(x) = ReLU(xW_1)W_2,SwiGLU 通过门控机制(gate)动态调节信息流通,实验表明在语言建模任务上能获得更低的困惑度。

数学公式

$$\text{SwiGLU}(x) = [\text{SiLU}(xW_{\text{gate}}) \odot (xW_{\text{up}})] W_{\text{down}}$$

其中 $\text{SiLU}(x) = x \cdot \sigma(x)$($\sigma$ 是 Sigmoid 函数),$\odot$ 表示逐元素乘法。

NanoQwen 中的实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class FeedForward(nn.Module):
    def __init__(self, config: NanoQwenConfig):
        super().__init__()
        if config.intermediate_size is None:
            # 自动计算中间层维度:hidden_size * 8/3,并对齐到 64 的倍数
            intermediate_size = int(config.hidden_size * 8 / 3)
            config.intermediate_size = 64 * ((intermediate_size + 64 - 1) // 64)

        self.gate_proj = nn.Linear(config.hidden_size, config.intermediate_size, bias=False)
        self.down_proj = nn.Linear(config.intermediate_size, config.hidden_size, bias=False)
        self.up_proj   = nn.Linear(config.hidden_size, config.intermediate_size, bias=False)
        self.act_fn = ACT2FN[config.hidden_act]  # SiLU 激活

    def forward(self, x):
        # SwiGLU: down_proj( silu(gate_proj(x)) * up_proj(x) )
        return self.dropout(self.down_proj(self.act_fn(self.gate_proj(x)) * self.up_proj(x)))
📝 三个线性层

SwiGLU 使用三个投影矩阵(gate_proj、up_proj、down_proj),比标准 FFN 的两个投影多一个。虽然参数增加了,但实验表明在相同参数预算下,SwiGLU 的效果优于标准 ReLU FFN。


5.6 MOE 混合专家

是什么?

MOE(Mixture of Experts,混合专家)是一种稀疏激活的架构,将前馈网络替换为多个"专家"网络。每个 token 只激活其中一部分专家,从而在增加模型容量的同时控制计算量。

为什么?

传统的 Dense 模型中,每个 token 都要经过全部参数的计算。MOE 让每个 token 只激活 Top-K 个专家,实现了"参数量大但计算量小"的效果。例如 NanoQwen-MOE 总参数 145M,但每个 token 实际激活的参数远少于此。

怎么做?

NanoQwen 的 MOE 实现包含三个核心组件:

1. 门控网络(MoEGate)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class MoEGate(nn.Module):
    def __init__(self, config):
        self.weight = nn.Parameter(torch.empty((n_routed_experts, hidden_size)))

    def forward(self, hidden_states):
        # 计算每个 token 对每个专家的得分
        logits = F.linear(hidden_states, self.weight)
        scores = logits.softmax(dim=-1)
        # 选择 Top-K 个专家
        topk_weight, topk_idx = torch.topk(scores, k=self.top_k, dim=-1)
        # 归一化权重
        topk_weight = topk_weight / (topk_weight.sum(dim=-1, keepdim=True) + 1e-20)
        return topk_idx, topk_weight, aux_loss

2. MOE 前馈网络(MOEFeedForward)

1
2
3
4
5
6
7
8
class MOEFeedForward(nn.Module):
    def __init__(self, config):
        # 多个路由专家
        self.experts = nn.ModuleList([FeedForward(config) for _ in range(n_routed_experts)])
        # 门控网络
        self.gate = MoEGate(config)
        # 共享专家(所有 token 都经过)
        self.shared_experts = nn.ModuleList([FeedForward(config) for _ in range(n_shared_experts)])

3. 辅助损失(Auxiliary Loss)

为了防止所有 token 都被路由到同一个专家(“专家坍塌”),MOE 引入了负载均衡辅助损失,鼓励 token 在各专家之间均匀分配。

NanoQwen 的 MOE 配置

参数含义
n_routed_experts4路由专家总数
num_experts_per_tok2每个 token 选择的专家数
n_shared_experts1共享专家数
aux_loss_alpha0.1辅助损失权重

六、NanoQwen 模型完整代码结构

6.1 模型配置类

NanoQwenConfig 继承自 transformers.PretrainedConfig,定义所有超参数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
class NanoQwenConfig(PretrainedConfig):
    model_type = "nanoqwen"

    def __init__(self,
        hidden_size=512,           # 隐藏维度
        num_hidden_layers=8,       # Transformer层数
        num_attention_heads=8,     # Query头数
        num_key_value_heads=2,     # KV头数(GQA)
        vocab_size=6400,           # 词表大小
        hidden_act='silu',         # 激活函数
        rms_norm_eps=1e-05,        # RMSNorm epsilon
        rope_theta=1000000.0,      # RoPE base
        flash_attn=True,           # Flash Attention
        use_moe=False,             # 是否启用 MOE
        ...
    ):

6.2 Transformer Block

每个 NanoQwenBlock 的结构为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class NanoQwenBlock(nn.Module):
    def forward(self, hidden_states, position_embeddings, ...):
        # Pre-Norm + Attention + 残差
        residual = hidden_states
        hidden_states, present_key_value = self.self_attn(
            self.input_layernorm(hidden_states),  # Pre-Norm
            position_embeddings, ...
        )
        hidden_states += residual  # 残差连接

        # Pre-Norm + FFN/MOE + 残差
        hidden_states = hidden_states + self.mlp(
            self.post_attention_layernorm(hidden_states)  # Pre-Norm
        )
        return hidden_states, present_key_value
💡 Pre-Norm vs Post-Norm

NanoQwen 使用 Pre-Norm(在子层之前做归一化),这是现代大模型的标准做法。相比 Post-Norm,Pre-Norm 的训练更稳定,不容易出现梯度消失或爆炸的问题。


6.3 模型主体与 LM Head

1
2
3
4
5
6
class NanoQwenForCausalLM(PreTrainedModel, GenerationMixin):
    def __init__(self, config):
        self.model = NanoQwenModel(config)           # Transformer 主体
        self.lm_head = nn.Linear(hidden_size, vocab_size, bias=False)  # 输出头
        # 权重绑定:Embedding 和 LM Head 共享权重
        self.model.embed_tokens.weight = self.lm_head.weight
⚠️ 权重绑定的重要性

embed_tokens 将 token ID 映射为向量(词表大小 x 隐藏维度),lm_head 将向量映射回词表分布(隐藏维度 x 词表大小)。两者的权重矩阵互为转置关系。共享权重不仅减少了约 3.3M 参数(6400 x 512),还让模型在输入和输出空间保持一致的 token 表示。


七、训练流程

7.1 混合精度训练

是什么?

混合精度训练(Mixed Precision Training)是指在训练过程中同时使用 FP32 和 FP16/BF16 数据类型,在关键计算中用低精度加速,在参数更新中用高精度保证稳定性。

为什么?

FP16 的计算速度是 FP32 的 2 倍以上,内存占用减半。但 FP16 的数值范围有限(6.1e-5 到 65504),很小的梯度值(如 1e-7)会被截断为 0,导致梯度消失。

怎么做?

NanoQwen 使用 PyTorch 的 torch.cuda.amp 实现混合精度训练:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# 1. 创建自动混合精度上下文
ctx = torch.cuda.amp.autocast()

# 2. 创建梯度缩放器(将小梯度放大,防止 FP16 下溢)
scaler = torch.cuda.amp.GradScaler(enabled=(args.dtype in ['float16', 'bfloat16']))

# 3. 训练循环
with ctx:
    res = model(X)                    # 前向传播在混合精度下进行
    loss = loss_fct(res.logits, Y)    # 损失计算

scaler.scale(loss).backward()         # 缩放后的损失反向传播
scaler.unscale_(optimizer)            # 恢复真实梯度
torch.nn.utils.clip_grad_norm_(...)   # 梯度裁剪
scaler.step(optimizer)                # 更新参数
scaler.update()                       # 更新缩放因子
📝 GradScaler 的工作原理

GradScaler 在反向传播前将 loss 乘以一个缩放因子(如 1024),使得小梯度也能在 FP16 范围内表示。在更新参数前再除以缩放因子恢复真实梯度值。如果检测到 NaN/Inf,则跳过本次更新并减小缩放因子。


7.2 梯度累积

是什么?

梯度累积(Gradient Accumulation)是指在多个 mini-batch 上累积梯度,然后一次性执行参数更新,等效于使用更大的 batch size。

为什么?

GPU 显存有限,无法一次放下很大的 batch。梯度累积让我们可以在不增加显存的情况下模拟大 batch 训练,而大 batch 通常能带来更稳定的梯度估计和更好的训练效果。

怎么做?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# 配置:accumulation_steps = 8
# 效果:实际 batch_size = 32 * 8 = 256

for step, (X, Y, loss_mask) in enumerate(train_loader):
    with ctx:
        res = model(X)
        loss = compute_loss(res, Y, loss_mask)
        loss = loss / args.accumulation_steps  # 损失除以累积步数

    scaler.scale(loss).backward()  # 梯度累积(不清零)

    if (step + 1) % args.accumulation_steps == 0:
        scaler.unscale_(optimizer)
        torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
        scaler.step(optimizer)
        scaler.update()
        optimizer.zero_grad(set_to_none=True)  # 累积完毕后才清零
💡 set_to_none=True

optimizer.zero_grad(set_to_none=True) 将梯度设为 None 而非零张量,可以节省内存。


7.3 余弦退火学习率

是什么?

余弦退火(Cosine Annealing)是一种学习率调度策略,学习率随训练进度按余弦曲线从高到低衰减。

为什么?

训练初期需要较大的学习率快速探索参数空间;训练后期需要较小的学习率进行精细调整。余弦退火提供了平滑的过渡,比线性衰减和阶梯衰减效果更好。

怎么做?

1
2
3
def get_lr(current_step, total_steps, lr):
    # lr_min = lr/10, lr_max = lr
    return lr / 10 + 0.5 * lr * (1 + math.cos(math.pi * current_step / total_steps))

学习率变化规律:

  • 起始时:接近 lr(如 5e-4)
  • 中间:逐渐下降
  • 结束时:接近 lr/10(如 5e-5)

7.4 DDP 分布式训练

是什么?

DDP(DistributedDataParallel)是 PyTorch 提供的数据并行训练方案。每个 GPU 持有模型的完整副本,数据被均匀分配到各 GPU 上,梯度在所有 GPU 间同步。

为什么?

单卡训练速度有限。DDP 可以线性提升训练吞吐量(2 卡约 2 倍加速),是最常用的多卡训练方案。

怎么做?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# 启动命令
# torchrun --nproc_per_node 2 train_pretrain.py

# 初始化进程组
dist.init_process_group(backend="nccl")
ddp_local_rank = int(os.environ["LOCAL_RANK"])
torch.cuda.set_device(f"cuda:{ddp_local_rank}")

# 包装模型
model = DistributedDataParallel(model, device_ids=[ddp_local_rank])

# 使用分布式采样器
train_sampler = DistributedSampler(train_ds)
train_loader = DataLoader(train_ds, sampler=train_sampler, ...)
📝 日志打印

在 DDP 训练中,只有 rank=0 的进程打印日志和保存模型,避免重复输出。NanoQwen 通过 Logger 函数封装了这个逻辑。


7.5 KV-Cache

是什么?

KV-Cache 是自回归推理时的一种优化技术。在逐 token 生成的过程中,之前位置的 Key 和 Value 会被缓存起来,新的 token 只需计算当前位置的 Key 和 Value,然后与缓存拼接。

为什么?

在没有 KV-Cache 的情况下,生成第 $t$ 个 token 时需要重新计算前 $t-1$ 个位置的所有 Key 和 Value,计算量为 $O(t \cdot d)$。使用 KV-Cache 后,只需计算第 $t$ 个位置的 Key 和 Value,计算量降为 $O(d)$。

怎么做?

1
2
3
4
5
6
# 在 Attention.forward 中:
if past_key_value is not None:
    # 将新的 K, V 与缓存拼接
    xk = torch.cat([past_key_value[0], xk], dim=1)
    xv = torch.cat([past_key_value[1], xv], dim=1)
past_kv = (xk, xv) if use_cache else None
💡 训练 vs 推理

KV-Cache 仅在推理时使用(use_cache=True)。训练时所有位置同时计算,不需要缓存。


八、数据集处理

8.1 PretrainDataset 实现

是什么?

PretrainDataset 是 PyTorch 的 Dataset 子类,负责将 JSONL 文件中的文本转换为模型可以消费的张量。

怎么做?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class PretrainDataset(Dataset):
    def __init__(self, data_path, tokenizer, max_length=512):
        self.tokenizer = tokenizer
        self.max_length = max_length
        self.samples = self.load_data(data_path)

    def load_data(self, path):
        samples = []
        with open(path, 'r', encoding='utf-8') as f:
            for line in f:
                data = json.loads(line.strip())
                samples.append(data)
        return samples

    def __getitem__(self, index):
        sample = self.samples[index]

        # Tokenize:将文本编码为 token IDs
        encoding = self.tokenizer(
            str(sample['text']),
            max_length=self.max_length,
            padding='max_length',      # 填充到 max_length
            truncation=True,           # 超长截断
            return_tensors='pt'
        )
        input_ids = encoding.input_ids.squeeze()

        # 构建 loss_mask:非 padding 位置为 True
        loss_mask = (input_ids != self.tokenizer.pad_token_id)

        # 构建自回归训练对
        X = torch.tensor(input_ids[:-1], dtype=torch.long)       # 前 N-1 个 token
        Y = torch.tensor(input_ids[1:], dtype=torch.long)        # 后 N-1 个 token
        loss_mask = torch.tensor(loss_mask[1:], dtype=torch.long) # 对应 Y 的 mask

        return X, Y, loss_mask

8.2 Loss Mask

是什么?

Loss Mask 是一个与目标序列等长的 0/1 向量,标识哪些位置参与损失计算,哪些位置应被忽略。

为什么?

由于 batch 内不同样本的长度不同,较短的样本会被 padding(用 <|endoftext|> 填充)到统一长度。这些 padding 位置不是真实的文本内容,不应参与损失计算,否则模型会学到"预测 padding token"这种无意义的模式。

怎么做?

1
2
3
4
5
6
# input_ids: [101, 7592, 2088, 102, 0, 0, 0]  (0 是 pad_token_id)
# loss_mask: [  1,    1,    1,   1, 0, 0, 0]

# 损失计算
loss = loss_fct(logits.view(-1, vocab_size), Y.view(-1)).view(Y.size())
loss = (loss * loss_mask).sum() / loss_mask.sum()  # 只计算非 padding 位置的平均损失

九、模型评估与推理

9.1 文本生成

是什么?

预训练模型的评估方式是"文本接龙":给定一个前缀(prompt),让模型自回归地生成后续文本。

怎么做?

NanoQwen 的评估代码(eval_model.py):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# 加载模型和分词器
model = NanoQwenForCausalLM(config)
model.load_state_dict(torch.load(checkpoint_path))
model.eval()

# 构建输入
prompt = "人工智能技术正在"
new_prompt = tokenizer.bos_token + prompt
inputs = tokenizer(new_prompt, return_tensors="pt").to(device)

# 生成文本
generated_ids = model.generate(
    inputs["input_ids"],
    max_new_tokens=512,
    do_sample=True,              # 采样生成
    temperature=0.85,            # 温度参数
    top_p=0.85,                  # 核采样
    pad_token_id=tokenizer.pad_token_id,
    eos_token_id=tokenizer.eos_token_id,
    streamer=TextStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
)

9.2 采样策略

Temperature(温度)

控制概率分布的"尖锐程度"。设 logits 为 $z$:

$$P(x_i) = \frac{\exp(z_i / T)}{\sum_j \exp(z_j / T)}$$
温度值效果
T < 1.0分布更尖锐,生成更确定
T = 1.0原始分布
T > 1.0分布更平坦,生成更随机

Top-p(核采样)

从概率最高的 token 开始,累积概率直到达到 p(如 0.85),只从这些 token 中采样。这确保了不会采到概率极低的"噪声" token。

NanoQwen 的默认配置

1
2
temperature = 0.85  # 略低于 1.0,偏向确定性
top_p = 0.85        # 保留累积概率 85% 的 token
生成示例

NanoQwen 评估时提供了一系列测试 prompt:

  • “今天天气很好,” -> 模型续写天气相关内容
  • “人工智能技术正在” -> 模型续写 AI 发展相关内容
  • “中国的传统文化包括” -> 模型续写文化知识

注意:预训练模型只能做文本接龙,不能进行真正的对话。对话能力需要后续的 SFT 微调阶段获得。


十、API 部署

10.1 FastAPI + vLLM

是什么?

将训练好的模型部署为 HTTP API 服务,使外部应用可以通过网络请求调用模型进行推理。NanoQwen 使用 FastAPI 作为 Web 框架,vLLM 作为推理引擎。

为什么?

  • FastAPI:高性能的 Python Web 框架,支持异步、自动生成 API 文档
  • vLLM:专为大模型推理优化的引擎,支持 PagedAttention、连续批处理等技术,推理吞吐量远高于原生 transformers

怎么做?

核心代码位于 api_server.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from fastapi import FastAPI
from vllm import LLM, SamplingParams

app = FastAPI(title="NanoQwen Pretrain API")

# 加载模型
model = LLM(
    model="./out/",
    trust_remote_code=True,
    tensor_parallel_size=1,
    gpu_memory_utilization=0.8,
    max_model_len=2048,
    dtype="bfloat16"
)

# 非流式接口
@app.post("/chat")
async def chat(request: ChatRequest):
    prompt = f"<|im_start|>user\n{request.message}<|im_end|>\n<|im_start|>assistant\n"
    sampling_params = SamplingParams(
        temperature=request.temperature,
        top_p=request.top_p,
        max_tokens=request.max_tokens
    )
    outputs = model.generate([prompt], sampling_params)
    return ChatResponse(response=outputs[0].outputs[0].text)

# 流式接口
@app.post("/chat/stream")
async def chat_stream(request: ChatRequest):
    return StreamingResponse(generate_stream(...), media_type="text/plain")

API 端点

端点方法功能
/GET健康检查
/healthGET模型加载状态
/chatPOST非流式推理
/chat/streamPOST流式推理(SSE)

启动服务

1
2
python api_server.py
# 服务运行在 http://0.0.0.0:8000

十一、完整训练流程速查

环境搭建

1
2
3
4
5
6
# 1. 创建 Conda 环境
conda create -n pretrain python=3.10
conda activate pretrain

# 2. 安装依赖
pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple

训练步骤

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# 3. 训练分词器(可选,已提供预训练好的分词器)
cd scripts
python train_tokenizer.py

# 4. 启动预训练
cd trainer
python train_pretrain.py

# 5. 分布式训练(多卡)
torchrun --nproc_per_node 2 train_pretrain.py --ddp

# 6. 模型评估
python eval_model.py

# 7. 部署 API
python api_server.py

关键超参数

参数默认值说明
epochs1训练轮数(实际建议 2-6 轮)
batch_size32批次大小
learning_rate5e-4基础学习率
max_seq_len512最大序列长度
accumulation_steps8梯度累积步数
grad_clip1.0梯度裁剪阈值
dtypebfloat16训练精度
save_interval100模型保存间隔(步)

十二、小测验

📝 测验说明

共 15 道题,涵盖本讲所有核心知识点。答案请参见 教程_预训练_答案.md

1. 在自回归语言建模中,一条长度为 N 的 token 序列,可以产生多少个"预测下一个 token"的训练信号?

2. Teacher Forcing 解决了什么问题?训练时和推理时的输入有什么区别?

3. NanoQwen 的 PretrainDataset 中,loss_mask 的作用是什么?为什么不能简单地对所有位置计算损失?

4. RMSNorm 与 LayerNorm 的核心区别是什么?RMSNorm 为什么更适合大模型?

5. 在 RoPE 旋转位置编码中,为什么 Query 和 Key 需要旋转,但 Value 不需要?

6. NanoQwen 配置了 8 个 Query 头和 2 个 KV 头,这属于什么注意力机制?repeat_kv 函数的 n_rep 值是多少?

7. SwiGLU 前馈网络使用了几个线性投影层?写出 SwiGLU 的计算公式。

8. 在混合精度训练中,GradScaler 为什么要先放大(scale)损失再反向传播?

9. 梯度累积 accumulation_steps=8batch_size=32 时,等效的实际 batch size 是多少?

10. 余弦退火学习率调度中,NanoQwen 的最低学习率是最高学习率的几分之几?

11. KV-Cache 为什么只在推理时使用而不在训练时使用?

12. MOE 架构中,门控网络使用什么函数来选择 Top-K 个专家?辅助损失的目的是什么?

13. NanoQwen 的 lm_headembed_tokens 使用了权重绑定(weight tying),这样做有什么好处?

14. vLLM 相比原生 transformers 的 model.generate() 有哪些推理优化?

15. NanoQwen 预训练模型可以进行对话吗?为什么?要实现对话能力还需要什么步骤?


十三、思维导图结构建议

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
大模型预训练
├── 1. 技术全栈概览
│   ├── 预训练 -> 微调(SFT) -> 对齐(RLHF) -> 部署
│   └── 预训练 = 知识的上限
├── 2. 预训练原理
│   ├── 自回归语言建模
│   │   ├── 条件概率 P(x_t | x_1,...,x_{t-1})
│   │   └── X = ids[:-1], Y = ids[1:]
│   └── Teacher Forcing
│       ├── 训练用真实历史(并行)
│       └── 推理用模型预测(自回归)
├── 3. 数据构建
│   ├── 数据来源(多个开源数据集整合)
│   ├── 数据清洗(长度过滤、格式整理)
│   ├── PII 检测(规则+NER+大模型)
│   └── 数据去重(哈希/MinHash/N-gram)
├── 4. 分词器
│   ├── BPE 算法
│   ├── 特殊 Token(endoftext/im_start/im_end)
│   ├── 词表大小选择(6400 vs 150K+)
│   └── chat_template 对话模板
├── 5. 模型架构
│   ├── RMSNorm(无均值归一化)
│   ├── RoPE(旋转位置编码)
│   ├── GQA(分组查询注意力)
│   ├── SwiGLU(门控激活FFN)
│   ├── MOE(混合专家)
│   │   ├── 门控网络(路由选择)
│   │   ├── 路由专家 + 共享专家
│   │   └── 辅助损失(负载均衡)
│   └── 权重绑定(Embedding = LM Head)
├── 6. 训练技术
│   ├── 混合精度(AMP + GradScaler)
│   ├── 梯度累积
│   ├── 余弦退火学习率
│   ├── 梯度裁剪
│   ├── DDP 分布式训练
│   └── KV-Cache(推理优化)
├── 7. 数据集处理
│   ├── JSONL 格式
│   ├── Tokenize + Padding + Truncation
│   └── Loss Mask(忽略 padding)
├── 8. 评估与推理
│   ├── 文本接龙测试
│   └── 采样策略(Temperature + Top-p)
└── 9. API 部署
    ├── FastAPI(Web框架)
    ├── vLLM(推理引擎)
    └── 流式/非流式接口

💡 学习建议
  1. 先通读理论部分,建立整体认知
  2. 对照源码逐模块阅读,重点理解 model_nanoqwen.py 中的每个类
  3. 实际运行训练脚本,观察 loss 的下降曲线
  4. 修改超参数(如 hidden_size、num_hidden_layers)进行实验,体会参数对模型行为的影响
  5. 尝试在评估模型时调整 temperature 和 top_p,感受采样策略对生成结果的影响