本节由 AI 整理生成,仅供参考。
#MiniMind — 项目解剖
参考实现已 vendored 到仓库根的 minimind/(和 src/ 同级)。
上游在 ~/Public/minimind
读仓库里这份,不要读那份。
本文回答三个问题:它是什么、trainer/ 九个脚本各训什么、model/ 和 dataset/ 该不该复制。
行数的写法。 出现两个数字的地方一律是 上游原始 / 本仓库。 本仓库那份是 vendored 后逐行加过注释的,所以第二列更大。 差值就是注释量,不是代码差异 —— 2026-09-28 实测:用
ast.unparse剥离注释和 docstring 后逐文件比对,22/22 逐字节相同。
##1. 它是什么
一个教学用的从零 LLM 项目。两个主线模型,结构刻意对齐 Qwen3 生态
(方便转 transformers / llama.cpp / ollama / vllm)。
| Model | params | d_model | n_layers | 说明 |
|---|---|---|---|---|
| minimind-3 | 64M | 768 | 8 | Dense ← 我们复现的目标 |
| minimind-3-moe | 198M-A64M | 768 | 8 | 4 experts / top-1 |
| minimind2 | 104M | 768 | 16 | 历史版本 |
| minimind2-moe | 145M | 640 | 8 | 历史版本 |
| minimind2-small | 26M | 512 | 8 | 历史版本 |
架构要点:Pre-Norm + RMSNorm · SwiGLU · RoPE θ=1e6(支持 YaRN 外推)·
GQA 8/4 · QK-norm · tied embeddings · max_position_embeddings=32768 · vocab 6400。
参数量精确值(可解析验算,见 src/config.py:param_breakdown):
TEXT
dense 63,912,192 # 实测 == 解析值
MoE 198,416,640 # 63.9M active为什么 d_model=768 / 8 层:README 引 MobileLLM —— 参数固定时深度比宽度重要。
但 d_model < 512 时词嵌入太窄会明显吃亏;> 1536 则加层比加宽更划算。
框架:纯 PyTorch。 全仓库 import 计数:67 × torch / 14 × transformers /
0 × trl / 0 × peft / 0 × HF Trainer。
transformers 只当接口层用 4 个地方,不是训练框架:
| 用处 | 为什么 |
|---|---|
PreTrainedModel / GenerationMixin / PretrainedConfig |
让 from_pretrained() / .generate() 能用 |
ACT2FN |
一个 {'silu': nn.SiLU()} 查表 |
MoeCausalLMOutputWithPast |
一个返回值容器 |
LoRA 也是自己写的(model/model_lora.py,65 行),不用 peft。
requirements.txt是一份混杂四类东西的清单(训练 + 网页 demo + 数据清洗 + 蒸馏), 而且过期(transformers==4.57.6与代码里的 5.x 兼容代码矛盾,torch被注释掉)。AGENTS.md里"忽略~/Public下所有 requirements.txt"就是这个原因。
##2. trainer/ — 一棵阶梯
九个脚本的循环骨架逐字相同,变的只有两件事:数据长什么样,和 loss 怎么算。
python
for step, batch in enumerate(loader):
lr = get_lr(epoch*iters + step, args.epochs*iters, 5e-4) # 同一套余弦,无预热
with autocast:
loss = <─── 唯一的区别 ───>
scaler.scale(loss).backward()
if step % accum == 0:
unscale / clip_grad_norm_(1.0) / step / zero_grad # 逐字相同
if step % log_interval == 0: log
if step % save_interval == 0: lm_checkpoint(...)| # | 文件 | 上游 | 仓库 | 输入数据 | 优化的目标 | 产出 |
|---|---|---|---|---|---|---|
| 0 | train_tokenizer |
189 | 211 | sft jsonl | 无(统计 BPE merge) | 词表 |
| 1 | train_pretrain |
164 | 216 | {"text"} |
预测下一个词 | pretrain_768.pth |
| 2 | train_full_sft |
165 | 185 | {"conversations"} |
预测回答的下一个词 | full_sft_768.pth |
| 3 | train_lora |
177 | 195 | 同上 | 同上,只训 0.76% 参数 | full_sft_lora_*.pth |
| 4 | train_distillation |
239 | 260 | 同上 | 学生模仿教师整个概率分布 | full_sft_768.pth |
| 5 | train_dpo |
219 | 242 | {"chosen","rejected"} |
好回答比坏回答更可能 | dpo_768.pth |
| 6 | train_ppo |
448 | 473 | rlaif(只有题) |
规则分 + reward model,配 critic | ppo_768.pth |
| 7 | train_grpo |
327 | 347 | 同上 | 同上,不用 critic | grpo_768.pth |
| 8 | train_agent |
485 | 504 | agent_rl |
同上 + 多轮工具调用 | agent_768.pth |
| — | rollout_engine |
224 | 244 | — | 不训练,生成回答的推理引擎 | — |
| — | trainer_utils |
209 | 258 | — | 共享层 | — |
trainer/ 合计 2,846 → 3,135 行;其中 9 个 train_*.py 是 2,224 → 2,422。
###谁 import 谁
2026-09-28 用 ast 从本仓库抽的真实 import 图(不是推测):
TEXT
零本地依赖 ──┬── model/model_minimind.py 509L 模型 + MiniMindConfig 全在这一个文件里
├── model/model_lora.py 103L
├── dataset/lm_dataset.py 330L 只要 tokenizer.json
├── trainer/rollout_engine.py 244L 只有 RL 组用
└── trainer/train_tokenizer.py 211L 我们不用
│
第 1 层 ──────────────────┴── trainer/trainer_utils.py 258L → import model_minimind
│ get_lr / lm_checkpoint / init_model / Logger / …
▼
第 2 层 ────────────────────── 8 个 train_*.py,每个只导出一个 train_epoch()
│ 全都 import: lm_dataset + model + trainer_utils
▼
第 3 层 ────────────────────── scripts/ 5 个 + eval_llm.py两个反直觉的地方:
trainer_utils.py依赖模型 —— 它里面有init_model()和LMForRewardModel。 所以顺序是 模型 → utils → 训练脚本,不是反过来。dataset/lm_dataset.py零依赖 —— 它只吃tokenizer.json。 所以它和模型可以并行写,先后随便。
###分三组理解
第一组(1 个)—— 学语言。 train_pretrain 从随机初始化开始,
loss = cross_entropy(logits[:, :-1], labels[:, 1:]),没有别的。
第二组(3 个)—— 学听指令。这三个的 loss 完全一样,都是同一个 cross-entropy。 区别只在两处:
- 数据格式:
SFTDataset套 chat 模板TEXT
<|im_start|>user\n1+1等于几?<|im_end|>\n<|im_start|>assistant\n2<|im_end|> - 掩码:
labels[:prompt_len] = -100,只在 assistant 回答上算损失。 这是关键 —— 否则模型会去学"怎么提问"。
full_sft |
lora |
distillation |
|
|---|---|---|---|
| 训哪些参数 | 全部 63.9M | 只 B·A |
全部(学生) |
optimizer 传什么 |
model.parameters() |
lora_params |
model.parameters() |
| 额外 | — | 基础权重冻结 | 另有教师模型(MoE) |
lora 的差别只有一行:optim.AdamW(lora_params, ...)。
distillation 是这组唯一 loss 不同的:
python
loss = alpha * ce_loss + (1 - alpha) * distill_loss
# ↑硬标签 ↑软标签: T² · KL(学生/T ‖ 教师/T)教师默认 --teacher_use_moe 1 --from_teacher_weight full_sft —— 即
把 MoE 的容量蒸馏进稠密模型。软标签带的信息比硬标签多:教师对错答案的
概率分布本身就是知识。
第三组(4 个)—— 学"哪个回答更好"。从这里开始没有标准答案,目标变成优化一个分数。
train_dpo(不需要 reward model、不需要采样,所以和 SFT 一样快):
python
pi_logratios = logπ(chosen) - logπ(rejected) # 当前模型
ref_logratios = logπ_ref(chosen) - logπ_ref(rejected) # 冻结参考模型
loss = -logsigmoid(beta * (pi_logratios - ref_logratios)) # beta 默认 0.15直觉:直接拉大"好答案相对坏答案"的概率差距,用冻结参考模型当锚防跑偏。
一个 batch 里 chosen/rejected 各占一半,靠 [:B//2] / [B//2:] 切开。
train_grpo 的关键只有一句 —— 用组内相对替掉整个 critic:
python
grouped_rewards = rewards.view(-1, num_generations) # [B, num_gen]
advantages = (rewards - grouped.mean(dim=1)) / (grouped.std(dim=1) + 1e-4)同一道题生成 N 个回答,组内均值和标准差当 baseline。比 PPO 少 121 行, 少的基本就是 critic。之后仍是 PPO 那套 clip + KL。
train_ppo 有 critic,所以要两套优化器(actor + critic)、两套 lr,
外加 GAE:delta = r + γ·V(t+1) - V(t)。这就是 447 行花在哪。
train_agent = GRPO 的算法 + 多轮循环(吐 <tool_call> → 执行 →
结果作 <tool_response> 拼回 → 再生成)。reward 是多项加减后 clip 到 ±3:
TEXT
-0.5 × 未闭合的 <tool_call> 标签数 标签扣分
+0.5 / -0.5 回答长度 5..800
+1.0 / -0.5 think 长度 20..300
+0.25 / -0.25 </think> 恰好出现 1 次
+ reward_model.get_score(...) RM 分
- rep_penalty(answer) 重复惩罚
+0.5 / -0.5×tool_gap 工具对齐分
+2.5 × 命中标准答案的比例 有 GT 时
-0.5 未完成###数据怎么流动
TEXT
tokenizer.json ──────────── 已有,不训(官方劝退重训)
│
▼
train_pretrain ──► pretrain_768.pth
▼
train_full_sft ──► full_sft_768.pth ← 变成会对话
├──► train_lora
├──► train_distillation (MoE 教师 → 稠密学生)
▼
train_dpo ──► dpo_768.pth
├──► train_ppo (有 critic) ┐
├──► train_grpo (无 critic) ├── 需要 reward model
└──► train_agent (多轮工具) ┘每一级吃上一级的产出。不能跳过 full_sft 直接跑 DPO —— DPO 的参考模型
需要一个已经会对话的模型。
##3. model/ — 三个文件,只该要一个
| 文件 | 上游 | 仓库 | 是什么 | 复制? |
|---|---|---|---|---|
model_minimind.py |
293 | 509 | 模型本体 | ❌ 不要(见下) |
model_lora.py |
65 | 103 | 手写 LoRA | ✅ 当参照物 |
tokenizer.json + tokenizer_config.json |
— | 453 KB | 词表 6400 | ✅ 已在 tokenizer/(仓库根) |
为什么不要 model_minimind.py: 我们实测两者逐位相同
(91 个 state_dict key 一致,max |logit diff| = 0.000e+00)。
再复制一份 = 仓库里两份模型定义 = PLAN.md 警告的那种混乱。
真身留在 minimind/model/ 当参考读物,永远不当依赖。
为什么 model_lora.py 值得复制: 它是 Phase 3 的对照物
(PLAN.md 3.2 要求可训练参数量与 peft 完全一致)。三处偏离标准做法:
- 只贴在方阵上 ——
in_features == out_features,所以只有q_proj和o_proj被加上;k_proj/v_proj(768→384)和 MLP 都不会。peft默认 target 是q,k,v,o→ 参数量对不上。 - 没有
alpha/r缩放 —— 就是B(A(x)),等价于alpha = rank。 - 猴补丁改
module.forward—— 所以到处是getattr(model, '_orig_mod', model)来兼容torch.compile。读得懂但不是惯用写法。
##4. dataset/ — 不是选择题,是硬依赖
| 文件 | 上游 | 仓库 | 是什么 | 复制? |
|---|---|---|---|---|
lm_dataset.py |
260 | 330 | 5 个 Dataset 类 + 2 个对话处理函数 | ✅ 被迫 |
dataset.md + __init__.py |
— | — | 写着"把数据文件放这里" | ❌ 没价值 |
为什么是被迫的: train_full_sft / train_lora / train_dpo /
train_grpo / train_ppo / train_agent 六个文件都
from dataset.lm_dataset import ...。不复制它们连 import 都过不去。
五个类各是一个训练阶段的"拼数据"逻辑:
| 类 | 输入格式 | 干什么 |
|---|---|---|
PretrainDataset |
{"text": ...} |
纯续写。已被 src/data.py 取代 |
SFTDataset |
{"conversations":[{role,content}...]} |
套 chat 模板,把 prompt 掩成 -100 |
DPODataset |
chosen / rejected 成对 | 两边都套模板 |
RLAIFDataset |
只有 prompt | 给 on-policy 生成用 |
AgentRLDataset |
多轮 tool use | Phase 4/5 |
两个辅助函数:pre_processing_chat(20% 概率注入随机 system prompt)、
post_processing_chat(80% 概率删掉空的 <think>\n\n</think>)。
顺带发现:
SFTDataset里的 prompt 掩码,正是PLAN.md3.1 要我们 从零实现的那个 completion-only masking。 所以dataset.py不只是脚手架, 它同时是 Phase 3 的对照物。
##5. 上游已 vendored 进仓库
minimind/ — 22 个 py 文件,4,667 → 5,352 行(+685,全部是注释;
整个目录含 json/md 共 48 个文件)。上游自己的目录布局
(trainer/ dataset/ model/ scripts/),所以与原版的 diff 只要一条命令:
bash
diff ~/Public/minimind/trainer/train_dpo.py minimind/trainer/train_dpo.py为什么要 vendored: ~/Public 是 NTFS,Windows 那边已经删过一个仓库。
这份在 git 里,不会消失,而且能与我们的代码并排看。
| 模型 | model/model_minimind.py —— 上游的,不是我们的。仍然加载 MiniMindForCausalLM |
| 路径 | 未适配 —— 仍是 ../dataset/、../out、../checkpoints |
| 第三方 | streamlit / vllm / ollama / lm-eval 都没装 |
⚠️ 这是用来 diff 的参照物,不是拿来跑的。 要跑用 src/train.py。
曾经有一份扁平化的适配移植版在
src/minimind/(17 文件、4,333 行、 每个文件距上游 5 处机械改动)。已删除 —— vendored 副本让它变得多余。 要恢复得从归档取:archive/llm-pre-restructure.tar.gz。 旧仓库在 2026-09-28 被完整重建过(用户选择抹掉.git),所以历史里的 提交号和git checkout HEAD~这类命令已经全部失效。
##6. 对我们的意义
PLAN.md 阶段 |
对应 trainer |
|---|---|
| Phase 2 预训练 | train_pretrain → 我们写 src/train.py |
| Phase 3 SFT | train_full_sft |
| Phase 3 LoRA | train_lora(自己写一个再和它对照) |
| Phase 4 RL | train_grpo(PLAN 4.3 点名) |
| Phase 5 尾巴 | train_distillation / train_agent |
最省心的一点:九个里面有 3 个是同一个 cross-entropy,只是喂不同数据。
我们自己实现的 pretrain 循环已经跑通过其中一个(那个实现已随重构归档,
现在的对应位置是 src/train.py)。Phase 3 的 train_full_sft
对我们来说其实是"换个 Dataset + 加个掩码",不是新东西。
真正新的只有第三组那 4 个 —— 因为那时优化目标第一次不再是"预测下一个词"。
##7. 一处实测更正
我最初说"把 dataset.py 的 15 个分词器调用点从 HF API 改成原生 API"。错了。
实测 tokenizers 0.23.2 上 Tokenizer.apply_chat_template 不存在,
所以那不是改方法名,而是要自己重写 3895 字符的 Jinja chat 模板。
正确做法是两个加载器各司其职,详见 02-tokenizer.md §5。