大模型预训练:从原理到实战
本教程是大模型技术全栈课程的第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 | 含义 |
|---|
| 0 | 101 | 7592 | 给定 token 101,预测 7592 |
| 1 | 7592 | 2088 | 给定 101+7592,预测 2088 |
| 2 | 2088 | 102 | 给定 101+7592+2088,预测 102 |
| 3 | 102 | 2023 | 给定 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 的数据清洗流程包括:
- 提取中文部分:从多语言数据集中筛选中文文本
- 长度过滤:筛掉超过 512 字的文本片段,控制单条数据的长度
- 格式整理:用
<|im_start|> 和 <|im_end|> 特殊标记包裹每段文本 - 拼接输出:将清洗后的文本拼接为
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 序列标注模型
- 自定义微调模型
原始文本:你好,我是张伟,邮箱是 zhangwei@email.com,电话是 138-1234-5678。
检测结果:
- 人名:“张伟”(NER模型识别)
- 邮箱:
zhangwei@email.com(正则匹配) - 电话:
138-1234-5678(正则匹配)
脱敏后:你好,我是[PERSON],邮箱是 [EMAIL],电话是 [PHONE]。
| 方法 | 依赖格式 | 理解语义 | 适用场景 |
|---|
| 规则法 | 是 | 否 | 邮箱、电话、身份证等格式固定的信息 |
| 模型法 | 否 | 是 | 人名、地名等需要上下文理解的信息 |
| 混合策略 | 否 | 是 | 生产环境推荐方案 |
3.4 数据去重
是什么?
数据去重是在预训练语料中去除重复或近似重复的文本片段。
为什么?
重复数据会导致模型对特定文本过拟合,降低泛化能力。研究表明,去重后的数据训练出的模型在下游任务上表现更好。
怎么做?
常用的去重方法包括:
- 精确去重:基于哈希(如 MD5/SHA256)检测完全相同的文本
- 模糊去重:使用 MinHash + LSH(局部敏感哈希)检测近似重复的文本
- 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
| Token | ID | 用途 |
|---|
<|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 的对比
| 特性 | LayerNorm | RMSNorm |
|---|
| 减均值 | 是 | 否 |
| 除标准差 | 是(方差归一化) | 是(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 的核心优势在于:
- 天然的相对位置编码:两个位置 $m$ 和 $n$ 的 Query 和 Key 做点积时,结果只依赖于相对位置 $m - n$
- 支持外推:可以处理比训练时更长的序列
- 不增加参数:旋转矩阵完全由位置决定,无需额外参数
数学原理
对于位置 $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缓存大小 | 性能 |
|---|
| MHA | N | N | 大 | 最优 |
| GQA | N | G (1<G<N) | 中等 | 接近MHA |
| MQA | N | 1 | 最小 | 略有下降 |
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_experts | 4 | 路由专家总数 |
num_experts_per_tok | 2 | 每个 token 选择的专家数 |
n_shared_experts | 1 | 共享专家数 |
aux_loss_alpha | 0.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
...
):
|
每个 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
|
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 在反向传播前将 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) # 累积完毕后才清零
|
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
|
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 | 健康检查 |
/health | GET | 模型加载状态 |
/chat | POST | 非流式推理 |
/chat/stream | POST | 流式推理(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
|
关键超参数
| 参数 | 默认值 | 说明 |
|---|
epochs | 1 | 训练轮数(实际建议 2-6 轮) |
batch_size | 32 | 批次大小 |
learning_rate | 5e-4 | 基础学习率 |
max_seq_len | 512 | 最大序列长度 |
accumulation_steps | 8 | 梯度累积步数 |
grad_clip | 1.0 | 梯度裁剪阈值 |
dtype | bfloat16 | 训练精度 |
save_interval | 100 | 模型保存间隔(步) |
十二、小测验
共 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=8,batch_size=32 时,等效的实际 batch size 是多少?
10. 余弦退火学习率调度中,NanoQwen 的最低学习率是最高学习率的几分之几?
11. KV-Cache 为什么只在推理时使用而不在训练时使用?
12. MOE 架构中,门控网络使用什么函数来选择 Top-K 个专家?辅助损失的目的是什么?
13. NanoQwen 的 lm_head 和 embed_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(推理引擎)
└── 流式/非流式接口
|
- 先通读理论部分,建立整体认知
- 对照源码逐模块阅读,重点理解
model_nanoqwen.py 中的每个类 - 实际运行训练脚本,观察 loss 的下降曲线
- 修改超参数(如 hidden_size、num_hidden_layers)进行实验,体会参数对模型行为的影响
- 尝试在评估模型时调整 temperature 和 top_p,感受采样策略对生成结果的影响