MiniMind 是一个用 PyTorch 原生实现的极小语言模型项目,完整训练链路(预训练、SFT、LoRA、DPO、RLAIF…)都开源。它的定位是低成本复现:官方按单卡 3090 估算,用 mini 数据集训出基础对话模型,预训练加 SFT 各一轮,约 2.31 小时、3 块钱。
我前后跑了两版。第一版按官方流程用 mini 数据集跑通 768×8(约 64M);第二版从零重训 768×16(约 123M),用自己清洗的完整语料做预训练、13G 语料做 SFT,最后用自建的音游语料做后训练。硬件条件不同,数字仅供参考。
跑下来设备是借的、语料是自己抓的、脚本是手写的,中间还崩过两次。按网上的说法,这属于小作坊造冰🧊 —— 不太正规,但确实能出货。
一、第一版:先按官方流程跑通
官方把训练分成两步”必须”的:
cd trainer && python train_pretrain.py # 预训练 → out/pretrain_768.pthcd trainer && python train_full_sft.py # 指令微调 → out/full_sft_768.pth数据从 ModelScope 下载 pretrain_t2t_mini.jsonl 与 sft_t2t_mini.jsonl 放进 ./dataset,官方说明这两个文件就够复现基础对话模型。脚本支持 --from_resume 1 断点续训(检查点在 ./checkpoints/,命名形如 full_sft_768_resume.pth),多卡用 torchrun --nproc_per_node N。权重放 ./out/ 后,eval_llm.py --weight full_sft 就能对话。
结构没有动,官方 minimind-3 的配置是:768×8、8 个 Q 头 / 4 个 KV 头(GQA)、词表 6400、max_pos 32768。
| 硬件 | 数据与轮数 | 耗时 | loss | |
|---|---|---|---|---|
| 官方估算 | 单卡 3090 | mini 集,预训练 1 epoch + SFT 1 epoch | ≈2.31 h(≈3 元) | — |
| 我的预训练 | A100 | 1,267,775 条 × 3 epochs | 2h09m | 2.236 → 1.765 |
| 我的 SFT | A100 | 904,979 条 × 2 epochs | 1h45m | 1.85 → 1.3304 |
折算单 epoch,我这边的预训练约 43 分钟,比官方的 3090 估算快一些。卡不同、数据版本也不同,这个比较只能说明量级一致。
环境上有两条值得记:服务器的 huggingface.co 不通,export HF_ENDPOINT=https://hf-mirror.com 可以绕过;requirements.txt 里锁的 numpy==1.26.4 与较新的 torch 冲突,放宽成 numpy>=2.0,<3。
二、改了一处数据加载,快了 3.2 倍
(第二版的正式训练里也用上了,就是后面配置里的 --dynamic_pad 1 --length_bucket 1。)
官方实现在 PretrainDataset 里把每条样本都 padding 到 max_seq_len。padding 位的 label 是 -100,不参与 loss 计算,但前向和反向都要算——浪费的是算力,对精度没有影响。
我改成两步:
__getitem__只返回真实序列,collate_fn在组 batch 时按批内最长对齐;- 训练前按 token 长度分桶:先排序、切 batch,再打乱 batch 顺序。
第二步是收益的来源。只做第一步时几乎看不到提升:batch 随机采样,768 长度的长尾样本几乎每批都会出现,“按批内最长”于是退化回”每批都 padding 到 768”。分桶让批内长度集中,padding 占比才真正降下来。
分桶需要预先缓存长度。SFT 数据要先渲染 chat template,比预训练慢一截:
python precompute_lengths.py dataset/xxx.jsonl dataset/xxx.jsonl.lengths.npy 768# SFT 格式:CORPUS_KIND=sft python precompute_lengths.py dataset/xxx.jsonl dataset/xxx.jsonl.lengths.npy 768| 方案 | 每 epoch | 3 epochs |
|---|---|---|
| 静态 padding 768 | ≈85 min | ≈4.3 h |
| 动态 padding + 长度分桶 | ≈37 min | 2h09m |
约 3.2 倍。改完我对比过 loss:与原实现绝对差 0.00e+00。padding 只应该改变算得多快。
三、一次失败的风格 SFT
full_sft_style_768.pth 把基线本来正常的对话能力练坏了。
原因可以量化:87,940 条风格数据(网络梗、贴吧语气)跑了 5 个 epoch,约 439,700 次样本更新;而基线 SFT 总共约 1,809,958 次。这批分布差异极大的数据吃掉了 24% 的更新量,学习率却没有调整。灾难性遗忘的典型条件基本都占了。
如果只是想让语气变一点,合适的工具是 LoRA:冻结主干、只训练低秩旁路,结构上不会破坏原能力,产物只有几 MB,推理时还能开关。MiniMind 自带纯手写的实现(train_lora.py + model/model_lora.py),下次我会用它。
四、语料:语言分布与数据量
语料:语言分布与数据量
第二版换成了自己清洗的语料,动手前先做了一遍语言分布统计:
| 语言 | 条数 | 占比 | 处置 |
|---|---|---|---|
| 中文 | 7,821,915 | 92.36% | 保留 |
| 纯拉丁 / 英文 | 550,543 | 6.50% | 保留 |
| 日文(含假名) | 20,254 | 0.24% | 保留 |
| 希腊 / 西里尔 / 阿拉伯 / 韩文等 | 76,415 | 0.89% | 删除 |
按”含外语字符即删”的严格规则删掉 75,761 行,剩下 8,364,884 条,约 2.12B token(按 239 token/条估算)。
再核对一下数据量够不够用:
| 模型 | 参数量 | Chinchilla 最优 token | 够不够 |
|---|---|---|---|
| 768 × 8 层 | 63.9 M | 1.28 B | 超出约 66% |
| 768 × 16 层(第二版) | 122.9 M | 2.46 B | 到 86% |
结论:这份数据对 100M 量级的模型已经够用,想继续提升常识能力,方向应该是加深——官方 README 也引了 MobileLLM 的结论,说 125M/350M 这个量级上 30~42 层的狭长结构优于 12 层的矮胖结构。第二版换成 16 层,就是照这个判断来的。
这份语料后面又做了两遍身份陈述清洗,并追加了一批人格语料(详见第八节),最终是 8,375,282 条。
五、第二版:768×16 从零重训
第二版换了配置和语料:
| 项 | 值 |
|---|---|
| 结构 | hidden_size 768 × num_hidden_layers 16,Dense(约 123M) |
| 起始权重 | from_weight none —— 从零开始 |
| 语料 | 自己清洗的 pretrain_t2t.jsonl,8,365,282 条 / 8.15 GB(后来追加人格语料变成 8,375,282 条,见第八节) |
| 超参 | seq 768、batch 64 × 累积 4(等效 256)、epochs 3、lr 5e-4、bf16 |
| 数据加载 | --dynamic_pad 1 --length_bucket 1(见第二节) |
单卡 A100 上,约 2 小时跑到第 1 轮的 28%(每轮约 13 万步),据此推算 3 轮要 20 小时上下。
loss 的表现(每轮取日志内均值):
| 轮次 | 均值 | 区间 |
|---|---|---|
| 第 1 轮(中段采样) | ≈1.97 | — |
| 第 2 轮 | 1.630 | 1.25 ~ 1.98 |
| 第 3 轮 | 1.533 | 1.17 ~ 1.84 |

