4299 字
21 分钟
一起来搓个迷你 LLM 吧:尝试复现 MiniMind

MiniMind 是一个用 PyTorch 原生实现的极小语言模型项目,完整训练链路(预训练、SFT、LoRA、DPO、RLAIF…)都开源。它的定位是低成本复现:官方按单卡 3090 估算,用 mini 数据集训出基础对话模型,预训练加 SFT 各一轮,约 2.31 小时、3 块钱。

我前后跑了两版。第一版按官方流程用 mini 数据集跑通 768×8(约 64M);第二版从零重训 768×16(约 123M),用自己清洗的完整语料做预训练、13G 语料做 SFT,最后用自建的音游语料做后训练。硬件条件不同,数字仅供参考。

跑下来设备是借的、语料是自己抓的、脚本是手写的,中间还崩过两次。按网上的说法,这属于小作坊造冰🧊 —— 不太正规,但确实能出货。


一、第一版:先按官方流程跑通#

官方把训练分成两步”必须”的:

Terminal window
cd trainer && python train_pretrain.py # 预训练 → out/pretrain_768.pth
cd 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
官方估算单卡 3090mini 集,预训练 1 epoch + SFT 1 epoch≈2.31 h(≈3 元)—
我的预训练A1001,267,775 条 × 3 epochs2h09m2.236 → 1.765
我的 SFTA100904,979 条 × 2 epochs1h45m1.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 计算,但前向和反向都要算——浪费的是算力,对精度没有影响。

我改成两步:

  1. __getitem__ 只返回真实序列,collate_fn 在组 batch 时按批内最长对齐;
  2. 训练前按 token 长度分桶:先排序、切 batch,再打乱 batch 顺序。

第二步是收益的来源。只做第一步时几乎看不到提升:batch 随机采样,768 长度的长尾样本几乎每批都会出现,“按批内最长”于是退化回”每批都 padding 到 768”。分桶让批内长度集中,padding 占比才真正降下来。

分桶需要预先缓存长度。SFT 数据要先渲染 chat template,比预训练慢一截:

Terminal window
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
方案每 epoch3 epochs
静态 padding 768≈85 min≈4.3 h
动态 padding + 长度分桶≈37 min2h09m

约 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,91592.36%保留
纯拉丁 / 英文550,5436.50%保留
日文(含假名)20,2540.24%保留
希腊 / 西里尔 / 阿拉伯 / 韩文等76,4150.89%删除

按”含外语字符即删”的严格规则删掉 75,761 行,剩下 8,364,884 条,约 2.12B token(按 239 token/条估算)。

再核对一下数据量够不够用:

模型参数量Chinchilla 最优 token够不够
768 × 8 层63.9 M1.28 B超出约 66%
768 × 16 层(第二版)122.9 M2.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.6301.25 ~ 1.98
第 3 轮1.5331.17 ~ 1.84

预训练 loss 曲线(第 2–3 轮)

两轮之间只降了 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 都没有。

处置分三步:

  1. 先验证检查点能用:第 2 轮第 15,000 步的续训检查点严格加载成功,16 层参数全部匹配,模型与优化器张量都是有限值;
  2. 把整个运行目录搬到 /dev/shm(内存盘),代码、语料、长度缓存、Arrow 缓存、检查点都放进去,训练路径不再碰 NFS——这等于把风险从”网络抖动”换成了”内存占用”,所以必须配上第三步;
  3. 每 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

SFT loss 曲线

第 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 掐死的:

  1. 改写身份,写到新文件,原文件全程不动;断言行数与原语料一致(8,365,282);
  2. cat identity_atri.jsonl >> 新文件,断言行数等于 8,375,282;
  3. 抽样校验 JSON:头 500 行、每 4 万行抽一行、尾行,检查 text 字段非空;
  4. mv 就位——同一个文件系统内的 rename,瞬时完成,不会出现”复制一个 8 GB 文件”的磁盘峰值;
  5. 只重算被改行和新追加行的长度缓存,不用把 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

音游后训练 loss 曲线

八千条数据跑了 20 轮,模型把一部分样本几乎背下来了(loss 掉到 0.01),另一部分依然错得厉害(loss 回到 2.5)。这是过拟合,只是在小语料上表现得不那么含蓄:loss 曲线在”背会的样本”和”没背会的样本”之间来回跳,看不出平滑下降的样子。

正确做法是把轮数压到三五轮,或者把语料扩到几万条,又或者用 LoRA 做这件事。


十一、最后的碎碎念#

最后的碎碎念

这一串实验下来,我记住的主要是几个只有亲手做过才知道的点:

  • 动态 padding 单独用没有意义,必须配长度分桶;
  • 数据量的边际收益掉得比想象中快:SFT 第二轮三十万步只换来 0.08,与其继续堆数据,不如把模型做大一点;
  • 加数据也可能变差:分布差异大、轮数多、学习率不变,就会把原有能力覆盖掉;
  • 小语料多跑几轮一定会过拟合,loss 曲线会毫不客气地暴露出来;
  • 多人共用的机器上,脚本要按”环境随时会失效”来写;网络文件系统上的 mmap 尤其要小心,I/O 抖动会被放大成致命信号;
  • 数据准备比训练更考验细节:追加语料时用 mv 就位、只重算增量长度,每一步都断言行数——这些都不难,但省下的是几十分钟和一次磁盘爆掉;
  • 模型本身能力确实有限。官方自己都说 Zero 版本”事实知识与泛化效果仍较有限”,我的体验完全一致。

后面想做的:风格和人格微调都改用 LoRA;音游语料扩到几万条再重跑一次后训练;把后训练的轮数和学习率调保守一点。

最后说句实话:整套东西是公开资料加手写脚本拼出来的,设备是借的、语料是自己抓的、训练还崩过两次。按网上的说法,这属于小作坊造冰🧊 —— 不正规,但确实能出货。

一起来搓个迷你 LLM 吧:尝试复现 MiniMind
https://blog.gclmichael.xyz/posts/minimind-atri/
作者
Michael De Santa
发布于
2026-10-15
许可协议
CC BY-NC-SA 4.0
评论