很多人提到大模型训练,第一反应就是“没有几十张卡根本搞不了”。但实际上,一个参数量只有四五十M 的 MiniMind 也能完整走一遍从零训练的流程,而且单张消费级显卡就能跑完。这一篇是这个学习笔记系列的第 10 篇,前面的内容已经把 Transformer 结构、数据准备、词表、训练原理这些理论基础都过了一遍,今天不做别的,就一个字:跑——动手从零训练一个 MiniMind。
这次我不打算讲太虚的东西,全程就是实操记录加上踩坑总结。MiniMind 是我自己搭的一个极简 decoder-only 语言模型,目标很明确:用最低的硬件成本,把“数据清洗 → 词表构建 → 模型配置 → 训练调参 → 生成验证”这条完整链路走通。适合谁看?如果你已经会用 PyTorch,但从来没完整训练过一个语言模型,对“训练到底是怎么一回事”还停留在概念层面,那这篇笔记可以直接照着敲。
1. MiniMind 是什么,为什么值得从零训练一遍
1.1 一个约 50M 参数的小模型,能用来干什么
MiniMind 不是一个玩具,也不是一个生产级模型,它更接近“教学标本”。它的结构和大模型完全一致:Embedding 层、多层的 Self-Attention、FFN、LayerNorm,最后接一个 LM Head 映射到词表概率分布。输入一段文本,模型输出下一个 token 的概率,然后不断把生成的 token 拼回去继续预测,这就是标准的 autoregressive 生成方式。
50M 参数是什么概念?如果每层 hidden size 用 512、共 12 层、中间层 2048,词表 6400,整体参数量大概在 45M 到 50M 之间。这个体量在 fp32 下大约占 180MB 内存,加上梯度和优化器状态也就 1GB 左右,一张显存 8G 的显卡都能塞下。它完全可以学会生成通顺的中文短句,比如输入“春天的阳光洒在”,它可能续写出“窗台上,一只猫正趴在花盆旁边打盹”。但不要指望它能做复杂的逻辑推理、长程记忆或指令跟随,那不是这个阶段的目标。
选择这个规模的原因很简单:训练一个模型,最重要的是先把“流程”跑通,而不是追求效果。你可以在几个小时内完成一个完整的训练循环,快速验证数据、词表、学习率等每个环节是否正常。如果一上来就用几 B 参数的模型,光排查 bug 的时间成本就能劝退大多数人。
1.2 从零训练 vs 直接微调,两条路线的差异
我见过不少朋友一上来就想微调某个开源大模型,但微调和从零训练要解决的问题完全不一样。微调是在一个已经会“说人话”的基座模型上,让它学会特定格式或新知识,就像在一篇已经写好的文章上润色、改结构。而从零训练是让模型从字符分布开始学习语言规律,就像让一个完全不会写字的人从零开始学造句,两者的难度和资源需求差了一个量级。
很多教程默认你直接用微调路线,因为省事。但如果你真正想理解模型内部机制,从零训练是绕不开的。你会亲眼看到 loss 从接近词表均匀分布的数值一路下降,会感受到数据质量对训练效果的直接影响,也会明白为什么学习率要预热、为什么梯度累积能解决显存不足、为什么 checkpoint 要分开保存。这些只有踩过一遍才能变成你自己的经验。
所以这篇笔记的定位很明确:不是教你训出一个能打的对话模型,而是通过 MiniMind 把训练全流程里每个环节的“为什么”都走一遍。后面再上更强的模型,你至少知道问题可能出在哪一步。
1.3 硬件门槛:实测下来真没那么高
先说结论:训练一个 50M 参数的 MiniMind,单张 16G 显存的显卡足够,8G 显存也有机会,只是需要把序列长度缩短一点或者把 batch size 调小。
我实测的配置大概是:batch size 8、max length 512、混合精度开启,显存占用大约 6G 到 10G。如果你的显卡只有 8G,可以把 batch size 降到 4,max length 降到 384,仍然可以训练,只是收敛速度和生成质量会有一点折扣。CPU 其实也能跑,但我强烈不建议,不是跑不动,是太慢了。一次 forward 和 backward 在 CPU 上要慢几十倍,训练到天亮可能还没跑完一个 epoch,耐心会被消磨光。
有一点要提醒:显存占用的大头往往不是模型参数本身,而是训练过程中的中间激活值。序列越长、batch 越大,激活值就越大。所以遇到显存不够,先砍 max length,再砍 batch size,这两个参数是显存敏感度最高的。
2. 训练前置准备:数据格式与词表构建的实操细节
2.1 数据从哪来、格式怎么定
训练语言模型,数据是一切的地基。MiniMind 这个规模不需要海量数据,但也别太少。我的经验是,准备几十万到几百万条中文短文本,总 token 量在 3 亿到 5 亿左右,训练出来的效果比较像样。如果只有几万条,模型能学到一些皮毛,但生成内容会颠三倒四,甚至反复说同一句话。
数据格式我很建议用 jsonl,一行一条,每条是一个 JSON 对象,里面放一个 text 字段。比如这样:
{"text": "春天的阳光洒在窗台上,空气中带着泥土的气息。"} {"text": "逻辑回归是一种经典的线性分类算法,它通过 Sigmoid 函数将线性输出映射到概率空间。"} {"text": "读完这本小说,我最大的感受是时间不会倒流,但人可以重新出发。"}为什么用 jsonl 而不是 txt 或者纯逗号分隔?因为 jsonl 可以方便地扩展字段,比如后面做指令微调时,你需要在同一条数据里同时存 prompt、response、source 等多个字段,jsonl 格式不需要重新设计数据管线。而且它在断点续传、按行读取、分布式处理上都非常友好。
数据来源我建议优先选已经清洗好的纯文本语料,比如百科类、新闻类、书籍类。如果你自己爬网页数据,清洗成本会非常高,而且很容易把一堆 HTML 标签和乱七八糟的符号喂给模型,训练出来全是乱码。
2.2 清洗数据,这步别偷懒
数据清洗看起来不起眼,但对训练结果的影响是决定性的。我见过一个朋友直接用原始网页文本训练,模型学了一堆“回车符”、“空格序列”和“阅读全文”之类的垃圾输出,这根本不是模型能力问题,是数据本身太脏。清洗这一步至少要覆盖以下几个方面。
第一,去 HTML 标签和网页噪声。正则表达式就能搞定。第二,统一全半角符号、去除连续空格、把多个换行合并成一个。第三,过滤超短文本和超长文本。太短的文本没有足够上下文,模型学不到语言结构;太长的文本会超过序列长度被截断,浪费数据。第四,去重。重复数据会让模型过度学习某些固定模式,生成时容易陷入重复循环。
下面是我常用的一个简化版清洗脚本,可以直接参考:
import json import re from tqdm import tqdm def clean_text(text): text = re.sub(r"<[^>]+>", "", text) text = text.replace("\n", " ") text = re.sub(r"\s+", " ", text) text = re.sub(r"[ \t]+$", "", text) return text.strip() with open("raw.txt", "r", encoding="utf-8") as f: lines = f.readlines() with open("train.jsonl", "w", encoding="utf-8") as out: for line in tqdm(lines): text = clean_text(line) if len(text) < 20 or len(text) > 512: continue out.write(json.dumps({"text": text}, ensure_ascii=False) + "\n")这个脚本只做基础清洗,但足够用了。记住一个原则:宁缺毋滥。数据里混入大量垃圾,模型就会学到垃圾;数据量少一点,只要干净,训练效果也不会差到哪去。
2.3 词表大小怎么选:从字符到 BPE
模型不认识中文汉字,它只认识整数索引。所以训练开始前,必须先把文本切分成 token,每个 token 对应一个数字 ID。这个切分工具就是 Tokenizer。中文和英文不太一样,按英文单词切分在中文上行不通,因为中文的词边界不明显。常见的有三种粒度:按字切分、按词切分、按 BPE 子词切分。
按字切分最简单,词表大小就是常用汉字数量,大概 3000 到 5000 就够。缺点是每个 token 的信息量太小,模型需要更长序列才能学到词级语义。按词切分需要分词工具,而且词表会非常大,OOV 问题严重。BPE 是介于两者之间的方案,它能自动从数据中学出高频字组合并策略,把“人工智能”这种高频词合并成一个 token,把低频词拆成更小的片段。
MiniMind 我用的是 6400 词表的 BPE。这个数量对中文来说够用,既不会像字符级那样序列过长,也不会像大词表那样增加 Embedding 参数量。词表大小直接决定了 Embedding 层和 LM Head 的参数规模,6400 词表折算下来大概是几百万参数,对 50M 模型来说比例合理。
训练 Tokenizer 可以用开源工具直接做。我用的是 tokenizers 库,核心代码不长:
from tokenizers import Tokenizer, models, trainers tokenizer = Tokenizer(models.BPE(unk_token="<unk>")) trainer = trainers.BpeTrainer( vocab_size=6400, special_tokens=["<pad>", "<bos>", "<eos>", "<unk>"], min_frequency=2, ) files = ["data/corpus.txt"] tokenizer.train(files, trainer) tokenizer.save("tokenizer/tokenizer.json")训练完 Tokenizer 后,要把全部训练数据预切成 token 并保存成二进制格式,比如 numpy 数组或内存映射文件。千万不要在训练循环里实时分词,那样会浪费大量时间在 CPU 和 GPU 的数据搬运上。数据预处理一次,后面无限复用,这才是正确姿势。
3. 模型结构与超参数:一份可直接复用的 MiniMind 配置
3.1 目标参数量反推配置:12 层 512 维够不够用
模型结构我直接参考了 GPT 家族的 decoder-only 架构。没有 Encoder,没有 Cross-Attention,就是纯粹的自回归网络:输入 token ID → Embedding → 12 层 Transformer Block → LM Head → 输出概率分布。
下面是 MiniMind 的完整配置,这份配置已经足够跑通流程,也经过了实际训练验证。
| 配置项 | 数值 | 说明 |
|---|---|---|
| vocab_size | 6400 | 词表大小,与 Tokenizer 保持一致 |
| max_position_embeddings | 512 | 最大序列长度 |
| hidden_size | 512 | Embedding 维度和隐藏层维度 |
| num_hidden_layers | 12 | Transformer Block 层数 |
| num_attention_heads | 8 | 注意力头数 |
| intermediate_size | 2048 | FFN 中间层维度 |
| activation_function | gelu | FFN 激活函数 |
| layer_norm_epsilon | 1e-5 | LayerNorm 的 epsilon |
| bos_token_id / eos_token_id / pad_token_id | 1 / 2 / 0 | 特殊 token ID,与词表对齐 |
这个配置算下来参数量大概在 45M 到 50M 之间。参数量主要由三块构成:Embedding 和 LM Head 占一部分;每层 Self-Attention 的 QKV 和输出矩阵占一部分;每层 FFN 占一部分。12 层累加起来,正好落在目标范围内。为什么不用更大的 hidden size?因为训练成本会非线性上升。hidden size 翻倍,注意力权重和 FFN 权重参数量会翻四倍,显存和训练时间都扛不住。
有人可能会问:12 层对 50M 参数来说是不是太深了?层数多是好事还是坏事,取决于数据量。数据量够,深度可以加深;数据量不足,太深的网络反而容易过拟合,导致生成内容千篇一律。我跑过 8 层的版本,生成质量略差一些,但收敛更快。所以如果你数据量比较小,可以把层数减到 8,训练效率更高。
3.2 学习率、batch size、序列长度:一组跑得稳的组合
超参数选择是新手最容易迷茫的地方,我直接给出一组经过验证的组合,然后解释为什么这么配。
我的基准配置是:学习率 3e-4,warmup 500 步,之后 cosine 衰减到 3e-5;batch size 8,梯度累积 4 步,相当于有效 batch size 32;序列长度 512;优化器用 AdamW,weight decay 0.1;训练 3 个 epoch。
学习率设为 3e-4 对 50M 小模型来说属于比较激进的取值。小模型参数量少,可以承受相对大的学习率,训练初期 loss 下降会更快。但随机初始化的 Embedding 矩阵非常脆弱,如果一上来就用大步长,很容易把 token 分布“打飞”,导致后续很难收敛。warmup 就是用来干这个的:前 500 步让学习率从 0 线性爬到 3e-4,模型在低学习率下先稳定下来,再逐步增大步长。
batch size 为什么是 32 而不是更大?对小模型来说,batch size 在 32 到 64 之间效果差别不大,但显存占用差别很大。batch 32、seq 512 的中间激活值已经不小了,16G 显卡刚好能压住。如果你的显卡只有 8G,可以改成 batch 4、累积 8,数学上有效 batch 还是 32,只是训练步数变多了。
这里有一个容易搞混的概念:有效 batch size = batch size × 梯度累积步数。梯度累积并不是把多个 batch 的数据拼在一起喂给模型,而是先算几个小 batch 的梯度,把梯度累加起来,再统一做一次参数更新。它的作用和放大 batch 一样,但对显存友好得多。
3.3 训练脚本的骨架:别急着上 Trainer,先手动跑一遍循环
现在框架工具很多,Trainer API 也很方便,但对于学习阶段的人来说,我建议先手动写训练循环。原因很简单:手写循环能让你看到每一步在做什么,出错时也更清楚问题在哪。
下面是一份简化但完整的训练骨架代码:
import json import torch from torch.utils.data import Dataset, DataLoader from transformers import GPT2Config, GPT2LMHeadModel from transformers import AutoTokenizer class TextDataset(Dataset): def __init__(self, data_path, tokenizer_path, max_length=512): self.data = [] tokenizer = AutoTokenizer.from_pretrained(tokenizer_path) with open(data_path, "r", encoding="utf-8") as f: for line in f: item = json.loads(line) ids = tokenizer.encode(item["text"], truncation=True, max_length=max_length) if len(ids) >= 32: self.data.append(ids) def __len__(self): return len(self.data) def __getitem__(self, idx): return torch.LongTensor(self.data[idx]) def collate_fn(batch): max_len = max(len(x) for x in batch) padded = torch.zeros(len(batch), max_len, dtype=torch.long) for i, x in enumerate(batch): padded[i, :len(x)] = x return padded config = GPT2Config( vocab_size=6400, max_position_embeddings=512, hidden_size=512, num_hidden_layers=12, num_attention_heads=8, intermediate_size=2048, bos_token_id=1, eos_token_id=2, pad_token_id=0, ) model = GPT2LMHeadModel(config) dataset = TextDataset("data/train.jsonl", "tokenizer/tokenizer.json") dataloader = DataLoader(dataset, batch_size=8, shuffle=True, collate_fn=collate_fn, num_workers=4) optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.1) total_steps = len(dataloader) * 3 scheduler = torch.optim.lr_scheduler.OneCycleLR( optimizer, max_lr=3e-4, total_steps=total_steps, pct_start=0.02, anneal_strategy="cos" ) device = "cuda" model.to(device) gradient_accumulation = 4 model.train() for epoch in range(3): for step, input_ids in enumerate(dataloader): input_ids = input_ids.to(device) outputs = model(input_ids, labels=input_ids) loss = outputs.loss / gradient_accumulation loss.backward() if (step + 1) % gradient_accumulation == 0: torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() scheduler.step() optimizer.zero_grad() if step % 100 == 0: current_lr = scheduler.get_last_lr()[0] print(f"epoch {epoch} step {step} loss {loss.item() * gradient_accumulation:.4f} lr {current_lr:.2e}") torch.save({ "epoch": epoch, "step": step, "model": model.state_dict(), "optimizer": optimizer.state_dict(), }, f"checkpoints/minimind_latest.pt")这份代码已经能跑了。注意我用 GPT2LMHeadModel 直接构造了 MiniMind 的结构,labels 传 input_ids 本身,模型内部会自动把每个 token 的预测与下一个 token 做交叉熵。如果训练中途断了,可以直接加载上一次保存的 state_dict 和 optimizer 继续跑。
4. 动手训练:命令行启动、损失曲线与 checkpoint 管理
4.1 启动训练:一行命令跑起来
把上面的代码整理成 train.py,再加上几个命令行参数,启动命令大概长这样:
python train.py \ --data_path ./data/train.jsonl \ --tokenizer_path ./tokenizer/tokenizer.json \ --batch_size 8 \ --gradient_accumulation 4 \ --learning_rate 3e-4 \ --max_length 512 \ --epochs 3 \ --save_dir ./checkpoints这些参数的含义在前面都已经解释过。第一次启动之前,我强烈建议先做一个“最小冒烟测试”:只取 10000 条数据,跑 50 步,确认 loss 在下降、日志正常输出、checkpoint 能保存。这个 50 步验证是整个训练流水线的“单元测试”,能帮你过滤掉绝大多数低级错误,比如数据路径写错、tokenizer 没对齐、模型配置异常等。
我自己第一次训练时就吃了这个亏:拿着全量数据直接开跑,结果 tokenizer 路径配错了,模型把每个中文字符都拆成 unk,训练了三个小时 loss 纹丝不动。如果先跑 50 步,一分钟就能发现问题。
正式训练启动之后,不要频繁盯着终端看。半分钟看一次 loss 就够了,剩下的时间可以看看显存占用和 GPU 利用率。显存占用接近上限但没报错就说明没问题,GPU 利用率 90% 以上说明数据加载没有拖后腿。
4.2 损失曲线怎么看:初始 loss 应该在什么位置
loss 是训练中最直观的反馈信号,但很多人不会看。先说一个初始值的判断标准:随机初始化的模型对词表 6400 个 token 的输出概率基本是均匀分布的,所以初始 loss 大约等于 ln(6400),也就是 8.76 左右。
如果你的初始 loss 不是这个量级,说明有问题。明显偏大,可能是模型输出出现了极端概率;明显偏小,可能是 label 配错了,或者 tokenizer 大部分 token 是同一个 ID。这些都是错误信号,不要忽略。
训练前 500 步由于 warmup 和数据随机性,loss 会出现比较明显的震荡,这是正常的。不要因为一次回升就慌。正确做法是看平滑移动平均,或者每隔 100 步打印一次的数值。大概几千步之后,loss 应该会跌破 5。训练到中后期,下降速度会明显放缓,loss 在 2 到 3 之间来回波动,这说明了模型已经学会了大部分语言规律,但要继续打磨细节还需要时间。我的经验是,50M 模型在 3 亿 token 数据上训练 1 个 epoch 后,loss 能降到 2.5 左右;训练 3 个 epoch,最终可能在 1.8 到 2.2 之间。再往下降到 1.5 以下,需要更多数据或者更大的模型。
loss 曲线下降越快并不代表模型越好,更重要的是生成验证。有时候训练过度,loss 很低,但生成内容全是训练数据的复读,这是过拟合。判断的标准永远不是曲线本身,而是模型的实际输出。
4.3 checkpoint 管理:多存几步,别只留最终结果
训练时间越长,checkpoint 管理越重要。很多人习惯只在训练结束后保存一个最终模型,如果中途崩了,或者发现某个阶段的效果更好,就完全没有回旋余地。
我建议至少保留两种 checkpoint:一种是最近的,覆盖保存,用于断点续训;另一种是每隔一定步数保存一次的历史 checkpoint,用于选优和回滚。MiniMind 这个规模,一份 checkpoint 几十 MB,多存几份完全没有压力。
保存 checkpoint 时不要只存模型权重,optimizer 状态、scheduler 状态、当前 epoch 和 global step 都要一起存。因为重新训练会改变学习率状态,从头接续会导致训练行为不一致。还有一个小技巧:每次保存后,顺手记录当前步数的生成样例,比如生成三句“春天的阳光洒在”的续写。这样你翻看历史 checkpoint 时,能直观看到每个阶段的模型能力变化,比只看 loss 有价值得多。
5. 训练完怎么验证:从自动续写到模拟对话
5.1 写一个简单的生成脚本
训练完成后,验证模型最简单的方式是让它续写句子。MiniMind 是预训练模型,不是对话模型,所以测试时给它前半句让它接着写,比问它“你是谁”更合理。
生成脚本参考:
from transformers import AutoTokenizer, AutoModelForCausalLM import torch tokenizer = AutoTokenizer.from_pretrained("./tokenizer/tokenizer.json") model = AutoModelForCausalLM.from_pretrained("./checkpoints/minimind_latest.pt") model.to("cuda") model.eval() prompt = "春天的阳光洒在" ids = tokenizer.encode(prompt, return_tensors="pt").to("cuda") with torch.no_grad(): out = model.generate( ids, max_new_tokens=50, do_sample=True, top_k=40, temperature=0.8, repetition_penalty=1.2, eos_token_id=tokenizer.eos_token_id, pad_token_id=tokenizer.pad_token_id, ) print(tokenizer.decode(out[0], skip_special_tokens=True))几个采样参数说明一下。temperature 控制概率分布的平滑程度,数值越大输出越随机,越小越确定。0.8 是我常用的折中值,太高达不到连贯效果,太低输出会显得呆板。top_k=40 表示只在概率最高的 40 个 token 里采样,能有效避免低概率的冷门 token 跑出来捣乱。repetition_penalty=1.2 是抑制重复的,小模型训练数据不够时很容易来回重复几句话,这个参数能压一压。
5.2 从输出看问题:重复、乱码、只会说“好的”
生成结果可以直接反映训练质量。如果输出流畅自然,说明语言建模基本成功;如果结果怪怪的,根据现象可以倒推原因。
最常见的现象是输出重复。模型可能生成“春天来了,春天来了,春天来了”这样的循环,原因通常有三个:训练步数不够、采样温度太高、数据里重复片段太多。先调采样参数,把 temperature 降到 0.7,top_k 降到 30,repetition_penalty 提到 1.3。如果仍然重复严重,说明模型欠拟合,需要继续训练或者增加数据量。
另一个现象是输出乱码或者中文变成拼音字母。这大概率是 tokenizer 和模型词表没对齐。比如训练时用的是 6400 词表的 tokenizer,生成时加载了另一个文件,或者特殊 token 的 ID 设置不一致。遇到这个问题,回到构造模型配置和 tokenizer 的地方,逐一核对 vocab_size、bos_token_id、eos_token_id 和 pad_token_id。
还有一个很典型的现象:模型只输出“嗯”“好的”“是的”这类短词。这不是模型坏了,而是训练数据里大量类似对话片段,模型学到了高频回答模式。解决方案是检查数据配比,减少这类短文本的占比,或者后续用指令微调数据去修正分布。
5.3 预训练模型不等于对话助手
这一点值得单独提出来,因为很多人会把“预训练完的模型”直接当成“ChatGPT 助手”。MiniMind 只具备了语言能力,不具备“听懂指令并执行”的能力。你问它“请写一首关于夏天的诗”,它大概率会续写几句无关内容,因为它根本没有学过指令格式。
这是正常现象。预训练阶段模型学习的是“语言本身长什么样”,比如词与词如何衔接、句法结构是否合理、语义是否通顺。指令跟随能力需要额外的指令微调才能获得,也就是拿一批“问题-回答”格式的语料,在预训练模型的基础上继续训练。那部分内容我计划放在系列的后续笔记里,这里先不用急。
所以验证预训练模型时,正确的姿势是给一个开放式的开头,让它续写,比如“窗外正在下雨,我想起”,然后观察后续是否连贯。断断续续、逻辑跳跃、写了上句忘了下句,在 50M 小模型上都很常见,不用灰心,只要语言层面通顺了,预训练的目标就基本达成了。
6. 训练路上的常见坑:问题定位与排查速查表
6.1 显存溢出:先砍序列长度,再砍 batch size
显存溢出是训练语言模型最常见的报错,几乎每个人都会遇到。报错信息通常是 CUDA out of memory。很多人的第一反应是换显卡,但绝大多数情况下,调参就能解决。
优先砍掉 max_length 的一半,从 512 降到 256。序列长度对显存影响极大,因为注意力矩阵的大小和序列长度的平方成正比。序列长度减半,注意力部分的显存占用直接降到四分之一。其次是 batch size 减半。都还不够,就开启梯度累积,保证有效 batch 不变,只是训练速度变慢。最后再考虑混合精度,也就是 AMP,能把部分权重和激活值转为 fp16,显存占用几乎减半。
记住一个技巧:如果训练到中途才爆显存,那可能是某个 batch 特别长导致的。检查 DataLoader 的 collate_fn,确认所有样本都截断到 max_length,不要出现极端长的序列。
6.2 loss 不降、甚至出现 NaN:定位顺序是数据、模型、超参
loss 不降是最让人焦虑的问题。我的排查顺序是:数据 → 模型 → 超参。
第一步检查数据。跑一个只有几百条数据的最小用例,看 loss 是否下降。如果小数据上也不降,说明不是数据量的问题。再检查 tokenizer,随机打印几条数据的 token ID,看是不是大量相同的 ID 或者大量 unk。第二步检查模型结构和 label。用模型跑一条固定数据,计算一次手工的交叉熵,对比模型返回的 loss 是否一致,判断 label 是否错位。第三步检查超参数。学习率 3e-4 对 50M 模型是安全的,但如果数据很小或者初始化有问题,可能会出现 NaN。遇到 NaN,先把学习率降到 1e-4,同时加上梯度裁剪,clip_grad_norm 设为 1.0,基本上能压住。
loss 不降还有一种柔和的情况:loss 下降一点就停住了,比如稳定在 5.5 不往下走。这通常不是 bug,而是数据量太少或者序列长度太短。模型只见过零散的短句,学不到完整的语言结构,自然停在一个不低的损失水平。
6.3 训练速度慢:瓶颈经常在数据读取而不是 GPU 算力
训练速度慢,先看 GPU 利用率。用 nvidia-smi 观察,如果 GPU 利用率长时间低于 80%,说明模型在等数据。
常见原因有三个。第一,DataLoader 的 num_workers 太小,数据从磁盘读到内存、再从内存搬到显卡的链路太长。把 num_workers 调到 8 或更高,尽量让数据加载和 GPU 计算并行起来。第二,每次训练前实时做 tokenize,这是最重的性能陷阱。正确的做法是训练前把所有数据一次性 tokenize 成 npy 或 mmap 格式,训练时只做索引读取。第三,打印日志太频繁。每一步都 print 会让 Python 的输出阻塞拖慢训练,100 步打印一次足够。
6.4 输出全是乱码或重复:从 tokenizer 和采样参数入手
生成结果乱码,优先检查 tokenizer。把 prompt 编码后再解码,看是否能还原。如果不能还原,说明 tokenizer 的 encoder 和 decoder 不匹配,或者保存/加载路径有问题。
输出重复的问题,前面已经说过,主要从采样参数和数据两方面排查。如果训练数据里有很多“嗯嗯”“啊啊”“哈哈哈哈”之类的噪声,模型会把这种高频模式学到骨子里。清洗阶段就要把这些过滤掉,比事后调参有效得多。
下面整理了一份速查表,方便训练时遇到问题直接对照。
| 症状 | 优先排查项 | 解决方向 |
|---|---|---|
| CUDA out of memory | max_length、batch_size | 缩短序列、减小 batch、梯度累积、混合精度 |
| loss 为 NaN | learning_rate、梯度 | 降学习率、打开梯度裁剪、检查数据 |
| loss 初始远偏离 ln(vocab_size) | tokenizer、label | 检查 token ID 和 label 对齐 |
| loss 停在平台期 | 数据量、序列长度 | 增加数据量、调大 max_length |
| 生成结果全乱码 | tokenizer | 核对 vocab_size 和特殊 token ID |
| 生成内容重复严重 | 采样参数、训练步数 | 调 temperature/top_k/repetition_penalty,多训 epoch |
| GPU 利用率低 | DataLoader | 增大 num_workers,预 tokenize 数据 |
| 训练中断后无法续训 | checkpoint | 保存 optimizer/scheduler/step 状态 |
最后再整理几条我踩坑之后的经验,希望对你有直接帮助。
第一,训练前先做 50 步冒烟测试,这能省下几个小时的无意义等待。第二,loss 曲线要学会看趋势,一次两次的回升都不必在意,真正要警惕的是长期不降和 NaN。第三,checkpoint 多存几份,保存时把 optimizer 状态一起带上,训练中断了能无缝续上。第四,数据清洗比调参更重要,脏数据会让模型学到一堆无意义模式,后期怎么调都拉不回来。
我自己在第一次完整跑完这个流程时,最深的体会是:从零训练小模型,锻炼的不是搭模型的能力,而是调试和观察的能力。你看着 loss 一点点降下去,看着生成质量从乱码变成通顺句子,那种对模型行为的直观理解,是直接用开源模型永远得不到的。MiniMind 这一遍走完,后续再做指令微调、做 RAG、换更大的模型,你心里都会有一条清晰的线索,知道问题出在哪一环。