训练大模型这事,过去两年我见过太多人拿着现成框架调参,跑通一遍就觉得自己会了,真被问到"Transformer 里的注意力矩阵为什么要做因果遮盖"就答不上来。最近正好有一个从零动手实现语言模型的教学项目特别火,不装预训练权重、不用现成封装,每一步都由你自己写出来,最终训练出一个能编故事的小模型,也就是标题里说的"故事大王"。这个项目非常适合两类人:一类是有 Python 基础、想真正弄懂大模型原理的开发者,另一类是已经跑过不少现成 demo、但总感觉技术在身外飘的初学者。跟完一遍,你对数据、tokenizer、模型结构、训练循环、采样生成的理解会完全不一样。
1. 项目整体路线:一个会讲故事的小模型是怎么设计出来的
1.1 为什么偏偏选"讲故事"这个任务
语言模型最本质的训练目标就一句话:给定前面已经出现的字符,预测下一个字符的概率。这个任务本身不挑场景,可以是新闻、代码、古诗,但教学项目里选择"故事"作为载体非常聪明。因为故事有明确的语境和连贯性:前面说了"从前有一位国王",后面如果接"今天天气很好"就明显不对,模型有没有学到东西,人一眼就能判断。相比之下,拿新闻或论文当语料,非专业人士很难立刻感觉到生成质量的好坏,也就缺少了快速反馈的乐趣。
另一个原因是故事文本在规模上很宽容。大模型动辄需要 TB 级数据,但一个教学项目只需要几万到几十万字符的文本就能让模型出现"可读性"的迹象。几万字符在任何个人电脑上都能秒级加载,这让"每次修改代码后快速重训看效果"成为可能。项目的核心目标不是做产品,而是让模型在参数量极小、训练时间极短的前提下,仍然能输出语法通顺、有一定情节联想的内容,这个标准定得很合理。
1.2 为什么"从零构建"比"调用现成库"更有价值
现在要跑一个语言模型有多容易?安装一个深度学习框架,下载几行预训练权重,再调用生成接口,二十分钟就能出一个看起来很像样的结果。但这种使用方式有一个致命问题:中间有大量的"黑箱"。HuggingFace 的 pipeline 把 tokenizer、模型结构、权重加载、采样全部封装好了,用户层面几乎接触不到张量形状的变化,也看不到损失函数是怎么计算的。
这个项目的设计思路恰恰相反。它要求你把每个环节都亲手写出来:字符表怎么构建、batch 怎么采样、注意力分数怎么算、反向传播怎么走、采样时 temperature 怎么影响概率分布。这里没有复杂的工程依赖,核心代码加起来可能几百行,但每行你都能说清楚它在做什么。这种做法的价值在于:所有论文里看起来抽象的概念——因果遮盖、多头注意力、LayerNorm、残差连接、交叉熵——在你的代码里都有具体的张量形状和数值,你可以随时打断点看它们的中间结果,这种"眼见为实"是看论文和调库都得不到的。
我自己的体会是,只有在亲手把注意力头拆开跑一遍之后,才能真正读懂"每个头关注不同的位置关系"这句话。比如有的头可能倾向于关注前一个词,有的头会关注较远的句法成分,这不是玄学,而是训练后权重矩阵里自然涌现的分布。你不亲手写一遍,这些理解永远是别人转述的二手知识。
2. 四个核心环节拆解:数据、模型、训练、生成
2.1 数据准备与 Tokenizer:字符级方案为什么够用
在动手写模型之前,必须先决定文本怎么转成数字。Tokenizer 要做的事就是把原始字符串变成一串整数 ID,同时保留可逆的映射关系。业界大模型普遍用 BPE(Byte Pair Encoding)或更复杂的分词器,把常见英文单词拆成 subword,词表从几万到几十万不等。但教学项目里通常从字符级 tokenizer 开始,理由很简单:字符级实现起来最短、最容易查错。
字符级 tokenizer 的核心逻辑只有三步:先对全部训练文本去重,得到一个字符集合;然后给每个字符分配一个编号,建立字符 -> 编号和编号 -> 字符两个方向的映射;最后提供 encode 和 decode 两个方法。以英文小写文本为例,字符集一般包含 26 个字母、空格、标点、换行等,词表规模通常在 60 到 70 之间。
class CharTokenizer: def __init__(self, text): self.chars = sorted(set(text)) self.stoi = {ch: i for i, ch in enumerate(self.chars)} self.itos = {i: ch for i, ch in enumerate(self.chars)} self.vocab_size = len(self.chars) def encode(self, s): return [self.stoi[c] for c in s] def decode(self, ids): return ''.join([self.itos[i] for i in ids])这里有几个细节值得说。第一,set(text)去重后必须排序,否则每次运行字符顺序可能不同,直接影响词表编号稳定性。第二,decode 时要通过 ID 反查字符再拼接,不能直接拿下标当字符用。第三,训练集和验证集应该共享同一个 tokenizer,不能在验证集上重新构建词表,否则会出现验证阶段遇到训练时没见过的字符、编码直接报错的问题。
字符级的局限也很明显:序列长度比词级长好几倍,模型需要学习更长距离的依赖,训练计算量也更大。但作为教学起点,它的直接优势是词表极小,softmax 输出层参数量少,初始损失差不多是ln(vocab_size),比如词表 65 的时候初始 loss 大约是 4.17,这个数值能帮你判断训练是否正常。等字符级跑通之后,再替换成 BPE tokenizer 也不会伤筋动骨,因为整个数据流接口是一样的,只需要换掉编码解码实现。
2.2 模型结构:一版极简但完整的 Transformer 实现
模型部分是这个项目最值得慢慢啃的环节。我不会堆砌完整代码,但会把结构骨架和关键思路拆开讲。核心结构是类似 GPT 的 decoder-only Transformer,由若干层 Block 堆叠而成,每个 Block 内部包含:带因果遮盖的多头自注意力、前馈网络、LayerNorm、残差连接。
最底层的输入是 token ID 序列。模型首先通过一个 token embedding 表把每个 ID 映射成一个稠密向量,同时还需要一个 position embedding 表来表示位置信息。训练时序列长度是固定的,假设为block_size,所以位置编号从 0 到block_size-1。两个 embedding 相加后,就得到了模型第一层的输入张量,形状是(batch, block_size, n_embd)。
每个 Block 的核心是带因果遮盖的自注意力。假设输入经过 LayerNorm 后得到x,形状是(B, T, C)。我们会用三个线性层分别算出 key、query、value,然后计算注意力分数矩阵:
wei = q @ k.transpose(-2, -1) * C ** -0.5这里的C ** -0.5就是缩放因子,目的是防止点积值过大导致 softmax 梯度消失,对应论文里的 scaled dot-product attention。因果遮盖的含义是:当前位置只能看到它自己以及更早位置的 token,不能看到未来信息。实现方式是构建一个下三角矩阵,把未来位置对应的分数填充为负无穷,这样 softmax 之后注意力权重就是 0。
tril = torch.tril(torch.ones(block_size, block_size)) wei = wei.masked_fill(tril[:T, :T] == 0, float('-inf')) wei = F.softmax(wei, dim=-1) out = wei @ v多头注意力就是把多个这样的头并行计算,每个头有独立的 QKV 线性层,输出后拼接起来再过一层线性映射。为什么要多头?因为每一组 QKV 参数不同,能学习到不同位置的依赖模式,相当于多个"视角"同时观察序列。前馈网络则是逐位置共享的两层全连接,中间层通常扩展 4 倍维度,再经过 ReLU 激活后压缩回来。LayerNorm 在前,残差连接在后,这是现代 GPT 常用的 pre-norm 结构,相比 post-norm 更容易训练稳定。
x = x + self.sa(self.ln1(x)) x = x + self.ffwd(self.ln2(x))最后接一个 LayerNorm 和线性输出层,输出维度等于词表大小,代表每个位置对下一个 token 的预测分数。整个模型参数量很小,以我之前配置过的一组参数为例:n_embd=128, n_head=4, n_layer=4, block_size=128, vocab_size=65,参数量大约在 50 万到 60 万级别。你可以自己粗算一下:token embedding 是65×128=8320,position embedding 是128×128=16384,每个 Block 里注意力相关的线性层约占4×(128×128)量级,加上前馈网络和 LayerNorm,四层叠加总共几十万参数。这样一个模型放在任何一台近十年的笔记本 CPU 上都能跑,这是教学项目得以成立的前提。
2.3 训练循环:损失函数、batch 组织与优化器选择
模型定义好之后,训练的本质就是反复做这样几件事:采样一批数据、前向传播算损失、反向传播算梯度、更新参数。这个循环看起来简单,但很多初学者会在数据组织上栽跟头。
训练数据是整本故事文本切分成的字符 ID 序列。训练时不是把整个序列一次性塞进去,而是先切块。假设block_size=128,就把长序列切成多个长度为 128 的片段,然后从中随机采样一个 batch。每个片段内部,输入是前 127 个字符,目标是后移一位的 127 个字符,用交叉熵计算预测偏差。这里的关键点是:预测的是"下一个字符",所以 target 永远比 input 向后偏移一位,这个 shift 逻辑必须写对。
def get_batch(split): data = train_data if split == 'train' else val_data ix = torch.randint(len(data) - block_size, (batch_size,)) x = torch.stack([data[i:i+block_size] for i in ix]) y = torch.stack([data[i+1:i+block_size+1] for i in ix]) return x, y优化器我建议直接用 AdamW,学习率从3e-4起步。这个数值是大量语言模型训练实践中沉淀下来的经验值:太高容易让 loss 震荡甚至变成 NaN,太低的收敛速度会让人失去耐心。每批数据量如果设置为 32 个序列、每个序列长 128,相当于每次更新能看到 4096 个字符作为上下文,已经足够一个小模型学到很多统计规律。
loss 的计算方式也值得讲清楚。模型输出的logits形状是(B, T, vocab_size),要计算交叉熵,需要把它压成(B*T, vocab_size),target 压成(B*T,),然后交给F.cross_entropy。这一步一定不要自己手动实现 log_softmax 再取负,因为 PyTorch 的交叉熵实现做了数值稳定处理,自己写很容易出 NaN。
训练过程中要同时关注训练集和验证集的 loss。验证集 loss 不参与参数更新,只用于判断是否过拟合。小模型在这个语料规模下通常不会像大模型那样快速过拟合,但如果出现验证集 loss 持续上升而训练 loss 继续下降,说明模型开始死记训练集文本了,这时可以考虑增大 dropout 或者提前停止。
2.4 采样生成:从概率分布到一段能被读懂的文本
训练完毕后的模型拿到手,怎么让它输出故事?原则是:给它一个初始 token,反复让模型预测下一个 token 的概率分布,按概率采样一个 token 拼接到序列末尾,再继续预测。
这里最容易忽略的是上下文长度。模型的位置 embedding 只学到了block_size以内的位置信息,所以生成超过这个长度时,必须只保留序列的最后block_size个 token 作为输入,而不是把整个生成历史都塞进去。
def generate(self, idx, max_new_tokens): for _ in range(max_new_tokens): idx_cond = idx[:, -block_size:] logits, _ = self.forward(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) return idx裸的torch.multinomial是直接从概率分布中采样,等价于 temperature=1。实际操作中你会发现纯温度采样的输出往往太散,容易在语法正确但毫无逻辑的内容之间徘徊。这时可以引入 temperature 缩放:把 logits 除以一个温度值后再做 softmax。温度越低,分布越尖锐,输出越保守;温度高于 1 时分布更均匀,输出更随机但可能更流畅。再进一步,Top-k 采样只从概率最高的 k 个 token 中选,Top-p 则从累积概率超过阈值的集合中选,这两种策略都能避免把概率分配给太多"不靠谱但有很多碎token"的选项。
在"故事大王"这个小模型身上,由于词表只有几十个字符,温度的影响非常直观。我实测过一组参数:temperature=0.8 时输出比较连贯,偶尔会重复短语;temperature=1.2 时字符跳跃明显,甚至会出现不存在的英文单词组合。建议先固定 temperature=1 跑几轮找感受,再调低到 0.6 到 0.8 之间,观察输出的变化趋势。
3. 实操走一遍:从空目录到第一次生成有效故事
3.1 环境准备与最小依赖
这个项目对环境的要求低到超出多数人的预期。只需要 Python 3.8 以上和 PyTorch(CPU 版本即可)。不需要 CUDA,不需要安装额外的自然语言处理库,也不需要下载任何预训练权重。是的,你可以把这次代码运行完全放在 CPU 上,因为我上面给的那组微型配置,在一台普通笔记本的 CPU 上训练 5000 步大约只需要几分钟到十几分钟,取决于你的 CPU 速度和 batch 大小。
如果你还没装 PyTorch,建议去官网按系统环境选择对应命令安装 CPU 版本,这会省掉很多驱动和 CUDA 版本的麻烦。装好之后在一个干净目录下创建脚本文件,把 tokenizer、模型、训练循环按顺序写入,就可以开跑了。
3.2 语料选择与预处理的小技巧
训练语料建议选一本或多个短篇童话故事合并的纯文本,来源最好是无版权文本或自己整理的素材。总量建议控制在 10 万到 30 万字符之间。为什么是这么多?因为太少比如一两万字符,模型很快就能把全部文本背下来,验证集 loss 和训练集 loss 会一起快速下降,但你得到的"生成能力"其实是背课文;太多比如上百万字符,CPU 训练时间会成倍拉长,初学者容易在中途失去耐心。
预处理阶段只需要做两件事:把文本统一成小写(可选),以及按比例切分训练集和验证集。这里有一个经常踩的坑:不能直接train_data = data[:int(0.9*len(data))]就把剩下 10% 当验证集,因为故事文本本身是有结构的,前面可能是目录、后面可能是结尾,直接按比例切会引入分布偏差。更稳妥的做法是先对全部文本做一次随机 shuffle 到 token 级别,或者至少把文本按段落打乱后再切分。字符级 tokenizer 对 shuffle 的容忍度比较高,混洗后切分是教学项目里最省事且合理的方案。
3.3 关键参数如何选择:一组可直接复制的配置
下面这组参数我实际跑过,效果稳定,作为教学基准很合适:
| 参数 | 取值 | 说明 |
|---|---|---|
| vocab_size | 65 | 由字符集自动决定 |
| block_size | 128 | 上下文长度,够一段短文本 |
| n_embd | 128 | 嵌入维度 |
| n_head | 4 | 注意力头数 |
| n_layer | 4 | Transformer Block 层数 |
| batch_size | 32 | 每批采样的序列数 |
| max_steps | 5000 | 总训练步数 |
| learning_rate | 3e-4 | AdamW 默认推荐范围 |
| dropout | 0.1 | 防止过拟合 |
这几个参数之间是有耦合的,不是随便填的。n_embd=128同时是 embedding 维度和注意力计算的宽度,n_head=4意味着每个头维度是 32,这是常见设计:头数乘以每个头的维度等于总嵌入维度。block_size=128决定了位置 embedding 表的大小和注意力分数矩阵的尺寸,太小了学不到长距离依赖,太大了 CPU 训练明显变慢。dropout=0.1是个安全默认值,能压制过拟合但不会让模型欠拟合。
训练时把每 500 步打印一次 loss,你会看到一条从 4.1 左右缓慢下降的曲线。500 步时通常到 3.2 左右,2000 步可能在 2.6 附近,5000 步大概在 2.3 到 2.5 之间。这个数值意味着什么?交叉熵 2.4 左右等价于平均每个字符的预测困惑度约e^2.4 ≈ 11,也就是模型平均在 11 个候选字符里犹豫。对字符级小模型来说,这个水平已经足够生成看起来有模有样、内部还有明显单词和空格结构的英文故事文本。
3.4 生成阶段的操作细节:上下文和温度如何搭配
训练完成后,生成函数需要传入一个初始上下文。最简单的做法是传一个零 ID,也就是字符集排序后的第一个字符,通常是换行符。但更好的做法是手动挑一个词,比如 "once" 或 "the ",先编码成 ID 序列作为起点。因为模型见过大量以 "the" 开头的故事,给一个合理前缀能让生成过程更快进入状态,而不是从一个孤立字符开始随机漂移。
生成长度建议从 300 到 500 个字符试起。太短看不出模型学到了什么,太长模型会出现特有的"重复循环"毛病,这也是字符级小模型最典型的失败模式之一:生成一段后突然反复输出同一个短语,然后越滚越长。遇到这种现象不要慌,这是模型在局部语境下陷入高概率循环,可以调高 temperature 打破循环,或者降低生成长度,也可以在生成后手动截断到合适位置。
采样时还有一个小细节:generate函数每次调用模型前都会把输入截断到block_size,这个逻辑千万不能漏。如果忘了裁剪,序列超过 128 后位置 embedding 查表会越界,程序直接报错。如果你用的是我上面给出的代码,idx_cond = idx[:, -block_size:]这一行就是干这个的,不要把它删掉或者改成别的逻辑。
4. 避坑记录:训练和推理中那些不骗人的问题
4.1 高频报错与排查速查表
我把实操中最常出现的问题整理成了表格,每一类都是我自己或身边同学真实遇到过的:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| loss 一直是 NaN | 学习率过大、文本里有无法编码的字符 | 把学习率降到 1e-4 试一次,检查 tokenizer 是否覆盖全部字符 |
| loss 几乎不下降 | 学习率太小、batch 太小、数据量太少 | 先用较大学率 1e-3 跑 200 步观察下降趋势 |
| 训练 loss 降但验证 loss 涨 | 模型过拟合 | 增大 dropout,扩大数据集,缩短训练步数 |
| 生成结果全部是同一个字符 | 因果遮盖写反、softmax 维度错误 | 检查tril矩阵,遮挡逻辑应该是上三角置为负无穷 |
| 生成内容语义重复严重 | 温度太低、模型容量不足 | 调高 temperature 到 0.9 以上,或增大 n_layer |
| 位置 embedding 报索引越界 | 生成时没裁剪上下文长度 | 检查generate里的idx_cond截断 |
| CPU 训练速度太慢 | block_size 太大、batch 太大 | 把 block_size 降到 64,batch 降到 16 |
4.2 训练长时间没进展怎么办:先看基础,再调参数
很多初学者遇到 loss 不降第一反应就是调模型结构,比如加层、加维度,这通常是错误的优先方向。我的排查顺序是:先确认数据流有没有问题。具体做法是取一个固定 batch,手动打出来看 input 和 target 是否真的偏移了一位,target 是不是对应 input 的下一个字符。如果数据流是错的,模型结构多复杂都没有意义。
然后看初始 loss 数量级。字符级模型初始 loss 应该约等于ln(vocab_size),你的字符集如果是 65,初始大约 4.17。如果第一次打印出来的 loss 远低于这个值,比如直接是 2 以下,说明训练集和验证集混在一起了,或者模型在某种奇怪的方式下看到了未来的 token,这时候排查数据泄漏比调参数更重要。
最后才轮得到学习率和模型配置。我见过很多次"loss 完全不降"的问题最后都出在一个小地方:输入没有做.to(device)或者数据类型不对,模型计算时全部在 CPU 上跑,量级不对导致梯度消失。这一类问题在调试时很难一眼看出,建议所有张量统一指定 dtype,并在 loss 打印时同时打印数据所在的设备。
4.3 生成质量调试:让"故事大王"的产出更像样
模型训练到了 loss 不再明显下降的阶段,生成的文本依然可能让人失望,比如出现大量空格、频繁重复的形容词、故事结构散乱。这并不代表训练失败,而是字符级小模型的天然限制:它没有足够的参数去建模长程结构。
想提升生成质量,有几个立竿见影的手段。第一个是降低温度到 0.7 到 0.8,输出会明显变得更加连贯,虽然代价是多样性下降;第二个是给生成函数增加一个"重复惩罚"逻辑,当某个 token 在最近 N 个位置已经出现过时,在采样前给它一个额外的分数惩罚,这个技巧对打破循环和重复短语效果显著;第三个是直接换用 BPE 级 tokenizer,词表从 65 提升到几千甚至几万后,模型处理单元从字母变为更接近单词的 subword,相同上下文长度能容纳更多有效信息,生成质量会有质的提升。
此外,如果你希望模型生成中文故事,字符级 tokenizer 同样可用,因为中文直接以汉字为字符单位,词表通常在 3000 左右,模型训练会更慢,但原理完全一致。需要额外注意编码统一成 UTF-8,避免乱码引入无效字符导致词表爆炸。
5. 一些个人体会:这个项目到底值不值得完整跟一遍
如果问我对这个项目的最终评价,我的回答是:值得,但要有耐心地跟,不要跳步。它的价值不在于代码量有多大,而在于它把大模型从"论文里的抽象符号"变成了"你可以打断点查看的中间张量"。跟完整个流程后,你再去看任何关于 GPT 结构、训练技巧、采样策略的讨论,都会觉得字字落到实处,因为你已经亲手操作过它们的每一个变量。
我自己的实践习惯是:第一遍照着项目思路把代码全部手敲一遍,一个函数一个函数地跑通,不复制粘贴;跑通后换一个与自己兴趣相关的数据集,比如中文武侠小说片段或者自己整理的领域文本,观察模型在不同语料下的行为变化;最后再把 tokenizer 从字符级换成 BPE 级,感受一下词表扩容对生成质量的影响。这三步走下来,绝大多数"好像懂了"的模糊理解都会被清理干净。
最后分享一个小技巧:训练过程中定期把模型参数保存下来,记录当时的 loss 值和生成样例,训练结束后你会得到一条完整的"模型成长曲线"。回头翻看前几百步生成的乱码和后几千步有模有样的句子,那种变化带来的直观冲击,比任何教程里的曲线图都更能帮你建立对深度学习训练过程的直觉。这个项目看起来只是做一个会讲故事的小模型,但走完一遍之后,你获得的是走进大模型世界的一张属于自己的地图。