在本地部署过几个开源基座模型之后,很多程序员的感受其实高度一致:模型确实能跑起来,但回答总不如官方Demo里那么“听话”。你问它东,它给你续写西;你要它按JSON格式输出,它偏要自由发挥加一段解释。这个差距十有八九出在监督微调(SFT)这一环上。SFT全称是Supervised Fine-Tuning,简单说就是拿一批人工标注好的“指令-回答”数据,对预训练基座模型做有监督的再训练,让模型学会人类期望的交互方式。这篇内容我会把SFT的原理、数据构造、超参数、训练配置、实操流程和踩坑记录一次性讲透。它不是泛泛的科普,而是对着能做事的实践总结,写下来之前已经在多台机器上反复验证过,建议直接存下来备查。
1. 先搞清楚SFT在整条链路里的位置
1.1 基座模型只会“续写”,不会“干活”
很多人第一次玩开源基座模型时都会有这个困惑:模型明明很大,常识也很丰富,为什么跟它对话就特别别扭?这是因为基座模型在预训练阶段的唯一目标函数是“下一个词预测”。它学到的本质是词与词之间的统计关联,你输入一段文本,它就按照统计规律往后接最可能的词,而不是“理解你的问题然后给出答案”。
拿生活化的例子来说,基座模型像一个博览群书但没参加过考试的学生。你问他一道题,他凭记忆和语感展开长篇大论,却不知道答案应该是一句话、一段代码还是一个JSON对象。他不知道考试有固定格式、有标准答案、有答题边界。这些“约定俗成”的东西,预训练阶段没有专门教过,只能靠SFT来补课。
这种能力差距在实际部署中非常致命。比如你打算做一个代码助手,希望模型根据需求生成Python脚本,基座模型可能会输出一大段自然语言解释,再夹几行伪代码,甚至把问题换个说法又重复一遍。而经过SFT的模型,才知道用户问代码时应该先给代码块,再给简要说明。所以SFT解决的是“形式对齐”和“指令跟随”问题,不是知识注入问题。
1.2 SFT、预训练和RLHF的分工关系
大模型从零到可用的链路,一般可以拆成三个阶段:预训练、SFT、偏好对齐(常见手段是RLHF/DPO)。这三者分工完全不同。预训练负责往里灌知识,让模型具备语言能力、事实记忆和推理基础,这一步成本最高,通常需要几千亿token和成百上千张显卡。SFT负责教模型“怎么把知识组织成用户想要的样子”,用几千到几万条高质量的指令数据就能完成任务。
偏好对齐则更进一步,它解决的是“模型知道答案是什么,但不一定知道哪个答案更符合人类品味”的问题。SFT给的是标准答案,就像教科书上的参考答案;偏好对齐给的是一堆答案排序,就像阅卷老师告诉你“这个回答比那个更流畅、更有用”。SFT是基础,没有好的SFT模型,偏好对齐做起来会非常吃力,因为模型连基本的指令格式都没学好。
我见过不少团队为了省事,跳过SFT直接尝试用偏好优化方法调基座模型,结果模型学会了说漂亮话,但依然不会严格按要求办事。原因很简单:排序学习只能调整回答风格的倾向,无法补上“格式不对齐就是听不懂人话”这个根本短板。SFT是让模型“听得懂人话”的最低成本方案。
1.3 为什么SFT数据不多,却能改变模型行为
一个值得思考的问题:预训练看了几千亿token,为什么SFT只用几万条数据就能明显改变模型行为?这里的关键在于,SFT改变的不是知识量,而是“概率分布的输出模式”。模型在预训练阶段其实已经见过大量问答类文本,它具备回答问题的潜力,只是不知道怎么判断“当前这是在问我问题,我应该认真回答而不是继续编故事”。
SFT相当于给模型做了行为示范:看到指令,停止发散,按格式输出。因为预训练已经给了它足够的语言能力和知识储备,SFT只需要“拨动开关”,把回答模式从续写切换到指令跟随。这也是为什么SFT数据不需要海量,但必须高质量、格式统一、覆盖充分。理解了这一点,就不会盲目堆数据量了。
2. SFT数据工程:成败的关键不在模型而在数据
2.1 数据长什么样:三种主流格式必须掌握
SFT数据的组织方式目前大体上有三种主流格式。第一种是“instruction/input/output”三段式,适合任务型微调,一条样本就是一个独立问题,可以附带输入内容。第二种是messages对话格式,按角色区分user和assistant消息,适合训练多轮对话能力。第三种是ShareGPT风格,用conversations数组保存完整对话历史,很多开源训练框架默认用这种格式。
我在实际项目中习惯把数据统一转成对话格式,因为即便单轮任务,也可以看成一轮对话。统一格式的好处是方便后续增加多轮样本,模型在推理时也容易保持上下文连贯。这里有个新手经常踩的坑:不同格式混在一起喂给模型,模型会学到混乱的交互预期。今天学的是“首轮直接答”,明天学的是“设身处地寒暄两句”,最后表现就是时灵时不灵。
举个例子,一条标准的对话格式样本大概长这样:
{ "conversations": [ {"role": "system", "content": "你是一个专业的Python开发助手。"}, {"role": "user", "content": "写一个函数,输入一个整数列表,返回所有偶数的平方。要求用列表推导式。"}, {"role": "assistant", "content": "```python\ndef square_evens(nums):\n return [x*x for x in nums if x % 2 == 0]\n```\n\n这个实现用列表推导式一次完成过滤和映射两个操作,时间复杂度为O(n)。"} ] }这个格式里system字段很重要,它定义了模型的人设和行为约束。很多项目为了省事不写system,模型也能跑,但泛化性和稳定性会差很多。我通常会让每条样本都带system,并且准备一个默认的system兜底,避免推理时传空值导致模板错乱。
2.2 数据从哪来:开源数据集、日志清洗与模型构造
SFT数据来源大致有三条路。第一条是直接用社区已有的开源SFT数据集,适合快速验证流程。但这类数据集往往质量参差不齐,可能有回答错误、格式混乱、重复过高等问题,直接拿来训练,模型很容易学到脏模式。第二条是从自己业务里捞真实数据,这是最推荐的方式。比如你做客服机器人,就把历史工单里优质的“用户问题-客服回答”对挑出来清洗。真实业务数据与目标场景高度匹配,微调效果通常是最好的。
第三条是用强模型构造数据,思路是用能力更强的模型批量生成问答对,再人工或者自动筛选。这种方式能快速扩充数据规模,但有个隐患:强模型自己可能也会犯错,错误会原样传给学生模型。我见过有团队用这种方式一天造了几万条数据,训完模型回答流畅但事实错误率很高,最后排查才发现数据源头就错了。所以用强模型造数据,抽样审核环节必须跟上。
还有一个值得注意的点:数据构造时要考虑分布。如果你做的助手要覆盖技术问答、闲聊、拒答三类场景,数据比例就要和实际使用场景接近。我见过有项目把闲聊数据堆到80%,结果模型变得很油滑,正事不爱干了。数据分布偏了就改不回来,重训成本非常昂贵。
2.3 清洗与质量控制:这些细节决定微调上限
数据清洗是SFT里最枯燥但最重要的一环。我一般按下面的顺序处理:先做全局去重,重复样本会让模型对特定句式过拟合,训练时看起来loss很低,实际泛化很差。然后做长度过滤,过短的样本没有学习价值,过长的样本会拖慢训练,还要处理超过max length的截断问题。接着做格式标准化,统一标点符号、统一换行、统一代码块的标记语言,让模型学到干净的模式。
更关键的是内容校验。如果数据里有明显的错误答案,模型会当作正确答案来学。这块我建议人力抽检加规则过滤双管齐下。比如代码类数据,可以尝试直接执行样本来验证正确性;知识类数据,可以用关键词匹配或交叉验证来做粗筛。用脚本删掉明显答非所问的样本,比多训几百条数据有用得多。
另外提醒一下编码问题,中文数据最容易在清洗时踩这个坑。统一转成UTF-8,去掉不可见字符和乱七八糟的空格,检查有没有打断JSON解析的转义符。这些小事在几百条数据时看不出问题,数据量到几万条时任何一个脏字符都可能让训练中断或者模型输出乱码。
2.4 用多少数据最合适:不是多多益善
SFT数据量没有一个固定标准,但可以给一个经验区间:场景单一的任务型微调,几千条高质量样本就够;通用助手的对话能力微调,一到三万条比较合适;再往上增加数据,收益会明显递减。原因前面说过,SFT主要是行为对齐,模型不需要通过SFT学海量新知识,它只需要足够多的示范来理解你的指令格式和输出偏好。
我实操下来的体会是,数据质量每提升一档,对最终效果的影响远大于数据量翻倍。同样一万条数据,粗洗和精洗之后的模型效果完全不是一个级别。宁可只有五千条高质量数据,也别硬凑两万条垃圾数据。额外的数据有时反而会引入噪声,让模型在部分场景下的表现倒退。
还有一个容易被忽略的事情:验证集的划分。SFT不是用完全部数据就万事大吉,要留出一部分样本做验证。我在每次训练前都固定切出5%-10%的数据做验证集,训练过程中持续观察验证集loss。如果训练集loss在降,验证集loss在涨,那就说明过拟合了,及时停掉可以省下大量时间。
3. 训练配置与超参数:把SFT跑稳的核心
3.1 全参微调还是LoRA:看条件选方案
SFT的实现方式主要分成全参微调和参数高效微调两大类,LoRA是后者的典型代表。全参微调是让基座模型的全部参数都参与更新,效果上限最高,但显存和算力要求也最狠。以7B模型为例,全参微调用AdamW优化器,动辄需要60GB以上的显存,单张消费级显卡基本做不了。LoRA则是冻结原始权重,只训练注入的低秩矩阵,显存占用大幅下降,7B模型的LoRA训练在24GB显存下就能跑起来。
从效果角度说,如果数据量足够大、任务足够复杂,全参微调确实能学到更多细微变化。但对于大多数业务场景,LoRA的效果已经非常接近全参微调,而且资源消耗少一个量级。我个人的选择标准是:24GB以下显存无脑用LoRA;能上全参微调的资源,优先把多余算力投给数据清洗和评估,而不是一味追求全参更新。LoRA还有一个额外优势,就是多个adapter可以共用一个基座模型,不同任务切换非常方便。
不过LoRA有个小坑:如果基座模型本身难以通过低秩更新达到理想效果(比如领域知识严重不足),LoRA的表现就会比全参微调差一截。这种情况你得先评估是“不会听指令”还是“不懂这个领域”,前者LoRA能解决,后者可能需要考虑增量预训练或者换更强的基座。
3.2 关键超参数选择与背后的逻辑
SFT常用的超参数并不复杂,但每个参数的取值范围和背后逻辑一定要清楚。学习率是第一个要小心设置的参数。全参微调一般用1e-5到2e-5,LoRA则可以用到1e-4到2e-4。因为LoRA只更新很少的参数,学习率可以步子大一点。如果学习率设得太高,模型会快速忘掉预训练学到的知识,表现出来就是loss降低很快但输出开始胡言乱语。
训练轮数同样需要克制。高质量SFT数据集,2到4个epoch是比较稳的范围。数据量很大时,甚至1个epoch就够了。这里有个常见的认知误区:多训几轮不一定是好事。模型反复看过同一批样本后,会对训练集的表达方式过度拟合,导致泛化能力下降。我在训练时会同时盯着训练loss和验证loss,一旦验证loss开始回升就立即停止,通常能比固定epoch训练得到更好的结果。
批大小和梯度累积对SFT也很关键。因为显存限制,单卡一般只能放很小batch,但batch太小会导致梯度噪声大、训练不稳定。解决办法是设置一个较小的per_device_batch_size,然后用梯度累积凑到等效batch size。比如per_device_batch_size=1,gradient_accumulation_steps=16,等效batch size就是16。我习惯把等效batch size控制在32到64之间,既稳定又不会太慢。
权重衰减一般设为0.01,warmup ratio设0.03左右,这些是经验值,大多数场景直接照用不会出错。学习率调度器推荐cosine,它在训练末期能把学习率平滑降到很低,有助于收敛到更好的局部最优。
3.3 LoRA的rank、alpha和target_modules怎么定
LoRA的rank是低秩矩阵的维度,直接影响可训练参数量和表达能力。常见的实践值是8、16、32。rank太小,模型学不到足够复杂的映射;rank太大,训练参数变多,但收益不一定成比例提升。我通常先用16跑一个小实验,观察效果后再决定要不要加大。alpha是缩放系数,一般设置为rank的两倍,比如rank=16时alpha=32。这个比例在大多数情况下表现良好。
target_modules的选择是个容易被忽视的细节。在Transformer结构里,LoRA可以注入到自注意力层的q/k/v/o投影矩阵,也可以注入到前馈网络的gate/up/down投影。我测试下来,同时注入注意力模块和前馈模块,效果比只注入注意力要好,尤其在做中文指令跟随任务时。原因是前馈网络承担了大量知识记忆的功能,通过低秩更新微调这部分,能让模型更快适应训练数据里出现的表达习惯。
还有两个细节值得提。第一,bias参数通常设成not update,保持和基座模型一致,减少训练参数总量。第二,lora_dropout建议设置0.05到0.1,防止过拟合,但如果验证集表现正常,dropout设小一点也能提升收敛速度,这个可以微调。
3.4 显存与训练稳定性配置
跑SFT之前先想清楚两个问题:用不用梯度检查点,用不用混合精度。梯度检查点是个显存换速度的手段,开启后显存占用可以降低大约30%-40%,代价是训练速度变慢。对7B模型加LoRA的场景,我建议开启它,因为显存往往比时间更紧张。混合精度方面,英伟达较新的卡建议直接用bf16,它的动态范围和稳定性都优于fp16,不容易出现loss溢出为NaN的问题。老显卡只支持fp16时,记得加上loss scale策略并持续监控训练日志。
序列长度(max seq length)是另一个需要仔细权衡的配置。它决定模型能看到多长的上下文。设得太短,长对话会被截断,模型学不到完整的依赖关系;设得太长,显存占用随长度线性增长,训练速度也会下降。我建议先统计一下训练数据中最长的样本长度,然后设定为覆盖80%-90%样本的长度值,剩下的长样本做截断处理,这样性价比最高。
还有两个工程层面的配置容易出问题。一个是保存间隔,建议同时按“步数”和“时间”两个维度设置,长时间训练时至少每小时能看到一个可用的checkpoint,避免意外中断后白白浪费时间。另一个是日志打印频率:打开日志打印,每10步打一次loss,监控文本生成。因为有时候loss很漂亮,实际生成的文本已经完全崩坏,只看数字发现不了问题。
4. 完整实操:从原始问答到微调上线
4.1 环境准备与最低硬件要求
实操第一步是准备环境。我的建议是先建一个独立的Python环境,避免依赖冲突。核心依赖就是深度学习框架、模型加载库、数据集库和LoRA库,这几样就能跑通整个流程。GPU方面,如果你想微调7B量级的开源基座模型,24GB显存是甜点位,用LoRA加梯度检查点可以比较舒服地跑起来。如果显存只有16GB,可以通过4bit量化加载基座模型,仍然能完成LoRA训练,只是量化后训练精度略有损失。
这里有个建议:第一次跑通全流程时不要直接上大模型。先用几亿参数量级的小模型跑通数据加载、训练、保存、推理的闭环,再换成目标模型。小模型调试成本低,跑完一个epoch只需几分钟,任何代码问题都能快速暴露。我每次接到新任务都先用小模型验证数据和代码,确认无误后再换大模型训练,这套流程帮我省下了大量调试时间。
基座模型选择的核心原则是:中英文、代码、数学能力匹配你的目标场景,且尽量选择指令跟随能力已经不错的版本。如果底子太差,SFT要补的课太多,效果必然受限。
4.2 数据格式转换脚本
不管原始数据是Excel、JSON还是数据库导出的CSV,第一步都要统一转成训练框架能读入的格式。下面这个脚本可以把简单的“问题-答案”列表转成对话格式的JSONL文件:
import json # 原始数据,假设每条是 {"question": "...", "answer": "..."} raw_data = [ { "question": "什么是列表推导式?", "answer": "列表推导式是Python中一种简洁地创建列表的语法,格式为 [表达式 for 元素 in 可迭代对象 if 条件]。" }, # 更多样本... ] SYSTEM_PROMPT = "你是一个专业的中文技术助手,请用简洁准确的语言回答用户的问题。" def convert_to_sharegpt(item): return { "conversations": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": item["question"]}, {"role": "assistant", "content": item["answer"]}, ] } with open("sft_train.jsonl", "w", encoding="utf-8") as f: for item in raw_data: f.write(json.dumps(convert_to_sharegpt(item), ensure_ascii=False) + "\n") print(f"转换完成,共 {len(raw_data)} 条样本")转换脚本的核心逻辑就是把每条样本包成system、user、assistant三段。有人问system每次都要写吗?我的建议是默认都写,哪怕内容一样。这样模型在训练中会稳定学习到“先读人设再看问题”的行为模式。另外,写入文件时一定加ensure_ascii=False,否则中文会被转成\uXXXX的Unicode转义序列,可读性极差,后续检查数据非常痛苦。
4.3 训练脚本与关键配置
数据准备好之后,训练脚本的核心部分如下。这个脚本以加载基座模型为基础,套上LoRA配置,然后通过训练参数控制训练过程:
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments, Trainer from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training from datasets import load_dataset # 1. 加载基座模型和tokenizer # base_model_path 换成你手中的基座模型路径 model = AutoModelForCausalLM.from_pretrained( "base_model_path", torch_dtype="bf16", device_map="auto", ) tokenizer = AutoTokenizer.from_pretrained("base_model_path") # 2. 设置pad token。如果tokenizer没有pad token,用eos代替 if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token model.config.pad_token_id = tokenizer.eos_token_id # 3. 配置LoRA lora_config = LoraConfig( r=16, lora_alpha=32, target_modules=[ "q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj", ], lora_dropout=0.05, bias="none", task_type="CAUSAL_LM", ) model = get_peft_model(model, lora_config) model.print_trainable_parameters() # 打印可训练参数量 # 4. 定义训练参数 training_args = TrainingArguments( output_dir="./sft_checkpoints", per_device_train_batch_size=1, gradient_accumulation_steps=16, learning_rate=1e-4, num_train_epochs=3, logging_steps=10, save_steps=200, save_total_limit=3, bf16=True, gradient_checkpointing=True, optim="adamw_torch", lr_scheduler_type="cosine", warmup_ratio=0.03, weight_decay=0.01, report_to="none", ) # 5. 加载数据集并按模板格式处理 dataset = load_dataset("json", data_files="sft_train.jsonl", split="train") def format_chat(example): # 把conversations拼成模型需要的模板文本 # 这里用简单的拼接方式,实际项目中建议使用tokenizer.apply_chat_template messages = example["conversations"] text = "" for msg in messages: role = msg["role"] content = msg["content"] if role == "system": text += f"系统:{content}\n" elif role == "user": text += f"用户:{content}\n" else: text += f"助手:{content}\n" text += "助手:" return {"text": text} tokenized_dataset = dataset.map( lambda x: tokenizer( format_chat(x)["text"], truncation=True, max_length=2048, padding=False, ), remove_columns=dataset.column_names, ) # 6. 创建Trainer并开始训练 trainer = Trainer( model=model, args=training_args, train_dataset=tokenized_dataset, tokenizer=tokenizer, ) trainer.train()这套配置有几个数值值得解释一下。per_device_train_batch_size=1配gradient_accumulation_steps=16的组合,是为了在24GB显存下跑7B模型的同时保持batch size不至过小。学习率1e-4是LoRA的常见起点,如果你发现训练早期loss出现明显波动,可以降到5e-5继续试。max_length设2048是因为大多数SFT样本并不需要那么长的上下文,这个长度在显存和表达能力之间比较均衡。
另外,训练时打印可训练参数量是一个好习惯。一个7B模型的LoRA训练,可训练参数通常只在几千万到一亿左右,占比不到2%。看到这个数字你就明白为什么LoRA这么快了。
4.4 推理验证与adapter合并
训练完成后,LoRA的权重是单独保存的,并不会直接写回基座模型。推理之前需要先加载adapter,验证效果。加载方式有两种:一种是直接生成,适合快速验证;另一种是把adapter合并回基座模型,生成一个新模型,适合部署上线。
from peft import PeftModel # 加载基座模型 base_model = AutoModelForCausalLM.from_pretrained( "base_model_path", torch_dtype="bf16", device_map="auto", ) tokenizer = AutoTokenizer.from_pretrained("base_model_path") if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token # 加载训练好的LoRA adapter model = PeftModel.from_pretrained(base_model, "./sft_checkpoints/checkpoint-600") model.eval() # 合并adapter到基座模型(部署时推荐) merged_model = model.merge_and_unload() merged_model.save_pretrained("merged_model") tokenizer.save_pretrained("merged_model")合并这一步值得多说两点。第一,合并后的模型体积和基座模型一样大,但推理时不再需要额外的adapter管理,可以直接用常规方式加载和部署。如果你用的是vLLM这类推理框架,合并后的模型兼容性更好。第二,合并操作会修改全部权重,所以合并前一定要把原始基座模型备份好。有一次我误操作直接覆盖了原始权重,只能重新下载模型,那个下载时间足够写一篇新博客了。
验证模型效果时,我建议同时准备三组测试用例:第一组是训练数据里没见过的类似题目,看模型泛化能力;第二组是训练数据里出现过的原题,看模型记住了没有;第三组是稍微挑战指令格式的用例,比如要求它用表格输出、用特定语气回答,看它是不是真的理解了指令跟随。
5. 常见问题与排查技巧实录
5.1 Loss降了模型反而变傻了
这个现象出现过太多次了。训练日志上loss一路下降,看起来非常健康,结果拿出去一测,模型回答质量反而比微调前更差。遇到这种情况,第一个怀疑对象就是数据质量。SFT模型会把训练数据中的错误、噪声和格式混乱原样学进去。如果loss降得很快,说明模型记性好,把脏数据也记住了。
第二个怀疑对象是数据分布。比如训练数据里80%都是“知识问答”,20%是“代码生成”,模型自然会偏向知识问答的输出风格,代码能力反而不升反降。检查一下训练数据的类别占比,把比例往目标场景调整,往往是这一类问题最简单的解决办法。
第三个原因可能是验证集划分不合适。如果你留出的验证集和训练集高度相似,验证loss会一直很乐观,等到真正上线面对新数据时才暴露问题。我建议验证集尽量和线上场景保持一致,而不是随机抽样。
5.2 灾难性遗忘怎么控制
灾难性遗忘是SFT最头疼的问题之一,表现是微调后模型对目标场景表现很好,但通用能力明显倒退,比如数学变差、常识错误增多。根本原因在于SFT训练数据分布和预训练数据分布差距太大,模型在拟合训练数据的过程中把原有的知识表达式覆盖掉了。
控制遗忘的方法有几个:第一个是降低学习率。学习率越高,权重改动幅度越大,遗忘越严重。我遇到过把学习率从1e-4降到5e-5之后,目标任务效果只差一点点,但通用能力明显变好。第二个方法是混合通用数据。在目标SFT数据里掺入10%-20%的通用指令数据或者预训练语料,让模型在原分布附近“巩固记忆”。
第三个方法是做多任务混合训练。把多个场景的数据混在一起训,而不是只训一个单独任务,模型对指令的理解会更稳健。如果你已经训练完了,发现模型通用能力受损,还有一个补救手段:用低学习率在通用数据上做几轮“恢复训练”,通常能让通用能力回来一部分,但要谨慎控制轮数,恢复过头了目标任务的收益又会丢掉。
5.3 输出成了复读机或者出现乱码
生成阶段出现复读机,很多人第一反应是调采样参数,比如降低temperature、调整repetition penalty,但从SFT侧来看,根因常常在训练阶段。学习率过高是常见元凶,模型在训练集上过拟合,生成的token序列容易掉进重复循环。把学习率降下来重新训练,复读现象经常自动消失。
数据里的重复样本也值得排查。如果某条样本在数据集中重复了几十次,模型对它的记忆特别深,输出时就容易反复横跳。用去重脚本把完全重复和近似重复的样本清理掉,能有效缓解这个问题。
至于乱码问题,优先检查tokenizer的pad token设置。很多基座模型的tokenizer没有pad token,训练时如果不指定,系统会拿eos token来凑,可能导致注意力掩码错乱。另一个检查点是数据文本本身,确认没有混入非法字符或者编码不一致的文本。
5.4 显存OOM的排查路线
训练中途报CUDA out of memory是最常见的工程问题,但解决办法并不复杂,按顺序处理就好。先减per_device_batch_size,从4减到2,再减到1。如果batch=1还是OOM,说明单条样本太大,检查max_length是不是设得过高,适当截断长样本。接着开启gradient_checkpointing,这个能省不少显存。
还不行就考虑换LoRA或者上量化。从全参微调切到LoRA,显存占用能下降一半以上;LoRA再加4bit量化加载,7B模型的训练甚至可以压到16GB显存以内。有一点要提醒:batch变小后训练稳定性会变差,记得同步调大gradient_accumulation_steps,把等效batch size维持住。用大batch换稳定,用累积换等效,是解决显存问题的通用思路。
5.5 微调效果怎么评估才靠谱
很多团队评估SFT效果只看训练loss,这是远远不够的。loss只能说明模型有没有拟合训练数据,不能说明模型能不能解决真实问题。我自己的评估体系分三层:第一层是规则指标,如果任务是分类或者抽取,计算准确率、F1这些客观指标;第二层是生成质量指标,比如代码任务的pass@k、文本摘要的ROUGE分,但这些只能做参考,不能完全替代人工;第三层是人工评测,准备几十条覆盖各场景的测试用例,逐个看模型输出,按有用性、正确性、格式规范度打分。
人工评测虽然费时,但它最能暴露问题。我通常会让不直接参与训练的人来做评测,这样能避免“自己训的模型怎么看都好”的主观偏差。打分表也不要搞得太复杂,三到五个维度足够了,重点看有没有出现答非所问、格式混乱、事实错误这三类致命问题。模型上线前至少做两轮评估,第一轮发现问题并重训,第二轮确认修复效果,这样形成闭环才算真正跑完一次SFT。
最后说一个我个人的习惯:每次SFT训练跑完之后,把所有配置、数据统计结果和评测记录整理成一个简单的文档留档。别小看这一步,模型效果不好时想回溯原因,这份记录能帮你快速定位是数据问题、参数问题还是训练代码问题。我做过的项目里,好几轮模型效果倒退都是靠文档记录快速查出了原因。如果你正准备开始自己的SFT实验,建议也养成这个习惯。