MiniMind 第 5 篇:《核心训练|单卡 3090 极速复现:预训练 + SFT 从零跑通》
承接上一篇内容:我们已经完成了 MiniMind 数据工程全流程 —— 训练了专属 6400 词表 Tokenizer,适配了预训练 / SFT/DPO 三类数据集,为模型训练准备好了「粮草」。
现在,终于到了 LLM 开发最激动人心的环节:亲手训练一个能对话的小模型。很多人觉得「训练大模型必须要集群、要百万级预算」,但 MiniMind 用 26M 参数 + 单卡 3090 打破了这个认知 ——预训练 1.5 小时 + SFT 0.5 小时,总共 2 小时,电费不到 3 块钱,就能从 0 到 1 训练出一个能流畅对话的语言模型。

本篇聚焦「核心训练流程」,纯工程视角 + 源码落地:
- 彻底搞懂预训练与 SFT 的本质区别,理解小模型训练的核心逻辑;
- 单卡 3090 训练环境的终极优化,榨干每一寸显存;
- 预训练全流程:参数配置、脚本执行、训练监控、结果评估;
- SFT 全流程:从预训练权重加载、对话数据适配到指令微调;
- 训练中常见问题(显存溢出、梯度爆炸、收敛缓慢)的解决方案;
- 训练完成后的模型导出与初步对话测试。
建议打开 MiniMind 源码
trainer/train_pretrain.py和trainer/train_full_sft.py对照阅读,边看原理边实操,理解更透彻!开源项目地址:https://github.com/jingyaogong/minimind
一、先明确:预训练 vs SFT,小模型训练的「两步走」战略
很多新手会混淆预训练和 SFT,甚至认为「直接 SFT 就能训练模型」—— 这是大错特错的。对 MiniMind 这种 26M 超小模型来说,「预训练 + SFT」两步走是必选之路,缺一不可。
1.1 核心区别:预训练「学语言」,SFT「学对话」
表格
| 训练阶段 | 核心目标 | 本质任务 | 数据类型 | 训练时长 | 模型能力 |
|---|---|---|---|---|---|
| 预训练 | 学习语言规律、常识、基础概念 | 无监督「词语接龙」,预测下一个 Token | 纯文本(如维基、新闻) | 1.5 小时(3090 单卡) | 能生成连贯文本,但不懂指令 |
| SFT(监督微调) | 学习对话逻辑、指令理解、回复风格 | 有监督「指令 - 回复」对齐 | 多轮对话(user-assistant) | 0.5 小时(3090 单卡) | 能理解用户指令,给出符合人类习惯的回复 |
简单来说:预训练让模型「识字」,SFT 让模型「懂规矩」。没有预训练的 SFT 就像「让一个文盲学对话」,效果极差;没有 SFT 的预训练就像「一个博学但不会聊天的人」,无法满足实际使用需求。
1.2 小模型训练的核心原则
MiniMind 26M 参数的训练,必须遵循「轻量化 + 高效率」的原则,避免大模型的复杂训练策略:
- 拒绝过度训练:预训练 2 个 epoch 即可(数据量足够时),SFT 1 个 epoch 就能达到理想效果;
- 优先单卡训练:避免分布式训练的通信开销,3090 24GB 显存完全够用;
- 混合精度必开:BF16/FP16 能减少 50% 显存占用,训练速度提升 30%+;
- 梯度裁剪必备:防止小模型训练时梯度爆炸,保证训练稳定性。
二、训练前准备:单卡 3090 环境终极优化
2.1 硬件与环境检查
确保你的 3090 满足以下条件:
- 显存:≥24GB(推荐),20GB 也可(需调整 batch size);
- 驱动:≥525.60.13(支持 CUDA 12.0+);
- CUDA:12.1(MiniMind 推荐版本);
- PyTorch:2.1.0+cu121(原生支持 BF16);
- 剩余磁盘空间:≥20GB(用于存储模型权重、日志、临时文件)。
检查命令:
# 检查 CUDA 版本
nvcc -V
# 检查 PyTorch 版本和 CUDA 可用性
python -c "import torch; print(torch.__version__); print(torch.cuda.is_available())"
# 检查 GPU 显存
nvidia-smi
2.2 显存优化的「黄金组合」(单卡 3090 必用)
小模型训练虽不费显存,但要做到「极速训练」,必须最大化显存利用率。MiniMind 推荐以下组合:
| 优化技巧 | 作用 | 显存节省 | 实现方式 |
|---|---|---|---|
| BF16 混合精度 | 降低参数 / 梯度精度(FP32→BF16) | 50% | 训练脚本中添加 --dtype bfloat16 |
| 梯度累积 | 小 batch size 模拟大 batch size | 30% | --batch_size 8 --gradient_accumulation_steps 2 |
| 梯度裁剪 | 限制梯度最大范数,防止爆炸 | - | --grad_clip 1.0(MiniMind 推荐值) |
| 关闭不必要日志 | 减少内存占用 | 5% | 训练时添加 --log_interval 100 |
| 预计算 RoPE 缓存 | 避免重复计算,提升速度 | - | MiniMind 源码已默认实现 |
2.3 数据与权重路径配置
确保数据集和 Tokenizer 已正确放置:
minimind/
├── dataset/
│ ├── pretrain_hq.jsonl # 预训练数据(1.6GB)
│ ├── sft_mini_512.jsonl # SFT 数据(1.2GB)
│ └── minimind_tokenizer.model # 自定义 Tokenizer
└── trainer/
├── train_pretrain.py # 预训练脚本
└── train_full_sft.py # SFT 脚本
三、核心训练 1:预训练 —— 让模型「学语言」
3.1 预训练的本质:高质量「词语接龙」
预训练的核心任务是「自回归语言建模」(Autoregressive Language Modeling, ALM),简单说就是「给前文,预测下一个词」。例如:
- 输入:「秦始皇是中国历史上的第一位」
- 模型预测:「皇帝」
MiniMind 预训练的源码逻辑(train_pretrain.py 核心片段):
def train_one_epoch(model, data_loader, optimizer, scaler, args, epoch, log_writer):
model.train()
total_loss = 0
start_time = time.time()
for step, batch in enumerate(data_loader):
input_ids = batch["input_ids"].to(args.device)
# 构建标签:shift right(输入是前 n-1 个词,标签是后 n-1 个词)
labels = input_ids.clone()
labels[:, :-1] = input_ids[:, 1:]
labels[:, -1] = -100 # 最后一个词不计算损失
# 混合精度训练
with torch.autocast(device_type=args.device.type, dtype=args.dtype):
outputs = model(input_ids)
logits = outputs.logits
# 计算交叉熵损失
loss_fct = nn.CrossEntropyLoss(ignore_index=-100)
loss = loss_fct(logits.reshape(-1, logits.size(-1)), labels.reshape(-1))
# 梯度累积
loss = loss / args.gradient_accumulation_steps
scaler.scale(loss).backward()
# 梯度裁剪
if (step + 1) % args.gradient_accumulation_steps == 0:
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip)
scaler.step(optimizer)
scaler.update()
optimizer.zero_grad(set_to_none=True)
# 日志记录
total_loss += loss.item() * args.gradient_accumulation_steps
if (step + 1) % (args.log_interval * args.gradient_accumulation_steps) == 0:
avg_loss = total_loss / args.log_interval
elapsed = time.time() - start_time
print(f"Epoch {epoch}, Step {step+1}, Loss: {avg_loss:.4f}, Speed: {elapsed:.2f}s/{args.log_interval}steps")
total_loss = 0
start_time = time.time()
这段代码是 MiniMind 预训练的核心,没有任何黑盒封装,每一步都清晰可见:构建标签→前向传播→计算损失→反向传播→梯度裁剪→参数更新。
3.2 预训练完整流程(实操步骤)
步骤 1:进入 trainer 目录
cd trainer
步骤 2:执行预训练命令(3090 单卡推荐)
python train_pretrain.py \
--model_name_or_path minimind-small \ # 模型配置(26M 参数)
--data_path ../dataset/pretrain_hq.jsonl \ # 预训练数据
--tokenizer_path ../dataset/minimind_tokenizer.model \ # 自定义 Tokenizer
--output_dir ../checkpoints/pretrain \ # 权重保存路径
--num_epochs 2 \ # 训练轮数(2 轮足够)
--batch_size 16 \ # 单卡 batch size
--gradient_accumulation_steps 1 \ # 梯度累积步数
--learning_rate 5e-4 \ # 学习率(小模型推荐值)
--max_seq_len 320 \ # 序列长度(适配 3090 显存)
--dtype bfloat16 \ # 混合精度(3090 支持 BF16)
--grad_clip 1.0 \ # 梯度裁剪
--log_interval 100 \ # 日志打印间隔
--save_interval 1000 \ # 权重保存间隔
--seed 42 # 随机种子(保证复现性)
关键参数说明
| 参数 | 作用 | 3090 推荐值 | 调整建议 |
|---|---|---|---|
num_epochs | 训练轮数 | 2 | 数据量 <100M 时用 3,>1G 时用 1 |
batch_size | 单卡批次大小 | 16 | 显存不足时减到 8,20GB 显存可用 12 |
max_seq_len | 最大序列长度 | 320 | 最长不要超过 512(小模型算力有限) |
learning_rate | 学习率 | 5e-4 | 小模型学习率可稍大,大模型一般用 1e-4 |
dtype | 数据精度 | bfloat16 | 3090 原生支持,比 FP16 更稳定 |
步骤 3:训练过程监控
训练开始后,你会看到类似以下的输出:
Loading MiniMind model with config: minimind-small
Loading dataset from ../dataset/pretrain_hq.jsonl
Tokenizer loaded from ../dataset/minimind_tokenizer.model
Epoch 1, Step 100, Loss: 3.2156, Speed: 12.34s/100steps
Epoch 1, Step 200, Loss: 2.9875, Speed: 11.98s/100steps
Epoch 1, Step 300, Loss: 2.8542, Speed: 12.11s/100steps
...
Epoch 2, Step 10000, Loss: 2.4315, Speed: 12.05s/100steps
Saving model to ../checkpoints/pretrain/epoch_2.pth
Training completed in 1h 28m 45s
关键观察指标:
- Loss 下降趋势:从 3.5+ 稳步下降到 2.4 左右,说明模型在有效学习;
- 训练速度:3090 单卡 BF16 下,每秒约处理 8-10 个 batch,2 轮训练约 1.5 小时;
- 显存占用:约 12-15GB(BF16+320 序列长度),3090 完全够用。
步骤 4:预训练结果评估
训练完成后,会在 ../checkpoints/pretrain 目录下生成 epoch_2.pth 权重文件。用以下代码测试预训练效果:
import torch
from model.model_minimind import MiniMindLM
from model.LMConfig import LMConfig
import sentencepiece as spm
# 加载模型配置和权重
config = LMConfig.from_pretrained("minimind-small")
model = MiniMindLM(config)
model.load_state_dict(torch.load("../checkpoints/pretrain/epoch_2.pth"))
model.to("cuda").eval()
# 加载 Tokenizer
tokenizer = spm.SentencePieceProcessor()
tokenizer.Load("../dataset/minimind_tokenizer.model")
# 测试文本生成(词语接龙)
prompt = "杭州是中国浙江省的省会,也是"
input_ids = tokenizer.EncodeAsIds(prompt)
input_ids = torch.tensor([input_ids], dtype=torch.long).to("cuda")
# 生成文本
with torch.no_grad():
output_ids = model.generate(
input_ids,
max_new_tokens=50, # 生成 50 个新 Token
temperature=0.7, # 随机性控制
top_p=0.9, # 核采样
do_sample=True # 启用采样生成
)
# 解码并输出
output_text = tokenizer.DecodeIds(output_ids[0].cpu().tolist())
print("生成结果:", output_text)
输出示例:
生成结果:杭州是中国浙江省的省会,也是中国著名的旅游城市,以西湖、灵隐寺等景点闻名于世。杭州还是阿里巴巴的总部所在地,数字经济发达,同时拥有龙井虾仁、西湖醋鱼等特色美食。
预训练模型应该能生成连贯、符合常识的文本,虽然可能不完美,但已经具备了基本的语言生成能力。
四、核心训练 2:SFT 监督微调 —— 让模型「学对话」
4.1 SFT 的本质:指令 - 回复对齐
SFT 的核心是「让模型学会理解用户指令,并给出符合人类偏好的回复」。MiniMind 的 SFT 流程和预训练类似,但有两个关键区别:
- 数据格式:用多轮对话数据(
conversations字段)代替纯文本; - 损失计算:只计算
assistant回复部分的损失,忽略user指令部分(避免模型学习生成指令)。
MiniMind SFT 损失计算的核心代码(train_full_sft.py 片段):
def compute_sft_loss(logits, labels, loss_mask):
"""
计算 SFT 损失:只计算 loss_mask 为 1 的位置(assistant 回复)
"""
shift_logits = logits[..., :-1, :].contiguous()
shift_labels = labels[..., 1:].contiguous()
shift_loss_mask = loss_mask[..., 1:].contiguous()
# 计算交叉熵损失
loss_fct = nn.CrossEntropyLoss(ignore_index=-100, reduction="none")
loss = loss_fct(shift_logits.view(-1, shift_logits.size(-1)), shift_labels.view(-1))
loss = loss.view(shift_labels.size())
# 应用损失掩码
loss = (loss * shift_loss_mask).sum() / shift_loss_mask.sum()
return loss
这里的 loss_mask 是关键 —— 它标记了哪些位置需要计算损失(assistant 回复),哪些位置不需要(user 指令),确保模型只学习「如何回复」,不学习「如何提问」。
4.2 SFT 完整流程(实操步骤)
步骤 1:执行 SFT 命令(基于预训练权重)
python train_full_sft.py \
--model_name_or_path minimind-small \
--from_pretrained ../checkpoints/pretrain/epoch_2.pth \ # 加载预训练权重
--data_path ../dataset/sft_mini_512.jsonl \ # SFT 数据
--tokenizer_path ../dataset/minimind_tokenizer.model \
--output_dir ../checkpoints/sft \
--num_epochs 1 \ # SFT 1 轮足够
--batch_size 12 \ # 比预训练稍小(对话数据更长)
--gradient_accumulation_steps 1 \
--learning_rate 2e-5 \ # SFT 学习率要小(避免破坏预训练知识)
--max_seq_len 340 \ # 对话数据序列长度稍长
--dtype bfloat16 \
--grad_clip 1.0 \
--log_interval 50 \
--save_interval 500 \
--seed 42
关键参数差异(与预训练对比)
| 参数 | 预训练值 | SFT 值 | 调整原因 |
|---|---|---|---|
num_epochs | 2 | 1 | 对话数据质量高,1 轮就能学会对话逻辑 |
batch_size | 16 | 12 | 对话数据序列更长,显存占用稍大 |
learning_rate | 5e-4 | 2e-5 | 小学习率避免破坏预训练知识 |
max_seq_len | 320 | 340 | 对话数据需要更长的序列长度 |
步骤 2:训练过程监控
SFT 训练输出示例:
Loading MiniMind model from pretrained weights: ../checkpoints/pretrain/epoch_2.pth
Loading SFT dataset from ../dataset/sft_mini_512.jsonl
Tokenizer loaded from ../dataset/minimind_tokenizer.model
Epoch 1, Step 50, Loss: 1.8756, Speed: 8.23s/50steps
Epoch 1, Step 100, Loss: 1.7234, Speed: 8.11s/50steps
Epoch 1, Step 150, Loss: 1.6542, Speed: 8.05s/50steps
...
Saving model to ../checkpoints/sft/epoch_1.pth
Training completed in 0h 45m 12s
SFT 的 Loss 会比预训练低很多(1.6-1.9 左右),因为对话数据的规律性更强,模型更容易学习。
步骤 3:SFT 模型对话测试
训练完成后,用以下代码测试对话效果:
import torch
from model.model_minimind import MiniMindLM
from model.LMConfig import LMConfig
import sentencepiece as spm
# 加载 SFT 模型
config = LMConfig.from_pretrained("minimind-small")
model = MiniMindLM(config)
model.load_state_dict(torch.load("../checkpoints/sft/epoch_1.pth"))
model.to("cuda").eval()
# 加载 Tokenizer
tokenizer = spm.SentencePieceProcessor()
tokenizer.Load("../dataset/minimind_tokenizer.model")
# 构建对话(遵循 SFT 数据格式)
def build_dialog_prompt(conversations):
prompt = ""
for turn in conversations:
if turn["role"] == "user":
prompt += f"<|im_start|>user: {turn['content']}<|im_end|>"
elif turn["role"] == "assistant":
prompt += f"<|im_start|>assistant: {turn['content']}<|im_end|>"
prompt += f"<|im_start|>assistant: " # 模型生成起始标记
return prompt
# 测试对话
conversations = [
{"role": "user", "content": "你好,介绍一下自己"},
]
prompt = build_dialog_prompt(conversations)
input_ids = tokenizer.EncodeAsIds(prompt)
input_ids = torch.tensor([input_ids], dtype=torch.long).to("cuda")
# 生成回复
with torch.no_grad():
output_ids = model.generate(
input_ids,
max_new_tokens=100,
temperature=0.7,
top_p=0.9,
do_sample=True,
eos_token_id=tokenizer.eos_id # 结束标记
)
# 解码并输出
output_text = tokenizer.DecodeIds(output_ids[0].cpu().tolist())
# 提取 assistant 回复
assistant_reply = output_text.split("<|im_start|>assistant:")[-1].split("<|im_end|>")[0].strip()
print("MiniMind 回复:", assistant_reply)
输出示例:
MiniMind 回复:你好!我是 MiniMind,一个超轻量的开源语言模型,由 jingyaogong 开发。我只有 26M 参数,用单卡 3090 2 小时就能训练完成,支持中文对话、文本生成等功能~
完美!经过 SFT 后的模型,已经能理解「介绍自己」的指令,并给出符合人类习惯的回复,不再是预训练时的「词语接龙」模式。
五、训练高频问题解决方案(3090 单卡专属)
5.1 显存溢出(最常见问题)
- 解决方案 1:降低 batch size(从 16→12→8);
- 解决方案 2:减小 max_seq_len(从 320→256);
- 解决方案 3:启用梯度累积(
--gradient_accumulation_steps 2); - 解决方案 4:改用 FP16(如果 BF16 不支持):
--dtype float16。
5.2 梯度爆炸(Loss 突然飙升到 10+)
- 解决方案 1:增大梯度裁剪值(从 1.0→1.5);
- 解决方案 2:降低学习率(从 5e-4→3e-4);
- 解决方案 3:检查数据质量,过滤含超长文本、乱码的样本。
5.3 训练收敛缓慢(Loss 下降不明显)
- 解决方案 1:增加训练轮数(预训练从 2→3);
- 解决方案 2:提高学习率(SFT 从 2e-5→3e-5);
- 解决方案 3:检查数据格式,确保 SFT 数据的
conversations字段正确; - 解决方案 4:增加数据量,混合
sft_512.jsonl和sft_1024.jsonl。
5.4 生成结果重复 / 无意义
- 解决方案 1:调整生成参数(temperature 从 0.7→0.9,top_p 从 0.9→0.8);
- 解决方案 2:增加 SFT 数据量,特别是高质量对话样本;
- 解决方案 3:检查 Tokenizer 质量,确保中文分词准确。
六、模型导出与部署准备
训练完成后,你需要将模型导出为可部署的格式,方便后续推理和应用开发。
6.1 导出完整模型权重
MiniMind 训练脚本会自动保存完整的 PyTorch 权重(.pth 文件),位于 ../checkpoints/pretrain 和 ../checkpoints/sft 目录下。你可以直接用这些权重进行推理,也可以导出为 Hugging Face 格式(方便使用 transformers 库)。
6.2 导出为 Hugging Face 格式(可选)
MiniMind 提供了导出脚本,可将权重转为 Hugging Face 兼容格式:
python export_hf_model.py \
--model_name_or_path minimind-small \
--checkpoint_path ../checkpoints/sft/epoch_1.pth \
--tokenizer_path ../dataset/minimind_tokenizer.model \
--output_dir ../hf_models/minimind-sft
导出后,你可以用 transformers 库快速加载:
from transformers import AutoModelForCausalLM, AutoTokenizer
model = AutoModelForCausalLM.from_pretrained("../hf_models/minimind-sft")
tokenizer = AutoTokenizer.from_pretrained("../hf_models/minimind-sft")
七、下篇内容预告
本篇我们完成了 MiniMind 的核心训练流程 —— 用单卡 3090 2 小时从零训练出了一个能流畅对话的小模型,理解了预训练「学语言」和 SFT「学对话」的本质区别,掌握了小模型训练的关键技巧。
下一篇(第 6 篇),我们聚焦模型性能提升的「最后一公里」:《MiniMind 性能优化|LoRA 高效微调 + DPO 偏好对齐 + 推理加速》将带大家学习 LoRA 轻量化微调(不用重训整个模型)、DPO 直接偏好优化(让模型生成更符合人类偏好的回复),以及推理阶段的速度优化(让 26M 模型在 CPU/GPU 上都能秒级响应),为模型的实际应用做好准备。
写在最后
很多人觉得「训练模型是大厂的专利」,但 MiniMind 用 26M 参数 + 单卡 3090 证明了:普通人也能亲手训练一个可用的语言模型。训练过程中,你不仅能看到 Loss 一步步下降,还能见证模型从「只会词语接龙」到「能流畅对话」的神奇转变 —— 这种成就感,是调包使用大模型无法比拟的。
建议大家动手修改训练参数(如学习率、batch size、序列长度),对比不同参数下的训练速度和模型效果,加深对小模型训练的理解。遇到问题不要怕,3090 单卡训练的容错率很高,大不了重新训练一次(也就 2 小时)。
项目地址:https://github.com/jingyaogong/minimind收藏 + 关注,下一篇带你搞定 LoRA+DPO + 推理加速,让你的 MiniMind 模型更实用、更快、更强!
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)