1. 先搞清楚"指令增量微调"到底在调什么
LLaMA-Factory 的指令增量微调,说白了就是:你手上已经有一个能对话的底座模型,它可能已经训过一轮指令,但在你的业务场景里表现得像个刚入职的实习生——答得挺流畅,就是不对味。这时候你把几百上千条"问题—标准答案"喂进去,让它把这块能力补上,同时尽量别把原来会的东西忘掉。整套动作在 LLaMA-Factory 里就是配置几个 yaml、跑一条命令的事,但坑全藏在细节里。
这篇内容面向三类人:一是刚接触大模型微调、想跑通第一个 LoRA 的工程师;二是手里有业务数据、试过直接 prompt 但效果不稳定,想上 SFT 的同学;三是已经跑通过一次全量指令微调、现在要做第二轮、第三轮"增量"补充,结果发现模型开始变傻、开始遗忘的人。第三类其实是最需要看这篇的,因为增量微调真正难的不是跑通,而是"跑完不变傻"。
我会从显存预算算起,一路讲到数据集构造、LoRA 参数取舍、增量阶段的防遗忘策略、权重合并导出、以及效果验证的土办法,最后给一份踩坑速查表。所有命令和配置都是在 8B 级别指令模型上实测能跑通的形态,你换个模型大小、换个底座,把路径和 template 改掉基本就能用。
先明确一个概念边界,很多人在这儿就混了。"指令微调"(SFT)指用"指令—回答"成对数据做监督训练,让模型学会遵循指令格式;"增量微调"指在已有权重(或已有 LoRA 适配器)基础上继续训,而不是从基座重来。这两个词叠在一起,意味着你的起点是一个已经会聊天的模型,目标是让它在特定领域聊得更对,而不是教它从零学会说话。起点不同,数据和参数的取法完全不一样,这是全文的前提。
2. 环境搭建与显存预算:动手前先把账算清
2.1 版本对齐比装得多更重要
LLaMA-Factory 这类框架迭代很快,配置项名称在不同版本之间发生过好几次变动(比如量化相关的参数名、packing 相关开关)。我见过最常见的一类"玄学报错",追到底都是框架版本、transformers 版本、CUDA 版本三者没对齐。
我的习惯是先把环境隔离干净,再装框架本体:
conda create -n lf python=3.10 -y conda activate lf python -m pip install -U pip setuptools wheelPython 选 3.10 是我个人偏好,3.9 到 3.11 一般都能跑,但不要用太新的版本,某些编译型依赖的预编译包还没跟上。装完基础环境后拉源码,用可编辑模式安装,把训练和质量评估相关的依赖一起带上:
git clone --depth 1 <框架官方仓库地址> cd LLaMA-Factory pip install -e ".[torch,metrics]"安装完第一件事不是急着训,而是验证:
llamafactory-cli version python -c "import torch; print(torch.__version__, torch.cuda.is_available(), torch.cuda.device_count())"cuda.is_available()返回 False 的话别往下走了,先把驱动和 torch 的 CUDA 版本对上。这里有个细节:不要迷信nvidia-smi右上角显示的 CUDA 版本,那只是驱动支持的最高版本,真正要看的是 torch 编译时链接的 CUDA 运行时版本,用torch.version.cuda打印出来才准。
注意:如果你打算用 4bit 量化训练(QLoRA),需要额外确认
bitsandbytes能在当前环境正常导入,并且你的卡支持 bf16。老卡只支持 fp16 的话,训练稳定性会差一截,学习率要往下压。
2.2 显存预算:三种训练方式的真实差距
动手前必须先算显存,否则你会经历"配置写完、命令敲下、三十秒后 OOM"的循环。显存大致由四块组成:模型权重、梯度、优化器状态、激活值。前三个是固定的,激活值随 batch 和序列长度线性增长,也是最容易失控的一块。
以 8B 模型为例,粗算一下三种方式(单位 GB,bf16 混合精度,开启梯度检查点):
| 训练方式 | 权重 | 梯度 | 优化器状态 | 激活(seq=2048, bs=1) | 合计约 |
|---|---|---|---|---|---|
| 全参微调 | 16 | 16 | 64(Adam fp32 每参数 8 字节) | 10~20 | 110 以上 |
| LoRA(fp16/bf16 底座) | 16 | ≈0 | ≈0.5 | 8~15 | 26~32 |
| QLoRA(4bit 底座) | 4~5 | ≈0 | ≈0.5 | 6~12 | 12~18 |
优化器状态那 64GB 是全参微调最致命的负担:Adam 要为每个参数存一阶动量和二阶动量,fp32 下每个参数 8 字节,8B 参数就是 64GB,光这一项就劝退单卡。LoRA 只训新增的低秩矩阵,参数量通常只有原模型的 0.1%~1%,所以梯度与优化器状态几乎可以忽略,这也是为什么单张 24GB 卡能玩 8B 模型的原因。
激活值的估算公式可以记个近似:激活 ≈ batch × seq_len × hidden_dim × layers × 系数,系数和是否开启梯度检查点、是否用 FlashAttention 强相关,开检查点大约能省 60%~70%。所以当你发现 2048 长度跑不动、降到 1024 就能跑的时候,不要以为是玄学,这是激活值的线性缩放。
一个实用判断:显存占用稳定在总量的 85%~90% 是最舒服的区间,逼近 95% 就容易在某个长样本上突然爆掉。留 10% 余量,比把 batch 从 1 调到 2 带来的收益更值。
2.3 卡型选择与多卡策略
单卡能跑 LoRA 就别急着上多卡。多卡会引入通信开销和更复杂的参数语义(尤其是 DeepSpeed ZeRO 阶段的选择),调试成本陡增,而 LoRA 场景下多卡带来的加速往往不是线性的——8 卡跑 LoRA,实测加速比经常只有 3~4 倍。
真的需要多卡,一般是两种情况:全参微调,或者单条样本序列长度超过 8K。这时候走分布式:
FORCE_TORCHRUN=1 NNODES=1 NPROC_PER_NODE=4 \ llamafactory-cli train configs/incremental_lora_sft.yaml全参微调建议配 ZeRO-3 加 CPU offload,代价是速度掉一半以上。我的观点很直接:预算有限的情况下,宁可把 LoRA 的 rank 调大一点、数据做精一点,也不要为了追全参而把工程复杂度拉满。LoRA 在多数指令跟随类任务上,和全参的差距远小于"数据质量差 10%"带来的差距。
3. 数据集:增量微调真正的胜负手
3.1 三种数据格式怎么选
LLaMA-Factory 支持多种数据格式,实际用得最多的是 Alpaca 和 ShareGPT 两类。
Alpaca 格式是单轮指令的标准形态:
[ { "instruction": "把下面这段话改写成正式书面语", "input": "这事儿咱明天再聊吧", "output": "此事我们明日再行商议。" } ]字段语义很清晰:instruction是指令,input是可选输入,output是期望输出。单轮、结构简单、便于批量生成,缺点是表达不了多轮对话和 system prompt。
ShareGPT 格式走的是消息列表:
[ { "conversations": [ {"from": "human", "value": "帮我看看这份合同有没有风险条款"}, {"from": "gpt", "value": "好的,请把条款内容发我……"} ], "system": "你是一名严谨的合同审查助手" } ]多轮、能带 system、能表达"用户追问—模型修正"的交互轨迹,是增量微调里更推荐的形式。原因很实际:你的业务场景大概率是多轮的,而模型在单轮数据上训出来的能力,迁移到多轮时会有明显衰减。
选择建议:如果你的业务是单次问答、分类、抽取、改写,用 Alpaca;如果是助手型对话、需要保持角色设定、需要多轮上下文,用 ShareGPT。两种格式别混在一个数据集里,除非你在dataset_info.json里分别登记、分别打标签,通过dataset_dir组合使用。
3.2 增量数据的构造原则
增量微调的数据构造,和第一次 SFT 有本质区别。第一次是从零建立指令跟随能力,数据要"广";增量是在已有能力上做局部强化,数据要"深"且"准"。几个我反复验证过的原则:
第一,答案要写成你希望模型输出的那个样子,而不是"正确答案"。模型的模仿能力极强,你给它的答案里带"首先、其次、最后"的三段式,它就会对所有问题都用三段式;你的答案里全是"根据相关规定",它就会变得官腔十足。所以在写增量数据时,我一般会先人工写 20~30 条"标杆样本",把语气、长度、格式、是否带列表、是否带免责说明全部定死,再让团队按这个模板扩量。
第二,难度要分层。全是简单样本,模型学不到边界;全是难题,模型会过度思考、输出冗长。我通常按 6:3:1 配比:60% 是典型业务问题(模型现在答得不够好的那批),30% 是边界与异常情况(输入为空、输入超长、问题有歧义、请求超出能力范围该怎么拒答),10% 是"陷阱样本"(看起来像业务问题但实际不适用,训练模型别乱套模板)。
第三,拒绝样本必须显式写进去。增量微调最常见的翻车方式是模型变得"过度配合"——你问什么它都硬答,哪怕它根本不知道。解决办法是在数据里放一批标准拒答,比如"这个信息我无法确认,建议你核对原始材料",并为这类样本配上多种问法。不做这件事,你的模型上线后一定会在某个边界 case 上胡说。
第四,同一条知识至少三种问法。单一问法训出来的模型,换个语序就答不上来。我一般对每条核心知识准备 3~5 个变体:正式问法、口语问法、带错别字的问法、带上下文的问法。这部分用脚本批量改写比手写高效,但改写完必须人工抽检,机器改写的变体经常把语义改歪。
3.3 数据登记与字段映射
数据文件放进data/目录后,要在dataset_info.json里登记,框架才能识别。Alpaca 格式的登记最省事:
{ "biz_instruct_v1": { "file_name": "biz_instruct_v1.json", "columns": { "prompt": "instruction", "query": "input", "response": "output" } } }ShareGPT 的登记要多写几行,主要是角色标签的映射:
{ "biz_chat_v2": { "file_name": "biz_chat_v2.json", "formatting": "sharegpt", "columns": { "messages": "conversations" }, "tags": { "role_tag": "from", "content_tag": "value", "user_tag": "human", "assistant_tag": "gpt", "system_tag": "system" } } }这里有个高频错误:tags里的user_tag/assistant_tag必须和你 JSON 里实际写的字符串一字不差。有人写的是user/assistant,配置里却填human/gpt,结果训练时所有样本的角色识别失败,loss 一直卡在高位,看起来很玄学,其实一行配置的事。
3.4 训练前必做的三项数据体检
数据不检查就开训,等于闭着眼睛开车。我在每次训练前固定跑三个检查:
import json from collections import Counter data = json.load(open("data/biz_chat_v2.json", encoding="utf-8")) lens, roles, bad = [], Counter(), [] for i, item in enumerate(data): conv = item.get("conversations", []) if not conv or conv[-1].get("from") != "gpt": bad.append((i, "结尾不是 assistant 回复")) continue for turn in conv: roles[turn["from"]] += 1 lens.append(len(turn["value"])) print("样本数:", len(data)) print("角色分布:", roles) print("长度分位: p50=%d p90=%d p99=%d max=%d" % ( sorted(lens)[len(lens)//2], sorted(lens)[int(len(lens)*0.9)], sorted(lens)[int(len(lens)*0.99)], max(lens))) print("异常样本:", bad[:10], "共", len(bad))三项体检对应三个结论:角色分布看 human/gpt 是否大致平衡,如果 assistant 回复平均长度远小于 human,说明你的数据大概率是"短答"型,训出来的模型也会变得惜字如金;长度分位决定cutoff_len该设多少,p99 决定了绝大部分样本能完整保留,设太小会截断答案、训出"话说到一半"的毛病;异常样本检查最后一句是不是 assistant,这是最容易出错的地方,最后一句是 human 的样本会污染训练信号。
一个真实的教训:我早期有一批数据,因为导出脚本的 bug,约 8% 的样本最后一句是 human。训练 loss 看着正常下降,但模型上线后开始"自问自答"——生成完答案还会自己再追问一句。找了半天以为是 template 的问题,最后发现是数据。训练前跑一遍结构校验,能省你两天。
4. 参数配置:LoRA、学习率与 batch 的三角平衡
4.1 全参、LoRA、QLoRA 怎么选
这个选择本质是"显存—效果—迭代速度"的三选二。
全参微调效果上限最高,但需要 100GB 级别显存,且每次实验都是一次完整训练,迭代速度慢,适合有明确大规模数据、且要做长期能力改造的场景。LoRA 冻结底座、只训低秩旁路,显存需求降到 1/4 左右,训练快、可插拔、多个任务可以训多个 adapter 分别挂载,是增量微调场景下的默认选择。QLoRA 把底座量化到 4bit 再做 LoRA,显存进一步压缩到 1/2,代价是训练速度慢(反量化有开销)且精度有轻微损失。
我的实际取法:增量微调优先 LoRA;只有在数据量少于 500 条、且需要模型深度适配某种输出风格时,才考虑全参;显存只有 16GB 以下时才用 QLoRA。理由很朴素——增量微调的数据量通常不大(几百到几千条),LoRA 的低秩约束正好起到正则化作用,反而不容易过拟合;全参在小数据上更容易把底座带偏。
4.2 LoRA 关键参数的实际含义
lora_rank(秩 r)决定旁路矩阵的容量。r=8 是保守起点,r=16~32 是常用区间,r=64 以上一般只在数据量大、任务复杂时才需要。我的经验是:r 增大到某个点后收益就饱和了,而显存和过拟合风险继续涨。判断方法很简单,固定其他参数,跑 r=8 / 16 / 32 三组,看验证集表现,通常 16 到 32 之间就有明显拐点。
lora_alpha是个缩放系数,实际生效的缩放是alpha / r。所以 r 和 alpha 要联动调整,常见配法是 alpha = 2r(r=16 配 alpha=32),这样缩放系数恒为 2,换 r 时不用重新调学习率。很多人只改 r 不改 alpha,结果缩放系数从 2 变成 4 或 1,学习率就相当于隐式变了,实验结论全乱。
lora_dropout在小数据上很有用,0.05~0.1 之间。数据只有几百条时我给 0.1,几千条时给 0.05,上万条时干脆给 0。
lora_target决定旁路挂在哪些层上。all表示所有线性层都挂,效果最好但显存和权重大;只挂q_proj,v_proj是最省的经典做法,效果略逊。我一般用all,因为增量场景下多出来的那点显存通常还能接受,而效果提升是实打实的。
4.3 学习率、epoch 与 batch 的换算
LoRA 的学习率比全参高一个数量级,常用 1e-4 到 2e-4;全参则是 1e-5 到 2e-5。原因是 LoRA 的旁路是随机初始化的,需要更大的步长才能快速收敛。增量微调时,如果你是在已有的 adapter 上继续训(第二次、第三次增量),学习率要再降一半到三分之一,比如从 1e-4 降到 3e-5,否则容易把上一轮学到的东西冲掉。
有效 batch 的换算是新手最容易忽略的地方:
有效 batch = per_device_train_batch_size × gradient_accumulation_steps × GPU 数量显存受限时,per_device_train_batch_size设 1,靠gradient_accumulation_steps堆到 16 或 32,效果上接近大批量,代价是训练变慢(每个 step 要跑多次前向反向)。我常用的组合是单卡 bs=2、累积 8,有效 batch 16;如果数据里长样本多,就降到 bs=1、累积 16。
epoch 数量上,增量微调我一般只跑 2~3 轮。指令微调不是预训练,1 轮往往不够(模型还没学会新模式),超过 3 轮就开始背答案——表现是训练 loss 一路降,验证集 loss 先降后升,同时模型开始"复读"训练集里的原句。判断过拟合最直接的信号不是 loss,而是你拿一条训练集里没有的、但同类的新问题去问它,看它还能不能按套路答。答得出来说明学到了模式,答不出来只会背原题,说明该停了。
4.4 一份可以直接抄的增量微调配置
### model model_name_or_path: /data/models/llama3-8b-instruct trust_remote_code: true template: llama3 ### method stage: sft do_train: true finetuning_type: lora lora_rank: 16 lora_alpha: 32 lora_dropout: 0.05 lora_target: all ### 增量:在已有 adapter 上继续训时打开下面这行 # adapter_name_or_path: /data/output/incremental_v1 ### dataset dataset: biz_chat_v2,biz_replay_v1 dataset_dir: data cutoff_len: 2048 max_samples: 100000 overwrite_cache: true preprocessing_num_workers: 8 ### output output_dir: /data/output/incremental_v2 logging_steps: 10 save_steps: 200 plot_loss: true overwrite_output_dir: true report_to: none ### train per_device_train_batch_size: 2 gradient_accumulation_steps: 8 learning_rate: 1.0e-4 num_train_epochs: 3.0 lr_scheduler_type: cosine warmup_ratio: 0.1 bf16: true gradient_checkpointing: true ddp_timeout: 180000000几个参数单独说明。dataset里写了两个数据集,第二个是回放数据,这是增量微调防遗忘的核心手段,后面单独讲。cutoff_len: 2048是根据数据体检的 p99 定的;如果你的数据 p99 只有 800,就设 1024,多余的显存留给 batch。overwrite_cache: true在调试阶段很有用——你改了数据但缓存没更新,训的还是旧数据,这种问题极难发现,养成改数据就开这个开关的习惯。report_to: none是关掉外部日志上报,内网环境不加这行可能卡在连接超时。
5. 开训:命令行与 WebUI 两条路
5.1 命令行训练的完整节奏
配置好之后,训练本身只有一行:
llamafactory-cli train configs/incremental_lora_sft.yaml但真正的"完整流程"是这样的:先跑 20 步冒烟测试,确认数据能正确加载、loss 是正常数值(不是 nan、不是恒定不变),再正式开训。冒烟测试把max_samples设成 50、num_train_epochs设成 0.01 就行,三十秒内能看出问题。
启动后重点盯三行日志:num tokens(样本总 token 数,能反推这批数据大概要训多久)、trainable params(可训练参数量,LoRA 一般在总参数的 0.5%~2%,如果显示 100%,说明finetuning_type没生效,在偷偷全参训练)、total train batch size(有效 batch,和你的预期对不上就要检查累积步数)。
日志里我还会关注grad_norm。稳定训练时它应该在一个小区间内波动,突然飙到几十甚至上百,通常是遇到了一个特别长的样本或者数据里有脏样本(比如超长重复文本)。偶尔一两次飙升问题不大,持续飙升就得回头查数据。
5.2 WebUI 适合调参,不适合长期训练
框架自带的 WebUI 起服务很简单:
llamafactory-cli webui默认起在本地端口,浏览器打开就能看到模型选择、数据选择、参数面板、训练曲线。它的价值在于快速试参数——改个学习率、换个 rank、点一下预览,不用来回改 yaml。训练过程中的 loss 曲线是实时刷新的,比盯终端日志直观得多。
但我不建议在 WebUI 里跑正式训练,两个原因:一是长时间训练时浏览器断连会导致你失去对进程的控制感(进程还在跑,但你不知道进度);二是 WebUI 里的参数最终还是要落成 yaml 才能复用和版本管理。我的习惯是 WebUI 试出参数组合,然后把参数抄回 yaml,正式训练全走命令行,配置文件进 git 管理。
有一点值得提醒:WebUI 里的"预览"功能会在训练前把样本按模板拼好展示给你看。这一步千万别跳过,它是验证 template 是否匹配的最快方式。你会亲眼看到
system有没有被正确加进去、特殊 token 有没有拼对、答案有没有被截断。我遇到过好几次预览时发现整个样本被包了一层多余的模板标记,纯粹是因为模型路径指向了一个非对话版本。
5.3 增量训练时的断点续训
增量微调往往不是一次跑完的,中途改数据、调参数很常见。续训的关键是保留 checkpoint 目录结构,让output_dir里存在checkpoint-xxx文件夹,然后:
llamafactory-cli train configs/incremental_lora_sft.yaml \ resume_from_checkpoint: true或者直接在 yaml 里写resume_from_checkpoint: /data/output/incremental_v2/checkpoint-1200。这里要区分两种"续":
- 续训同一轮训练:用
resume_from_checkpoint,优化器状态、学习率调度器状态都会恢复,等于从中断点接着跑。 - 新一轮增量:用
adapter_name_or_path指向上一轮的 adapter 输出目录,此时优化器状态重置,学习率重新走 warmup,这是"学到的东西继续叠加"。
这两个混用会出问题。我见过有人想做新一轮增量,结果用了resume_from_checkpoint,训练确实接着跑了,但学习率调度器也接着上一轮末尾的低学习率,新数据几乎没学进去,白白跑了一天。
6. 灾难性遗忘:增量微调真正的敌人
6.1 遗忘是怎么发生的
模型参数是共享的。你用 500 条新数据去调整那 0.5% 的 LoRA 参数,梯度更新的方向会优先满足这 500 条样本,而原本支撑其他能力的表征方向如果没有样本"拉住",就会被顺带推偏。表现出来就是:新任务答得挺好,但你随口问一句常识问题,它开始胡说八道;或者原本流利的中文输出开始夹杂英文片段。
这不是框架的 bug,是连续学习的基本矛盾。LoRA 相比全参已经把遗忘程度压低了很多(因为底座冻结,只有旁路被改),但做多轮增量、且每轮数据都比较窄的时候,遗忘依然会明显出现。
判断遗忘是否发生,我有个特别土但特别有效的办法:手里常备一份10 条"保命测试题",覆盖模型原本擅长的几个方向(常识问答、格式改写、简单算术、拒答、多轮澄清)。每轮增量训练完,先跑这 10 条,如果开始出现明显退化,说明遗忘已经发生了,别急着上线。
6.2 回放数据的配比与实验
防遗忘最有效的手段是数据回放:把一部分旧数据混进新数据里一起训。原理很直接——旧样本的梯度会把参数往原来的方向拉,和新增量的梯度形成对抗,最终落在一个兼顾两者的位置。
配比上,我一般从新:旧 = 1:0.2 起步,然后根据遗忘程度调整。遗忘严重就加到 1:0.5,甚至 1:1;遗忘不明显就降到 1:0.1。旧数据的来源可以是第一轮 SFT 的训练集(抽一部分),也可以是你从线上日志里捞的、模型当初答得好的样本——后者更有针对性。
实测发现两个规律:第一,旧数据的"多样性"比"数量"更重要。200 条覆盖 20 个方向的旧数据,比 2000 条同一个方向的旧数据防遗忘效果好得多。第二,回放数据的难度要和新数据大致相当,如果旧数据全是简单问答、新数据全是复杂推理,回放的效果会打折,因为简单样本的梯度太弱,拉不住。
回放比例不是调一次就完事,它跟数据规模、训练轮数、学习率都耦合。我的做法是固定其他变量,只改配比跑三组(1:0.1 / 1:0.3 / 1:0.5),用那 10 条保命测试题 + 新任务的 20 条测试题做交叉评估,选平衡点最好的那组。
6.3 用学习率和轮数做"减法"
除了加回放数据,还有两个更省事的控制手段。
降学习率。增量训练的学习率控制在首轮的 1/2 到 1/3,比如首轮 1e-4,增量就用 3e-5 到 5e-5。学习率小,参数移动的步子就小,对原有表征的破坏也小。代价是新能力学得慢,需要多跑一点步数,但这个交换在增量场景下是划算的。
减少轮数 + 早停。增量数据往往只有几百条,3 轮以上极易过拟合。我通常跑 2 轮,同时在验证集上做早停:把数据按 9:1 切出验证集,eval_steps设为 50,配load_best_model_at_end相关参数,验证 loss 连续几次不降就停。很多人不做验证集切分,全程盲训,最后只能凭感觉选 checkpoint,这是很浪费的——你已经有数据了,切 10% 出来做验证,成本极低。
还有一个偏门但好用的招:冻结部分层。如果遗忘特别严重,可以在 LoRA 基础上再限制只训练靠后的若干层(靠近输出的层更影响风格,靠前的层更影响基础语言能力)。保留靠前的层不动,基础语言能力就不容易崩。这个手段在框架里通过additional_target或自定义lora_target的正则表达式实现,属于进阶操作,一般配好回放数据就不需要。
7. 导出、合并与验证
7.1 两种导出形态的选择
训练完的 LoRA 是一个小目录(通常几十到几百 MB),有两种用法:
第一种,不合并,推理时动态加载。底座模型 + adapter 目录,推理时一起加载。好处是灵活——你可以为不同业务训多个 adapter,共用一份底座,按请求路由。缺点是推理时要多一步加载,且部分推理框架对动态加载的支持不完整。
第二种,合并成一个完整模型。把 LoRA 权重按W + BA × scale融进底座,输出一个标准模型目录。好处是通用性最好,任何推理框架都能直接加载,且没有额外的运行时开销。
合并用一条命令:
### 导出配置 export.yaml model_name_or_path: /data/models/llama3-8b-instruct adapter_name_or_path: /data/output/incremental_v2 template: llama3 finetuning_type: lora export_dir: /data/models/llama3-8b-biz-v2 export_size: 4 export_device: cpu export_legacy_format: falsellamafactory-cli export configs/export.yamlexport_device: cpu是个实用选项,合并过程不需要 GPU,可以在 CPU 机器上慢慢跑,不占用训练卡。export_size: 4表示按 4GB 分片,方便传输和加载。export_legacy_format: false输出的是较新的权重格式,如果你的推理框架比较老,可能需要改成 true。
一个容易被忽略的点:合并时用的
template必须和训练时一致。合并本身不涉及模板,但如果你后续用框架自带的推理脚本验证,模板不匹配会导致对话格式错误,输出质量断崖式下跌,让你误以为是训练出了问题。
7.2 验证:先跑通,再跑对
合并完先做加载验证:
llamafactory-cli chat configs/export.yaml能正常起来、能正常对话,说明模型文件没坏。这一步不要跳过,我遇到过合并过程中磁盘写满导致文件截断的情况,加载时直接报错,早发现早重跑。
然后做行为验证,分三层:
第一层,格式验证。用 10 条新任务的典型问题去问,看输出格式是否符合你在训练数据里定义的样式(有没有多余的前后缀、有没有漏掉必填字段、长度是否合理)。这一层不过,说明训练数据或模板有问题,不用往下看了。
第二层,能力验证。用 30~50 条训练集里没有的新问题(这是关键,必须是没有见过的)去测,人工或用规则打分。我一般用"完全正确 / 部分正确 / 错误 / 拒答"四档做人工标注,算个准确率。这个数字才是你判断这轮增量有没有价值的依据,而不是训练 loss。
第三层,回归验证。跑那 10 条保命测试题,确认原有能力没有明显退化。如果新任务准确率涨了 20 个点,但保命测试从 9/10 掉到 5/10,这轮训练是失败的,需要加回放数据重来。
7.3 评估的土办法与硬指标
没有条件搭完整评测流水线时,几个土办法很管用。
双模型对比打分。拿一个能力更强的模型(或者干脆用训练前的原始模型)当裁判,把同一个问题的两个回答匿名打乱顺序,让它选哪个更好。这个方法有偏置(裁判模型偏爱长回答、偏爱自己的风格),但用于同一个模型的 A/B 对比足够灵敏。关键是要打乱顺序,否则裁判会有位置偏好。
规则匹配。如果你的任务输出是结构化的(JSON、固定字段、分类标签),直接写脚本比对就行,准确率、召回率、格式合规率一起算。这类任务的评估成本最低,也最该做自动化。
人工抽检的最小样本量。如果只能人工看,我建议至少抽 50 条,且要分层抽:简单样本、困难样本、边界样本各占一定比例。只抽简单样本会给你虚假的安全感。抽检时记录"错在哪"——是理解错了、格式错了、还是知识错了,这三类的修复手段完全不同(前者加数据、中者改模板、后者补知识)。
8. 踩坑实录与排查速查表
8.1 显存与性能类问题
OOM 但显存看起来还有余量。大概率是某个超长样本在训练中途触发了峰值。解决顺序:先降cutoff_len(最有效),再开gradient_checkpointing,再把per_device_train_batch_size降到 1。不要一上来就上 4bit 量化,那是杀鸡用牛刀。
训练速度慢得离谱。三个常见原因:没开 bf16(用 fp32 训,速度差 2 倍以上)、没装 FlashAttention(长序列下差异明显)、preprocessing_num_workers设成 0(数据预处理成了瓶颈)。逐个排查,速度通常能提 2~3 倍。
多卡训练比单卡还慢。通信开销吃掉了并行收益。检查是否误开了 ZeRO-3(LoRA 场景一般用 ZeRO-2 或不开)、卡间是否是高速互联、ddp_timeout是否设得太小导致反复重连。
8.2 训练效果类问题
loss 完全不降。按这个顺序查:学习率是不是设成了 1e-6 这种量级(LoRA 要 1e-4);finetuning_type是不是写错了导致没训任何参数(看日志里的trainable params);数据是不是没被正确加载(看num tokens是否为 0);标签是不是全被 mask 掉了(有个train_on_prompt相关的开关,配置不当会导致只学 prompt 不学回答)。
loss 降了但模型不会用。典型原因是训练和推理的模板不一致。训练时用template: llama3,推理时用了别的模板或者干脆裸奔没加对话格式,模型看到的输入分布和训练时完全不同。这类问题的表现是"模型好像傻了",其实是被喂了格式错误的问题。
模型开始复读训练集原句。过拟合的典型信号。降轮数、加 dropout、加数据多样性,三选二基本能解决。如果数据本身就只有几十条,那再怎么调都会背,该做的是扩数据而不是调参。
输出被截断在半句话。两个可能:cutoff_len太小,训练样本的答案被截断,模型学会了"话说一半";或者推理时的max_new_tokens设得太小。前者改数据或改 cutoff,后者改推理参数。
8.3 常见问题速查表
| 现象 | 最可能原因 | 优先排查动作 |
|---|---|---|
| 启动即 OOM | cutoff_len 过大 / batch 过大 | 降到 1024、bs=1 先跑通 |
| 训练中随机 OOM | 长样本峰值 | 开梯度检查点,过滤超长样本 |
| loss 恒定不变 | 学习率过小 / 参数未生效 | 查 trainable params 占比 |
| loss 变 nan | fp16 溢出 / 学习率过大 | 换 bf16,学习率降一半 |
| 训练快但效果差 | 数据量太少 / 模板不匹配 | 扩数据,核对 template |
| 新任务好了老任务废了 | 灾难性遗忘 | 加 20%~50% 回放数据,降学习率 |
| 输出格式总是不对 | 训练数据格式不统一 | 抽查 20 条训练样本的输出格式 |
| 合并后模型没变化 | adapter 路径写错 / 未生效 | 检查 adapter 目录里的权重文件大小 |
| 推理时回答很短 | 训练数据答案普遍偏短 | 补充长答案样本,或在 prompt 里约束 |
| 多轮对话第二轮就崩 | 训练数据全是单轮 | 补多轮 ShareGPT 格式数据 |
8.4 几条不会写在文档里的经验
第一,先跑通再调优,别一上来就追效果。我见过太多人第一次就跑 5000 条数据、rank 64、3 轮,跑完发现效果不对,根本不知道是哪一步的问题。正确顺序是:50 条数据 + 默认参数跑通(验证链路)→ 500 条 + 调学习率(验证方向)→ 全量数据 + 调 rank 和轮数(追求效果)。每一层只改一个变量,出问题才知道该回退到哪。
第二,checkpoint 别只留最后一个。save_steps设小一点,多存几个,训完拿中间的几个 checkpoint 分别测一下。很多时候倒数第二个 checkpoint 比最后一个效果好——最后一两百步可能已经在过拟合了。这个发现能帮你白捡一两个点的准确率。
第三,增量微调不是万能药。如果模型缺的是事实性知识(比如某个产品的具体参数),SFT 是最差的注入方式——它容易让模型"记住又记错",还会引发幻觉。这类需求应该走检索增强,把知识放在外部,让模型学会"查了再说"。SFT 真正擅长的是改变输出行为:格式、语气、推理步骤的组织方式、什么时候该拒答。搞清楚你的需求属于哪一类,能省下大量无效训练。
第四,留一份"训练配置档案"。每次训练的数据版本、yaml、评估结果,记在一个表里。增量微调会做很多轮,没有档案的话,两周后你完全不记得"v3 那版为什么比 v2 好"。这个习惯看起来笨,但它决定了你是在做实验还是在碰运气。
我个人在做多轮增量时最深的体会是:模型不会因为你多喂了 200 条数据就突然变聪明,它只会变得更像你喂的那 200 条数据。所以真正决定成败的,从来不是 rank 调到了多少、学习率是 1e-4 还是 2e-4,而是你在写那 200 条数据时,有没有想清楚"我到底希望它在什么情况下、用什么语气、输出什么形状的东西"。参数只是把这个意图忠实地放大或扭曲,意图本身不清楚,参数学得再准也没用。