简介:围绕大模型指令微调(Instruction Tuning)的入门讲解型课件,面向刚接触自然语言处理与大模型微调的算法学习者及课程备课教师,用于快速厘清从传统机器学习到预训练加提示的技术脉络。内容以NLP四范式为主线,串联tf-idf加朴素贝叶斯、word2vec加LSTM、BERT微调到预训练加Prompt的演进路径,并落到大语言模型构建的预训练、有监督微调、奖励建模与强化学习四个阶段。重点展开指令微调在弥合下一词预测目标与指令执行差距、约束输出行为、低成本适配特定领域三方面的价值,以及指令数据集中指令、输入、期望输出三要素和模板集成、模型自动生成两种构建方法,并标注可循的参考文献出处。包内为1个pptx文件,约2.65MB,以要点与流程示意呈现,便于直接用于组会分享或课堂讲授,已有4262人学习。
1. 讲透指令微调的这份PPT:从NLP四范式到大模型微调,先看清它给的是坐标系
手上有一份讲大模型指令微调的课件,翻到中间会发现它没有一上来就甩公式:先把自然语言处理拆成四个范式,再把大模型构建拆成预训练、有监督微调、奖励建模、强化学习四段,最后落到 LoRA 这类高效微调技术上。这类材料的价值不在某一页的参数表,而在于它把“为什么微调”“微调卡在哪一步”“微调要花多少显存”三件事串成了一条线。深度学习做过几年的人看它不会嫌浅,刚接触大模型微调的新手照着目录也能搭出第一版 SFT 流程。下面按我实际的拆读顺序,把能直接抄的部分、需要自己补的部分和容易翻车的地方分开说。
2. NLP四范式与大模型四阶段:先找准自己卡在哪一格
这份课件的前十几页信息密度最高,也最容易被快速翻过去。多数人急着看 LoRA 那几页,结果参数抄完了才发现自己的任务根本不该走微调这条路。四范式和四阶段这两组框架,作用就是先把“你的问题属于哪一类”定下来,再决定动不动显卡。
2.1 四范式的分界线是“人还要做多少活”
第一范式是 tf-idf 特征加朴素贝叶斯、SVM 这类传统机器学习算法。特征是人挑的,词表是人剪的,模型只负责在固定维度上划决策边界。第二范式换成 word2vec 特征加 LSTM、TextCNN 这类深度学习结构,特征工程的工作量明显下降,词向量由语料自己学出来,准确率比第一范式高一截,但每个任务还是要单独训一个模型。
第三范式是预训练加 fine-tuning,BERT 是典型代表。一次预训练,多个下游任务共享同一套参数,小数据集就能训出可用模型,代价是模型体积上去了。第四范式是预训练加 Prompt 加预测,把“分类”改写成“填空”,比如把情感分类改成“这条评论很[MASK]”,训练数据需求比第三范式又少一个量级。课件里这四段并列摆放,实际是一条“人工投入递减、模型规模递增”的曲线。
| 范式 | 典型组合 | 标注数据需求 | 特征工程 | 单卡可跑性 |
|---|---|---|---|---|
| 第一范式 | tf-idf + 朴素贝叶斯 | 千条级 | 高,人工设计 | 笔记本即可 |
| 第二范式 | word2vec + LSTM | 万条级 | 中,需调词向量 | 单卡消费级 |
| 第三范式 | BERT + fine-tuning | 千条级 | 低 | 单卡 16G 起 |
| 第四范式 | 预训练 + Prompt | 百条级 | 低,但需设计模板 | 推理即可,微调另算 |
看这张表要带着自己的任务去对。任务只是换个输出风格、类别只有三五种、标注样本几百条,第四范式的 Prompt 就够用,硬上微调是拿算力换心理安慰。输出格式严格、领域术语密集、评测有硬指标,才轮到第三范式的微调。这份 PPT 把四范式放开头不是凑页数,它回答的是“配不配得上微调”这个问题。
2.2 大模型四阶段:SFT 是“对齐”的第一刀
课件把大模型构建拆成预训练、有监督微调、奖励建模、强化学习四段,这个顺序不能乱。预训练阶段的目标函数是预测下一个词,吃的是海量无标注文本,产出的是一个“续写能力很强但不太听话”的基座。有监督微调(SFT)接在它后面,用指令数据集把“续写”掰成“执行任务”。奖励建模再往后,用偏好排序数据训一个打分模型;强化学习拿这个打分模型当奖励信号去优化策略。
| 阶段 | 目标 | 数据形态 | 相对算力 | 产出物 |
|---|---|---|---|---|
| 预训练 | 下一个词预测 | 万亿级无标注文本 | 100 | 基座模型 |
| 有监督微调 | 指令跟随 | 万到百万级指令对 | 1 | SFT 模型 |
| 奖励建模 | 学人类偏好 | 成对/排序偏好数据 | 1 | 打分模型 |
| 强化学习 | 按奖励优化策略 | 提示词 + 奖励模型 | 5~20 | 对齐后模型 |
课件里把 SFT 的好处归成三条,翻成工程语言是这样的。第一,它弥合了模型“预测下一个词”和用户“要它照做”之间的目标差,这是最本质的一条。第二,指令本身是对输出的约束,模型行为变得更可控可预测,人有了干预模型的通道,线上出问题时知道从哪加约束。第三,它计算高效,不需要大规模重训,也不用改模型架构,一个新领域几千条高质量指令对就能让模型明显转向。
这三条里,第二条最容易被低估。很多人做微调只盯着评测分数,忽略了指令数据其实是在给模型划边界。你希望模型在不知道答案时明确说不知道,那就得在指令集里放这类样本;你希望它输出严格 JSON,那就得让训练数据里的输出全是合法 JSON。边界是喂出来的,不是调出来的。
奖励建模和强化学习之所以排在 SFT 之后,是因为偏好数据要有可比性:得先有一个能稳定产出候选回答的 SFT 模型,人工或模型才能对它的一堆输出排序。跳过 SFT 直接做偏好学习,候选质量参差不齐,打出来的分噪声极大。
2.3 一张对照表判断自己该走哪条路
把四范式和四阶段合起来看,选择其实只有几个岔路口。下面这张表是我自己在动手前会先填一遍的,填完基本就不会走错方向。
| 你的处境 | 信号 | 建议路线 | 理由 |
|---|---|---|---|
| 输出格式固定,字段必须齐全 | 解析失败率高 | SFT + LoRA | 格式约束靠指令数据喂 |
| 领域术语多,通用模型答不准 | 术语召回低 | SFT + LoRA | 领域知识可从数据注入 |
| 只是语气不对、太啰嗦 | 人工评分低但内容对 | Prompt 工程 | 不改参数,迭代快 |
| 知识天天更新 | 时效性问题多 | 检索增强 | 微调记不住新事实 |
| 有大量偏好排序数据 | 有 A/B 标注 | RM + 强化学习 | 优化的是偏好而非格式 |
| 标注样本不足 500 条 | 数据薄 | Prompt + 少量示例 | 微调容易过拟合模板 |
最后一行值得多说一句。样本量小的时候,微调最常见的结局不是效果差,而是模型把模板背了下来:训练 loss 一路降到很低,换一种问法就露馅。这种情况在后面的数据集构建章节还会展开,这里先记住结论——数据不够就先别动参数。
3. 指令数据集构建:三要素的边界与两条造数路线
微调流程在课件里只有两步:构建指令数据集、在指令数据上做有监督训练。真正吃掉八成时间的是第一步。模型结构是现成的,训练脚本是现成的,唯独数据得自己一点点攒。
3.1 instruction、input、output 三要素的边界
课件把指令数据的每个实例拆成三部分:instruction 是描述任务的自然语言序列,input 是可选的补充上下文,output 是期望的回答。这个定义看着简单,实际写起来最容易糊的是 instruction 和 input 的边界。
一条合格样本长这样:
{ "instruction": "从下面的售后对话中提取用户诉求,按 JSON 输出,字段为 problem 和 expect。", "input": "用户:我上周买的杯子到货就裂了。客服:抱歉,您想怎么处理?用户:换一个同款的,别再有质量问题。", "output": "{\"problem\": \"商品到货破损\", \"expect\": \"更换同款商品\"}" }instruction 里必须包含三样东西:任务动词(提取、判断、改写、生成)、输出约束(JSON、只答正向或负向、不超过 50 字)、必要的判定口径。input 只放“这一条样本特有的内容”,不要把任务说明混进去。output 要写“期望的回答”,不是“唯一正确答案”——同一件事有多种合理表述时,选你最希望线上看到的那种写法。
| 字段 | 作用 | 常见错误 |
|---|---|---|
| instruction | 定义任务与输出约束 | 写成“请处理下面的文本”,没有任务动词 |
| input | 提供本条上下文 | 把任务说明也塞进来,和 instruction 重复 |
| output | 期望回答 | 混入解释性前缀,或长度远超线上需求 |
有三类错误最伤模型。一是 instruction 里缺少输出约束,模型自由发挥,线上解析全部失败。二是 output 里混进“好的,以下是提取结果:”这类客套前缀,训练完模型每次都要先客套一句。三是同一条样本里 instruction 和 input 语言不一致,一半中文一半英文,模型会学出中英混杂的输出习惯。
3.2 路线一:模板把“文本-标签”改写成指令对
课件给的第一种构建方法是从已有标注数据集成:用模板把文本-标签对改写成 instruction-output 对。这条路线成本最低,手上的分类、抽取、匹配数据集都能复用。关键在于模板不能只有一条。
import json, random # 每个标签至少准备 3 条表述不同的模板,模板池越大,模型越难背模板 TEMPLATES = { "正向": [ "判断下面这条评论的情感倾向,只回答“正向”或“负向”。", "给这条用户评论打情感标签,可选值为正向、负向。", "这条评论表达的是好评还是差评?只输出标签本身。", ], "负向": [ "判断下面这条评论的情感倾向,只回答“正向”或“负向”。", "给这条用户评论打情感标签,可选值为正向、负向。", "这条评论表达的是好评还是差评?只输出标签本身。", ], } def to_instruction_pair(text, label, rng): """把 (文本, 标签) 转成一条指令样本""" template = rng.choice(TEMPLATES[label]) # 同标签下随机选模板 return { "instruction": template, "input": text.strip(), # 只留正文,去掉采集时的噪声字段 "output": label, } if __name__ == "__main__": rng = random.Random(42) # 固定种子,保证每次生成结果可复现 samples = [] for text, label in load_labeled_data(): # 已有标注数据,返回 (文本, 标签) if label not in TEMPLATES: continue # 标签不在映射表里直接丢弃,别硬凑 samples.append(to_instruction_pair(text, label, rng)) with open("sft_data.jsonl", "w", encoding="utf-8") as f: for s in samples: f.write(json.dumps(s, ensure_ascii=False) + "\n")这段脚本的逻辑很直白:读标注数据、按标签抽模板、拼成三元组、落成 JSONL。几个参数值得掰开说。rng = random.Random(42)固定随机种子,同一批数据每次生成的模板分配一致,方便复现和对比实验。模板池每个标签至少 3 条,实际做下来 8 到 12 条效果更稳,表述要覆盖“判断”“分类”“打标签”等不同动词,句长也要有长有短。text.strip()这一步别省,采集来的文本常带全角空格和零宽字符,留着会让模型学到奇怪的分词边界。
这条路线有个隐性上限:模板是从人写的句子里抽的,多样性受限于模板池。模板池只有三五条时,模型很快学会“看到某个句式就输出某个标签”,指令跟随能力并没有真正建立。缓解办法是在模板里加变量,比如把“只回答正向或负向”换成“只回答 A 或 B,A 表示正向,B 表示负向”,同一语义不同符号体系,模型被迫去理解任务而不是记句式。
3.3 路线二:种子指令 + 大模型扩写
课件给的第二种方法是自动构建:手写一小批种子指令,用大模型扩写出更多指令,再把指令喂给模型拿输出。这条路线适合没有现成标注数据的场景,比如内部知识问答、特定格式的报表生成。
# 扩写提示词:约束越硬,产出越可用 EXPAND_PROMPT = """你是指令数据扩写器。参考下面的种子样本,生成一条同领域、不同表述的新样本。 要求: 1. 任务类型与种子保持一致,不要换成别的任务; 2. input 长度控制在 50 到 300 字,必须是一段完整的中文文本; 3. output 必须是合法 JSON,字段与种子完全一致; 4. 只输出 JSON 对象,不要任何解释。 种子样本: {seed} """ def expand(seed, n=30, temperature=0.9): results = [] for _ in range(n): raw = call_llm(EXPAND_PROMPT.format(seed=seed), temperature=temperature) item = safe_json_loads(raw) # 解析失败直接丢弃,不做修补 if item and validate(item, seed): # 字段、长度、语言三项校验 results.append(item) return results扩写这条路线,参数决定成败。n=30是每条种子扩出的候选数,种子 50 条、每条扩 30 条,理论上能拿到 1500 条,去重和校验后通常剩六到七成,够一个小领域用。temperature=0.9是扩写阶段的常用值,太低会导致产出高度雷同,太高会跑题,0.8 到 1.0 之间按种子质量微调。validate里建议至少卡三道:字段名与种子一致、input 长度在区间内、output 能被 JSON 解析。
扩写出来的数据必须过滤,这一点课件讲得比较简略,实操中却是分水岭。模型扩写天然偏向“标准答案”风格,输出长度趋同、句式趋同,直接拿去训会让模型学会一种腔调。常见做法是做三件事:按 5-gram 的 Jaccard 相似度去重,阈值 0.8 以上只留一条;统计 instruction 首词分布,某个动词占比超过 20% 就补种子;把 output 长度画个直方图,砍掉最长和最短的 5% 分位。
3.4 造完数据先做三项体检
数据写完不要直接开训,先跑一遍统计。这一步花十分钟,能省掉后面几个小时的排错。
from collections import Counter import json def ngrams(text, n=5): t = text.replace(" ", "") return {t[i:i+n] for i in range(max(len(t) - n + 1, 1))} def jaccard(a, b): return len(a & b) / max(len(a | b), 1) rows = [json.loads(line) for line in open("sft_data.jsonl", encoding="utf-8")] # 1. 近似重复率 sets = [ngrams(r["instruction"] + r["input"]) for r in rows] dup = sum(1 for i in range(len(sets)) for j in range(i + 1, min(i + 50, len(sets))) if jaccard(sets[i], sets[j]) > 0.8) print("近似重复对数:", dup) # 2. 输出长度分布 lens = sorted(len(r["output"]) for r in rows) print("输出长度 P50/P90/P99:", lens[len(lens)//2], lens[int(len(lens)*0.9)], lens[int(len(lens)*0.99)]) # 3. 指令动词多样性 verbs = Counter(r["instruction"][:4] for r in rows) print("高频指令开头:", verbs.most_common(5))三项指标对应的健康区间:近似重复率控制在 1% 以内,超过说明模板或扩写重复严重;输出长度 P99 不应超过训练时max_length的三分之一,否则大量样本被截断;指令开头的高频项占比不超过 20%,某一类开头独大意味着任务类型太单一。这三项里任何一项异常,先修数据再谈训练,模型不会替你兜底。
4. 指令微调训练落地:全参和 LoRA 之间怎么选参数
数据备好之后进入训练环节。课件在这一段给了高效微调的分类框架,但没给具体数值,实际调的时候参数怎么定,得结合显存和任务一起看。
4.1 先算显存,再谈效果
全参微调的显存开销大致由四块构成:模型权重、梯度、优化器状态、前向激活。以 7B 模型、bf16 混合精度为例,权重约 14GB,梯度同量约 14GB,Adam 类优化器要为每个参数存两份状态,约 56GB,加起来已经 84GB,还没算激活。实际跑起来单卡 80G 都紧张,通常要配 ZeRO 或张量并行。这套配置对个人开发者基本关上门。
高效微调把可训练参数压到千分之几,梯度和优化器状态随之缩水,权重仍要常驻显存,但整体门槛降到单卡 24G 能跑 7B,16G 能跑小参数模型。下面是三种常见配置的粗略对照。
| 方案 | 可训练参数占比 | 7B 模型显存需求 | 训练速度 | 适用场景 |
|---|---|---|---|---|
| 全参微调 | 100% | 80G 以上,需并行 | 慢 | 数据量大、要改模型能力 |
| LoRA | 0.1%~1% | 16~24G | 快 | 绝大多数领域适配 |
| 冻结底座只训头部 | 1%~5% | 20G 左右 | 快 | 分类、抽取类任务 |
判断标准可以简化成一句话:数据量在十万条以下、目标是让模型学会一种输出格式或领域表达,LoRA 就够,没必要上全参。全参微调真正有优势的场景是继续预训练式的领域注入,那需要千万级 token,个人项目很少碰到。
4.2 三类高效微调方法的边界
课件把高效微调分成三类,这个划分比按方法名罗列更有用。
添加式方法在原始模型里插入原本不存在的可训练模块,Adapter 是在每层后面接一个小瓶颈网络,基于 Prompt 的方法是在输入或每层前拼一段可训练向量。这类方法的共同点是训练和推理时结构有变化,Adapter 会带来额外的串行计算,推理延迟略增。
规范化方法不动结构,只指定某些已有参数参与更新。BitFit 是最典型的做法,冻结全部权重只训偏置项,可训练参数占比通常在万分之几,效果在分类任务上意外地能打,但生成任务的提升有限。
重参数化方法假设“适配所需的变化是低秩的”,把权重更新拆成两个低秩矩阵相乘。LoRA 就属于这一类,训练时只学低秩矩阵,推理时可以把低秩矩阵乘回原权重,合并后结构完全不变,不增加推理延迟。这条性质是 LoRA 在工程上胜出的关键原因。
课件还提到内在提示的概念:不同任务之间存在一个共享的低维子空间。翻成实用结论是,如果你有多个相近的下游任务,不必每个任务单独训一套参数,可以共用一部分低秩结构,只在任务维度上分开。这个思路在任务族很多、每个任务数据都不多的时候特别省事。
4.3 LoRA 的四个参数怎么定
LoRA 的配置文件看着字段多,真正影响结果的只有四个:r、alpha、dropout、target_modules。
r是低秩矩阵的秩,决定表达能力。分类、抽取这类输出空间小的任务,8 到 16 够用;开放式生成、风格迁移这类需要较大改动的任务,32 到 64 更稳。盲目调大r不会一直涨点,参数量上去之后过拟合会先来。
alpha是缩放系数,实际生效的是alpha / r这个比例。常见做法是让alpha = 2 * r,也有直接用alpha = r的。前者的更新幅度更大,学习率可以相应调小;后者更保守,适合数据量少、怕训崩的场景。
dropout作用在低秩分支上,0.05 是稳妥起点,数据量小于一万条时可以提到 0.1。
target_modules决定把低秩矩阵挂到哪些线性层上。只挂q_proj、v_proj是省显存的做法,效果打折;挂全部线性层(注意力四个投影加 FFN 三个)效果最好,显存占用增加有限,我一般默认全挂。
# lora_sft.yaml:7B 基座 + 全线性层 LoRA 的起步配置 base_model: /models/base-7b # 本地基座路径 data_path: ./sft_data.jsonl lora: r: 16 # 低秩维度,生成任务可提到 32 alpha: 32 # 缩放系数,取 2r dropout: 0.05 target_modules: # 全部线性层,不要只挂 qv - q_proj - k_proj - v_proj - o_proj - gate_proj - up_proj - down_proj training: max_length: 1024 # 按样本长度 P99 设,别拍脑袋写 4096 per_device_batch_size: 2 gradient_accumulation_steps: 8 # 等效 batch = 2 × 8 × 卡数 learning_rate: 2.0e-4 # LoRA 常用 1e-4 到 3e-4 lr_scheduler: cosine warmup_ratio: 0.03 num_epochs: 3 bf16: true gradient_checkpointing: true # 显存不够时开,换 20% 左右速度 logging_steps: 10 eval_steps: 100 save_steps: 200# 单机 8 卡启动,脚本名按自己工程的入口改 torchrun --nproc_per_node=8 train_lora.py --config configs/lora_sft.yaml配置里几个数值的来由值得说明。max_length: 1024不是随手写的,它应该等于数据体检时输出长度 P99 加上 input 长度 P99,再留 10% 余量;写成 4096 只会让每条样本都补齐到 4096,显存白烧。learning_rate: 2.0e-4是 LoRA 的常见起点,全参微调通常用 1e-5 到 2e-5 量级,两者差一个数量级,因为 LoRA 只更新少量参数,需要更大的步长。gradient_accumulation_steps: 8配合per_device_batch_size: 2,在 8 卡上等效 batch 是 128,这个量级对小数据集偏大,数据少于五千条时把累积步数降到 4。gradient_checkpointing是显存不够时的后悔药,代价是训练速度下降两成左右,显存够就别开。
4.4 训练中该盯的三条线
训练日志里指标一大堆,真正需要盯的是三条:训练 loss、梯度范数、验证 loss。
训练 loss 在前 50 步快速下降是正常的,之后应该缓慢下行并带小幅抖动。如果 loss 长时间横盘,先看学习率是不是太小,再看数据里 instruction 和 output 是不是严重不匹配。如果 loss 突然掉到接近 0,大概率是数据里有大量重复样本,模型在背答案。
梯度范数是判断训练是否稳定的直接信号。正常区间在 0.1 到 10 之间波动,持续大于 50 说明有异常样本或学习率过大,此时应该开梯度裁剪,阈值设 1.0。持续接近 0 说明梯度消失,检查是不是误冻了全部参数。
验证 loss 的拐点决定什么时候停。训练 loss 还在降、验证 loss 开始抬头,就是过拟合的起点,早停设在这里。LoRA 的验证 loss 曲线通常比全参平缓,抬头不明显,这时候更该看第 6 章那套用例回归,而不是只信 loss。
5. 避坑与排查:五条从数据和显存里换来的经验
理论和配置都齐了,真正的差距在排错。下面五条是这类项目里出现频率最高的问题,每条按现象、原因、解决写,照着对号入座能省不少时间。
5.1 数据侧的三次翻车
第一条,训练 loss 很低,上线答非所问。现象是训练集上的 loss 降到 0.3 以下,模型在训练样本上几乎逐字复现,换一种问法就答偏。原因是模板池太单薄,同一标签只有一两条模板,模型学会了“看到这个句式就吐这个标签”,任务本身没学会。解决办法是把模板池扩到每个标签 8 条以上,同时在 instruction 里加入变量,比如更换输出约束的表述方式、调换任务动词,让同一语义有多种表面形式。改完之后训练 loss 会上升,泛化反而变好。
第二条,验证 loss 不降。现象是训练 loss 稳步下降,验证 loss 从第 200 步开始横盘甚至上扬。原因通常是 instruction 和 output 的语言或风格不一致,比如指令是中文、输出混着英文术语;或者 output 长度分布有长尾,大量样本被max_length截断,模型学到的是半截句子。解决办法是统一语言、统计截断率,截断率超过 5% 就把max_length按 P99 重设,或者对超长样本单独做摘要式改写。
第三条,模型开口就是“作为一个AI助手”。现象是微调后模型对任何问题都先来一句免责声明。原因是训练数据里混入了对话式模板或大量拒答样本,模型把这句当成了输出的默认前缀。解决办法是过滤掉带客套前缀的 output,把拒答类样本控制在 5% 以内,并且拒答样本要按“什么情况下拒答”写清楚条件,不能全是无条件拒答。
5.2 训练侧的两次翻车
第四条,第一步就 OOM。现象是训练脚本刚加载完模型就爆显存,还没开始前向。原因多半是max_length设得远大于实际需要,或者per_device_batch_size直接照搬别人的配置。解决办法是按数据体检的 P99 设max_length,把 batch 降下来用梯度累积补等效 batch,打开梯度检查点,再不行就换 8bit 优化器状态。有个容易忽略的点:验证集的 batch 也要跟着降,很多人只改了训练侧,跑到第一次 eval 才崩。
第五条,LoRA 训完合并进基座,效果反而下降。现象是训练时验证指标正常,合并权重后同一批用例的通过率掉了十几个点。原因有三个常见来源:合并时精度不一致,训练用 bf16、合并转成 fp16 掉了精度;target_modules在训练和合并两个脚本里写得不一样,有一部分低秩矩阵没合进去;基座用了量化版本,合并脚本按全精度处理,数值对不上。解决办法是把训练配置、合并配置、评测用例三者绑定成一个版本一起存档,合并前后跑同一批固定用例做对比,通过率差异超过 3 个点就先查target_modules是否一致。
| 现象 | 首先排查 | 快速验证方式 |
|---|---|---|
| loss 极低但泛化差 | 模板多样性 | 换 5 种问法手测 |
| 验证 loss 不降 | 截断率、语言一致性 | 统计 output 长度 P99 |
| 输出固定前缀 | 拒答样本比例 | 抽样 100 条看开头 |
| 第一步 OOM | max_length、batch | 打印样本长度分布 |
| 合并后掉点 | 精度、target_modules | 合并前后同用例对比 |
6. 微调后的验证:30 条固定用例比 loss 曲线更可信
训练跑完,loss 曲线好看不代表模型能用。我现在的习惯是维护一套 30 条左右的固定用例,每次训练结束都跑一遍,用通过率而不是 loss 判断这次改动值不值得留。
用例分成五组,每组 5 到 8 条:指令遵循(能不能按要求的格式输出)、领域知识(术语用得对不对)、长输入(接近max_length的样本会不会崩)、边界拒答(不知道的时候会不会硬编)、风格一致性(输出长度和语气是否稳定)。用例要写死输入,解码参数固定成贪心或温度 0,保证同一模型每次输出一致,这样两次训练的差异才可比。
import json cases = [json.loads(l) for l in open("regression_cases.jsonl", encoding="utf-8")] def run_suite(model, tokenizer, cases): passed, detail = 0, [] for c in cases: out = generate(model, tokenizer, c["instruction"], c["input"], temperature=0.0, max_new_tokens=c.get("max_new_tokens", 256)) ok = check(c, out) # 格式用例走 JSON 解析,知识用例走关键词命中 passed += ok detail.append({"id": c["id"], "ok": ok, "output": out}) return passed / len(cases), detailcheck函数按用例类型分派:格式类直接尝试 JSON 解析并校验字段名,知识类检查关键实体是否出现,拒答类判断输出里有没有“无法确定”之类的表述,长输入类只看是否截断或复读。这套东西写起来不到一百行,但它是唯一能告诉你“这次改动让模型变好还是变坏”的东西。
再补一个技巧:把每次训练的用例输出落盘成result_{timestamp}.json,下次训练后做 diff。通过率持平但输出变短了、变啰嗦了、某个字段开始漏了,diff 里一眼能看到,光看总分是看不出来的。这套用例库本身也要维护,线上每发现一个坏 case,就固化成一条新用例,库越跑越准。
从那以后我每次动参数前都强制先把这套用例跑一遍基线,哪怕是换个r值这种小改动。吃过一次亏:某次调大r之后训练 loss 更好看,直接上了线,结果格式合规率掉了八个点,回滚查了两天才发现是低秩维度过大导致模型开始自由发挥。基线不复现,后面的对比全是玄学。希望帮到你。
本文还有配套的精品资源,点击获取