简介:《Happy-LLM:从零开始的大语言模型原理与实践教程》是一份面向希望深入掌握大型语言模型原理与训练过程的读者的系统性PDF教程,由Datawhale团队发起,适合已接触过self-llm指南并想进一步动手实践的学习者。资源整体为一个PDF文件,大小约19.06MB,内容以章节目录呈现,便于离线阅读与按进度学习。目前已有557人学习,获得社区关注。教程从NLP基础讲起,逐步深入到Transformer架构的自注意力机制、Encoder-Decoder结构、不同架构模式,再到从零训练语言模型;随后通过动手实现完整的LLaMA2模型,讲解Tokenizer训练、小型LLM预训练等环节,并引入LoRA/QLoRA等微调方法。实战部分涵盖RAG与Agent应用场景,帮助读者将理论落地。学习者可借此系统掌握大模型从原理到训练、微调与部署的全流程,具备独立构建和应用LLM的能力。
1. 为什么说“从零开始”才是学大语言模型最快的路
不少朋友学大语言模型,第一步就栽在资料选择上。某开发者跟我聊过,他啃了三周的在线教程,笔记抄了一大本,打开真实的代码仓库时还是懵的——数据为什么要切成固定长度窗口、位置编码为什么有好几种写法、学习率曲线为什么先升后降,每一处都像黑匣子。我自己的体会正相反:真正让我把这些概念串起来的,不是哪篇爆款讲解,而是老老实实走完一遍从零训练。几百万参数的小模型,随机初始化,在公开语料上跑一个通宵。等这轮训练结束,再回头看那些动辄几十亿参数的模型仓库,很多配置项突然就能看懂了。这份名为 Happy-LLM 的教程,单从标题就能看出它的路线:不急着让你微调现成基座,而是让你亲手把最小的语言模型“搓”出来。适合人群很明确:准备进入模型训练方向的从业者,以及已经在调参、但遇到问题只能靠猜的开发者。这份 PDF 值不值得投入,取决于你是否真的跟着动手跑完了那个最小模型。
2. 原理第一站:Token与嵌入,大语言模型给文本“编号”的那套规则
先解决一个最容易被低估的问题:模型看到的文本长什么样。大语言模型处理的并不是我们眼中的句子,而是一串数字,这串数字由分词器决定。分词规则的好坏,直接决定训练成本、生成质量,以及后续换数据集时会不会出兼容性问题。从零开始的教程,几乎都会把分词放在第一个大章节,而不是当成一个to_dict接口带过,原因就在这里。
2.1 BPE分词:词表是怎么从语料里“长”出来的
BPE(字节对编码)算不上需要严格设计的算法,它更像一个自动做合并的统计过程。初始词表是所有字符,然后反复找语料里出现频率最高的相邻符号对,把它合并成一个新符号,再继续找下一对,直到词表达到目标大小。这个过程中,新词是从语料里自己“长”出来的,不依赖人工整理词表。
下面这段代码是 BPE 合并的核心循环,我从工程实现里把最关键的逻辑单独拆了出来,真实项目会进一步用优先队列提速,但原理完全一样。
def bpe_merge_once(ids, pair_counts, merge_rank): # 找出当前频次最高的相邻符号对 best_pair = max(pair_counts, key=pair_counts.get) # 给这个相邻对分配一个新的整数 id new_id = max(merge_rank.values()) + 1 if merge_rank else 256 merge_rank[best_pair] = new_id # 线性扫描序列,把所有 best_pair 整体替换成新符号 new_ids = [] i = 0 while i < len(ids): if i < len(ids) - 1 and (ids[i], ids[i + 1]) == best_pair: new_ids.append(new_id) i += 2 else: new_ids.append(ids[i]) i += 1 return new_ids, merge_rank这段代码的逻辑分三步:先从统计表里取出频次最高的相邻符号对;给这对符号分配一个新的整数 id;再对序列做一次整体替换。真实实现里每一轮合并后都要更新相邻对的频次统计,不然下一轮拿到的还是旧数据。我拆到这里是想说明一个关键点:合并本身没有任何语义判断,纯粹看统计频次,这也是为什么训练语料的质量会直接作用在词表上。
需要提前拍板的参数只有一个:词表大小vocab_size。我一般从 8000 起步,跑通全流程后再决定要不要扩到 16000 或 32000。这个词表大小真的不能随便定:一方面词表越大,嵌入矩阵参数越多,8 千词表和 8 万词表在显存上的差距,会让初期实验直接变慢;另一方面,词表偏小时,同一段文本会被切成更多 token,模型要处理更长的序列,训练和推理时间都跟着涨。BPE 训练完成后还要处理两类特殊 token:一类是填充、分隔用的占位符,另一类是未登录词兜底用的[UNK]。在固定长度窗口的训练模式里,[PAD]通常可以省掉,因为长度不足直接截断就行;但[UNK]必须有,否则遇到语料里没见过的字符,分词器会抛错而不是正常处理。
提示:训练分词器之前,先做一层简单的文本清洗。HTML 标签、控制字符、过长的空白串都要先去掉。否则 BPE 会把这类“杂物”当成稳定模式学进词表,白白占用词表名额,后面生成时还会复现出一堆乱码。
2.2 嵌入矩阵与位置编码:让模型知道词的语义和先后顺序
分词结束后,每个 token 拿到一个整数 id。模型要做的第一件事就是查表,把 id 映射成向量。这个映射就是嵌入矩阵,也叫 Token Embedding。它是随机初始化的,随训练不断更新,训练结束后向量空间里语义相近的词会聚在一起。值得注意的是一次嵌入查询本质就是查表,计算量很小,真正的参数量在“词表大小 × 向量维度”的乘积上。
嵌入解决了“词长什么样”,但还没解决“词在第几位”。自注意力机制对所有位置是同时计算的,如果不加位置信号,模型会把“猫追老鼠”和“老鼠追猫”看成同一串记号。位置编码要做的,就是给每个位置的向量注入“我在第几位”的信息。三种常见做法的对比如下。
| 方式 | 实现思路 | 优点 | 缺点 |
|---|---|---|---|
| 可学习绝对位置编码 | 训练一张[block_size, n_embd]的查表 | 实现简单,训练稳定 | 超过训练长度后没有兜底方案 |
| 正弦位置编码 | 用正弦/余弦函数生成固定位置信号 | 不需要训练参数,理论可外推 | 灵活性一般,效果不突出 |
| 旋转位置编码(RoPE) | 把相邻维度的向量按位置做旋转 | 相对位置语义清晰,长文本外推性好 | 实现比前两种复杂 |
在小模型实验里,这三种方案的最终损失差距通常很小。我的建议是第一次跑通用可学习绝对位置编码,因为实现只需要一行nn.Embedding(block_size, n_embd),出了问题也好排查;等后续做长序列实验再换 RoPE,否则初期调试工作量会翻倍。
嵌入与位置编码组合在一起,在模型里就三步:查 token 表、查位置表、相加。很多教程把这步画成复杂结构图,实际代码非常短。
tok_emb = nn.Embedding(vocab_size, n_embd) pos_emb = nn.Embedding(block_size, n_embd) def embed_input(token_ids): B, T = token_ids.shape token_vec = tok_emb(token_ids) # [B, T, n_embd] pos_vec = pos_emb(torch.arange(T, device=token_ids.device)) # [T, n_embd] return token_vec + pos_vec.unsqueeze(0) # 广播到 [B, T, n_embd]这里的block_size是训练时固定的最大序列长度,它既是网络结构参数,也是数据切分参数。模型训练完后,这个值就不要随意改大了,否则位置表会越界;临时改小倒是可以,但会浪费一部分训练好的位置表示。
3. 原理核心:注意力与Transformer,模型在哪个环节“读”懂了文本
进到 Transformer 内部之后,很多人的耐心会在这里耗尽。注意力公式里的Q、K、V三个矩阵、缩放因子、因果掩码,每一项单独看都能理解,合在一起就晕了。这节我把注意力拆成可直接复现的计算步骤,顺带说清楚归一化、残差连接这些“小零件”为什么省不了。
3.1 自注意力中的 Q、K、V 到底在算什么
自注意力的计算流程其实很朴素:每个位置产生三个向量,一个负责“提问”,一个负责“被检索”,一个负责“内容”。当前词先拿自己的查询向量q去和所有位置的键向量k做点积,得到对每个位置的关注程度,再按这个程度加权求和所有位置的值向量v。下面这段代码是单头自注意力的最小实现,本质就是公式本身。
import math import torch def attention(q, k, v, causal_mask=None): d_k = q.shape[-1] # 1. q 和所有位置的 k 做点积,得到两两相关分数 scores = q @ k.transpose(-2, -1) / math.sqrt(d_k) # 2. 自回归模型需要遮住未来位置,将分数置为负无穷 if causal_mask is not None: scores = scores.masked_fill(causal_mask == 0, float('-inf')) # 3. softmax 转成概率权重 weights = torch.softmax(scores, dim=-1) # 4. 按权重加权所有位置的 v return weights @ v计算分四步:q和k做矩阵乘法得到所有位置两两之间的点积;除以根号下d_k做缩放;如果是自回归语言模型,用一个下三角 mask 把未来位置的分数置为负无穷,这样 softmax 之后未来位置的权重就是 0;最后用权重加权v。除以根号d_k这一步常被当成细节跳过,但它很关键:向量维度越大,点积数值的方差越大,softmax 越容易进入梯度饱和区,训练会变慢甚至停在原地。
一个常见疑问是:注意力是否一定要用 mask。在从零训练的语言模型里,mask 是必须的。训练目标是“根据前 T 个 token 预测第 T+1 个 token”,如果模型能看到未来 token,损失会直接塌缩成复制任务,模型学不到任何可泛化的东西。因果掩码的意义就是强制模型只用过去的信息做预测。
多头注意力把同样的过程复制多份,每份用不同的线性投影,让模型同时关注多种模式:一个头可能关注词法,一个头关注长距离约束,一个头关注句法短模式。head 数量不是越多越好,小模型里 4 到 8 个头已经够用。以参数量视角看,注意力的核心成本在q、k、v和输出投影四个矩阵上,4 层模型总共 12 个注意力矩阵,参数量并不算大,真正占参数预算的是后面的 MLP。
3.2 前馈网络、归一化与残差连接:那些“小零件”为什么省不了
注意力完成的是 token 之间的信息交换,接下来每个 token 要独自经过一个前馈网络,把交换来的信息做一次非线性变换。这个 MLP 通常是四倍宽度:把n_embd维度升到4 * n_embd,过 GELU,再降回n_embd。它不做 token 间通信,只做逐位置的变换,相当于给每个位置单独配了一个小分类器。
归一化的作用常被低估。LayerNorm 在序列维度上计算均值和方差,训练早期数值不稳定时能兜底。训练更深的模型时,很多人会换成 RMSNorm,它去掉了均值中心化,只做方差缩放,参数更少,在深模型中效果和 LayerNorm 差距不明显。相比之下,BatchNorm 对 batch 维度的依赖在语言模型里很尴尬——语言模型通常 batch size 很小,统计出来的均值和方差抖动很大。某开发者的血泪经验:把 LayerNorm 换掉,用 BatchNorm 训练两层小模型,损失曲线从一开始就是锯齿状,最后也没救回来。
残差连接是最容易被忽视的零件。没有它,深层模型中梯度在反向传播时会被连续矩阵乘法反复缩放,训练极不稳定。Transformer 块的标准顺序一般分两种:先归一化再进子层(Pre-Norm),或先进子层再归一化(Post-Norm)。从零开始的小实验建议直接用 Pre-Norm,训练更稳,对学习率峰值不那么敏感。早期几代经典架构用的是 Post-Norm,实现起来对超参数更挑剔,初学者没必要在这个选择上硬磕。
4. 动手实践:从零训练一个小语言模型的最少步骤
这一章是整个从零路线里最出活的部分。目标不是训练出能对话的模型,而是用几百万参数规模把“数据准备 → 模型搭建 → 训练循环 → 观察曲线”完整跑通。整个过程在单卡上十几分钟就能看到损失明显下降,很适合用来建立对超参数的手感。
4.1 数据准备:把语料切成固定长度窗口
训练语言模型的第一步不是搭网络,而是准备数据。文本先经过分词器变成整数 id 序列,再按固定长度切成窗口。窗口长度block_size直接对应模型的位置表长度,这个参数在 4.2 的模型定义里也会出现。下面这个数据集类接收的是已经编码好的 id 序列列表,负责把每个样本切成输入x和目标y两个对齐张量。
import torch from torch.utils.data import Dataset class TextWindowDataset(Dataset): def __init__(self, encoded_chunks, block_size=256): # 只保留长度足够切一个完整窗口的样本 self.samples = [c for c in encoded_chunks if len(c) >= block_size + 1] self.block_size = block_size def __len__(self): return len(self.samples) def __getitem__(self, idx): ids = self.samples[idx] x = torch.tensor(ids[:self.block_size], dtype=torch.long) y = torch.tensor(ids[1:self.block_size + 1], dtype=torch.long) return x, y这个类做了三件事:过滤掉长度不足的文本;对每个样本取前block_size个 id 作为输入;把输入整体右移一位作为目标输出。语言模型的训练目标就是根据前 T 个 token 预测第 T+1 个 token,所以x和y天然错开一位,不需要额外构造标签。block_size=256在显存和上下文长度之间算一个平衡点,显存紧张可以先降到 128,后续再加。
构造 DataLoader 时,batch_size要和block_size一起考虑,因为它俩的乘积直接决定单次前向的显存占用。
from torch.utils.data import DataLoader loader = DataLoader(dataset, batch_size=16, shuffle=True, num_workers=0)shuffle=True在语言模型训练里容易被忽略,但它很重要。如果不打乱顺序,模型会先看到文本开头的一批窗口,再看到中间的一批,损失曲线会周期性波动,让人误以为训练不稳定。
4.2 模型搭建:一个几百万参数的 Decoder-Only 结构
模型采用 Decoder-only 结构,重复堆叠 Transformer 块。这个规模足够学到一些词法和短距离搭配模式,训练时间也可接受。下面是完整的模型定义,我在类名上直接标了 Decoder,方便和编码器结构区分。
import torch import torch.nn as nn class DecoderBlock(nn.Module): def __init__(self, n_embd, n_head): super().__init__() self.ln1 = nn.LayerNorm(n_embd) self.attn = nn.MultiheadAttention(n_embd, n_head, batch_first=True) self.ln2 = nn.LayerNorm(n_embd) self.mlp = nn.Sequential( nn.Linear(n_embd, 4 * n_embd), nn.GELU(), nn.Linear(4 * n_embd, n_embd), ) def forward(self, x): norm_x = self.ln1(x) attn_out, _ = self.attn(norm_x, norm_x, norm_x, need_weights=False) x = x + attn_out # 第一次残差连接 x = x + self.mlp(self.ln2(x)) # 第二次残差连接 return x class DecoderLM(nn.Module): def __init__(self, vocab_size, n_embd=256, n_head=4, n_layer=4, block_size=256): super().__init__() self.tok_emb = nn.Embedding(vocab_size, n_embd) self.pos_emb = nn.Embedding(block_size, n_embd) self.blocks = nn.ModuleList([DecoderBlock(n_embd, n_head) for _ in range(n_layer)]) self.ln_f = nn.LayerNorm(n_embd) self.head = nn.Linear(n_embd, vocab_size, bias=False) def forward(self, idx): B, T = idx.shape token_vec = self.tok_emb(idx) # [B, T, n_embd] pos_vec = self.pos_emb(torch.arange(T, device=idx.device)) # [T, n_embd] x = token_vec + pos_vec.unsqueeze(0) for block in self.blocks: x = block(x) x = self.ln_f(x) logits = self.head(x) # [B, T, vocab_size] return logits几个参数需要解释。n_embd=256是向量维度,在这个规模下比较经济;n_head=4配合 256 维,每个头分到 64 维,是常见配置;n_layer=4让梯度传播路径短一些,初期训练不容易炸。vocab_size必须和分词器训练出来的实际词表大小一致。粗算一下参数量:嵌入矩阵约vocab_size × 256,词表 8000 时约 204 万参数,加上后续层总参数量在 250 万上下。这个计算是一个很好的参照系:参数量大头被词表吃掉,加层数加维度的影响反而直观可算。
我在这里用了现成的nn.MultiheadAttention,纯粹是为了展示块结构。初学阶段建议自己写一个几行的注意力实现,像 3.1 里那样,能直接看到中间张量形状,对理解多头机制帮助很大。训练时的下三角 mask 要记得传给attn,否则因果性就丢了。
4.3 训练主循环与关键超参数:损失应该怎么降
训练主循环本身很枯燥,真正的技术含量在超参数搭配。下面是一个标准的单步训练函数,我把梯度裁剪和调度器更新都放了进去。
import torch import torch.nn.functional as F def train_step(model, x, y, optimizer, scheduler=None, grad_clip=1.0): model.train() optimizer.zero_grad() logits = model(x) loss = F.cross_entropy(logits.view(-1, logits.size(-1)), y.view(-1)) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), grad_clip) optimizer.step() if scheduler is not None: scheduler.step() return loss.item()logits的原始形状是[B, T, vocab_size],view(-1, ...)把 B 和 T 两个维度合并,一次性对整批所有位置算交叉熵。梯度裁剪上限设为 1.0,目的是防止个别异常梯度把损失拉飞。调度器这里用的是按步更新的方式,而不是按 epoch,这样 warmup 步数更可控。
显存不足时最常见的补救手段是梯度累积,它用时间换空间,等效扩大 batch size。
accum_steps = 4 loss = loss / accum_steps loss.backward() if (step + 1) % accum_steps == 0: optimizer.step() optimizer.zero_grad()每步先把损失除以累积步数,再反向传播,攒够accum_steps步才更新一次参数。这样等效 batch size 从 16 变成了 64,但显存占用不变。学习率是整个训练里最玄学的旋钮,我一般用 AdamW,峰值学习率3e-4,warmup 1000 步,波动大了就降到1e-4重新跑。下面这张表可以直接当做一个初始模板抄走。
| 参数 | 建议值 | 说明 |
|---|---|---|
| block_size | 256 | 序列长度,显存紧张先降到 128 |
| batch_size | 16 | 与 block_size 共同决定显存 |
| n_embd / n_layer / n_head | 256 / 4 / 4 | 参数量约 250 万 |
| learning_rate | 3e-4 | AdamW 下比较稳,波动大就降一个量级 |
| warmup_steps | 1000 | 前 1000 步学习率线性升到峰值 |
| grad_clip | 1.0 | 梯度裁剪上限 |
| accum_steps | 4 | 等效 batch size 扩大到 64 |
从零训练大语言模型的“跑通”标准,不是损失低到某个绝对值,而是损失曲线平滑下降到初值的 60% 以下,并且生成样例里能出现词层面的连贯结构。8000 词表时,随机初始化损失大约接近log(8000)即 9 左右,能降到 5 左右就可以停下来看看生成效果了。
5. 训练避坑:损失不降、显存溢出、生成乱码的排查清单
这一章是我在类似小模型实验里反复踩过的坑,每一条都有明确的排查顺序。如果你训练中遇到同样问题,直接按下面的顺序查,能省下大半个晚上。
5.1 损失卡住不降
现象:训练进行到几百步后损失稳定,继续训练一整晚几乎没有变化。
原因:最常见的是学习率过大,模型参数在损失曲面的谷底附近来回震荡,到不了更优点;其次是数据里混入了大量清洗不干净的噪声,模型在拼命拟合噪声;还有一种情况是词表里未登录词比例过高,有效 token 分布太稀疏,训练信号不足。
解决:先看初始损失是否接近log(vocab_size),如果一开始就差很远,说明分词或数据加载环节有问题;再观察训练后期损失的波动幅度,波动大就调低学习率,例如从3e-4降到1e-4;最后抽一批训练样本人工看一遍,确认没有控制字符和乱码。
5.2 显存溢出(OOM)
现象:训练到第 N 步直接 OOM 中断,重启后跑一会儿又崩。
原因:block_size × batch_size的乘积太大,中间激活张量占满了显存;有时候是模型里不自觉地复制了大张量,比如在循环里反复调用token_ids.cuda(),每调用一次就留一份拷贝。
解决:先把batch_size降到 8 或 4,再把block_size从 256 降到 128,两个方向二选一;显存还不够就上梯度累积,它是等价恢复 batch 容量的手段。另外排查一下验证流程,不要把验证集和训练集同时装载在显存里。
5.3 生成全是重复词或乱码
现象:采样出来的文本是“的的的的”或者一堆生僻字,完全不像正常句子。
原因:温度temperature设得太低,比如 0.1 以下,softmax 分布被压窄,模型反复挑最高概率的那个词;也可能是采样时没有屏蔽填充位置的无效 token,模型在空白占位符上继续生成。
解决:把temperature调到 0.8 到 1.0 区间重新采样;如果问题依旧,检查分词器是否给文本加了不该有的开头或结尾拼接,导致生成循环从边界符号开始;最后确认生成代码里用的是model.eval(),训练和推理模式下 dropout 的差异也会造成这类现象。
5.4 训练早期损失不降反升
现象:前几十步损失不但没下降,反而向上走了一段。
原因:最常见是 warmup 没生效,学习率一开局就顶到峰值,模型被大步长踢出了正常区域;也可能是 AdamW 的epsilon设得太小,数值上出现不稳定的更新。
解决:打印前 100 步的实际学习率曲线,确认是从接近 0 开始线性增长;把学习率峰值降一个数量级,比如从3e-4降到3e-5,跑 100 步看趋势;若仍然上升,检查数据加载顺序,确认shuffle是不是意外关掉了。
5.5 同一份代码换机器后复现不出来
现象:同一份代码在 A 机器和 B 机器上跑,损失曲线差异很大,A 收敛了 B 没有。
原因:随机种子不同、底层算子对算法选择的差异、以及框架版本对算子融合的处理不同,都会放大数值差异。
解决:固定torch.manual_seed和numpy.random.seed,设置torch.backends.cudnn.benchmark=False,禁用自动选择算法的逻辑。然后记录显卡、驱动、框架版本,这些信息在从零教程的运行环境说明里值得占一页。
6. 进阶技巧:用困惑度与生成样例给模型“验收”
损失是一个带偏移量的指标,单独看数字不容易判断好坏。把交叉熵转成困惑度 PPL 更直观,它等于exp(平均交叉熵)。PPL=50 可以理解为模型在每个词的位置平均在 50 个候选词之间犹豫;PPL=10 说明平均只在 10 个候选里选择。计算方式不复杂,可以写成一个独立的评估函数。
import math import torch import torch.nn.functional as F @torch.no_grad() def evaluate_ppl(model, loader): model.eval() total_loss, total_tokens = 0.0, 0 for x, y in loader: logits = model(x) loss = F.cross_entropy( logits.view(-1, logits.size(-1)), y.view(-1), reduction='sum' ) total_loss += loss.item() total_tokens += y.numel() return math.exp(total_loss / total_tokens)关键在reduction='sum':先把整批损失求和,最后除以见过的总 token 数,这样算出来的平均交叉熵不受 batch 大小影响。PPL 和词表大小是绑定的,词表越大,PPL 下限越高,所以不要拿不同词表尺寸的模型直接对比。
模型训练到损失不再明显下降后,把它从训练脚本里单独拿出来,写一个自回归生成循环:输入几个起始词,让模型逐步生成新 token。固定随机种子和temperature,同一段开头跑出多组结果,人工对比是否出现语义连贯的短语。小模型不需要追求句子通顺,能出现词频模式就已经证明有效学习。
我现在的习惯是:任何一次训练结束,不看 loss 曲线,先看 20 条不同温度下的生成样例。很多损失很低的模型其实只会复读高频短语,样例比指标更能暴露问题。希望这些细节能帮你在从零开始这条路上少走几趟弯路。
本文还有配套的精品资源,点击获取