花两个多小时,我在一张 RTX 4090 上从零训练了一个 64M 参数的中文小模型。听到这件事的人第一反应基本都一样:你图什么?图它参数少,还是图它训练快?其实都有,但最根本的原因是——当大模型的门槛已经被推到千亿参数、上万张显卡的时候,反而应该有人踏踏实实地把一个小模型从数据到权重的全过程完整跑一遍。minimind 这个开源项目就是干这个的,而我这次实测想搞清楚的问题只有一个:2 小时的时间投入,换来的到底是一个会蹦字的玩具,还是一个真能看出门道的东西。
先说结论:2 小时后我拿到的不只是一个玩具。它能接续一段话,能完成基础的中文问答,偶尔还会给出让我意外的连贯表达——但它也会一本正经地胡说八道。如果你有 24G 显存,或者手里只有一张 8G 显存的卡,也想完整见识一下大模型训练全流程,这篇文章值得你看完。我会把环境准备、数据选择、Tokenizer 训练、stage1 预训练、stage2 指令微调的完整过程,以及我踩过的三个比较隐蔽的坑全部摊开来讲,该给参数给参数,该给命令给命令,尽量不让你走弯路。
1. 为什么我把学习重心放在 64M 参数,而不是一步到位追大模型
1.1 一个"完整但不贵"的训练链路
很多人聊到大模型训练,第一反应是"我没有 A100,这事跟我没关系"。但 minimind 这类项目恰好证明了另一条路:大模型训练的完整链路——数据清洗、分词器训练、预训练、指令微调、偏好对齐——在小模型上同样存在,而且成本低到个人开发者完全承受得起。
我这次选 64M 参数版本,核心原因是它处在"学习价值"和"时间成本"的平衡点上。26M 参数连基础句式都容易学崩,很多模型层面的问题会被参数规模掩盖,训练完你只会觉得"它笨是正常的";200M 参数训练时间会拉长到一天级别,不适合"2 小时见结果"的定位。64M 则刚好:模型结构足够复杂,过拟合、梯度不稳定、数据敏感这些真实问题都会一一暴露;同时显存占用只要 5GB 左右,4090 跑起来非常轻松,甚至一些旧一点的显卡也能跟上。
这个选择还有一层更现实的考虑:模型越大,排查问题越困难。64M 参数下,你对数据做任何一点改动,都能在很短的时间内看到 loss 曲线的变化。这种"即时反馈"是学习阶段最珍贵的东西。等你在小模型上练出了手感,再切换到 200M、1B 甚至更大的模型,很多操作其实是同一套逻辑。
1.2 "2 小时"的时间预算是怎么算出来的
关于标题里的"2 小时",我需要说明这不是随便定的,而是有一个粗略的算账过程。训练一个大模型的计算量近似等于 6 倍参数量乘以训练 token 数,也就是 6ND。64M 参数,训练 2 亿 token,总计算量大约就是:
6 × 64M × 2亿 ≈ 7.68 × 10^16 FLOPs
RTX 4090 的 BF16 理论算力是 165 TFLOPs。实际训练过程中,由于小模型的计算密度低,显存带宽和数据加载会拖后腿,整体利用率通常只有三成到四成。按 40% 利用率算,每秒实际算力大约 66 TFLOPs,那么理论耗时是:
7.68 × 10^16 / (66 × 10^12) ≈ 1164 秒,也就是大约 20 分钟
等一下,这个结果比我实际花的时间少很多。原因在于我计算的理论算力没有计入数据加载、日志打印、评测、tokenizer 训练以及频繁的中间保存开销。把这些问题算进去,2 小时是一个比较务实的预估。如果你是 3090 或者 4080,时间会拉长一些,但整体仍然可控。
不过这里也要泼一盆冷水:64M 参数不是"缩小版的 ChatGPT",它更像一块教学芯片,用来理解训练机制。你要指望它像 7B 模型那样聊得面面俱到,肯定不现实。它的价值在于:训练过程中每一步都会反馈到模型行为上,参数变化是可观察的,适合拿来做实验和积累直觉。
2. 实测环境与依赖安装:一张 4090 能解决的事,别想太多
2.1 硬件与软件栈
先交代一下我这次实测的环境:
| 项目 | 配置 |
|---|---|
| GPU | NVIDIA RTX 4090 24GB |
| CPU | Intel i7-13700K |
| 内存 | 64GB DDR5 |
| 系统 | Ubuntu 22.04 LTS |
| Python | 3.10 |
| PyTorch | 2.1.0 + CUDA 12.1 |
| transformers | 4.36.0 |
| datasets | 2.16.0 |
| tokenizers | 0.15.0 |
| accelerate | 0.26.0 |
实际跑下来最大的感受是:64M 模型对显存几乎没有压力。stage1 预训练过程中显存峰值大概在 5.2GB 左右,加上数据缓存也没有超过 6GB。所以如果你手里只有一块 8G 显存的显卡,也可以跑通整个流程,只是训练时间会翻倍,尤其要留意数据加载和序列长度的设置。
2.2 两个容易卡住的依赖问题
第一个坑是 PyTorch 版本。很多人新装环境时图方便直接pip install torch,结果装成了 CPU 版,训练时发现一晚上跑不完一个 epoch。检查方法很简单,在 Python 里执行torch.cuda.is_available(),返回 False 就说明装错版本了。正确做法是到 PyTorch 官网找对应 CUDA 版本的安装命令安装。
第二个坑是 flash-attention。网上不少教程一上来就推荐装这个依赖,结果不少人卡在编译环节折腾半小时。对于 64M 这种小模型,注意力计算量非常小,默认的 attention 实现已经足够快,完全不需要 flash-attention。省掉这个依赖,环境配置能少走很多弯路。
装完依赖之后,建议先跑一次模型 forward,确认能正常输出,再做任何训练:
from transformers import AutoConfig, LlamaForCausalLM config = AutoConfig.from_pretrained("minimind_64_config.json") model = LlamaForCausalLM(config) print(model.num_parameters())如果你看到的参数数量在 6400 万左右,说明环境基本就绪。这一步提前验证,可以避免训练到一半才发现配置不对,白白浪费时间。
3. 数据与 Tokenizer 先行:小模型的"偏科"从这里埋下
3.1 数据量怎么定,才是符合 2 小时约束的做法
很多人第一次训练小模型,看到网上说"训练数据越多越好",就直接把几个 GB 的语料丢进去。结果就是 2 小时连一个 epoch 都跑不完,最后只能草草收场。我这次一开始就按"参数量 × 训练总 token 数"这条线来配数据:64M 参数对应 2 亿 token 的训练量是比较务实的,文本文件大约在 500MB 左右。
实际准备数据时,我用了两部分:一部分是从开源中文语料中抽出来的通用文本,另一部分是自己攒的问答对。在喂给模型之前做了三件事:
- 去掉重复段落,避免模型把高频样本背下来而不是学到规律;
- 过滤明显乱码、纯英文广告和过短的无意义句子;
- 按 512 token 的长度切块,超出部分直接截断。
这里比较推荐直接用 Hugging Face datasets 的Dataset.map来做切块,速度和内存都友好。处理完的数据统一转成 jsonl 格式,每一行是一条训练样本。数据量不用贪多,关键在于干净和覆盖度。
还有一点值得注意:小模型的数据混合比例会直接影响它的"偏科"方向。如果通用语料占 90%,问答对只占 10%,最后模型会更像一个续写机器,而不是一个问答助手。这个比例在 stage2 指令微调前要重新考量,很多人在小模型上做得不理想,不是参数太小,而是数据配比一开始就没想清楚。
3.2 为什么不用现成 tokenizer,而要自己训一个
minimind 项目支持使用现成的中文词表,但如果你真的想"从零训练",我建议自己训一个 BPE tokenizer。原因很简单:通用 tokenizer 对中文支持往往不理想,一个常用汉字会被拆成 2 到 3 个字节级 token,等于白白放大序列长度,训练效率会下降 30% 以上。
我用的策略是训练一个 vocab_size=20000 的 BPE tokenizer,min_frequency 设为 2。代码非常简单,几分钟就能训完:
from tokenizers import Tokenizer, models, pre_tokenizers, trainers tokenizer = Tokenizer(models.BPE(unk_token="<unk>")) tokenizer.pre_tokenizer = pre_tokenizers.Metaspace() trainer = trainers.BpeTrainer( vocab_size=20000, min_frequency=2, special_tokens=["<s>", "</s>", "<unk>"] ) tokenizer.train(files=["data/all_corpus.txt"], trainer=trainer) tokenizer.save("model/tokenizer.json")训完之后我自己统计过,一个平均 20 个汉字的短句,在这个 tokenizer 下会被切成 18 个 token 左右,效率比直接套用通用词表高不少。如果你发现某些常用字频繁变成<unk>,说明语料覆盖不够或者 vocab_size 太小,可以适当把 min_frequency 降到 1 重新训练。
这一步的重要性很容易被低估。我见过很多人花大力气准备训练数据,却在 tokenizer 上草草了事,最后模型生成质量上不去,还找不到原因。Tokenizerr 是模型看到的"第一层世界",这一步偷懒,后面所有调参都要加倍还回去。
4. 2 小时训练过程全记录:stage1 预训练 + stage2 指令微调
4.1 stage1 预训练:让模型先学会中文的"语感"
minimind 的训练链路分三个阶段:stage1 预训练、stage2 指令微调、stage3 偏好对齐。我这次在 2 小时内完成了前两个阶段。stage1 的目标是让模型学会中文句子的基本统计规律——不是理解世界,而是先形成语感。
我用的模型配置没有大调,直接参考项目仓库里 64M 的默认配置,词表大小是 20000。关键超参数如下:
| 参数 | 数值 |
|---|---|
| effective batch size | 64 |
| micro batch size | 32 |
| gradient_accumulation_steps | 2 |
| max_seq_len | 512 |
| learning_rate | 5e-4 |
| warmup_steps | 2000 |
| scheduler | cosine decay,末值 1e-4 |
| optimizer | AdamW,beta=(0.9, 0.95),weight_decay=0.1 |
训练命令大概长这样:
python train.py \ --model_type minimind_64 \ --data_file data/pretrain_data.jsonl \ --tokenizer model/tokenizer.json \ --max_seq_len 512 \ --batch_size 32 \ --gradient_accumulation_steps 2 \ --learning_rate 5e-4 \ --max_steps 6100这里我解释一下 6100 步怎么来的。effective batch 是 64,每条样本最长 512 token,那么每步吃掉 64 × 512 = 32768 token。目标训练 2 亿 token,用除法一算就是约 6100 步。实际跑下来,stage1 大约用了一个半小时。
训练日志里能很清晰地看到模型在"进入状态":前 2000 步 loss 从 8.4 快速下降到 3.0 左右,这是 warmup 阶段,学习率从 0 爬升,模型开始抓住中文的高频词汇搭配;2000 到 5000 步,loss 缓慢降到 1.8 附近,下降速度明显变慢,说明模型开始学习更隐晦的语义结构;5000 步之后 loss 曲线出现小幅波动,属于正常现象。
4.2 stage2 指令微调:让模型学会回答而不是接话
stage1 结束后,模型更像一个"续写器"。你给它一句开头,它本能地想把句子写完;但如果你问它问题,它大概率不会"回答",而是继续预测最可能的下一个 token。所以必须做 stage2 指令微调,用大量"问题-回答"的数据教会它角色转换。
SFT 数据我用了约 12 万条中文指令数据,考虑到 2 小时的时间预算,stage2 只训练 600 步,大约 15 分钟就结束。SFT 阶段的关键是学习率要用小很多,否则预训练学到的语感会被冲掉,出现"训练几分钟就变回傻瓜"的灾难性遗忘现象。
SFT 参数如下:
python train_sft.py \ --model_weights model_ckpt.pt \ --data_file data/sft_data.jsonl \ --batch_size 16 \ --max_seq_len 512 \ --learning_rate 2e-5 \ --max_steps 600数据格式很简单,每行一个 JSON 对象:
{"prompt": "请用一句话介绍自己", "response": "我是一个用 minimind 训练的中文小模型。"}我的切身体会:SFT 阶段不是训练步数越多越好。600 步跑完,我拿同样的 prompt 测试,模型已经能稳定给出"像模像样"的回答。如果继续加到 1200 步,回答反而会变得死板,因为模型开始过拟合训练数据里的固定句式。所以小模型的 SFT 阶段,宁可少训一点,也不要贪多。
4.3 训练过程中的实测数字
整个训练过程的几个关键数字,我记录如下,方便你对照自己的机器估算:
| 指标 | stage1 | stage2 |
|---|---|---|
| 显存峰值 | 5.2GB | 6.8GB |
| 平均吞吐 | 约 28k token/s | 约 22k token/s |
| 总步数 | 6100 | 600 |
| 耗时 | 约 1.5 小时 | 约 15 分钟 |
stage2 的吞吐比 stage1 低,原因是 prompt 和 response 拼接后,输入序列的平均长度更长,训练数据也更集中。整体看下来,2 小时的时间预算非常紧凑,但老话说得好——时间越紧,越能逼你少做无用功。
5. 2 小时后它到底能干嘛:生成、问答与能力边界实测
5.1 续写与主题生成:有语感,没逻辑
训练结束后我没有马上上测试集做量化评估,而是先做了一轮"人的直觉评估"。因为对于 64M 参数这种规模,BLEU 和困惑度只能告诉你"有没有学会统计规律",但用户最关心的其实是"它说的话像不像人话"。
第一轮测试是文本续写。输入是"人工智能正在改变世界,它不仅改变了":
人工智能正在改变世界,它不仅改变了我们的生活方式,还改变了我们的思维方式。
这句话语法通顺,逻辑也基本成立,尽管内容比较"安全",没什么信息量。对 64M 参数来说,能稳定输出这种句子,说明 stage1 的语感学习是有效果的。
第二轮测试是主题生成。我给了一个开放主题"冬天的早晨",模型生成了一段话,大意是"冬天的早晨,寒冷的风吹过街道,雪地上没有行人,只有几棵老树在风中摇曳"。这段文字里"寒冷、雪、风、树"这些词都和冬天强相关,句子也有画面感,但整体没有故事线,更像是在堆叠高频词汇。
小模型的生成能力就是这样的:它能抓住"词的搭配",抓不住"事件的发展"。如果你想让它写一个完整的小故事,第三句话开始就会出现重复或者跑偏。这不是 bug,而是参数容量决定的必然结果。
5.2 指令问答:简单任务能哄住人,复杂推理原形毕露
经过 stage2 指令微调之后,模型倒是学会了一个非常重要的能力:知道自己是在被提问,而不是在续写。
我测试了几个典型问题。问"请用一句话介绍自己",它能答出"我是一个人工智能助手"。问"中国的首都是哪个城市",它答"北京"。这类高频事实性问题,只要在训练语料里出现得足够多,模型就能记住并正确复现。
但到了逻辑推理环节,问题就出来了。我问它:"小王有 3 个苹果,又买了 2 个,现在一共有几个?"它回答"5 个"——这个侥幸答对了。但同样的题目,换成"小王有 7 个苹果,用掉了 3 个,又收到 4 个,现在一共有几个",它的回答就开始混乱,有时甚至给出一个大于 10 的答案。这说明它并没有真正学会加法,只是记住了一些高频问答组合。
有一个更典型的例子:我问"如果一个数除以 3 余 2,除以 4 余 3,问这个数最小是多少",小模型直接开始编,完全意识不到这是个数学问题。这个现象恰恰说明,语言模型的"知识"本质上还是统计关联,不是形式逻辑。
5.3 和 7B 模型放在一起看,差距到底在哪
为了让"64M 到底能干嘛"这个问题有参照系,我把手头一个 7B 量级的开源模型拉出来做了同样的测试,结果对比如下:
| 能力维度 | 64M 模型 | 7B 参考模型 |
|---|---|---|
| 单次回答稳定长度 | 50 字以内 | 可长可短 |
| 逻辑推理 | 极弱 | 中等 |
| 知识覆盖面 | 仅训练语料高频内容 | 更广 |
| 重复问题 | 频繁出现 | 偶发 |
| FP16 部署显存 | 约 0.2GB | 约 14GB |
| 单次推理耗时(CPU) | 毫秒级 | 秒级 |
这张表不是为了打击 64M 模型的信心,而是为了说明:小模型的作用半径是"短小、高频、简单"的任务,超出这个半径的任务就会频繁答非所问。对我来说,这个结果已经值回 2 小时的投入了,因为我看清了它的能力边界在哪里——比背一百遍论文更直观。
6. 这段训练里踩过的三个坑:显存、过拟合与评估失真
6.1 显存 OOM 的元凶不是模型,而是序列长度
小模型显存是真的够用,但我第一版 stage2 配置把 max_seq_len 设成了 1024,部分长指令样本被填充得很满,显存瞬间冲到 11GB。虽然 4090 扛得住,但如果你手头的卡只有 8G 显存,这一步已经 OOM 了。
排查过程其实很有意思。我一开始以为是 batch size 设太大了,从 16 一路降到 4,结果显存只降了一点点。后来仔细看数据加载器里每个 batch 的 token 数才发现,问题出在序列长度:训练数据里有些 prompt 特别长,被 padding 到 1024 之后,显存开销成倍增加。改成 512 长度,并把超过长度的样本直接截断,显存立刻降回 6GB 以下。
所以给大家一个经验:小模型出现 OOM,优先检查序列长度和数据加载器,而不是立刻怀疑模型本身。很多时候模型占用的显存只占小头,padding 带来的无效计算才是大头。
6.2 SFT 阶段一多训就过拟合,生成开始复读机
第一次跑 SFT,我把 12 万条数据训了 3 个 epoch,结果生成质量反而变差:任何 prompt 都会引向训练集中几个高频回答的变体,你问它"什么是人工智能",它回一段套话;你问它"今天天气怎么样",它还是回一段套话。整个模型就像一个坏掉的复读机。
这个问题的本质是 64M 参数的容量太小,装不下 12 万条问答模式的全部多样性。模型学习了高频回答的统计规律,却无法为每条 prompt 建立独立的特征映射,于是所有输入都被"折叠"到少数几个输出模式上。
把 epoch 改成 1,学习率保持 2e-5 之后,复读机症状明显缓解。后来我翻训练日志,发现第二个 epoch 的评估 loss 已经开始上升——典型的过拟合信号。所以对于 64M 参数这种规模,SFT 阶段 1 个 epoch 通常是够用的,如果不够,你更应该怀疑数据质量,而不是盲目加训练轮数。
6.3 数据污染让测试正确率虚高
另一个很隐蔽的坑出现在评估环节。我想测"模型知不知道中国首都是北京"这个问题,就把这句问话写进测试集,结果模型答对了。但后来检查数据 pipeline 才发现,这句问话在某个开源语料里出现过,已经被训练数据吃进去了——也就是说,我做了一次标准的"开卷考试",但以为是闭卷。
在大模型评测里,数据污染同样存在,而且更防不胜防,因为网上开源数据太多,你很难保证测试集里的句子没在预训练语料里出现过。但小模型对这种污染的敏感度更高,因为它的泛化能力弱,很容易通过"背题"拿到高分,本质上并没有真正学会对应能力。
我后来改用自己现写的 20 条问题做盲测,并明确排除所有在语料中出现过的问句,得到的结果才算可信。所以做小模型评估时,一个基本的纪律是:测试数据必须在训练完成后再准备,或者至少保证测试样本没有出现在来源语料中。
7. 从 2 小时到下一步:用 DPO 和量化把项目推向可用
7.1 用 DPO 补上"偏好对齐"这一步
stage1 学会生成,stage2 学会回答,但这还不够。实际测试中我注意到,模型对同一个问题可能给出两个互相矛盾的答案,因为它只是学到了"答案的统计分布",没有学会"挑选更符合人类偏好的那个"。minimind 的 stage3 做的就是这件事:DPO 偏好对齐。
我计划再花 1 到 2 小时,用大约 2 万条偏好对数据把这一步补上。偏好对数据的组织方式很简单:同一个 prompt,配一个模型自己的回答和一个更优的人工回答,DPO 会让模型逐渐加大优质回答的概率。对小模型来说,DPO 的效果通常比 RLHF 更容易稳定,也不需要复杂的 PPO 工程实现,是个人开发者做对齐的首选方案。
7.2 导出 GGUF,部署到本地离线环境
训练完模型之后,我准备走一遍"训练到部署"的最后一公里:把权重导出成 GGUF 格式,再用 llama.cpp 跑 CPU 推理。64M 模型量化后大约只有 30 到 40MB,完全可以在没有 GPU 的机器上实时运行,这正好发挥它低延迟、低资源消耗的优势。
这个步骤对个人开发者来说很有参考价值。很多人在本地部署的所谓小模型,动辄几个 GB 甚至十几个 GB,而 64M 模型在 CPU 上几乎可以做到即时响应。虽然能力有限,但作为个人知识库问答、离线文本辅助工具,便宜好用反而变成了它的核心竞争力。
7.3 再做一轮 64M 与 200M 的对照实验
最后我还想做一个对照实验:同样的数据,同样的脚本,把模型从 64M 换成 200M 再训一次。两个模型在统一测试集上的差异,会比任何论文里的 Scaling Law 曲线都直观。毕竟,在真实的数据上亲手做一次规模对比,比读一百遍理论都管用。
这个对照实验我还打算加上一组"数据量减半"的变体,用来观察:到底是参数规模对结果影响更大,还是训练数据量对结果影响更大。到手之后,我可以把四个模型的生成结果、loss 曲线、推理速度和显存占用放在同一张表里,那会是一份很有参考价值的实测笔记。
跑完这 2 小时,我心里对"大模型训练"这件事的认知发生了一个关键变化:它不再是黑箱,而是一个由数据、tokenizer、超参数、训练步数共同决定的系统工程。64M 参数的小模型不会改变世界,但它把大模型训练从"玄学"拉回了"工程"。如果你也想搞懂从零训练的每一步到底在干嘛,我给你一个最直接的实操建议:别纠结参数太小、显存不够,先从 minimind 的 64M 配置开跑,先跑通,再跑大。我自己就是从这次实测开始,才真正理解了数据、参数和训练步数三者之间那个微妙的三角形关系。