拓冰建站拓冰建站
首页 / 资讯中心 / 正文

小模型指令微调实战:数据混合、模板设计与过拟合避坑指南

最近两三个月我反复在折腾同一件事拿一个 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 显存单张 12GB4090 / 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(instructioninstruction, responseresponse)这里用 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_codeTrue) 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( instructionins, responseresp ) for i, (ins, resp) in enumerate(zip(examples[instruction], examples[output])) ] tokenized tokenizer(texts, truncationTrue, max_length512, paddingmax_length) tokenized[labels] tokenized[input_ids].copy() return tokenized dataset Dataset.from_json(/path/to/train.jsonl) dataset dataset.map(tokenize_fn, batchedTrue, remove_columns[instruction, output]) lora_config LoraConfig( r16, lora_alpha32, target_modules[q_proj, k_proj, v_proj, o_proj], lora_dropout0.05, biasnone, task_typeCAUSAL_LM, ) model AutoModelForCausalLM.from_pretrained(model_name, torch_dtypeauto) model get_peft_model(model, lora_config) model.print_trainable_parameters() training_args TrainingArguments( output_dir./sft_minimal, per_device_train_batch_size2, gradient_accumulation_steps4, learning_rate2e-5, num_train_epochs4, weight_decay0.01, warmup_ratio0.05, lr_scheduler_typecosine, logging_steps10, save_strategyepoch, eval_strategyepoch, bf16True, gradient_checkpointingTrue, max_grad_norm1.0, remove_unused_columnsFalse, ) trainer Trainer( modelmodel, argstraining_args, train_datasetdataset, eval_datasetdataset.select(list(range(0, len(dataset), 10))), # 仅演示正式实验请单独切验证集 data_collatorDataCollatorForLanguageModeling(tokenizertokenizer, mlmFalse), ) trainer.train()几个参数的解释per_device_train_batch_size2 是为了照顾 12GB 显存配合 gradient_accumulation_steps4 得到有效 batch size 8既保证了训练稳定又不会显存爆炸。gradient_checkpointingTrue 能显著降低显存占用代价是训练速度变慢但 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_tokens128): input_text build_template_list()[0].format(instructionprompt, response) inputs tokenizer(input_text, return_tensorspt) outputs model.generate( inputs.input_ids.to(model.device), max_new_tokensmax_new_tokens, do_sampleTrue, temperature0.7, top_p0.9, pad_token_idtokenizer.pad_token_id, ) return tokenizer.decode(outputs[0], skip_special_tokensTrue) print(generate_reply(什么是过拟合, model, tokenizer)) print(generate_reply(用大白话说说过拟合这回事, model, tokenizer))第二句“用大白话说说过拟合这回事”就是改写版测试。如果第一个输出正常、第二个输出明显变差说明模型还是更依赖训练时的固定措辞。我会继续压低学习率、增加模板多样性或减少 epoch然后再来一轮。6. 常见问题与避坑清单6.1 问题排查速查表现象可能原因解决办法训练 loss 不下降学习率过低 / 数据长度太短 / 模板错误导致模型没学到有效信号确认模板拼接正确学习率从 1e-4 起逐步下调检查 tokenizer 是否把完整文本切开验证 loss 先降后升典型过拟合回到验证 loss 最低的 checkpoint减小 epoch降低学习率生成结果总在重复用户输入数据里缺少“直接回答”的样本或模板固定导致复读增加边界样本引入多样模板检查训练数据里指令和回答分隔是否清晰生成结果为空或只有 eospad_token 未设置设置 tokenizer.pad_token tokenizer.eos_token推理时换模板就崩模板过拟合准备 5-8 种训练模板system 内容保持一致训练后多做改写版测试显存不足 OOMbatch 或 max_length 过大降低 per_device_train_batch_size开启 gradient_checkpointing截断样本到 384 token加了通用语料后回答风格跑偏通用语料比例过高或截断不规范把通用语料控制在 10%-15%截断时保留完整句子避免喂入不完整文本6.2 几条基于踩坑的补充建议第一固定随机种子。虽然我给的示例代码里没有写但正式实验时请在数据处理前固定 seedimport 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 是不是过拟合的那个。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门