从零训练MiniMind:12M参数小模型的完整训练闭环
这是 MiniMind 学习笔记的第 10 篇。前面九篇聊了注意力机制、Transformer 结构、损失函数这些偏理论的内容这一篇不聊原理直接动手从零训练一个 MiniMind。MiniMind 是我给自己这个模拟项目起的代号——一个只有十几兆参数的小模型单看效果完全不具备实用价值但它能让你在几十分钟内亲眼看到一条 loss 曲线从 4 掉到 3再让模型说出第一句像样的话。这篇笔记适合两类人一类是刚看完 Transformer 理论、想找个最小例子真正跑起来的人另一类是已经跑过现成模型但始终没搞懂从数据到训练再到推理这整条链路是怎么串起来的人。不需要分布式不需要堆显卡一台普通带独立显卡的电脑就够核心目的只有一个把训练闭环走通。1. 动手前先算一笔账MiniMind 做多大才不会翻车1.1 按显存反推参数量一张普通显卡的极限在哪里动手写代码前我做的第一件事不是开编辑器而是先算显存预算。很多新手容易犯的错是上来就套一个比较大的模型架构然后训练时直接爆显存之后再回头改结构白折腾半天。正确顺序应该反过来先确定手里有什么硬件再反推模型能做到多大。MiniMind 的参数量我定在 12M 左右。这个数字不是随便拍的而是根据显存占用公式粗略算出来的。训练时一个参数一般要占 4 字节用于存储参数本身另外 4 字节存梯度如果用了 AdamW 这类带动量的优化器还要额外 8 字节保存一阶和二阶动量。也就是说一个参数在训练过程中大约占 16 字节。按 12M 参数算12 * 1000000 * 16 字节大约是 192MB。这还只是模型参数、梯度和优化器状态这三项不算中间激活值。实际训练时 batch 中的每个 token 都要经过各层计算会留下临时激活值这部分随 batch 大小和序列长度线性增长。所以即便加上激活值12M 的 MiniMind 放在一张 8GB 显存的普通显卡上也完全没压力甚至 4GB 显存的机器都能跑。如果你手里显存更大比如 12GB 甚至 24GB可以把模型调到 60M 左右训练时间会明显变长但体验会更接近“真训练”。我建议第一次跑闭环时不要贪大先保证过程顺利MiniMind 的定位就是小、快、能跑通。1.2 技术选型少依赖、可复现为主在框架选择上我用了 Python 生态里最主流的动态图深度学习框架直接用它的核心 API没有引入任何重量级训练库。为什么不引那些封装太好的库因为 MiniMind 的定位是“搞懂每一行代码在干什么”如果训练代码全是别人封装好的高层接口那跑完一遍之后你还是不知道模型内部发生了什么。依赖越少出问题的面就越小。我实际用到的只有三样动态图框架本身、一个基础的数值计算库、标准库里的 json 和 random。Tokenizer 都没有用现成的大词表工具而是自己写了五十行不到的字符级字典。这样做的另一个好处是整个项目在换一台机器后能快速重复跑起来不存在环境上面千奇百怪的坑。核心流程我梳理成四步准备纯文本语料、把文本切成 id 序列、搭建最小 Transformer 模型、跑训练循环。这四步每步都不复杂但每一步都藏着细节后面几节我会挨个拆开讲。2. 数据与 Tokenizer模型的第一口粮食2.1 语料选择少而精比大而全重要模型能不能学会说话数据质量比数据量更重要尤其对 MiniMind 这种小模型来说。大模型吃海量噪音还能靠参数量硬扛小模型没有这种余量喂进去十份脏数据可能连那几份干净数据的光环都盖掉了。我准备了一份大约 2MB 的纯文本语料内容是中文短文片段、百科条目和几段问答统一保存为 UTF-8 编码的 txt 文件。这个量级换算成 token 大概是几十万个字对大模型来说连热身都不够但对 MiniMind 来说刚刚好能让我在十分钟内完整跑完多个 epoch快速观察训练动态。清洗语料时我做了三件事第一统一把换行符处理成 \n去掉各种零宽字符和不可见符号第二将全角空格、连续空白替换成单空格第三把一些明显残缺的句子直接丢掉。这一步看似不起眼但能省掉后续大量排查时间。脏数据最典型的表现是 loss 训练很久都降不下去或者模型生成时突然冒出一堆乱码字符。语料干净了这两个问题能少一大半。2.2 字符级 Tokenizer 的落地实现MiniMind 的 Tokenizer 我选了最简单的字符级方案。思路很直白把每个常见汉字、英文字母、数字和标点符号都当作一个独立 token建立字符到整数 id 的映射。为什么不直接上 BPE因为 BPE 需要先在大语料上统计词频、学习合并规则对 MiniMind 这种小规模项目来说训练分词器本身带来的复杂度远大于收益。字符级方案虽然会让序列长度变长但它不依赖额外数据逻辑透明出了问题一眼就能定位。我的词表分三部分第一部分是特殊 token包括 [PAD]、[UNK]、[BOS]、[EOS]固定占前四个 id第二部分是常用汉字我从语料里把所有出现过的汉字去重后按频率排序然后截取前 5000 个第三部分是英文字母、数字和常见标点大概一百多个。最后词表大小约 5100。构建 tokenizer 的代码很简单import json def build_vocab(texts, max_vocab5000): # 统计字符频率 char_count {} for text in texts: for ch in text: char_count[ch] char_count.get(ch, 0) 1 # 按频率从高到低排序 sorted_chars sorted(char_count.items(), keylambda x: x[1], reverseTrue) # 特殊 token 固定放在最前面 specials [[PAD], [UNK], [BOS], [EOS]] vocab {s: i for i, s in enumerate(specials)} # 常用字符补充进词表 for ch, _ in sorted_chars[: max_vocab - len(specials)]: if ch not in vocab: vocab[ch] len(vocab) return vocab def encode(text, vocab): return [vocab.get(ch, vocab[[UNK]]) for ch in text] def decode(ids, vocab): id2char {i: ch for ch, i in vocab.items()} return .join(id2char[i] for i in ids)这里有一个细节如果语料清洗不彻底出现了一些频率低但真实存在的生僻字符它们可能落在词表之外编码时就会变成 [UNK] 占位符。对 MiniMind 来说这种损失可以接受因为常用字已经覆盖了绝大多数文本。2.3 构造训练样本把文本切成有顺序的片段文本本身是一串字符但模型要学的不是字符而是“给定前面若干字符预测下一个字符”。所以我们需要把所有文本切成长度为 seq_len 的输入段同时为每一段构造对应的目标输出。我选择 seq_len 128。也就是说模型每次读 127 个字符预测第 128 个字符然后整体右移一位输入变成原序列的 2 到 128目标变成 3 到 129。这种“下一个字符预测”模式正是自回归语言模型的标准训练方式。切分语料时我先按自然段落拆开再把每个段落切成最多 128 字符的小块。如果某个小块不足 128 字符就在末尾补上 [PAD] 补到固定长度。为了不让模型把 padding 位置也当成要学习的内容后面计算 loss 时我会用 ignore_index 把 padding 位置直接跳掉。构造样本的核心逻辑def build_samples(texts, vocab, seq_len128): samples [] for text in texts: ids encode(text, vocab) for i in range(0, len(ids) - seq_len, seq_len): chunk ids[i: i seq_len 1] if len(chunk) seq_len 1: break samples.append((chunk[:-1], chunk[1:])) # 对不足 seq_len 的尾部片段单独处理 # 这里固定用最后一段不足部分补 [PAD] 到 seq_len return samples注意 target 序列不是随便从输入序列右移一位就完事它还包含了 padding 位置。后面训练时需要用掩码把 padding 对应的 loss 屏蔽否则模型会拼命学习把 [PAD] 预测成另一个 token整个训练目标就歪了。这一步看起来不起眼却是新手最容易踩的坑之一。我在第一次跑实验时忘了设置 ignore_index结果 loss 一直卡在 4 附近降不下去当时一度以为模型架构写错了。3. 模型结构手写一个极简 GPT3.1 从 Embedding 到 Attention 再到预测头MiniMind 的模型结构就是经典的 decoder-only Transformer但规模缩到不能再缩。整个模型由四部分组成Token Embedding、位置编码、两层 Transformer Block、最后的输出头。Token Embedding 的作用是把 token id 转成向量。MiniMind 的 embedding 维度 d_model 设为 128也就是每个 token 用一个 128 维向量表示。位置编码我用的是可学习的参数矩阵序列长度为 128所以位置编码的维度是 [128, 128]。把 token 的 embedding 和位置向量相加得到带位置信息的输入向量。Transformer Block 是核心。每个 Block 内部包含一个多头自注意力机制和前馈网络外面包了残差连接和 LayerNorm。MiniMind 设了两个 Block每个 Block 的注意力头数为 4 头。头数太少表达力不够头数太多会让小模型在数据量不足时更快过拟合4 头是这个小规模下的折中选择。最后是一个 Linear 层把 128 维的隐状态映射到词表大小 5100 维再取预测各 token 的概率分布。模型结构代码可以简化成下面这样import torch import torch.nn as nn class MiniMind(nn.Module): def __init__(self, vocab_size5100, d_model128, n_layer2, n_head4, seq_len128): super().__init__() self.token_embedding nn.Embedding(vocab_size, d_model) self.position_embedding nn.Embedding(seq_len, d_model) self.blocks nn.ModuleList([ TransformerBlock(d_model, n_head) for _ in range(n_layer) ]) self.ln_final nn.LayerNorm(d_model) self.lm_head nn.Linear(d_model, vocab_size, biasFalse) def forward(self, x): seq_len x.size(1) positions torch.arange(seq_len, devicex.device).unsqueeze(0) x self.token_embedding(x) self.position_embedding(positions) for block in self.blocks: x block(x) x self.ln_final(x) logits self.lm_head(x) return logitsTransformerBlock 内部实现我特意没有用框架自带的 transformer 模块而是手写了一个缩放点积注意力。原因很简单用自带模块虽然两行就能写完但你永远不知道掩码怎么加、多头怎么拆。手写一遍之后那些概念才是真的你的。3.2 关键超参数为什么这样设模型结构只是骨架超参数决定训练动态。MiniMind 的关键超参数我列在下面超参数取值说明vocab_size5100词表大小来自字符级 tokenizerd_model128向量维度控制模型容量n_layer2Transformer Block 数量n_head4注意力头数seq_len128输入序列长度batch_size64每批训练样本数learning_rate3e-4初始学习率warmup_steps200前 200 步线性热身epochs20训练轮数学习率 3e-4 是我在小模型上反复试过比较稳妥的起点。如果数据量更小可以降到 1e-4如果显存充足且想更快收敛也可以试 5e-4但新手不建议一上来就用大学习率容易在训练初期直接把 loss 打飞。Warmup 的作用是让模型前几步不要迈太大先在 200 步内从接近 0 的学习率逐步升到 3e-4这样训练更稳定。总步数怎么估算呢假设语料约 2MB按汉字常用字符统计大约 60 万个字符切成长度 128 的样本后大约有 4000 条。每轮 epoch 有 4000 / 64 62.5 步训练 20 个 epoch 下来大约 1250 步。这个规模在普通显卡上只需要几十分钟。3.3 训练循环让梯度真正跑起来训练循环本身不复杂但有几个细节值得注意。第一个是 loss 的计算必须让 padding 位置不参与损失。第二个是 optimizer 用 AdamW这是 Transformer 训练的标配比普通 Adam 多了权重衰减能减少过拟合。第三个是学习率调度我在热身期结束后用余弦衰减把学习率平滑降到零。核心训练代码长这样import torch from torch.optim import AdamW model MiniMind(vocab_sizelen(vocab)) optimizer AdamW(model.parameters(), lr3e-4, weight_decay0.01) criterion nn.CrossEntropyLoss(ignore_indexvocab[[PAD]]) losses [] for epoch in range(epochs): for batch in data_loader: input_ids batch[input_ids] target_ids batch[target_ids] logits model(input_ids) # [batch, seq_len, vocab] loss criterion( logits.transpose(1, 2), target_ids ) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() losses.append(loss.item())我实际跑的时候loss 在第 100 步左右从 3.7 降到 3.0300 步之后进入 2.6 附近到训练末尾最低能到 2.2。这个数值只是参考因为 loss 绝对值跟词表大小、语料难度直接相关不用硬套。关键是看趋势如果训练了几百步后 loss 几乎不动或者某一刻突然变成 NaN那大概率是前面某一步出了问题需要对照第 4 节排查。这里插一句grad_norm 截断到 1.0 是我从第一次训练翻车后养成的习惯。小模型虽然参数量不大但早期梯度偶尔会出现极端值一旦某个梯度爆掉loss 就会直接升天。加上这句之后训练稳定了许多。4. 常见问题排查loss 不降、显存不够怎么办4.1 训练异常速查表我跑 MiniMind 也翻过好几次车把遇到过的典型问题整理成了一张表后面再跑类似项目直接对着查症状可能原因处理方式loss 是 NaN学习率过大调小 lr比如从 3e-4 降到 1e-4loss 是 NaN语料里有无法编码的异常字符清洗数据检查 [UNK] 频率loss 长期不降padding 位置参与了 loss 计算检查 CrossEntropyLoss 的 ignore_indexloss 长期不降lr 太小适当增大 lr 或调整 warmuploss 下降后反弹学习率没有衰减加余弦衰减或 StepLR过拟合严重数据量太小模型反复背诵增加语料、增大 dropout、降低 epochs显存不足batch_size 太大调小 batch或开梯度累积生成全是重复内容采样参数不合适调高 temperature或做重复惩罚这些问题的共性在于小模型训练失败大多不是架构写错而是数据处理或超参数细节上出了偏差。尤其是 padding 导致 loss 不降我遇到不止一次每次都以为自己模型写错了最后发现只是 ignore_index 没设。4.2 显存优化与加速的实用技巧MiniMind 的体量其实很难真的耗尽显存但如果你想把它放大到 60M 或者更大显存优化就必须考虑了。这里分享几个我实际操作中觉得好用的土办法。第一个是梯度累积。显卡一次只能装下 batch 16但你想用 batch 64 的效果可以一次算 16 个样本、连续累积四次的梯度然后再做一次参数更新。这种方式能模拟大 batch代码上只是多了一个累积计数器和一次判断对训练效果影响很小。第二个是混合精度训练。如果框架支持自动混合精度开启 FP16 能让显存占用进一步下降。但小模型上用混合精度效果不一定好有时反而会因为精度损失导致训练不稳定所以时间换稳定还是稳定换速度需要自己权衡。我的建议是只有当你确认训练已经很稳定再尝试开混合精度。第三个是减小序列长度。MiniMind 用 128 的序列长度已经很小但如果你想测试模型在更长文本上的表现序列长度变成 512 后显存占用会成倍增加。先用 128 跑通再逐步拉长是更稳妥的做法。显存不是靠技巧省出来的而是靠前期参数预算设计出来的这句话我在很多项目里都验证过。5. 推理与评估让 MiniMind 开口说话5.1 自回归生成与采样参数训练结束之后模型已经学到了一些字符之间的组合规律。推理时我们不再给它完整文本而是只给一个开头让它一个 token 一个 token 地往后猜。这个“猜”的过程需要采样策略不能每次都选概率最大的那个字符否则模型会陷入重复循环。我最常用的采样方式是 temperature top-p。temperature 控制概率分布的平滑程度temperature 越低越倾向选高概率 token输出更保守temperature 越高越倾向探索低概率 token输出更多样。top-p 则是在每一步只保留累计概率超过 0.9 的那些候选 token从这些小集合里重新归一化采样。这样既能避免重复又不会太乱。生成代码示意如下def generate(model, tokenizer, prompt, max_new_tokens50, temperature0.7, top_p0.9): model.eval() input_ids encode(prompt, tokenizer) with torch.no_grad(): for _ in range(max_new_tokens): x torch.tensor([input_ids[-128:]], dtypetorch.long) logits model(x)[0, -1, :] / temperature probs torch.softmax(logits, dim-1) # top-p 过滤 sorted_probs, sorted_indices torch.sort(probs, descendingTrue) cumulative torch.cumsum(sorted_probs, dim0) kept cumulative top_p kept[torch.nonzero(kept).max()] True filtered_probs torch.zeros_like(probs) filtered_probs.scatter_(-1, sorted_indices[kept], sorted_probs[kept]) # 采样 next_token torch.multinomial(filtered_probs, 1).item() input_ids.append(next_token) if next_token tokenizer[[EOS]]: break return decode(input_ids, tokenizer)这里的第一个细节是每次只取最后 128 个 token 作为输入保证长度始终不超过模型支持的序列长度。第二个细节是kept数组一定要保证至少保留一个 token否则multinomial会因为输入全是 0 而报错。5.2 评估先看记忆再看泛化MiniMind 训练完之后我先做了两个测试。第一个测试是“背诵测试”从训练语料里挑一句完整的话去掉后半句让模型补全。如果它能把训练集里的内容复述出来说明模型容量足够、训练流程没问题。第二个测试是“泛化测试”给它一个训练语料里没有的开头看它能不能生成语法通顺、逻辑不算太离谱的后续。我用训练语料里的一句“人工智能正在深刻改变我们的生产生活方式”做了补全测试MiniMind 给出的前半段几乎一字不差后半段开始出现一些同义替换但语序正常。这个结果说明模型已经学到了一些局部语言模式。泛化测试我给了一句“如果明天太阳从西边升起”模型接得逻辑混乱但至少句子成分完整、用词没有大问题。这个表现说实话离“智能”很远但它证明了整条链路是通的数据清洗、分词、模型训练、推理采样每一个环节都能正常工作。对小模型来说能稳定产出语法通顺的短句已经是一个合格的里程碑。想让它更接近真正有意义的对话后面的方向无非是加大语料、加大模型、换成 BPE 分词但这已经不是这一篇笔记的范围了。我个人跑完这一遍最大的体会是从零训练模型最难的地方不在模型代码而在于你愿意花多少时间把数据处理和超参数调试这些“脏活”做扎实。MiniMind 虽然小但它完整踩过了所有会踩的坑以后再跑大模型项目时很多出问题的本能反应就是这么训练出来的。如果你也想试试建议别直接复制代码而是先把手里的文本数据准备好一行一行跟着写。跑通的那一刻训练闭环带给你的踏实感比看十篇原理笔记都实在。