两轮之间只降了 0.1 左右,收敛速度已经明显放缓——这个规模的模型,这份数据量基本吃到头了。图里只覆盖第 2–3 轮,前半段的记录在下面的故障里丢了。
六、中途的两次故障
第一次:NAS 一卡,训练进程直接”段错误”退出。
跑到第 2 轮第 16,000 步附近,进程收到信号 7(BUS_ADRERR),崩溃转储里就这一条;同一时段内核连续报 NFS server not responding 和 lost locks。整个过程几乎没有 OOM,显存也一直正常——这次崩溃跟内存、显存都没关系。
为什么 NAS 一卡,进程就”段错误”退出
这个机制我想写清楚,因为它和”内存不够”是两回事。
语料是通过 mmap 映射进来的(HuggingFace datasets 的 Arrow 文件、长度缓存都是这个机制)。mmap 是懒加载:映射时并不读数据,真正访问到某一页才触发缺页,由内核去把这一页取回来。文件在 NFS 上,这一步就变成一次网络 RPC。
问题恰恰出在这里:
- 用普通
read()读文件,遇到 NFS 抖动,最坏是这次读变慢或返回错误,应用层还有机会重试; mmap的取页发生在缺页处理里,应用层没有任何介入的机会。当 NFS 延迟高到读超时,或者客户端丢了锁(日志里的lost locks)导致属性与缓存失效,内核填不上这一页,只能把SIGBUS交给进程。
BUS_ADRERR 的字面意思是”访问的地址不存在”,落在文件映射上,常见情形是内核认为这一页已经不在文件的有效范围内(比如元数据在 NFS 异常后被判为失效、文件被当成变短了)。结果都一样:进程在访问内存的那一刻直接死掉,Python 抛不出异常,PyTorch 也没机会重试。
顺带说清一件事:三种”挂掉”看着像,机制完全不同——CUDA OOM 是 torch 抛异常(这次没有)、被 OOM killer 杀是 SIGKILL(也没有)、SIGBUS 来自缺页处理。这也解释了为什么崩溃转储里只有一个信号编号,连一条 Python traceback 都没有。
处置分三步:
- 先验证检查点能用:第 2 轮第 15,000 步的续训检查点严格加载成功,16 层参数全部匹配,模型与优化器张量都是有限值;
- 把整个运行目录搬到
/dev/shm(内存盘),代码、语料、长度缓存、Arrow 缓存、检查点都放进去,训练路径不再碰 NFS——这等于把风险从”网络抖动”换成了”内存占用”,所以必须配上第三步; - 每 15 分钟把权重、检查点和日志原子同步回 NFS,退出时再同步一次;同步成功才删
/dev/shm里的副本,失败就保留,避免丢恢复点。
从第 2 轮第 15,000 步恢复后,很快越过了原来的崩溃点(16,500 步时 loss 1.5651),后续正常跑完。顺带清掉了一个 1.3 GB 的崩溃转储和一个 471 MB 的临时检查点。
第二次:共享 GPU 被占满。
另一次失败是 CUDA OOM,报错时连 144 MB 都分配不出来——同卡上另一个进程占了 49.5 GB。这是多人共用服务器的常态,只能等卡(脚本里写了”无计算进程、显存 ≤200 MiB、利用率 0%“才启动,并且不终止别人的任务)。
这两次的经验:在共用的机器上,训练脚本要把”环境随时会失效”当成常态——数据尽量放本地盘或内存盘,网络文件系统只用来放检查点和备份;检查点要能严格加载验证;恢复逻辑要能跨进程重启。
七、完整 SFT:13G 语料
第二版的 SFT 用了自己清理的完整语料 sft_t2t.clean2.jsonl:4,977,055 条 / 13 GB。
| 项 | 值 |
|---|---|
| 轮数 | 2 epochs,每轮 155,533 步 |
| 学习率 | 1e-5,余弦衰减到 1e-6 |
| loss | 第 1 轮均值 1.290 → 第 2 轮均值 1.210 |
| 区间 | 第 1 轮 0.60 ~ 2.03;第 2 轮 0.39 ~ 1.75 |

