做 few shot SFT 最怕的不是模型学不会,而是“学得太快”。我有一次拿六十来条人工清洗过的对话样本去微调一个开源底座模型,第一轮 epoch 还没跑完,训练 loss 就掉到了零点零几,看起来验证集也还行。等我把模型接到真实对话环境里一测,才意识到它把大部分输入都往那几十条样本的答案模板上靠:换个说法问同一个问题,它背诵原文;问个完全不相关的话题,它也能强行拐回训练集主题。后来我把这种状态叫作“训练集满分,真实场景零分”。
这篇文章会把我在 few shot SFT 上跟过拟合斗智斗勇的经验梳理一遍,从症状、机制到数据侧、训练侧、损失函数层的对策,最后给一套可量化的检测流程。无论你是刚开始接触少样本微调,还是已经在用 LoRA 跑业务模型,应该都能从里面找到可以直接抄走的配置和经验。
1. 先认清症状:训练曲线越漂亮,越要警惕过拟合
1.1 训练loss快速归零的“满分幻觉”
少样本场景下最常见的误判,就是把训练 loss 当成了模型是否学好的核心指标。几十条样本、两三个 epoch,训练 loss 一路俯冲到 0.02,甚至接近 0,很多人的第一反应是“成了”。但这里藏着一个大问题:一个参数量动辄几十亿的模型,想要背下几十条输入输出映射,几乎是零成本的事。
我常用的判断标准是看训练集 loss 和验证集 loss 之间的 gap。如果训练 loss 还在降,验证 loss 却开始反弹或者震荡走高,这就不是“模型不够聪明”,而是模型开始把训练样本当成标准答案死记硬背了。可 few shot 场景往往没有足够的验证集,很多人到这一步根本察觉不到异常,等到部署上线才翻车。
所以少样本微调里的第一个原则是:不要迷信训练 loss。它降到极低,只说明模型有足够的容量去记忆,不代表它学到了可泛化的规则。
1.2 真实场景里的复读机与“什么问题都往领域答”
过拟合模型在真实场景里的表现非常有辨识度。最典型的是复读机行为:用户把问题换一种措辞表达,模型依然输出训练集里的原答案,好像完全没听懂这句话。再严重一点,模型会形成“领域路径依赖”——不管用户问什么,它都要把话题拽回自己训练过的那个领域里。
我印象很深的一个例子是,用 50 条电商客服对话做 few shot SFT,训练集里全是“退款”“退货”“物流”相关的问题和话术。模型训练完之后,你问它“今天天气怎么样”,它回答的开头竟然是“您好,关于您的退款申请,我们已经收到……”这不是模型变蠢了,而是它在少量样本里发现了一个“捷径”:只要输出这类话术,loss 就低。至于输入是不是匹配,它已经顾不上了。
这种行为的本质,是模型学到的是“输入到输出的固定映射”,而不是任务背后的规则。规模越大的模型,越容易把这种固定映射学得又快又好,这也是 few shot 过拟合比大样本微调更难处理的原因。
1.3 更隐蔽的代价:预训练通用能力被覆盖
除了任务本身跑偏,过拟合还会带来一个更隐蔽的代价:模型原本的通用能力被覆盖。少样本数据分布非常窄,当模型把参数往窄分布方向猛拉时,预训练阶段积累的开放式对话能力、知识问答能力、多样化的语言风格都会受损。
一个很典型的迹象是,微调前底座模型写出来的句子自然多变,微调后只剩一种腔调。哪怕是跟领域完全无关的闲聊,模型也会自动收敛成训练集里的表达风格。有人把这个叫“灾难性遗忘”,但在 few shot SFT 里,它往往不是彻底遗忘,而是被过拟合的强信号覆盖住了。
这里有个我一直在用的土办法:微调之前,先拿 5 到 10 个开放域问题问一遍底座模型,把回答存档;微调完成后再拿同样的问题去问微调后模型,做一个并排对比。如果通用回答质量肉眼可见地下降,那不管领域评测分数多高,我都默认这次微调是有问题的。
2. 原理层面为什么越少样本越容易过拟合
2.1 模型容量与数据量严重失衡
过拟合的根本原因,可以从容量和数据量的角度看。一个拥有几十亿参数的模型,本身就是一个巨大的记忆体。你给它几十条样本,它完全可以把这些样本逐个存入参数分布里,而不需要提炼任何抽象规则。
打个比方:一个记忆力极好的学生,考前只背了三道题。这三个题目考多少分他都能默写出来,但换一道同类型的新题,他就不会了。模型也是一样,预训练阶段已经让它具备了强大的模式存储能力,few shot 数据量太少,根本不足以约束它往“泛化”的方向走,反而给了它走捷径的空间。
理解了这一点,就会明白为什么 rank、学习率、冻结层这些在常规微调里不太敏感的参数,在 few shot 场景里会变得特别敏感。因为我们需要主动限制模型“背题”的能力,逼它只能学个大概的规则。
2.2 有限样本里的表面模式,会被梯度反复强化
少样本数据往往带有很强的人工挑选痕迹,覆盖面有限。如果这些样本里频繁出现某些关键词或句式,模型就会把这些表面相关性当成规律来学。
举个例子:训练集里如果很多样本都包含“订单号”这个词,模型可能学到“只要输入里有订单号,就输出某个模板”;如果几条样本都要求“先道歉再处理”,模型会把“道歉前置”当成所有回复的铁律。这是一种伪规律,但它确实能让训练 loss 降得很快,因为数据量太少,没有其他样本去冲抵这种偏置。
从优化角度看,小 batch 下如果每条样本的高频模式一致,梯度的方向会高度趋同。模型每更新一步,就会往同一个方向再推一点。几轮 epoch 下来,参数就被推到一个很极端的区域。这也是为什么我会在数据侧花大量时间做多样性审计,而不是事后再指望用正则去救。
2.3 交叉熵目标天然鼓励“过度自信”
交叉熵损失的作用机制,是不断提高正确 token 的概率、压低其他 token 的概率。样本量充足时,这种压力会被分布广泛的训练数据摊薄,模型学到的是一种概率分布;但样本量极少时,交叉熵会把模型快速推向“对训练样本过于确定”的状态。
说白了,交叉熵不关心模型是否“合理”地完成任务,它只看最后输出的分布能不能贴合训练标签。当几十条样本反复出现时,模型很容易把某个答案的概率推到接近 1,形成尖锐的 one-hot 分布。这种过度自信,恰恰就是过拟合的特征。
所以后面要讲的标签平滑、KL 散度惩罚,本质上都是在跟交叉熵的这种“自信”较劲,让模型不要对训练集里的噪声和巧合过于认真。
3. 数据侧防过拟合:先让样本“经得起放大”
3.1 多样性审计:删掉冗余样本,保留信息量
数据是防过拟合的第一道防线,而且优先级比调参高得多。我拿到 few shot 样本后的第一件事,不是急着写训练脚本,而是做一次多样性审计。
具体操作我之前分享过,这里再讲一遍步骤:
- 把每一条样本的输入文本用 embedding 模型编码成向量;
- 计算两两之间的余弦相似度;
- 设定一个阈值,一般 0.85 左右,把相似度超过阈值的样本归到一组;
- 每组里只保留信息量最大的一到两条,其余删掉。
删完之后再按主题粗分一下,看分布是否均匀。比如 60 条样本里如果 40 条都在问“退款”,那这个数据集即使有 60 条,实际有效信息量也低得可怜。模型在训练时反复看到退款相关的话术,自然会把“退款”当成最高优先级模式。
这步做完,样本数量可能从 60 掉到 40,但相信我,这 20 条的减少换来的往往是更低的过拟合风险。少样本阶段,每一条样本都要有不可替代的信息量。
3.2 合法扩充数据的两个有效姿势:改写与格式扩展
删完之后如果觉得样本量确实不够,可以做受控的数据扩充。这里我推荐两种方式。
第一种是同义改写。把每条样本的输入输出用大模型改写三到五个版本,比如变换提问方式、调整句式结构、增加角色前缀等。但要注意风险:很多模型批量改写出来的文本,只是换了个词序,语义相似度依然很高,对多样性贡献不大。所以改写完之后,我建议再用 embedding 相似度过滤一遍,只保留确实“长得不一样”的版本。
第二种是格式扩展。如果任务要求输出 JSON、Markdown 或某种结构化格式,不要所有样本都用同一种格式。比如 JSON 的键顺序、嵌套层级、字段命名方式,都适当变一变。这么做能让模型学到“格式背后的语义”,而不是把某一套固定的格式写法背下来。
数据扩充量控制在原来的三到五倍以内比较合适。少样本条件下,盲目扩到几十倍很容易把噪声一起放大。
3.3 负样本和边界样本,同样承担正则化职责
很多人做 few shot SFT 只收集正样本,就是“用户问什么,模型答什么”。但只靠正样本训练出来的模型,特别容易出现“什么输入都接活”的过拟合问题。
我建议在数据集里混入两类特殊样本。第一类是负样本:用户的问题明显不在任务范围内,模型需要礼貌拒绝或表示无法处理。比如客服模型里加一条“附近有什么好吃的”,期望输出是“抱歉,我暂时不掌握本地生活信息”。这类样本能让模型学会识别任务边界,而不是看到任何输入都往领域模板上套。
第二类是边界样本:用户信息不完整时,模型应该先追问而不是直接给结论。这类样本并不多,但效果奇好,它们能教会模型“不同输入状态应该走不同的输出路径”,从根上减少把所有输入都汇聚到同一条生成路径上的倾向。
你可以把这两类样本看作一种数据层面的正则化。它们的数量可以少,但不能没有。
3.4 输入输出长度配比,一个容易被忽略的坑
还有一个数据侧的坑,是输入输出长度的配比。如果训练样本的输出都集中在三四十字,模型会对生成长度形成很强的偏好,正式使用时凡是需要长回答的问题,它都答得又短又空;反过来,如果样本都是长输出,模型又容易啰嗦。
我处理少样本数据时,会特意检查输出长度的分布,确保既有短的、也有中等和偏长的样本。输入侧也是一样,不要全是同一种长度的 query。训练阶段可以按样本实际长度做 batch 内的 padding,但不要把所有样本都强行 pad 到同一个超长长度,那样会让模型忽略长度变化这个信号。
长度特征也是一种容易过拟合的模式,只是它藏在文本表面之下,不对比看根本注意不到。
4. 训练侧防过拟合:把学习速度和参数改动幅度同时压住
4.1 学习率、epoch与有效更新次数
数据准备好之后,训练超参是下一个关键环节。少样本场景下,我强烈建议把学习率压到比常规微调更低。
这里给出我常用的起点配置,不是唯一答案,但可以作为参照:
| 参数 | 全参微调起点 | LoRA/QLoRA起点 |
|---|---|---|
| 优化器 | AdamW | AdamW |
| 学习率 | 1e-5 ~ 2e-5 | 1e-4 ~ 2e-4 |
| batch size | 8 ~ 16 | 8 ~ 16 |
| epoch | 1 ~ 2 | 1 ~ 3 |
| weight decay | 0.01 ~ 0.1 | 0.01 ~ 0.1 |
| 梯度裁剪 | 1.0 | 1.0 |
| warmup | 5% ~ 10% 步数 | 5% ~ 10% 步数 |
| 标签平滑 | 0 ~ 0.1 | 0.05 ~ 0.1 |
这里有个概念值得认真算一下:有效更新次数。它的粗略公式是 数据量 × epoch ÷ batch size。比如 50 条样本、batch size 8、跑 1 个 epoch,总共只有 7 步左右的有效更新。这么点步数,模型连 LoRA 增量都未必充分训练,所以不是“只要 epoch 少就不过拟合”。
我的经验是,few shot 场景下“低学习率、多重复几轮”比“高学习率、一两个 epoch”更稳。比如 LoRA 用 1e-4 的学习率跑两到三个 epoch,配合标签平滑和 KL 约束,效果通常比 2e-4 跑一个 epoch 要好。学习率低一点,每次参数跳动的幅度就小一点,模型就更难在某个错误方向上走远。
4.2 LoRA低秩微调天然是正则,但要选对rank
LoRA 在少样本场景里几乎是我的首选方案。它的核心思路是冻结原始权重,只训练低秩的增量矩阵。可训练参数占比很小,这本身就是一种参数层面的正则,大幅度限制了模型改写自身表示的能力。
但 LoRA 不是随便配个 rank 就完事。rank 越大,增量矩阵的表达能力越强,过拟合风险也越高。few shot 场景下我通常从 rank 8 到 16 起步,alpha 设置为 rank 的一到两倍,也就是 16 到 32。不要一上来就用 rank 64,完全没有必要,反而更容易把几十条样本的细节全部记住。
LoRA 的 dropout 也不要忽略,0.05 到 0.1 都可以。它的作用是在训练时给增量路径加随机扰动,让模型不至于过度依赖某几条样本产生的固定方向。还有个小经验:LoRA 的目标模块不要只盯着 attention,很多模型在 MLP 层上加 LoRA 效果更明显,具体可以按模型架构和任务类型去试。
4.3 冻结embedding与lm_head,防止表示层漂移
少样本微调时,embedding 层和 lm_head 层是我建议优先冻结的模块。这两个层的参数量非常大,而且直接关系到 token 的表示和词表分布。如果让它们在几十条样本上跟着梯度更新,很容易把预训练阶段学到的语义表示拉偏。
embedding 被拉偏的典型后果,是模型用词变得单一,某些 token 的概率被异常抬高。lm_head 被拉偏的典型后果,是生成的文本倾向于从训练集的用词区间里选词,导致整体风格趋同、缺乏多样性。冻结住它们,相当于让模型只能在 transformer 内部的投影空间里做小幅调整,不太容易伤及预训练知识。
实际操作上,配置 LoRA 的 target_modules 时只包含内部的 attention 和 MLP 投影层,不让 embedding 和 lm_head 进入可训练名单。如果任务必须要模型学会输出新的特殊 token,再考虑解冻 lm_head,但这时一定要配合 KL 散度惩罚,否则灾难性遗忘会来得特别快。
4.4 weight decay、梯度裁剪、EMA这些细功夫
除了上面几个大头,还有一些看起来不起眼但实际很管用的训练细节。
weight decay 建议设置在 0.01 到 0.1 之间。它会对权重做衰减,抑制参数朝过大方向漂移。梯度裁剪也很重要,max_grad_norm 设成 1.0 就好。少样本下 batch 内的样本方差大,偶尔会出现很大的梯度,如果不裁剪,一步更新就能把参数推出很远。EMA 是我近半年越来越依赖的一个技巧:维护一份权重移动平均,推理时用平均权重而不是最后一步的权重。它相当于间接做了时间维度的正则,对缓解少样本训练后期 loss 震荡有奇效。
另外提醒一句:训练时开 dropout,评估和推理时关掉 dropout。这个基础细节在少样本场景下更关键,因为数据少,dropout 提供的随机性是模型避免死记硬背的重要助力。
5. 损失函数层面的三招:让模型“不敢”死记硬背
5.1 标签平滑:降低对训练答案的绝对信心
训练数据少,交叉熵又天生鼓励过度自信,这时候给它手动降降温是个好办法。标签平滑的做法是把 one-hot 目标改成软标签:比如 ε 设为 0.1,正确 token 的概率不再是 1,而是 0.9,剩下的 0.1 平均分给其他 token。
这样一来,模型不需要在训练集上达到绝对的 one-hot 分布,也就不会产生“非此答案不可”的极端偏置。少样本场景下我个人会从 ε=0.1 起步,如果任务对输出格式要求非常严格,可以降到 0.05;如果模型明显复读,也可以试着调到 0.2。
实现不算麻烦,如果用 Transformers 的 Trainer,直接设置 label_smoothing_factor 参数;如果自己写训练循环,在 CrossEntropyLoss 里也有对应的 label_smoothing 参数。成本几乎为零,但对过拟合的抑制很直观。
5.2 KL散度约束:把输出分布拉回预训练状态
比标签平滑更主动的做法,是在损失函数里加一项 KL 散度惩罚。具体思路是:冻结一份微调前的底座模型作为 reference,训练时计算当前模型输出分布与 reference 输出分布之间的 KL 散度,把它乘上一个权重系数,加到原始交叉熵 loss 上。
这样做的作用是,模型在训练样本上可以把对应答案的概率拉高,但不允许整个输出分布偏离预训练状态太远。形象点说,交叉熵在往前拽,KL 约束在往回拉,两个力平衡的结果就是“学到了一些东西,但没学过头”。
代码示意大致长这样:
# 示意:在标准交叉熵之外加一项 KL 散度约束 cur_log_probs = model(input_ids).log_softmax(-1) base_log_probs = ref_model(input_ids).log_softmax(-1) kl_div = F.kl_div( cur_log_probs, base_log_probs, reduction="batchmean", log_target=True, ) loss = ce_loss + 0.3 * kl_div权重系数 λ 可以从 0.1 到 0.5 之间调。数据越少、样本越窄,λ 就可以适当调大一点。实现时要注意 reference 模型不需要梯度,它的前向结果可以缓存或者用同一个模型在冻结状态下发一份副本。显存稍微多点,但换来的是模型稳定性的显著提升。
5.3 混入通用数据,成本和效果最稳的“伪正则”
如果不想写 KL 散度的代码,还有一个更朴素但同样有效的替代方案:在训练集里混入一部分通用指令数据。比如领域样本 50 条,就再混 10 到 20 条通用问答样本,让模型一边学新任务,一边复习原有的表达能力。
这本质上也是一种分布约束。通用数据会把部分梯度引向“保持原模型能力”的方向,抵消纯领域样本带来的单向拉扯。代价低、实现简单,效果也非常稳。很多开源的 SFT 配方里都会混入通用数据,背后就是这个道理。
需要注意节奏:通用数据比例不要太高,否则领域任务学不到位;也不要太低,否则起不到约束效果。我个人通常控制在 20% 到 30% 左右,先跑一轮看领域评测和通用能力评测的变化,再动态调整比例。这个思路可以作为 KL 散度约束的简易入门替代,两者叠加使用也可以。
6. 怎么确认没有过拟合:一套可执行的检测流程
6.1 训练集/验证集的loss曲线怎么读才有意义
防过拟合做得怎么样,不能靠感觉,要量化。第一个量化手段还是训练集和验证集的 loss 曲线,但要读得聪明一点。
如果样本量在 100 条以上,可以考虑做 80/20 划分,留一部分样本做验证。观察的重点不是单次验证 loss 的绝对值,而是它和训练 loss 之间的 gap 变化趋势。训练 loss 持续下降、验证 loss 开始反弹,这是最直接的过拟合信号。少样本下的验证集通常很小,单次 loss 波动很大,所以建议多看几步的平滑趋势,或者做几次不同划分交叉验证。
如果样本量少于 50,我不建议强行划分验证集,因为分出去之后训练数据更少了,反而容易欠拟合。这种极端少样本场景,重心应该放在下面的对比生成和指标检测上。
6.2 与基座模型做对比生成,比看曲线更靠谱
我觉得少样本过拟合最有效的检测方式,是直接拿基座模型做 AB 对比。准备几组输入,既包括领域内的典型问题,也包括领域外的开放闲聊,然后分别用微调前和微调后的模型生成,排在一起看。
人工看几个维度就行:相关性、多样性、自然度。如果领域内题目答得不错,但一遇到域外问题就往领域模板上拐,那基本就是过拟合;如果连领域内问题的回答都开始复读训练集原句,说明已经严重过拟合了。
这个测试成本很低,效果却非常直接。我一般会固定准备 5 个问题,每个实验迭代后都把三个版本的输出拿过来对比打分。与其去猜验证集 loss 有没有意义,不如亲自看几段生成结果来得踏实。
6.3 量化信号:distinct值、输出长度方差与困惑度
人工对比之外,还可以上一些量化指标辅助判断。最简单的是 distinct-1 和 distinct-2,分别表示生成结果中单字和二元词组不重复的比例。过拟合模型生成文本的 distinct 值会明显下降,因为它翻来覆去就那么几个词、几个句式。
输出长度的均值方差也是很好的信号。正常模型的回答长度会有一定波动,过拟合模型会收敛到训练集里的固定长度区间附近,方差急剧变小。此外,可以算生成结果的困惑度:在训练集上困惑度异常低,在域外文本上困惑度异常高,这往往意味着模型把训练样本背得太牢了。
需要提醒的是,这些指标只能辅助判断,不能单独作为证据。比如任务本身要求固定格式输出,distinct 值天然就会偏低。所以我的习惯是把它当作一个快速筛查工具,发现可疑信号后再用人工对比确认。
6.4 最后一个工程师技巧:检查参数更新幅度
最后一个指标可能很多人没试过:检查参数更新幅度。在训练前保存一份模型权重,训练后对比每个模块参数的 L2 范数差异,或者直接看 LoRA 增量矩阵的范数大小。
如果某个模块的参数变化幅度明显大于其他模块,同时验证集表现又没跟上,那这个模块大概率就是过拟合的重灾区。这有助于反向定位问题:是 attention 层学过头了,还是 MLP 层没有约束住,还是 lm_head 被拉偏了。
比如我发现某次微调里 attention 层的增量矩阵范数异常大,验证集上生成又开始复读,就会先把学习率降下来,或者把 KL 散度权重调大,再重新跑一轮。这种排查方式比盲目调参有目标得多,也算是我自己比较依赖的内部技巧。
最后分享一个我每次做 few shot SFT 之前都会过的检查清单,算是个人习惯:
- 先做数据多样性审计,删掉高相似样本,补上负样本和边界样本;
- 确认输出长度分布不是单一峰值,必要时做同义改写和格式扩展;
- LoRA 从 rank 8 到 16 起步,冻结 embedding 和 lm_head;
- 学习率用正常微调的十分之一左右,epoch 控制在 1 到 3;
- 加上标签平滑,条件允许时加 KL 散度约束,或者至少混入少量通用数据;
- 训练前后保存 base 模型输出,跑对比生成,顺带看一眼参数更新幅度。
这一套组合拳打完,基本上能把“训练集满分、真实场景零分”的概率压到很低。少样本微调本身就是一个在欠拟合和过拟合之间找平衡的活儿,不用指望一步到位,先跑通、再量化、再微调。过拟合这东西就是这样,你堵住了它的一条去路,它还会换一条路冒出来,但只要检测手段跟得上,它就跑不远。