1. 为什么要在本地从零训练一个 64M 参数的语言模型
大多数人第一次接触大语言模型,都是从调用 API 开始的。调几次之后就会产生一种错觉:模型是个黑盒,输入一段话,输出一段话,中间发生了什么完全不知道。这种状态做应用没问题,但如果你想真正理解语言模型是怎么工作的,光看论文和博客是不够的,必须亲手训练一次。
问题在于,训练一个像样的模型成本太高。7B 参数的模型,光是加载权重就要十几 GB 显存,全量微调更是需要多卡 A100 集群。对于个人开发者来说,这个门槛直接把"从零训练"变成了"只能围观"。
MiniMind 这个项目的价值就在这里:它把模型规模压缩到 64M 参数,整个训练流程——从分词器训练、预训练、监督微调,到最后的对话效果验证——全部可以在单张消费级显卡上跑完,甚至用 CPU 也能勉强跑通。按照项目给出的数据,预训练阶段用一张 3090 大约 2 小时,电费折算下来不到 3 块钱。
这个规模当然做不了什么正经业务,但它能让你完整地走一遍训练管线。你会亲眼看到 loss 从 10 以上降到 3 左右,看到模型从输出乱码变成能简单对话,看到 SFT 前后模型行为的差异。这些体感是看多少篇论文都换不来的。
这篇文章我会把整个流程拆开讲:数据怎么准备、分词器为什么要单独训、预训练和 SFT 的区别在哪、显存不够怎么调、训练完怎么验证效果。每一步我都会说明为什么这么做,以及我在实际操作中踩过的坑。
2. MiniMind 的整体架构与训练管线拆解
2.1 64M 参数是怎么分配出来的
先看模型结构。MiniMind 采用的是标准的 Decoder-only Transformer 架构,和 GPT 系列同源。64M 参数听起来很小,但拆开看其实分配得挺合理:
| 组件 | 配置 | 参数量占比 |
|---|---|---|
| Token Embedding | 词表 6400,维度 512 | 约 3.3M |
| 每层 Attention | 8 头,头维度 64 | 约 2.1M/层 |
| 每层 FFN | 隐藏维度 2048 | 约 4.2M/层 |
| 层数 | 8 层 | — |
| 输出投影 | 512 到 6400 | 约 3.3M |
词表只有 6400,这是关键设计。常规中文模型词表动辄 5 万以上,光 embedding 就占掉大量参数。MiniMind 把词表压到 6400,意味着它用的是字符级或小粒度 subword 分词,牺牲了一部分语义压缩效率,换来的是参数预算能更多分给 Transformer 层本身。
8 层、512 隐藏维度、8 个注意力头,这个配置在 2020 年左右的轻量模型里很常见。它的表达能力有限,但足以学会基本的语法结构和简单的事实关联。你可以把它理解成"能说人话的最小可行模型"。
2.2 三阶段训练管线:预训练、SFT、可选的对齐
MiniMind 的训练流程分三个阶段,每个阶段的目标完全不同:
第一阶段:预训练(Pretrain)。目标是让模型学会"预测下一个 token"。喂进去的是海量无标注文本,模型通过不断预测被遮住的下一个词来调整参数。这个阶段结束后,模型能生成语法通顺的句子,但不会遵循指令——你问它"今天天气怎么样",它可能会接着编一段新闻。
第二阶段:监督微调(SFT)。目标是让模型学会"按照指令回答"。数据格式变成"问题-答案"对,模型学习的是在给定问题的情况下输出对应答案。这个阶段数据量小得多,但质量要求高。
第三阶段:对齐(可选)。包括奖励建模和强化学习,让模型的输出更符合人类偏好。MiniMind 把这个阶段做成可选的,因为对小模型来说,SFT 之后的效果已经能看出明显变化,对齐的边际收益不高。
注意:很多人会跳过预训练直接做 SFT,觉得反正预训练数据难找。但这样做的结果是模型没有语言基础,SFT 阶段会非常不稳定,loss 震荡严重。预训练是地基,不能省。
2.3 为什么这个管线值得亲手跑一遍
我见过不少开发者,微调过好几个模型,但从来没碰过预训练。他们对"模型怎么学会说话"这件事的理解是断裂的。亲手跑一遍 MiniMind 的完整管线,你会建立起几个关键认知:
第一,你会理解数据质量对预训练的影响有多大。同样的模型结构,喂不同来源的语料,loss 下降曲线完全不一样。
第二,你会看到 SFT 数据格式的重要性。同样的问题,答案的写法不同,模型学到的行为就不同。
第三,你会对"参数量"和"能力"的关系有直观感受。64M 能做什么、不能做什么,跑完就有数了。
3. 环境准备与依赖安装中的实际坑点
3.1 硬件门槛与显存估算
官方给出的最低配置是单卡 8GB 显存。我实测下来,预训练阶段 batch size 设为 16、序列长度 512 的情况下,显存占用大约 6.5GB。如果你的显卡只有 6GB,需要把 batch size 降到 8,同时开启梯度累积来保持等效 batch size。
显存占用的主要来源有三块:模型参数本身(64M 参数,fp32 下约 256MB)、优化器状态(Adam 需要保存一阶和二阶动量,约 512MB)、激活值(和 batch size、序列长度成正比)。前两块是固定的,第三块是你能调的。
如果你只有 CPU,也能跑,但速度会慢到令人发指。我试过用 8 核 CPU 跑预训练,一个 epoch 要十几个小时。建议至少有一张 6GB 以上的 N 卡。
3.2 依赖安装的版本陷阱
MiniMind 依赖 PyTorch、transformers、datasets 这几个核心库。安装时最容易出问题的是 PyTorch 和 CUDA 的版本匹配。我的建议是直接用官方推荐的组合:
pip install torch==2.1.0 torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118 pip install transformers==4.36.0 datasets==2.16.0 pip install sentencepiece protobuf这里有几个坑要说清楚。第一,transformers 版本不要装最新的,4.36 之后的版本改了一些 API,MiniMind 的代码可能不兼容。第二,sentencepiece 是训练分词器必须的,但它的编译依赖比较多,如果 pip 装不上,可以试试 conda。第三,protobuf 版本冲突是常见问题,如果报错说 descriptor 相关,降级到 3.20.x 通常能解决。
提示:装完之后跑一句
python -c "import torch; print(torch.cuda.is_available())",确认输出是 True。如果 False,说明 CUDA 没配好,后面训练会直接报错。
3.3 目录结构规划
在开始之前,建议把目录结构规划好,不然后面数据、模型、日志混在一起很难管理。我的做法是:
minimind/ ├── data/ │ ├── raw/ # 原始语料 │ ├── processed/ # 清洗后的数据 │ └── sft/ # SFT 数据 ├── tokenizer/ # 分词器文件 ├── checkpoints/ # 训练中间产物 ├── outputs/ # 最终模型 └── logs/ # 训练日志这个结构不是必须的,但能让你在训练中断后快速定位到该从哪里恢复。
4. 分词器训练:被大多数人忽略的关键一步
4.1 为什么不能直接用现成的分词器
很多人会想,直接用 BERT 或 GPT 的分词器不就行了?技术上可以,但效果会打折扣。现成分词器的词表是针对大规模通用语料训练的,词表动辄几万。MiniMind 的模型维度只有 512,如果词表是 5 万,embedding 层就会占掉 2560 万参数,接近总参数的一半,留给 Transformer 层的预算就不够了。
所以 MiniMind 选择自己训练一个 6400 词表的分词器。这个规模的分词器,embedding 层只占 330 万参数,剩下的 6000 多万参数都能用在真正的计算层上。
4.2 用 sentencepiece 训练分词器的完整流程
训练分词器的代码大致是这样:
import sentencepiece as spm spm.SentencePieceTrainer.train( input='data/processed/corpus.txt', model_prefix='tokenizer/minimind', vocab_size=6400, model_type='bpe', character_coverage=0.9995, num_threads=8, pad_id=0, unk_id=1, bos_id=2, eos_id=3, )几个参数需要解释。model_type='bpe'表示用字节对编码,这是目前最主流的选择,平衡了词表大小和语义粒度。character_coverage=0.9995表示覆盖 99.95% 的字符,剩下的作为 unknown 处理。这个值不要设成 1.0,否则遇到生僻字会出问题。
pad_id、unk_id、bos_id、eos_id这四个特殊 token 的编号要记牢,后面数据处理和模型定义都要用到。MiniMind 用的是 0/1/2/3 这套编号,和很多开源模型不一样,别搞混。
4.3 分词器质量怎么验证
训练完之后,一定要验证分词器的效果。我一般会做两个测试:
第一,编码解码一致性。随便找一段中文,编码再解码,看是否和原文一致。如果不一致,说明有字符没被覆盖。
第二,压缩率。统计一段文本编码后的 token 数量,除以字符数。中文的理想压缩率在 0.6 到 0.8 之间,也就是一个 token 对应 1.2 到 1.6 个汉字。如果压缩率接近 1,说明分词器粒度太细,模型学起来会吃力。
sp = spm.SentencePieceProcessor() sp.load('tokenizer/minimind.model') text = "今天天气不错,适合出去走走。" tokens = sp.encode(text, out_type=str) print(tokens) print(f"压缩率: {len(tokens) / len(text):.2f}")我踩过的一个坑是:语料里有大量英文和数字,但训练时没做归一化,导致分词器把每个数字都当成独立 token。后来在预处理阶段加了全角转半角、数字统一处理,压缩率才正常。
5. 预训练阶段:数据、参数与 loss 曲线解读
5.1 预训练数据从哪来、怎么清洗
MiniMind 官方推荐用开源中文语料,比如维基百科 dump、新闻语料、小说文本。数据量不需要很大,几 GB 纯文本就够 64M 模型学出基本语言能力。
清洗流程我总结成四步:
- 去重:用 MinHash 或简单的哈希去重,去掉重复段落。重复数据会让模型过拟合。
- 过滤:去掉长度过短(少于 10 字)或过长(超过 2048 字)的样本,去掉含大量特殊符号的样本。
- 归一化:全角转半角、统一标点、去掉多余空白。
- 拼接:把所有文本拼成一个长文件,用分词器编码成 token id 序列,存成二进制文件。
这里有个细节:预训练数据的拼接方式会影响训练效率。如果每条样本单独 padding 到固定长度,会浪费大量计算在 padding token 上。更好的做法是把所有文本拼成一条长序列,然后按固定长度切块。这样每个 batch 都是满的,没有浪费。
5.2 关键超参数怎么定
预训练的超参数直接决定训练能不能收敛。我整理了一份实测可用的配置:
| 参数 | 值 | 说明 |
|---|---|---|
| batch_size | 16 | 8GB 显存下的安全值 |
| seq_len | 512 | 再长显存不够 |
| learning_rate | 3e-4 | 小模型可以用大一点 |
| warmup_steps | 500 | 防止初期 loss 爆炸 |
| weight_decay | 0.01 | 常规正则化 |
| grad_clip | 1.0 | 梯度裁剪,必开 |
| epochs | 3 | 再多会过拟合 |
学习率是最关键的。3e-4 对 64M 模型来说偏大,但配合 warmup 和梯度裁剪是安全的。如果你发现 loss 在前几百步震荡剧烈,把学习率降到 1e-4 试试。
5.3 loss 曲线怎么看、异常怎么处理
正常的预训练 loss 曲线应该是:前 500 步快速下降,从 10 左右降到 5 以下;然后进入缓慢下降期,几千步后稳定在 3 到 4 之间。如果出现以下情况,说明有问题:
- loss 不降反升:学习率太大,或者数据有问题。先检查数据里有没有大量乱码。
- loss 降到 2 以下:可能是过拟合,或者数据重复度太高。检查去重是否彻底。
- loss 震荡剧烈:batch size 太小,或者梯度裁剪没开。
- loss 卡在某个值不动:模型容量不够,或者学习率衰减太快。
我实测下来,3 个 epoch 大约 15000 步,loss 能稳定在 3.2 左右。这个水平的模型,生成的中文已经语法通顺,但逻辑性一般。
提示:训练过程中一定要定期保存 checkpoint。我遇到过训练到一半断电的情况,没有 checkpoint 就只能重来。建议每 1000 步存一次。
6. SFT 微调:让模型学会"回答问题"
6.1 SFT 数据的构造逻辑
预训练完的模型只会续写,不会问答。SFT 的目的就是教会它"看到问题,输出答案"这个模式。数据格式通常是:
<bos>问题:今天天气怎么样?答案:今天天气晴朗,气温适宜。<eos>关键在于,问题和答案之间要用固定的分隔符,让模型学会区分。MiniMind 用的格式比较简单,你可以根据自己需求调整。
SFT 数据量不需要大,几千到几万条就够。但质量要求高:答案要准确、简洁、风格统一。如果答案风格混乱,模型学出来的行为也会混乱。
6.2 训练参数与预训练的区别
SFT 阶段的超参数和预训练有明显区别:
| 参数 | 预训练 | SFT |
|---|---|---|
| learning_rate | 3e-4 | 1e-5 |
| epochs | 3 | 3-5 |
| batch_size | 16 | 8 |
| 数据量 | GB 级 | MB 级 |
学习率要降一个数量级,因为模型已经有基础,大学习率会破坏预训练学到的知识。batch size 可以小一点,因为数据量小,小 batch 反而有助于跳出局部最优。
6.3 怎么判断 SFT 是否成功
SFT 成功的标志不是 loss 降到多低,而是模型行为的变化。训练前后各测几个问题,对比输出:
- 训练前:模型可能会续写问题,比如问"今天天气怎么样",它输出"今天天气怎么样?我这边也是……"
- 训练后:模型应该直接回答,比如"今天天气晴朗,适合外出。"
如果训练后模型还是续写,说明 SFT 数据格式有问题,或者学习率太大把预训练知识冲掉了。
我踩过的一个坑是:SFT 数据里问题和答案的比例失衡,问题太长答案太短,导致模型学会了"忽略问题直接输出短句"。后来调整了数据配比,问题答案长度接近 1:2,效果才正常。
7. 训练完成后的效果验证与推理部署
7.1 用哪些指标评估小模型
64M 模型不能用 MMLU 这种 benchmark 来评,它肯定不及格。更实际的评估方式是:
- 语法正确性:生成的句子是否通顺,有没有明显的语法错误。
- 指令遵循:给定简单指令,模型是否按指令输出。
- 事实一致性:问一些常识问题,看回答是否离谱。
- 多样性:同一个问题多次生成,输出是否有变化。
我一般会准备 20 个测试问题,人工打分。分数不重要,重要的是对比训练前后的变化。
7.2 推理时的参数调节
推理阶段有几个参数可以调:
outputs = model.generate( input_ids, max_new_tokens=128, temperature=0.7, top_k=50, top_p=0.9, repetition_penalty=1.1, )temperature控制随机性,0.7 是比较平衡的值。top_k和top_p是采样策略,配合使用能避免生成重复内容。repetition_penalty对小模型特别重要,因为小模型容易陷入重复循环。
7.3 部署到本地的最简方案
训练完的模型可以存成 Hugging Face 格式,用 transformers 直接加载:
from transformers import AutoModelForCausalLM, AutoTokenizer model = AutoModelForCausalLM.from_pretrained('outputs/minimind-sft') tokenizer = AutoTokenizer.from_pretrained('tokenizer/minimind')如果想做成服务,可以用 FastAPI 包一层,几十行代码就能跑起来。不过要提醒一句:64M 模型的推理速度很快,CPU 上也能做到实时响应,但输出质量有限,适合做演示或教学,不适合生产环境。
8. 几个只有亲手跑过才会知道的细节
第一个细节:预训练数据的顺序会影响收敛速度。我试过把维基百科放在最前面、小说放在最后,结果模型前期学得很快,后期 loss 下降变慢。后来打乱顺序,整体收敛更平滑。原因是不同来源的文本难度不同,集中喂同一类数据会让模型在某个阶段过拟合。
第二个细节:SFT 之后模型会"变笨"。这是正常现象,因为 SFT 数据量小,模型会偏向于模仿 SFT 数据的风格,损失一部分预训练学到的通用能力。如果你发现 SFT 后模型连基本的续写都不会了,说明 SFT 过头了,减少 epoch 或降低学习率。
第三个细节:分词器的特殊 token 编号一定要和模型定义一致。我有一次改了分词器的 bos_id,忘了同步改模型配置,结果训练出来的模型输出全是乱码。排查了半天才发现是编号对不上。
第四个细节:显存不够时,梯度累积是比降低 batch size 更好的选择。降低 batch size 会影响梯度稳定性,而梯度累积可以在保持等效 batch size 的同时降低显存占用。具体做法是每算 N 个 micro-batch 才更新一次参数。
第五个细节:训练日志要记全。除了 loss,还要记学习率、梯度范数、显存占用。梯度范数突然变大通常意味着要出问题,提前干预能省很多时间。
这套流程跑下来,你对语言模型的理解会从"知道概念"变成"知道每一步在干什么"。64M 模型的能力边界很清楚,但训练它的经验可以迁移到任何规模的模型上。