这篇能做出什么
跑完这条链路,你会得到四样东西:
1. 一份清洗好、配比明确、格式统一的 SFT 数据集(JSONL,带统计报告);
2. 一个按阶段训练出来的对话模型(LoRA 适配器或合并后的完整权重);
3. 一套可复现的训练配置(YAML + 启动脚本),换模型、换数据都能直接改;
4. 一份评测对比表,能回答"后训练到底有没有用、哪个阶段贡献大"。
链路本身是:数据准备 → 阶段化 SFT → 超参选择 → 合并部署 → 三层评测。Rufus-Air 配方的思路是"分阶段喂不同性质的数据,每阶段用不同的学习率",而不是把所有数据混在一起跑一个 epoch。下面按这个思路落地。需要说明的是,配方里的具体阶段命名、数据配比和超参取值,以 Rufus-Air 官方发布为准,这里给的是一套等价、可执行的实现骨架。
前置条件清单
- 硬件:单张 24GB 显存显卡可以用 LoRA 跑 7B 级别模型;全参微调需要多卡,具体显存需求以模型官方文档为准。
- 基础模型:选一个开源权重模型,基座版或指令版都行。指令版起点更高,基座版可塑性更强。具体选哪个以官方文档当前版本为准。
- 软件:Python 环境、PyTorch、transformers、peft、trl 或 LLaMA-Factory 之类的训练框架、deepspeed(多卡)、vLLM(推理与评测)、lm-evaluation-harness(标准基准)。版本以各自官方文档当前版本为准,不要抄别人的固定版本号。
- 数据:公开指令数据集 + 自己业务里脱敏后的真实对话。注意每个数据集的许可证,商用前逐条确认。
- 时间:数据清洗通常比训练更耗时。7B 模型 LoRA 跑 10 万条样本,单卡大概是数小时量级,具体看序列长度和吞吐。
第 0 步:把配方拆成可执行清单
分阶段的目的很实际:通用对话数据量大但质量参差,领域数据量小但密度高,安全与风格数据量更小但直接决定体感。混在一起跑,小数据会被大数据淹没;分开跑,每个阶段的学习率都能调到位。
| 阶段 | 数据性质 | 数据量级 | 学习率 | epoch |
|---|---|---|---|---|
| Stage 1 | 通用指令对话 | 大(十万条级) | 基准值 | 2~3 |
| Stage 2 | 领域/任务数据 + Stage 1 采样回放 | 中(万条级) | 基准的 1/2 ~ 1/3 | 2~3 |
| Stage 3 | 风格、格式、安全拒答 | 小(千条级) | 基准的 1/5 ~ 1/10 | 1~2 |
| Stage 4(可选) | 偏好对数据,做 DPO/ORPO | 小到中 | 比 SFT 更低 | 1 |
Stage 2 里"回放"很关键:从 Stage 1 的数据里随机抽 10%~30% 混进 Stage 2,能明显缓解AI 词典:灾难性遗忘">灾难性遗忘。
第 1 步:环境与目录
```bash
python -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install torch transformers datasets peft trl accelerate
pip install deepspeed # 多卡训练才需要
pip install vllm # 推理与批量评测
pip install lm-eval # 标准基准评测
```
目录结构建议固定下来,后面所有脚本都按这个路径写:
```bash
mkdir -p project/{data/raw,data/clean,configs,scripts,outputs,evals}
cd project
```
把原始数据集丢进 data/raw,清洗产物放 data/clean,训练输出放 outputs。
第 2 步:数据准备
2.1 统一成 messages 格式
不管原始数据是 alpaca 格式、sharegpt 格式还是自己爬的,先统一成下面这种 JSONL,每行一条:
```json
{"messages": [{"role": "system", "content": "你是一个乐于助人的助手。"}, {"role": "user", "content": "帮我把这段话改得更正式。"}, {"role": "assistant", "content": "好的,请把原文发给我。"}]}
```
统一格式的好处是:训练时直接套模型的 chat template,推理时也用同一套 template,不会出现"训练用 A 模板、推理用 B 模板"的错位。
2.2 清洗:五道过滤
```python
scripts/clean.py
import json, hashlib, re, sys
from collections import Counter
def norm(s):
return re.sub(r"\s+", " ", s).strip()
def sig(item):
用首轮 user + assistant 做精确去重指纹
msgs = item["messages"]
key = "".join(m["content"] for m in msgs[:3])
return hashlib.md5(norm(key).encode("utf-8")).hexdigest()
seen = set()
stats = Counter()
out_path, in_path = sys.argv[1], sys.argv[2]
with open(in_path, encoding="utf-8") as fin, open(out_path, "w", encoding="utf-8") as fout:
for line in fin:
item = json.loads(line)
msgs = item.get("messages", [])
1) 结构完整性:必须有 user 和 assistant,且交替
if not any(m["role"] == "user" for m in msgs) or not any(m["role"] == "assistant" for m in msgs):
stats["drop_structure"] += 1
continue
2) 长度过滤:过短没信息量,过长多半是脏数据
total_len = sum(len(m["content"]) for m in msgs)
if total_len < 40 or total_len > 20000:
stats["drop_length"] += 1
continue
3) 去重
s = sig(item)
if s in seen:
stats["drop_dup"] += 1
continue
seen.add(s)
4) 脏话/占位符过滤(按业务补充规则)
text = "".join(m["content"] for m in msgs)
if re.search(r"lorem ipsum|<\|.*?\|>|\uFFFD", text, re.I):
stats["drop_junk"] += 1
continue
5) 归一化空白
for m in msgs:
m["content"] = norm(m["content"])
fout.write(json.dumps({"messages": msgs}, ensure_ascii=False) + "\n")
stats["keep"] += 1
print(stats)
```
跑法:
```bash
python scripts/clean.py data/clean/general.jsonl data/raw/general.jsonl
```
去重之后还要看一眼长度分布和角色分布。如果 assistant 的平均长度只有 user 的十分之一,说明数据里大量是"用户长问题 + 助手敷衍一句",训出来的模型也会敷衍。
2.3 配比
一个可用的起点配比是:通用对话 60%、任务/领域 30%、风格与安全 10%。这个比例不是定死的,调法看现象:
- 模型回答格式乱、不按指令 → 提高"风格与格式"占比;
- 模型答非所问、领域知识不足 → 提高领域占比;
- 模型只会一件事,闲聊就崩 → 提高通用占比。
按配比采样时用固定随机种子,把采样结果落盘成独立的 jsonl 文件,评测出现问题时能回溯到底用了哪批数据。
2.4 只对 assistant 计算 loss
这一步经常被忽略,但影响很大。训练时要把 user 部分的 label 设成 -100,否则模型会去学"怎么提问"而不是"怎么回答"。用 TRL 或 LLaMA-Factory 时通常有开关(类似 train_on_inputs: false 或 assistant_only_loss),确认它打开,并写一个 5 条样本的 sanity check 打印出真实参与 loss 的 token。
第 3 步:超参选择
下面是一份 YAML 骨架,字段名按常见训练框架的习惯写,落到具体框架时对照官方文档改。
```yaml
configs/sft_stage1.yaml
model_name_or_path: <BASE_MODEL> # 以官方文档当前版本为准
stage: sft
dataset:
file: data/clean/stage1_mix.jsonl
template: default # 与推理保持一致
cutoff_len: 8192
packing: true # 把短样本拼进同一序列,提升吞吐
train_on_inputs: false # 关键:只对 assistant 算 loss
training:
output_dir: outputs/stage1
per_device_train_batch_size: 2
gradient_accumulation_steps: 16
learning_rate: 1.0e-5 # 全参起点;LoRA 用 1e-4 ~ 2e-4
lr_scheduler_type: cosine
warmup_ratio: 0.03
num_train_epochs: 2
weight_decay: 0.1
max_grad_norm: 1.0
bf16: true
gradient_checkpointing: true
logging_steps: 10
save_steps: 500
save_total_limit: 5
seed: 42
```
LoRA 阶段额外加:
```yaml
lora:
r: 32
lora_alpha: 64 # 常见做法是 alpha = 2r
lora_dropout: 0.05
target_modules: [q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj]
```
几个经验值:
- 学习率:全参 1e-5 ~ 2e-5;LoRA 1e-4 ~ 2e-4。Stage 2 减半或降到 1/3,Stage 3 再降。
- epoch:2~3 就够。超过 3 轮很容易开始背数据,表现为训练 loss 一直降、评测集分数不涨反跌。
- 全局 batch:按 token 数算更稳,一个梯度步覆盖 50 万 ~ 200 万 token 是常见区间。显存不够就加
gradient_accumulation_steps,别硬拉per_device_train_batch_size。 - 序列长度:从 4096 起步,确认数据里有长样本再往上加。开
packing时注意别把不同对话粘成一条。
第 4 步:分阶段训练
```bash
Stage 1:通用指令
python scripts/train.py --config configs/sft_stage1.yaml
Stage 2:领域数据 + 回放,从头加载还是接 Stage 1?
推荐接 Stage 1 的产物,学习率降低
python scripts/train.py --config configs/sft_stage2.yaml \
--model_name_or_path outputs/stage1 \
--learning_rate 5e-6
Stage 3:风格与安全,数据量小,1 个 epoch
python scripts/train.py --config configs/sft_stage3.yaml \
--model_name_or_path outputs/stage2 \
--learning_rate 2e-6 \
--num_train_epochs 1
```
多卡时用 deepspeed 启动:
```bash
deepspeed --num_gpus=8 scripts/train.py --config configs/sft_stage1.yaml \
--deepspeed configs/ds_zero2.json
```
训练过程中重点盯三件事:
1. loss 曲线:健康形态是先快速下降再平缓。如果前 50 步几乎不动,八成是学习率太低或模板没生效;如果前 20 步就掉到接近 0,八成是数据重复或者 label 泄漏(把 assistant 内容也算进了输入)。
2. grad norm:偶尔尖峰正常,一直很大说明学习率偏高。
3. 吞吐与显存:开 packing 和 gradient_checkpointing 前后的 tokens/s 对比一下,确认优化真的生效了。
每个阶段结束后都存一份独立产物,别覆盖。后面评测要按阶段对比,覆盖了就没了。
第 5 步:合并与冒烟测试
```bash
LoRA 合并成完整权重
python scripts/merge_lora.py \
--base <BASE_MODEL> \
--adapter outputs/stage3 \
--out outputs/stage3_merged
```
起一个推理服务做冒烟测试:
```bash
vllm serve outputs/stage3_merged --port 8000 --max-model-len 8192
```
然后拿固定的 10 条 prompt 跑一遍,包括:普通问答、多轮追问、要求输出 JSON、要求拒绝越界请求、中英混排。这 10 条 prompt 要存进仓库,每次训练完都跑,形成回归测试习惯。
第 6 步:三层评测
只训练不评测,等于闭眼开车。评测分三层,从粗到细:
第一层:标准基准
```bash
lm_eval --model vllm \
--model_args pretrained=outputs/stage3_merged,tensor_parallel_size=1 \
--tasks <TASK_NAMES> \
--batch_size auto \
--output_path evals/stage3_bench.json
```
任务名以 lm-evaluation-harness 官方文档当前列表为准。这一层的作用是防止"体感变好但通用能力塌方"——后训练最常见的副作用就是通用能力下降。
第二层:自建评测集
从真实业务场景里抽 100~300 条,覆盖主要用例,写成和训练数据同样的 messages 格式,单独放一个文件。这个文件永远不能进训练集,建议用哈希去重脚本卡一道。
打分用 rubric,比如每条 0~3 分:0 分答错或拒答、1 分方向对但信息缺失、2 分可用、3 分完全满足要求。可以人工打,也可以用更强的模型做 LLM-as-judge,但一定要:
- 用固定 prompt 模板和固定温度(建议 0);
- 让 judge 先输出理由再输出分数,便于排查;
- 抽 20 条人工复核,确认 judge 和人的打分方向一致。
第三层:对比矩阵
把各阶段产物和基线放一起跑同一个评测集:
| 模型 | 基准任务 | 自建集均分 | 格式遵循率 | 拒答正确率 |
|---|---|---|---|---|
| 原始基座/指令模型 | ||||
| Stage 1 | ||||
| Stage 2 | ||||
| Stage 3 |
这张表是整条链路的交付物。它能直接告诉你:Stage 2 到底有没有带来领域提升,Stage 3 是不是把模型训"怂"了(拒答率飙升、正常问题也开始推脱)。
常见坑与排错
loss 不下降:先查模板。把一条训练样本按 chat template 渲染出来打印,肉眼确认 <|user|>、<|assistant|> 这些特殊 token 位置正确。很多框架在模板不匹配时会静默降级成纯文本拼接。
loss 掉得太快:查数据泄漏。如果 assistant 的内容同时出现在了 input 里,模型学的是复制粘贴。用 train_on_inputs: false 并打印实际参与 loss 的 token 数验证。
模型变成复读机:通常是 eos token 没被正确监督,或者 max_new_tokens 设得太大。检查数据里每条样本结尾是否有结束符,并确认模板渲染时加了 eos。
灾难性遗忘:Stage 2 之后模型不会闲聊了。解决办法是把 Stage 1 数据按 10%~30% 回放,或者降低 Stage 2 的学习率和 epoch 数。
评测分数高但体感差:说明自建评测集和真实场景脱节。回头补真实 case,别急着调超参。
评测集泄漏:训练脚本里加一道断言,把评测集的文件哈希和训练集逐条比对,命中就报错退出。
显存 OOM:按影响从大到小依次调:降 cutoff_len → 开 gradient_checkpointing → 降 per_device_train_batch_size → 增 gradient_accumulation_steps → 换 ZeRO-3 或 QLoRA。
多轮对话丢上下文:检查训练数据里多轮样本的比例。如果全是单轮,模型学不会指代消解。
下一步建议
1. 加一个偏好优化阶段。用自建评测集里模型答得不好的 case,人工改写正确答案,构造成 chosen/rejected 偏好对,跑一轮 DPO 或 ORPO。通常比继续堆 SFT 数据有效。
2. 做数据消融。固定其它条件,只改一个配比(比如领域数据从 30% 调到 15%),看自建集分数怎么变。两三轮下来你就知道哪些数据在起作用。
3. 把评测变成 CI。每次改数据或超参,自动跑一遍自建集和基准任务,把对比表存成历史记录。后训练最容易出现"改 A 修好、弄坏 B"。
4. 考虑部署侧的量化。训练完的权重按部署框架的要求做量化,量化后必须重跑一遍评测,确认掉点可接受。
5. 沉淀配方文档。把每阶段的配比、学习率、epoch、评测结果写进一个 README,附上配置文件和随机种子。半年后你自己或者同事接手时,这份文档比代码更值钱。
整条链路跑通一遍大概需要几天时间,其中数据清洗占一半以上。先把 Stage 1 跑通、评测跑通,再往上叠 Stage 2 和 Stage 3,每一步都有对比数据,比一次性配齐所有阶段更稳。
