承接上一篇内容:我们已经完成了 MiniMind 数据工程全流程 —— 训练了专属 6400 词表 Tokenizer,适配了预训练 / SFT/DPO 三类数据集,为模型训练准备好了「粮草」。

现在,终于到了 LLM 开发最激动人心的环节:亲手训练一个能对话的小模型。很多人觉得「训练大模型必须要集群、要百万级预算」,但 MiniMind 用 26M 参数 + 单卡 3090 打破了这个认知 ——预训练 1.5 小时 + SFT 0.5 小时,总共 2 小时,电费不到 3 块钱,就能从 0 到 1 训练出一个能流畅对话的语言模型

本篇聚焦「核心训练流程」,纯工程视角 + 源码落地:

  1. 彻底搞懂预训练与 SFT 的本质区别,理解小模型训练的核心逻辑;
  2. 单卡 3090 训练环境的终极优化,榨干每一寸显存;
  3. 预训练全流程:参数配置、脚本执行、训练监控、结果评估;
  4. SFT 全流程:从预训练权重加载、对话数据适配到指令微调;
  5. 训练中常见问题(显存溢出、梯度爆炸、收敛缓慢)的解决方案;
  6. 训练完成后的模型导出与初步对话测试。

建议打开 MiniMind 源码 trainer/train_pretrain.pytrainer/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 参数的训练,必须遵循「轻量化 + 高效率」的原则,避免大模型的复杂训练策略:

  1. 拒绝过度训练:预训练 2 个 epoch 即可(数据量足够时),SFT 1 个 epoch 就能达到理想效果;
  2. 优先单卡训练:避免分布式训练的通信开销,3090 24GB 显存完全够用;
  3. 混合精度必开:BF16/FP16 能减少 50% 显存占用,训练速度提升 30%+;
  4. 梯度裁剪必备:防止小模型训练时梯度爆炸,保证训练稳定性。

二、训练前准备:单卡 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 size30%--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数据精度bfloat163090 原生支持,比 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 流程和预训练类似,但有两个关键区别:

  1. 数据格式:用多轮对话数据(conversations 字段)代替纯文本;
  2. 损失计算:只计算 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_epochs21对话数据质量高,1 轮就能学会对话逻辑
batch_size1612对话数据序列更长,显存占用稍大
learning_rate5e-42e-5小学习率避免破坏预训练知识
max_seq_len320340对话数据需要更长的序列长度
步骤 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.jsonlsft_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 模型更实用、更快、更强!

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