第 2 轮相比第 1 轮只降了 0.08。 三十万步换来这点下降,说明瓶颈已经不在数据量上——和前面 Chinchilla 的估算对得上:对 100M 量级的模型,这份数据早就够了,继续堆数据不如把模型做大。这也是第二版换成 16 层的原因。
八、人格微调:让它成为 Atri
这部分实际做了四步,顺序就是时间线。
1. 清洗预训练语料里的身份陈述(9/26)
语料里大量”我是一个 AI 助手、由某公司开发”这类文本会先入为主,后面对齐得一直跟这个先验较劲。我用规则把明确的自称统一成 Atri、把明确的开发者陈述指向自己,全量改写 5,917 条。
处理时留了三条后路:被改写的原始行单独存盘、可以回滚;未命中的行保持原始字节不动;输出记录 SHA256 便于校验。
局限也写清楚了——预训练文本没有角色分隔,规则只能命中”明确的自称/作者句式”,小说对白、翻译题里的第一人称不会被改到。声称全部覆盖是不成立的。
2. 生成人格认知对话,直接追加进预训练语料(9/27)
写了个生成器,输出的是预训练用的 {"text": ...} 格式,共 10,000 条。
追加这一步没有采用”复制一份大文件再改”的做法。语料 8.15 GB,而磁盘一直很紧,所以走了一条流水线,并且放在 tmux 里跑——上一轮的训练就是被反复断开的 SSH 掐死的:
- 改写身份,写到新文件,原文件全程不动;断言行数与原语料一致(8,365,282);
cat identity_atri.jsonl >> 新文件,断言行数等于 8,375,282;- 抽样校验 JSON:头 500 行、每 4 万行抽一行、尾行,检查
text字段非空; mv就位——同一个文件系统内的 rename,瞬时完成,不会出现”复制一个 8 GB 文件”的磁盘峰值;- 只重算被改行和新追加行的长度缓存,不用把 8.15 GB 全部重新 tokenize。
3. 第二遍清洗(9/27 晚)
第一遍漏了状语、量词、繁体写法,以及”的 + 名词”这类句式,于是补了第二遍规则清洗。这一遍加了一道硬指标验收,直接数残留的自我归因句子:
我是阿里云 / 我是由阿里云 / 作为阿里云开发我是 OpenAI / 我是由 OpenAI / 我是 Qwen / 我是通义这些字符串在清洗后的语料里逐条计数,确认没有漏网。语料走到这一步已经含追加的人格语料,所以只清洗、不再追加。
4. 在新语料上又跑了一轮预训练(9/27 晚 → 9/28 中午)
输出 rq3_final_768.pth。之后再把同一套问句导出一份对话格式的版本(12,000 条)用于指令微调,也就是 9/30 产出的 full_sft_atri_768.pth。
效果上最直接的变化是:问它是谁、谁开发的,回答会稳定落在 Atri 这个名字上,不再念叨语料里的厂商名。人格设定那一栏我写的是”想成为可爱、善良、正直的样子”,纯属个人趣味。
九、音游语料:规则合成,不调用 LLM
后训练用的语料是我自己攒的,主题是音游和东方:舞萌DX(maimai)/ Phigros / Arcaea / 东方 Project。
数据源三个:萌娘百科的 MediaWiki API(正文)、THBWiki(东方专题)、萌娘DB 数据集(结构化人物字段,403 个东方人物,省掉逐页抓取)。抓的过程中踩到一个接口限制:萌娘百科的 list=search 被封禁,只能用 action=opensearch 按标题找页面。
核心设计是让 wiki 结构直接映射成语料结构,全程不调用 LLM 生成回答:
| wiki 结构 | 语料结构 |
|---|---|
| 章节标题(玩法 / 角色 / 曲目…) | 提问意图(每类一套问法模板) |
| 段落句子打包成 55~220 字 | 回答正文(直接用原文片段) |
| 一页的多个章节块 | 一条 2~4 轮对话 |
在此之上再加一层结构化事实语料(本名、别名、发色瞳色、登场作品),回答短且可核对,和正文语料互补。
合成后还有一道质检:轮数必须 2~4 且角色交替、开场不能是追问句、正文回答 ≥55 字、事实回答 ≥16 字。不合格的样本默认丢弃,调试时可以保留。
最终产出 8,047 条对话语料 + 708 条结构化人物事实(这中间迭代了四轮,脚本和数据版本都留着)。
十、音游后训练:过拟合的现场
后训练从 SFT 权重继续,配置是 768×16、lr 5e-6(衰减到 5e-7)、batch 16 × 累积 2、bf16、20 epochs、每轮 628 步。
loss 的样子很典型:
| 现象 | 数值 |
|---|---|
| 单轮内 loss 中位数在 | 0.08 ~ 2.5 之间反复横跳 |
| 最小值 | 0.0087 |
| 最大值 | 3.29 |

