最近两三个月,我反复在折腾同一件事:拿一个 1.5B 级别的小模型做指令微调。听起来很简单,但数据的混合比例、提示模板怎么设计、以及模型从第几个 epoch 开始过拟合,这三个问题几乎贯穿了我的每一次实验。我把这段经历整理成了一套“最小复现”方案——用不到 5000 条样本、一张 12GB 左右显存的显卡、一两个小时跑完,把指令微调的整个链路走通,同时把数据混合、模板和过拟合这三件事讲清楚。这篇内容适合刚接触 SFT、想在有限资源下快速验证想法的人,也适合已经被“模型背答案、换句话就翻车”折磨过的同学。我不讲分布式训练那套复杂工程,只聚焦在小模型指令微调里最朴素、也最容易出问题的地方。
1. 小模型指令微调在做什么,为什么值得复现
1.1 小模型到底指多小
我所说的小模型,一般指 1B 到 7B 之间的 decoder-only 语言模型,实操里更常用的是 1B 到 3B 这一段。相比 70B 那种大模型,小模型最大的优势不是效果,而是门槛:单卡能训,显存要求低,推理便宜,方便做消融实验。你可以今天改数据配比,明天换模板,后天加正则化,每次实验都能在上小时内看到结果,这种快速反馈对训练策略的验证非常重要。
我最近用的主力是两个模型:Qwen2.5-1.5B 和 LLama-3.2-1B,偶尔也用 Gemma-2-2B 做交叉验证。选它们不是因为效果最好,而是因为公开权重容易获取、tokenizer 成熟、社区资料多。还有一个原因:小模型对数据问题特别敏感。你把数据污染了、配比偏了、模板写乱了,1B 模型会立刻在生成结果里暴露问题;同样的数据喂给 7B 甚至更大的模型,它可能靠预训练阶段学到的能力硬扛过去。所以如果你想把“数据混合”“模板设计”“过拟合”这些底层问题研究明白,小模型反而比大模型更好的实验载体。
1.2 “最小复现”的三条边界
所谓“最小复现”,不是说不认真,而是把变量收敛到最少,用可承受的成本得到一个稳定、可对比的结果。我在设计这套流程时给自己定了三条边界,你可以根据自己的资源和目标调整。
| 维度 | 最小配置 | 我的实际配置 | 目的 |
|---|---|---|---|
| 数据规模 | 指令数据 3000 条 | 指令 4000 条 + 通用语料 500 条 + 边界样本 500 条 | 够学任务格式,又不至于掩盖过拟合 |
| 算力 | 单张 8GB 显存 | 单张 12GB(4090 / 3090 / A10 都行) | 让训练能在一两小时内完成 |
| 训练时间 | 30 分钟到 2 小时 | 1.5B 模型 + 5k 数据 + 4 epoch,约 1.5 小时 | 保证能做多组对比实验 |
这套配置的目标很明确:跑通“训练 loss 下降、验证 loss 可控、生成样例可用”的完整流程,并且让你可以快速验证“如果我把数据混合比例从 9:1 改成 8:2,会发生什么”“如果我把模板从固定模板换成多模板,过拟合会缓解吗”这一类问题。复现的意义不在得到一个部署级模型,而在让你能清楚地看到每个超参数对结果的影响方向。
2. 数据混合:配比本身就是超参数
2.1 只喂纯指令数据会翻车
我先讲第一次踩坑。最开始做指令微调,我以为数据越“纯”越好,于是把所有样本全部整理成“用户问一句,助手答一句”的指令数据,大概 5000 条,直接丢进去训。训练 loss 一路下降,验证 loss 也像模像样,结果推理时立刻露馅:模型把用户输入当成前缀复述一遍,然后才不情不愿地接一个回答;有些样本里它甚至学会了“背诵”训练集中的通用套话,换一个问法就完全不会了。
这个现象的本质是灾难性遗忘加过拟合的叠加。预训练阶段模型学到的语言能力,在指令微调阶段被大量同质化的对话数据覆盖了。小模型没有足够参数同时记住“语言知识”和“对话格式”,只能优先记对话格式,结果语言表达能力退化。纯指令数据的问题不止于此,如果所有样本都用同一个模板、同一个句式,模型还会对模板本身产生过拟合,后面我在模板那一节详细说。解决办法就是做数据混合,让模型在微调时仍然能接触到多种形态的文本。
2.2 一个可抄作业的混合配方
我现在常用的混合配方是:指令数据 80%、通用语料 10%、边界样本 10%。按 5000 条总量来计算,就是 4000 条指令数据、500 条通用语料、500 条边界样本。
- 指令数据:从公开数据集(如 Alpaca、Dolly、LMSYS-Chat 子集)里筛掉低质量样本,再手工补一部分业务场景样本。不追求数量,但要求任务类型多样:问答、摘要、发散生成、信息抽取、改写,每类占一点。
- 通用语料:从 WikiText、C4 或任何干净文章里随机截取 512 token 左右的片段,保留纯文本,不加指令、不加回答结构。作用是维持基础语言能力,防止模型只会“一问一答”而忘记怎么连贯通顺地写长句。
- 边界样本:这里包括两类,一类是“无法回答”的样本,模型应该拒绝回答而不是硬编;另一类是“多轮对话历史”样本,让模型学会区分不同说话人。
别小看那 10% 的通用语料。我做过一次对比,同样 4000 条指令数据,不加通用语料时验证集困惑度明显偏高,生成文本里经常出现病句和重复片段;加上 10% 通用语料后,同一个 epoch 的生成流畅度明显改善。原因不复杂:通用语料让模型在微调过程中仍然有空间复习预训练阶段的语言分布,不至于被指令数据完全带跑。你也可以用 85:10:5 或 70:20:10,只要不是 100:0:0,整体趋势基本一致。
2.3 数据清洗的细节决定成败
数据混合比例定好了,如果样本本身脏,后面全白搭。我每次都会做三件事:去重、长度过滤、平衡检查。
去重我用最朴素的精确去重,先对 instruction 和 output 拼接后做 hash,遇到完全重复的直接丢掉。如果数据量大,再考虑用 MinHash 做模糊去重,但 5000 条的量级用精确去重就够了。
长度过滤方面,instruction 太短(比如只有一个词)或 output 太长(超过 max_length 的 70%)的样本我都会重新评估。小模型的 context 有限,一条 1024 token 的样本塞进去,一个 batch 只能装很少几条,训练效率很低,而且模型容易因为长样本学到的梯度噪声变大。我一般把样本长度控制在 512 token 以内,最多不超过 768。
平衡检查是指看数据里任务类型和回答风格是否偏。如果 4000 条指令里有 2500 条是“写邮件”,模型学完就只会写邮件了。我会按任务标签分组,保证任一任务不超过总量的 40%。这段检查看起来麻烦,但能避免你在调参时被数据分布问题误导。
3. 模板:比想象中更容易引发过拟合
3.1 模板不是“格式”而是学习锚点
指令微调里的模板,就是把原始指令包装成模型输入的那段固定结构。最常见的是 ChatML 风格和 Alpaca 风格。ChatML 长这样:
<|im_start|>system 你是一个可靠的助手。<|im_end|> <|im_start|>user {instruction}<|im_end|> <|im_start|>assistant {response}<|im_end|>Alpaca 风格长这样:
Below is an instruction that describes a task. Write a response that appropriately completes the request. ### Instruction: {instruction} ### Response: {response}很多初学者觉得模板只是“外壳”,怎么套都一样。实际上模板对模型行为有直接影响。模型在微调时不只是学“instruction 到 response 的映射”,它还在学“看到某段固定文本之后该做什么”。如果你的模板里 system 角色写着“你是一个可靠的助手”,模型会倾向于把这句话当作行为锚点;如果模板里没有 system,模型就会把 user 消息本身当作唯一的触发条件。
我吃过一次亏:把 system 改成“你是一个乐于助人的中文AI助手”,但训练数据里大量样本根本没有 system 消息,导致模型对 system 位置非常敏感,推理时一旦用上了完整 ChatML 模板,输出风格反而更不稳定。后来我把 system 消息改成所有样本统一注入,并且保持一句话,不搞任何花哨设定,问题立刻缓解。
3.2 固定模板会把模型学成“复读机”
固定模板在训练时效率很高,因为所有样本结构一致,模型很容易收敛。但代价是模板本身会被牢牢记住。你训练时用的是“### Response:”作为回答开头,推理时如果把分隔符改成“答案:”,模型可能就不知道该往哪里接;训练时所有指令都以“请回答”开头,推理时换个“帮我解释”的说法,模型输出质量可能明显下降。
这就是“模板过拟合”。它的典型症状是:训练集和验证集上的 loss 都很低,但当你把用户指令稍作改写后,模型表现断崖式下跌。我在 2.1 节里遇到过的“复读用户输入”现象,有一部分就是固定模板的锅——模型把模板结构背得太死,遇到没见过的提问句式时会先用“复读+重复模板”来拖延。
缓解办法是在训练阶段引入模板多样性。不改变语义,只改变包裹方式。我通常会给同一批指令数据准备 5 到 8 种模板:
- 使用系统角色描述
- 不使用系统角色
- 使用 Alpaca 风格的英文模板
- 使用中文口语化模板(“用户问:…”)
- 使用“指令:”和“回答:”分隔
- 使用“请根据以下要求完成任务”这类引导句
实现方式很简单,在数据处理阶段给每条样本算一个随机索引,然后从模板列表里取一个来做包装。要注意一点:多样性太高也会坏事。我试过准备 20 种模板,结果模型训练时总是切换格式,收敛变慢,反而表现出一种不上不下的“模板糊涂”状态。5 到 8 种是比较稳的区间,既有变化,又能让模型学到稳定的结构规律。
3.3 system 字段到底该放什么
很多人喜欢在 system 里塞一大堆人设和规则,比如“你是资深律师,请从法律角度分析问题,回答要包含法条依据”。对小模型来说,这不是加分项而是负担。
模型参数量小,context 窗口本来就有限。一条长 system 占掉几百 token,留给真实指令和答案的空间就少了,训练时更难学到任务目标。而且如果 system 内容每条样本都不同,等于在格式化模板里额外引入了一个高方差变量,模型很难稳定地结合 system 来做应答。我的习惯是:system 尽量短,只保留一句话,例如“你是一个可靠的中文助手”。如果某项任务确实需要人设,我会单独做一个带人设的数据子集,而不是在所有样本里都加。
4. 过拟合的判定与止损
4.1 不要只盯训练 loss
指令微调中的过拟合,最常出现在训练 loss 还在降、验证 loss 已经开始反弹的时候。但和小模型配合使用时,loss 曲线并不是唯一可靠的信号。因为很多任务只有“格式正确”和“内容合理”两层标准,而 loss 低到一定程度后,模型可能已经把模板背熟,却在语义理解上开始偷懒。
我每次训练都会做一张记录表,把以下几个信号放在一起观察:
| 信号 | 正常状态 | 过拟合状态 | 发现时机 |
|---|---|---|---|
| 训练 loss | 下降后趋于平缓 | 持续下降 | 每个 logging step |
| 验证 loss | 下降后接近或略低于训练 loss | 先降后升 | 每个 epoch |
| 生成样本的信息量 | 能提取关键点,句子通顺 | 输出重复、套话多 | 每个 epoch 后 |
| 改写指令后的表现 | 语义不变时仍能正确回答 | 稍换措辞就答非所问 | 训练结束后 |
| 模板外样本表现 | 能处理无模板/不同模板的输入 | 只在训练模板下有正常表现 | 训练结束后 |
我一般会在每个 epoch 结束后保存一次 checkpoint,同时用固定的一组 50 条验证样本做生成测试。这里的验证样本不是随机抽的,而是包含“训练样本改写版”“全新问题”“模板外写法”三类,每一类都单独看输出。过拟合严重时,通常“训练样本改写版”已经翻车,而“全新问题”更是惨不忍睹。
4.2 最隐蔽的“模板过拟合”怎么测
模板过拟合不像普通 loss 反弹那么直白,因为它只体现在“输入格式变化后输出恶化”。我吃过一次大亏:模型在固定模板的验证集上取得了 0.43 的 loss,日常测试也还不错,结果一上线做真实对话,用户根本不会按我的模板发消息,效果直接崩了。
从那以后我加了一个测试动作:把验证集里的指令换一种方式表达,比如把“请写一封请假邮件”改成“帮我拟一封邮件,主题是请假”,把“解释什么是过拟合”改成“用大白话说说过拟合这回事”。只改表述,不改意图。如果模型输出明显变差,说明它还没有真正理解任务,只是在背模板和措辞。这个测试成本很低,但对小模型尤其关键,因为参数少,模型更容易走“捷径”去记表面结构而不是深层语义。
4.3 控制过拟合的几个有效手段
在小模型指令微调里,控制过拟合我不会一上来就换模型,而是按顺序做下面几件事:
第一,降低学习率。1.5B 模型做指令微调,我通常用 2e-5,如果过拟合出现得早,改成 1e-5 或 1.5e-5。学习率降下来后,模型不会那么激进地拟合训练集中的噪声和格式特征。第二,调整 epoch。不要无脑训 10 个 epoch,小模型一般 3 到 5 个 epoch 就够。如果第 4 个 epoch 验证 loss 开始反弹,果断选择第 3 个 epoch 的 checkpoint,而不是硬着头皮训完。第三,加权重衰减。默认 weight_decay 我设 0.01,如果验证集和训练集 loss 差距明显,可以调到 0.05 或 0.1,让模型参数不要太大。
我很少对小模型用冻结底层这种做法。因为小模型参数量本身就少,冻结一部分层会进一步限制表达能力,而且对指令微调来说收益不大。更有效的做法是控制数据:把多余的同质化数据减掉、增加通用语料和边界样本、引入模板多样性。数据层面的调整,往往比模型层面的正则化更治本。
5. 最小复现全流程:从 JSON 到训练脚本
5.1 环境与数据准备
下面这套流程基于 Python 3.10、transformers 4.40+、torch 2.1+,并配合 peft 做 LoRA。用 LoRA 的原因很实际:1.5B 全量微调虽然也能跑,但占用显存更高,收敛也慢;用 LoRA 把可训练参数量压到几百万,训练速度和显存占用都更友好,而且在小模型上效果足够。
数据格式我用 JSONL,每行一条:
{"instruction": "请用一句话解释什么是梯度消失", "output": "梯度消失是指在深层网络中,反向传播时梯度逐层衰减,导致浅层参数几乎无法更新。"} {"instruction": "帮我列出三种提高模型推理速度的方法", "output": "1. 使用量化;2. 剪枝;3. 使用更高效的注意力机制。"}处理时用一个函数把 instruction 和 output 包装成带模板的文本。我这里展示一个支持多样模板的版本:
import hashlib import random def build_template_list(): return [ "<|im_start|>system\n你是一个可靠的中文助手。<|im_end|>\n<|im_start|>user\n{instruction}<|im_end|>\n<|im_start|>assistant\n{response}<|im_end|>", "使用下面的指令完成一项任务,回答要直接清晰。\n指令:{instruction}\n回答:{response}", "### Instruction:\n{instruction}\n\n### Response:\n{response}", "用户:{instruction}\n助手:{response}", "请根据以下要求作答:\n{instruction}\n\n我的回答:{response}", ] def format_sample(instruction, response, template_list): idx = int(hashlib.md5(instruction.encode()).hexdigest(), 16) % len(template_list) template = template_list[idx] return template.format(instruction=instruction, response=response)这里用 instruction 的 md5 值作为模板选择依据,而不是每次都随机,可以保证同一条样本在不同训练轮次里始终使用同一个模板,减少 template 切换带来的额外随机性。如果你的数据量比较多,也可以直接用 random 随机选,效果差别不大。
5.2 训练脚本核心配置
有了格式化后的文本,下一步就是把文本 tokenize 成模型输入。注意要设置 padding 和 truncation,否则同 batch 里长度不一致会报错。
from datasets import Dataset from transformers import ( AutoTokenizer, AutoModelForCausalLM, DataCollatorForLanguageModeling, Trainer, TrainingArguments, ) from peft import LoraConfig, get_peft_model model_name = "Qwen/Qwen2.5-1.5B" tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True) if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token def tokenize_fn(examples): texts = [ build_template_list()[i % len(build_template_list())].format( instruction=ins, response=resp ) for i, (ins, resp) in enumerate(zip(examples["instruction"], examples["output"])) ] tokenized = tokenizer(texts, truncation=True, max_length=512, padding="max_length") tokenized["labels"] = tokenized["input_ids"].copy() return tokenized dataset = Dataset.from_json("/path/to/train.jsonl") dataset = dataset.map(tokenize_fn, batched=True, remove_columns=["instruction", "output"]) lora_config = LoraConfig( r=16, lora_alpha=32, target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], lora_dropout=0.05, bias="none", task_type="CAUSAL_LM", ) model = AutoModelForCausalLM.from_pretrained(model_name, torch_dtype="auto") model = get_peft_model(model, lora_config) model.print_trainable_parameters() training_args = TrainingArguments( output_dir="./sft_minimal", per_device_train_batch_size=2, gradient_accumulation_steps=4, learning_rate=2e-5, num_train_epochs=4, weight_decay=0.01, warmup_ratio=0.05, lr_scheduler_type="cosine", logging_steps=10, save_strategy="epoch", eval_strategy="epoch", bf16=True, gradient_checkpointing=True, max_grad_norm=1.0, remove_unused_columns=False, ) trainer = Trainer( model=model, args=training_args, train_dataset=dataset, eval_dataset=dataset.select(list(range(0, len(dataset), 10))), # 仅演示,正式实验请单独切验证集 data_collator=DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False), ) trainer.train()几个参数的解释:per_device_train_batch_size=2 是为了照顾 12GB 显存,配合 gradient_accumulation_steps=4 得到有效 batch size 8,既保证了训练稳定,又不会显存爆炸。gradient_checkpointing=True 能显著降低显存占用,代价是训练速度变慢,但 1.5B 模型影响不大。eval_strategy 是我在 transformers 新版本里的写法,旧版本对应的是 evaluation_strategy。如果你直接用完整数据集做演示,eval 部分会偏高,正式实验请从训练集之外单独切一个验证集。
如果你用的是 8GB 显存,可以把 max_length 从 512 降到 384,或者把 batch size 改为 1、梯度累积改为 8,也基本能跑。跑完以后,LoRA 权重可以 merge 回底座模型,得到一个完整的推理模型。
5.3 评估时候怎么判断“训好没”
训练结束后,我喜欢做两组评估。第一组是定量评估,加载验证集算 loss,并对比训练集 loss。两者差距不大且验证 loss 没有反弹,说明过拟合风险可控。第二组是定性评估,写一个简单的生成函数,拿验证集样本以及改写版指令去生成结果,人工看输出。
def generate_reply(prompt, model, tokenizer, max_new_tokens=128): input_text = build_template_list()[0].format(instruction=prompt, response="") inputs = tokenizer(input_text, return_tensors="pt") outputs = model.generate( inputs.input_ids.to(model.device), max_new_tokens=max_new_tokens, do_sample=True, temperature=0.7, top_p=0.9, pad_token_id=tokenizer.pad_token_id, ) return tokenizer.decode(outputs[0], skip_special_tokens=True) print(generate_reply("什么是过拟合?", model, tokenizer)) print(generate_reply("用大白话说说过拟合这回事", model, tokenizer))第二句“用大白话说说过拟合这回事”就是改写版测试。如果第一个输出正常、第二个输出明显变差,说明模型还是更依赖训练时的固定措辞。我会继续压低学习率、增加模板多样性或减少 epoch,然后再来一轮。
6. 常见问题与避坑清单
6.1 问题排查速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 训练 loss 不下降 | 学习率过低 / 数据长度太短 / 模板错误导致模型没学到有效信号 | 确认模板拼接正确;学习率从 1e-4 起逐步下调;检查 tokenizer 是否把完整文本切开 |
| 验证 loss 先降后升 | 典型过拟合 | 回到验证 loss 最低的 checkpoint;减小 epoch;降低学习率 |
| 生成结果总在重复用户输入 | 数据里缺少“直接回答”的样本,或模板固定导致复读 | 增加边界样本;引入多样模板;检查训练数据里指令和回答分隔是否清晰 |
| 生成结果为空或只有 eos | pad_token 未设置 | 设置 tokenizer.pad_token = tokenizer.eos_token |
| 推理时换模板就崩 | 模板过拟合 | 准备 5-8 种训练模板;system 内容保持一致;训练后多做改写版测试 |
| 显存不足 OOM | batch 或 max_length 过大 | 降低 per_device_train_batch_size;开启 gradient_checkpointing;截断样本到 384 token |
| 加了通用语料后,回答风格跑偏 | 通用语料比例过高或截断不规范 | 把通用语料控制在 10%-15%;截断时保留完整句子,避免喂入不完整文本 |
6.2 几条基于踩坑的补充建议
第一,固定随机种子。虽然我给的示例代码里没有写,但正式实验时请在数据处理前固定 seed:
import numpy as np import torch def set_seed(seed): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed)不固定 seed 的话,模板选择和 batch 采样都会引入随机性,你看到的结果可能不是超参数导致的,而是噪声导致的。第二,每个实验保存一份完整的训练配置。我习惯把数据配比、模板列表、学习率、epoch、batch 放到一个 yaml 或 json 文件里,随模型一起保存。过两周回来看,能清楚知道这个 checkpoint 为什么是这个行为。第三,不要一开始就上大模型。先用 1B 跑通流程,把数据混合和模板都调稳了,再迁移到 7B 或更大的模型上。迁移成本很低,因为数据配方和模板策略在小模型上有效,在大模型上通常只会更稳。
6.3 一点个人体会
做了几周小模型指令微调后,我最大的感受是:它不像大模型微调那样可以靠“大力出奇迹”。小模型的每一项超参数变化,都会被放大到肉眼可见。数据混合比例差一点,模板里多一句人设,学习率偏了两倍,结果都会在生成样例里暴露得清清楚楚。这其实是好事,因为很多原理在小模型上能看得最透。如果你想理解指令微调到底在学什么,与其直接去调 70B,不如先花 1.5 小时把 1.5B 跑熟。它教会我的不是怎么把 loss 压到最低,而是怎么判断一个模型是不是真的学会了任务,而不是背下了模板和答案。以后再做任何微调,我都会先把这三件事检查一遍:数据是怎么混合的、模板有没有多样性、以及当前训练轮次的 checkpoint 是不是过拟合的那个。