八千条数据跑了 20 轮,模型把一部分样本几乎背下来了(loss 掉到 0.01),另一部分依然错得厉害(loss 回到 2.5)。这是过拟合,只是在小语料上表现得不那么含蓄:loss 曲线在”背会的样本”和”没背会的样本”之间来回跳,看不出平滑下降的样子。
正确做法是把轮数压到三五轮,或者把语料扩到几万条,又或者用 LoRA 做这件事。
十一、最后的碎碎念
最后的碎碎念
这一串实验下来,我记住的主要是几个只有亲手做过才知道的点:
- 动态 padding 单独用没有意义,必须配长度分桶;
- 数据量的边际收益掉得比想象中快:SFT 第二轮三十万步只换来 0.08,与其继续堆数据,不如把模型做大一点;
- 加数据也可能变差:分布差异大、轮数多、学习率不变,就会把原有能力覆盖掉;
- 小语料多跑几轮一定会过拟合,loss 曲线会毫不客气地暴露出来;
- 多人共用的机器上,脚本要按”环境随时会失效”来写;网络文件系统上的
mmap尤其要小心,I/O 抖动会被放大成致命信号; - 数据准备比训练更考验细节:追加语料时用
mv就位、只重算增量长度,每一步都断言行数——这些都不难,但省下的是几十分钟和一次磁盘爆掉; - 模型本身能力确实有限。官方自己都说 Zero 版本”事实知识与泛化效果仍较有限”,我的体验完全一致。
后面想做的:风格和人格微调都改用 LoRA;音游语料扩到几万条再重跑一次后训练;把后训练的轮数和学习率调保守一点。
最后说句实话:整套东西是公开资料加手写脚本拼出来的,设备是借的、语料是自己抓的、训练还崩过两次。按网上的说法,这属于小作坊造冰🧊 —— 不正规,但确实能出货。