1. 从一次真实需求说起:llama3微调到底解决什么问题
【AIGC调研系列】这个标题听起来像内部技术分享的编号,实际上它就是我在过去两个月里连续做了四轮 llama3 微调实验之后攒下来的复盘。之所以要单独把“llama3微调具体案例”拎出来讲,是因为网上关于 LoRA、QLoRA 的教程多如牛毛,但真正能把“为什么这么配参数”“显存到底花在哪”“训完之后怎么验收”讲透的并不多。大部分内容停在“跑通脚本”这一步,而实际项目里,跑通只是最不值钱的一环。
先说清楚这篇文章适合谁看:如果你手里有一张 24G 显存的消费级卡或者一张 40G/80G 的数据中心卡,想让 llama3 系列模型学会某个垂直领域的说话方式、输出格式或者业务口径,那这篇可以直接照着抄;如果你只是想拿模型聊聊天、写写文案,那提示词工程和检索增强才是更划算的路子,微调属于用大炮打蚊子。全文会围绕一套完整的落地流程展开:需求判断、数据构造、显存估算、训练脚本、效果验收、故障排查,每个环节都给出我实际用过的数字和参数,不做空谈。
这一轮实验的背景很朴素:某业务方需要模型把一堆半结构化的工单文本整理成固定字段的 JSON,字段有十来个,还有枚举值约束。直接拿基座模型加提示词去跑,字段缺失率大概在 18% 左右,枚举值写错的比例更高;换成微调之后,字段完整率拉到 99% 以上,枚举错误基本清零。这个对比就是我做这一系列调研的起点。
1.1 三个典型场景,判断你该不该微调
我一般拿三个问题去筛需求。第一个问题:任务是不是“格式约束型”?比如固定 JSON、固定话术、固定分类标签体系。这类任务微调收益最高,因为模型需要学的是输出结构而不是新知识,几百到几千条样本就能收敛得很好。第二个问题:任务是不是“风格迁移型”?比如让模型学会某个行业的表达习惯、客服语气、公文口吻。这类任务中等收益,需要的数据量更大一些,通常 2000 条以上才不容易跑偏。第三个问题:任务是不是“知识注入型”?比如让模型记住你们公司内部的几百条产品参数。这类任务微调是下策,知识更新一改就得重训,检索增强才是正解。
我自己踩过的一个坑是拿微调去做知识问答。当时灌了 8000 条内部文档问答对,训练 loss 降得很漂亮,但一上测试集就露馅:问到训练集里没出现过的组合问题,模型开始一本正经地编。原因很简单,微调改变的是模型的输出分布偏好,不是给它装了一个可查询的数据库。几百条事实还能勉强记住,上千条就开始互相干扰。后来这个场景我把方案换成了检索加提示词,微调只保留在“把检索结果整理成规范格式”这一层,效果反而更好。
所以判断标准可以归结成一句话:微调擅长教“怎么说”,不擅长教“说什么”。把这句话记住,能省掉一大半无效实验。
1.2 微调、检索增强、提示词工程的分工边界
这三者的关系经常被搞混。我习惯用一个类比:提示词工程是给新员工写的工作手册,检索增强是给他配了一个能随时查的资料库,微调是让他在你们公司实习三个月养成习惯。手册成本最低、改起来最快;资料库解决“记不住”的问题;实习解决“做事方式不对”的问题。
优先级上,我的实际做法永远是:先写提示词,跑到瓶颈;再加检索,看是否解决;最后才考虑微调。因为微调的隐性成本很高——数据标注、训练环境、效果回归、模型版本管理,每一环都要人力。一次微调从准备数据到验收,我一个人做大概需要三到五个工作日,这还是脚本已经跑熟的情况下。如果是第一次做,环境折腾两天很正常。
还有一个容易被忽略的点:微调和提示词不是互斥的。我现在的标准配置是“微调后的模型 + 精简版系统提示词 + 少量示例”。微调把格式和风格固化下来,提示词只保留那些需要频繁调整的业务规则。这样业务改规则的时候不用重训模型,只改提示词就行,迭代成本降了一大截。
1.3 硬件与时间成本先算账
动手之前先把账算清楚,能避免很多无效折腾。llama3 8B 这个量级,全参微调在单张 80G 卡上是跑不动的,具体原因后面显存那一节会详细拆。LoRA 微调在 24G 卡上可行但吃紧,需要开梯度检查点并且把序列长度压到 1024 到 2048 之间;QLoRA 也就是 4bit 量化加 LoRA,24G 卡跑起来比较从容,代价是训练速度慢一些,大约是 bf16 LoRA 的 0.6 到 0.7 倍。
时间上给个参考值:5000 条样本、序列长度 2048、单卡 24G、QLoRA、3 个 epoch,大概需要 6 到 9 小时。同样的数据量在单张 A100 80G 上跑 bf16 LoRA,大约 2 到 3 小时。如果只有一张 4090 24G,建议把 batch size 压到 1,梯度累积拉大,用时间换显存。
我自己的实验环境是单卡 24G 加一台 80G 的机器轮换用。小规模试参数在 24G 上跑,确认配置没问题再上 80G 跑全量,这样试错成本低。这个习惯建议你也养成,很多人一上来就在大机器上跑,参数配错了白烧几个小时算力。
2. 数据准备:数据集的质量决定微调上限
我必须把数据这一节放在训练之前,因为在我做过的四轮实验里,效果差异的七成来自数据,三成来自参数。同一套 LoRA 配置,换一批更干净的数据,字段完整率能从 92% 提到 99%;反过来,参数怎么调都救不了一堆脏数据。这一节讲清楚格式选型、清洗脚本和数据量经验值。
2.1 数据格式选型:Alpaca 还是 ShareGPT
Alpaca 格式就是三个字段:instruction、input、output,适合单轮任务,结构扁平,处理起来最省心。ShareGPT 格式是 messages 数组,每条消息带 role 和 content,适合多轮对话,能保留上下文依赖关系。
我的选择逻辑很简单:任务是单轮的,比如“给一段文本,输出 JSON”,就用 Alpaca 格式;任务涉及追问、澄清、多轮修正,比如客服对话模拟,就用 ShareGPT 格式。这两者在训练时最终都会被 chat template 拼成一条完整的 token 序列,区别只在于你构造数据时的组织方式。
有一个细节要特别注意:llama3 系列有自己的一套特殊 token,包括文本开始标记、角色起始标记、角色结束标记、文本结束标记。这些 token 必须由 tokenizer 的 chat template 自动生成,千万不要在手写数据里手动拼这些标记字符串。我见过有人图省事直接在 output 字段里写入角色标记,结果训练出来的模型在推理时疯狂输出结束符,一问一答变成一串乱码。正确的做法是数据里只放纯文本内容,拼模板这一步交给tokenizer.apply_chat_template。
举个我实际用的单轮样本,长这样:
{ "instruction": "你是工单信息抽取助手。请从下面的工单描述中抽取字段,输出严格 JSON,不要输出任何解释文字。", "input": "客户反馈昨天下午下单的蓝色保温杯收到时杯盖有裂纹,订单号 A12345,希望换货。", "output": "{\"category\": \"商品质量\", \"product\": \"保温杯\", \"issue\": \"杯盖裂纹\", \"order_id\": \"A12345\", \"expect\": \"换货\"}" }注意 output 里我特意只放纯 JSON,没有 Markdown 代码块包裹。这一点很关键,训练数据里带不带反引号,直接决定推理时模型会不会给你套一层代码块。业务方要的是能直接解析的字符串,那就不能在训练时惯坏它。
2.2 数据清洗与去重的实操脚本
原始数据通常来自人工标注或者从历史日志里抽,脏是必然的。我固定会跑四道清洗:长度过滤、去重、格式校验、异常字符处理。
长度过滤的作用是砍掉极端样本。太短的样本信息量不足,比如 instruction 只有“抽取一下”四个字;太长的样本往往超过序列长度被截断,反而引入噪声。我的经验阈值是:instruction 加 input 总长度在 20 到 3000 字符之间,output 在 5 到 2000 字符之间。
去重我用的是归一化之后的精确匹配加近似匹配两步。精确匹配就是去掉空白和标点后比对,近似匹配用字符级 n-gram 的 Jaccard 相似度,阈值设在 0.85。这一步能砍掉 5% 到 15% 的重复样本,重复样本的危害是让模型对某些模式过度自信,输出变得呆板。
格式校验主要针对结构化输出任务。如果 output 要求是 JSON,那就必须能解析成功,解析失败的样本要么修要么删。我一般会把解析失败的样本单独存一个文件,人工扫一遍,经常能发现标注规范本身有歧义。
import json, re, hashlib from difflib import SequenceMatcher def normalize(text): return re.sub(r"[\s\u3000]+", "", text).lower() def dedup(samples, threshold=0.85): seen, kept = set(), [] for s in samples: key = hashlib.md5(normalize(s["instruction"] + s["input"]).encode()).hexdigest() if key in seen: continue dup = False for k in kept[-2000:]: # 只在最近窗口内做近似比对,控制复杂度 if SequenceMatcher(None, normalize(s["input"]), normalize(k["input"])).ratio() > threshold: dup = True break if not dup: seen.add(key) kept.append(s) return kept def validate_json_output(samples): ok, bad = [], [] for s in samples: try: json.loads(s["output"]) ok.append(s) except Exception: bad.append(s) return ok, bad这段脚本不复杂,但它是我每次开工前的固定动作。省下这一小时,后面能省掉一整天的调参困惑。
2.3 数据量与配比的经验值
数据量这件事,我的经验值是分档的:单任务格式约束,800 到 2000 条就能看到明显效果;风格迁移类,2000 到 5000 条比较稳妥;涉及多任务混合,5000 条以上并且要做配比控制。低于 500 条不是不能训,但很容易过拟合,模型会把训练集里的一些无关特征也学进去,比如某个固定订单号前缀出现频率高,它就开始在输出里瞎编这个前缀。
超过 2 万条之后收益递减很明显。我试过把数据从 5000 条扩到 20000 条,字段完整率从 98.2% 提到 99.1%,但训练时间翻了四倍。这种投入产出比在业务上通常不划算,不如把精力花在补充那 5000 条里缺失的边界案例上。
配比这块要重点说。如果你的数据全是垂直领域样本,训完之后模型的通用对话能力会明显退化,问它一个常识问题都答得磕磕绊绊。我的做法是掺入 10% 到 20% 的通用指令数据,比如日常问答、简单推理、开放式写作。这个比例不用太精确,我试过 10% 和 20%,最终效果差异在人工盲评里几乎看不出来,但完全不掺和掺 10% 的差异非常明显。
还有一个小技巧:把最难的那批样本重复 2 到 3 次。比如你的任务里有一种特别长的工单,字段特别多,模型老是漏字段,那就把这类样本复制几份放进训练集。这比调学习率管用得多,本质上是给难样本加权,只是用了最朴素的方式实现。
3. 训练方案设计:LoRA 参数怎么定
方案设计这一节,我想把“为什么”讲透。很多人抄配置的时候只抄数值不抄逻辑,换个模型或者换个任务就懵了。这里从显存估算开始推导,一路推到 LoRA 的秩、alpha、目标模块怎么选。
3.1 全参、LoRA、QLoRA 的取舍
全参微调的意思是模型所有参数都参与梯度更新。llama3 8B 有 80 亿参数,bf16 精度下权重占 16GB,梯度再占 16GB,Adam 优化器还要维护一阶矩和二阶矩两份 fp32 状态,每份 32GB,加起来 64GB,另外混合精度训练通常还要保留一份 fp32 的主权重副本,又是 32GB。这些加起来已经 128GB 了,还没算激活值。所以全参微调 8B 模型,单张 80G 卡是不够的,至少需要两张做参数分片,或者用更激进的优化器状态分片方案。
LoRA 的思路是不动原始权重,在旁边挂两个小矩阵做低秩分解,训练时只更新这两个小矩阵。这样优化器状态和梯度的体积跟可训练参数量成正比,而不是跟总参数量成正比。可训练参数通常只占总量的 0.1% 到 1%,显存压力骤降。
QLoRA 是在 LoRA 基础上把冻结的主干权重做 4bit 量化。8B 参数从 16GB 压到 5GB 左右,代价是每次前向传播要反量化,速度慢一些,精度上有一点点损失。实测下来对于格式约束类任务,QLoRA 和 bf16 LoRA 的最终效果差异在 1 个百分点以内,完全可以接受。
选型建议我整理成一张表:
| 方案 | 24G 卡可行性 | 训练速度 | 效果 | 适用场景 |
|---|---|---|---|---|
| 全参微调 | 不可行 | 快 | 最好 | 多卡环境、数据量充足 |
| bf16 LoRA | 勉强,需压序列长度 | 基准 1.0 | 很好 | 单卡 40G/80G |
| QLoRA 4bit | 从容 | 约 0.65 | 略降 | 单卡 24G、快速验证 |
3.2 显存估算的完整推导
我喜欢把账算明白,这样遇到 OOM 的时候知道该砍哪一块。显存大致分四块:模型权重、梯度、优化器状态、激活值。
模型权重:8B 参数,bf16 每参数 2 字节,16GB;4bit 量化每参数约 0.5 字节,加上量化常数等开销,实际约 5GB 到 5.5GB。
梯度:只对可训练参数计算。LoRA 的可训练参数量可以精确算出来。以秩 r=16、目标模块包含注意力的四个投影和 MLP 的三个投影为例,单层单模块的参数增量是 r 乘以输入维度和输出维度之和。q_proj 是 16×(4096+4096)=131072,k_proj 和 v_proj 因为输出维度是 1024,各是 16×(4096+1024)=81920,o_proj 是 131072,gate_proj 和 up_proj 是 16×(4096+14336)=294912,down_proj 是 16×(14336+4096)=294912。单层合计约 131 万参数,32 层合计约 4200 万参数,占 8B 的 0.52%。梯度按 bf16 算,约 84MB,可以忽略不计。
优化器状态:如果用 8bit 分页 Adam,每参数约 2 字节,4200 万参数就是 84MB,同样可以忽略。如果用标准 fp32 Adam,每参数 8 字节,约 336MB,也还好。所以 LoRA 的优化器开销基本不是瓶颈。
激活值才是真正的大头。激活值大小跟 batch size、序列长度、隐藏维度、层数都成正比,粗略估算公式是 batch × 序列长度 × 隐藏维度 × 层数 × 系数,系数取决于具体实现,通常在 10 到 20 之间。以 batch=1、序列长度 2048、隐藏维度 4096、32 层估算,激活值可能到十几 GB。开启梯度检查点之后,只保留每层的输入,中间的激活值在反向传播时重算,显存能降到三分之一到四分之一,代价是训练速度慢 20% 到 30%。
综合下来,24G 卡跑 QLoRA、序列长度 2048、batch=1、开梯度检查点,峰值显存大约 14 到 18GB,留有余量。跑 bf16 LoRA 的话,权重 16GB 加上激活值,很容易顶到 23GB 以上,所以我一般会把序列长度压到 1024,或者 batch 保持 1 并把梯度累积拉到 16。
3.3 LoRA 秩、alpha、目标模块的选择逻辑
秩 r 决定低秩矩阵的表达能力。r 越大,能拟合的改动越复杂,但参数变多、过拟合风险上升。我的经验是:格式约束类任务 r=8 到 16 足够;风格迁移类 r=16 到 32;如果是想让模型学一种全新的输出语言或者复杂推理链,才需要 r=64 以上。大部分业务场景 r=16 是甜点值,我四轮实验里三轮都用 16。
alpha 是缩放系数,实际生效的缩放是 alpha 除以 r。业界常见做法是 alpha 取 r 的两倍,也就是 r=16 时 alpha=32,这样缩放系数是 2。这个设置等价于把学习率放大了两倍,所以如果你把 alpha 调大,学习率要相应调小,否则容易训飞。我一般固定 alpha=2r,然后只调学习率这一个变量。
target_modules 的选择上,早期教程只挂 q_proj 和 v_proj,那是从早期模型的经验沿用下来的。对 llama3 这种结构,我实测把注意力四个投影全挂上,再加上 MLP 的三个投影,效果比只挂两个好一截,尤其在需要改变输出格式的任务上。代价是可训练参数从 0.2% 涨到 0.5%,显存和时间增加有限,值得。
lora_dropout 我设 0.05,数据量少于 2000 条的时候会提到 0.1,纯粹是防过拟合。这个参数影响不大,不用纠结。
4. 动手跑起来:完整训练流程与代码
这一节是实操部分,从环境到合并权重一条龙。我用的技术栈是 transformers 加 peft 加 trl,这三个库的版本匹配是个大坑,后面会说。
4.1 环境搭建与版本对齐
版本这件事我要重点提醒。peft 和 trl 的接口变动比较频繁,尤其是 SFTTrainer 的参数名,不同版本之间差异很大。我建议锁定一套经过验证的组合,不要盲目升级。我自己用的一套是 transformers 4.43 左右、peft 0.12 左右、trl 0.9 左右、bitsandbytes 0.43 左右、accelerate 0.33 左右、torch 2.3 以上。这套组合我跑过好几轮没出问题。
安装顺序也有讲究,先装 torch,再装 transformers 和 accelerate,最后装 peft、trl、bitsandbytes。因为 bitsandbytes 会检测 torch 的 CUDA 版本,顺序反了容易装出一个用不了的版本。
pip install torch==2.3.1 --index-url https://download.pytorch.org/whl/cu121 pip install transformers==4.43.3 accelerate==0.33.0 pip install peft==0.12.0 trl==0.9.6 bitsandbytes==0.43.3 pip install datasets==2.20.0装完之后跑一行验证,确认 CUDA 可用、量化后端能加载:
import torch, bitsandbytes as bnb print(torch.__version__, torch.cuda.is_available(), torch.cuda.get_device_name(0)) print(bnb.__version__)如果 bitsandbytes 报找不到库文件,八成是 CUDA 版本和编译版本不匹配,卸载重装对应 CUDA 版本的包即可。这个问题我在两台机器上都遇到过,属于常见现象。
4.2 数据加载与 chat template 处理
数据加载这一步,核心是把 Alpaca 格式转成模型能吃的 token 序列。llama3 的 chat template 已经在 tokenizer 里内置了,直接调用就行。
from datasets import load_dataset from transformers import AutoTokenizer model_id = "模型仓库路径/llama3-8b-instruct" tokenizer = AutoTokenizer.from_pretrained(model_id) tokenizer.pad_token = tokenizer.eos_token # llama3 默认没有 pad token,必须手动指定 def format_sample(sample): messages = [ {"role": "system", "content": sample["instruction"]}, {"role": "user", "content": sample["input"]}, {"role": "assistant", "content": sample["output"]}, ] text = tokenizer.apply_chat_template(messages, tokenize=False) return {"text": text} ds = load_dataset("json", data_files="train.json")["train"] ds = ds.map(format_sample, remove_columns=ds.column_names)这里有个细节:pad_token必须设置,否则 batch 内长度不一致时会报错。llama3 系列默认只定义了结束符没定义填充符,直接拿结束符当填充符是最省事的做法,训练时用DataCollatorForLanguageModeling并在配置里把填充对应的 label 设成 -100,就不会参与损失计算。如果忘了这一步,模型会学着去预测填充符,推理时容易在回答末尾多吐几个结束符。
如果你的样本是多轮的,messages 数组里就按顺序放多组 user 和 assistant,模板会自己处理。
4.3 训练脚本全量与参数注释
这是完整脚本,我把它拆成模型加载、LoRA 配置、训练参数三段讲。
import torch from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training from trl import SFTTrainer, SFTConfig model_id = "模型仓库路径/llama3-8b-instruct" bnb_config = BitsAndBytesConfig( load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True, ) model = AutoModelForCausalLM.from_pretrained( model_id, quantization_config=bnb_config, device_map="auto", torch_dtype=torch.bfloat16, ) model.config.use_cache = False model = prepare_model_for_kbit_training(model, use_gradient_checkpointing=True) lora_config = LoraConfig( r=16, lora_alpha=32, lora_dropout=0.05, bias="none", task_type="CAUSAL_LM", target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"], ) training_args = SFTConfig( output_dir="./out_llama3_lora", per_device_train_batch_size=1, gradient_accumulation_steps=16, num_train_epochs=3, learning_rate=2e-4, lr_scheduler_type="cosine", warmup_ratio=0.03, logging_steps=10, save_strategy="epoch", save_total_limit=2, bf16=True, gradient_checkpointing=True, optim="paged_adamw_8bit", max_seq_length=2048, dataset_text_field="text", report_to="none", ) trainer = SFTTrainer( model=model, args=training_args, train_dataset=ds, peft_config=lora_config, tokenizer=tokenizer, ) trainer.train() trainer.model.save_pretrained("./out_llama3_lora/final")逐参数说一下为什么这么设。per_device_train_batch_size=1配gradient_accumulation_steps=16,等效全局 batch 是 16。单卡 24G 上 batch 只能设 1,靠累积拉大等效 batch 是为了让梯度更平滑。如果显存宽裕,可以设 batch=2、累积 8。
学习率 2e-4 是 LoRA 的常用起点,实际范围在 1e-4 到 3e-4 之间。如果 loss 前几十步就剧烈震荡,说明偏大,降到 1e-4;如果 loss 几乎不动,说明偏小,可以升到 3e-4。全局 batch 越大,学习率可以适当调大一些,这两者有一定联动关系。
num_train_epochs=3是我默认的档位。数据量少于 1000 条的时候我会设 5 个 epoch,但会盯紧验证集;数据量超过 1 万条的时候设 2 个 epoch 就够。3 个 epoch 在 5000 条数据上是 900 多步,属于比较典型的量级。
warmup_ratio=0.03是预热比例,前 3% 的步数里学习率从 0 线性升到目标值。这个设置能避免训练初期梯度方向太乱把权重带偏。lr_scheduler_type="cosine"是余弦衰减,后期学习率平滑下降,收敛更自然。
optim="paged_adamw_8bit"是分页版 8bit 优化器,显存友好,还能避免显存碎片导致的突发 OOM。这个参数用 QLoRA 的时候尤其推荐。
max_seq_length=2048要根据数据实际长度分布来定。我的做法是先统计训练集 token 长度的分布,取 95 分位数往上一点。如果 95 分位是 1800,那就设 2048;如果只有 800,那就设 1024,能省一半激活显存,速度也快不少。设得过大是浪费,设得过小会截断样本,被截断的样本里如果长回答被切掉一半,模型就学不到完整的结束结构。
4.4 训练过程监控与 checkpoint 管理
训练启动之后,第一件事是看 loss 曲线的头 50 步。正常情况是从 2 点几附近开始,几十步内降到 1 点几,然后缓慢下降。如果前 50 步 loss 完全不动,检查是不是数据里的 label 全被 mask 掉了;如果 loss 剧烈震荡甚至变成 NaN,检查学习率和精度设置。
我习惯开着logging_steps=10打印,同时另开一个终端看显卡占用:
watch -n 2 nvidia-smi --query-gpu=memory.used,memory.total,utilization.gpu --format=csv显存占用应该是一条平线,如果有缓慢上升的趋势,说明有内存泄漏或者缓存没释放,常见原因是use_cache忘了关。GPU 利用率应该在 70% 以上,如果只有 30% 到 40%,说明数据加载是瓶颈,可以调大 dataloader 的 num_workers,或者把数据预处理提前做完存成 tokenized 数据集。
checkpoint 我设的是每 epoch 存一次,只留最近两个。存太多会把磁盘占满,一个 8B 模型的 LoRA adapter 大概几十 MB,不算大,但如果你改成保存完整模型就很吓人。合并权重这件事我建议训练全部结束之后单独做一次,不要在每个 checkpoint 上都合并。
如果要中断训练,用 Ctrl+C 之后检查最近的 checkpoint 是否完整,然后从那个 checkpoint 续训。trl 默认支持 resume,把trainer.train(resume_from_checkpoint=True)传进去即可,不过 LoRA 续训有时候会有优化器状态对不齐的小问题,能一次跑完就一次跑完。
4.5 权重合并与推理验证
训练完的产物是 LoRA adapter,体积小,但要部署到推理服务上,通常需要合并回主干模型。合并的逻辑是把低秩矩阵乘回去加到原始权重上,注意这里要用未量化的 bf16 主干来做合并,不能拿 4bit 量化的模型合并,否则精度损失会叠加。
from peft import PeftModel from transformers import AutoModelForCausalLM, AutoTokenizer import torch base_id = "模型仓库路径/llama3-8b-instruct" base = AutoModelForCausalLM.from_pretrained(base_id, torch_dtype=torch.bfloat16, device_map="cpu") model = PeftModel.from_pretrained(base, "./out_llama3_lora/final") merged = model.merge_and_unload() merged.save_pretrained("./merged_llama3_lora", safe_serialization=True) tokenizer.save_pretrained("./merged_llama3_lora")合并建议在 CPU 上做,显存占用低,8B 模型合并大概需要十几分钟,内存要留 30GB 以上。合并完的文件大约 16GB。
推理验证的时候,一定要用和训练一致的 chat template。我见过有人训练用模板,推理时手写拼接,结果模型表现大打折扣,还以为是自己训坏了。正确做法是继续用apply_chat_template:
messages = [ {"role": "system", "content": "你是工单信息抽取助手,只输出 JSON。"}, {"role": "user", "content": "客户说上周买的红色水壶漏水,订单号 B67890,要求退款。"}, ] inputs = tokenizer.apply_chat_template(messages, add_generation_prompt=True, return_tensors="pt").to(model.device) outputs = merged.generate(inputs, max_new_tokens=256, do_sample=False, repetition_penalty=1.05, eos_token_id=tokenizer.eos_token_id) print(tokenizer.decode(outputs[0][inputs.shape[-1]:], skip_special_tokens=True))do_sample=False是贪心解码,格式约束类任务建议这么设,输出更确定。repetition_penalty=1.05是为了防止模型陷入重复输出,但不要设太高,超过 1.2 容易让输出变得生硬。
5. 效果验证:怎么判断这次微调成功了
训练 loss 降了不代表模型可用,这是我在项目里反复强调的一点。loss 衡量的是模型对训练分布的拟合程度,跟业务效果之间隔着好几层。这一节讲我用的三层评估法。
5.1 三层评估法:规则、模型、人工
第一层是规则校验,针对结构化输出任务。把测试集跑一遍,统计 JSON 可解析率、必填字段完整率、枚举值合法率。这三个指标是硬指标,达不到就不能上线。我的验收线是可解析率 99% 以上、字段完整率 98% 以上、枚举合法率 99% 以上。这一层可以完全自动化,写个脚本跑几百条样本,几分钟出结果。
第二层是模型评审。用一个大模型当裁判,给测试样本打分,评分维度包括准确性、完整性、格式规范性。这一层适合开放式生成任务,比如改写、总结、话术生成。要注意裁判模型本身有偏好,所以我会把基座模型和微调模型的输出打乱顺序做盲评,避免位置偏差。同一批样本跑两轮,看两次评分的一致性,一致性低于 80% 说明评分标准本身有问题,要重新设计。
第三层是人工盲评。抽 50 到 100 条样本,让不参与训练的人看输出,判断“能不能直接用”。这一层成本最高但最真实。我的经验是自动化指标和人工判断的相关性大概在 0.7 左右,总有那么一批样本指标很好看但人一看就觉得别扭,这些往往是格式对了但语义跑偏的情况。
5.2 人工盲评的表格设计
盲评表我一般设计成三列:样本编号加输入、输出 A、输出 B。评审人不知道哪个是基座哪个是微调,只回答三个问题:哪个更好、好的那个能不能直接用、差的那个具体差在哪。第三个问题是开放式的,经常能挖出自动化指标看不到的问题。
举个实际例子,有一轮微调之后字段完整率到了 99.5%,但人工盲评里有人指出“期望处理方式”这个字段虽然填了,但填的是“换货”,而原始文本说的是“退货”。这就是典型的语义错误,结构完全正确,规则校验查不出来。后来我在训练数据里补了 200 条容易混淆的对比样本,这个字段的准确率才提上来。
| 评估维度 | 检查方式 | 验收线 | 发现问题 |
|---|---|---|---|
| 格式可解析 | 脚本自动校验 | 99% 以上 | 多余文字、代码块包裹 |
| 字段完整 | 脚本自动校验 | 98% 以上 | 漏字段、字段名拼错 |
| 枚举合法 | 脚本自动校验 | 99% 以上 | 编造不存在的枚举值 |
| 语义正确 | 大模型评审加人工盲评 | 90% 以上 | 结构对但内容错 |
| 通用能力 | 固定通用题集回归 | 不下降超过 5% | 日常问答变差 |
5.3 上线前的压力测试项
上线前我会额外跑几组极端输入。空输入、超长输入、纯符号输入、多语言混杂输入,各跑几十条,看模型会不会崩。常见问题是超长输入被截断之后模型仍然硬着头皮输出,或者空输入时开始复读训练集里的某条样本。这些在测试集里通常覆盖不到。
还有一组是分布外测试。拿一些明显不属于训练领域的输入喂进去,看模型会不会强行套用格式。比如训练数据全是电商工单,喂一条技术故障描述,看它是老老实实说无法处理,还是硬编一个商品类目出来。这个能力在真实场景里很重要,硬编比报错危害大得多。
我的处理办法是在训练数据里掺入 3% 到 5% 的拒答样本,明确教模型在信息不足时输出固定的兜底话术。这些样本很重要,但经常被忽略。
6. 踩坑实录:llama3 微调最常见的问题排查
这一节是我这些轮实验里攒下来的真实故障记录。按发生频率排序,OOM 排第一,loss 异常排第二,通用能力退化排第三。
6.1 显存 OOM 的六层排查顺序
遇到 OOM 别急着换卡,按顺序往下砍,一般砍到第三层就够用了。
第一层砍 batch size,从 2 降到 1,或者从 1 保持但把梯度累积加大。第二层砍序列长度,2048 降到 1024,这一刀下去激活值能少一半。第三层开梯度检查点,gradient_checkpointing=True,代价是速度慢两三成。第四层换 4bit 量化,从 bf16 LoRA 切到 QLoRA。第五层降 LoRA 秩和 target_modules 数量,从 7 个模块砍到 2 个。第六层才是上参数分片或者换硬件。
按这个顺序走,大部分人卡在第二层或第三层就解决了。我遇到过最诡异的一次 OOM 是显存还剩 4GB 却报错,原因是显存碎片,加了PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True环境变量就好了。这个变量在长序列训练时很管用,建议默认加上。
还有一个隐蔽的 OOM 来源是评估阶段。训练时开了evaluation_strategy但没把 batch size 单独调小,评估时的显存峰值可能比训练还高。要么关掉训练中途评估,要么把per_device_eval_batch_size设成 1。
6.2 loss 不降或者降了但答非所问
loss 完全不动,先查标签。用tokenizer.decode把处理后的样本打印出来,重点看 assistant 部分的 token 有没有被正确保留。如果用了自定义的 loss mask 逻辑,很容易把整条序列都 mask 掉了,那 loss 永远是零梯度。用DataCollatorForCompletionOnlyLM的时候,响应模板字符串必须和 chat template 生成的格式完全一致,差一个空格都会导致 mask 失败。
loss 降了但答非所问,通常是两个原因。一是学习率太大,模型在训练集上过拟合得很凶,但泛化崩了,这时候把学习率降一半,epoch 减到 2 试试。二是训练数据里混进了大量无关样本,比如标注时把一些闲聊也放进去了,模型学到的是“先闲聊再输出格式”,表现出来就是回答前面先扯两句。这种情况要回头筛数据。
我还遇到过一次 loss 正常但输出全是重复的,查下来是训练数据里有大量高度相似的样本,模型坍缩到了一个固定模式。去重之后就好了。所以前面说的去重不是可选项。
6.3 通用能力退化与灾难性遗忘
这是最容易被忽略的问题。垂直数据灌多了,模型会忘记怎么正常说话。症状是问它一个普通问题,它也要套用你那个输出格式。比如你的任务是输出工单 JSON,问它“今天天气怎么样”,它给你返回一个 JSON。
缓解手段有三个。掺通用数据是最直接的,10% 到 20% 的通用指令样本能明显缓解。控制 epoch 数也有用,同样数据跑 1 个 epoch 比跑 5 个 epoch 退化更轻,我一般不超过 3 个。第三个手段是把 LoRA 秩调小,r=8 相比 r=64 对原始权重的扰动更小,退化也更轻。
验证方法很简单,准备 100 条通用题,跑一遍基座模型和微调模型,对比人工评分。如果下降超过 5%,就该回头调整数据配比了。这个回归测试我建议固化成流程,每次微调都跑一遍。
6.4 常见问题速查表
| 现象 | 最可能原因 | 处理动作 |
|---|---|---|
| 启动即 OOM | 序列长度过大或量化没生效 | 降到 1024,确认 load_in_4bit |
| 训练中途 OOM | 显存碎片 | 加 expandable_segments 环境变量 |
| loss 恒为 0 | 标签被全部 mask | 检查响应模板字符串是否匹配 |
| loss 震荡剧烈 | 学习率过大或数据噪声 | 学习率减半,检查异常样本 |
| 输出带 Markdown 代码块 | 训练数据里带了反引号 | 清洗数据,去掉包裹符号 |
| 输出末尾重复结束符 | 训练时未 mask 填充位 | 填充位 label 设为 -100 |
| 推理效果远差于训练 | chat template 不一致 | 统一用 apply_chat_template |
| 通用问答能力下降 | 垂直数据占比过高 | 掺 10% 到 20% 通用数据 |
| 输出字段名拼错 | 训练数据里拼写不统一 | 统一字段名再训 |
| 合并后精度下降 | 用量化模型做合并 | 用 bf16 主干合并 |
这张表我贴在工位上,出问题先扫一眼,能省不少时间。
最后分享一个小习惯:每次训练前,把这次的数据量、序列长度、秩、学习率、全局 batch、预计步数写在一个小文本文件里,跟训练产物放一起。三个月后回头看,你会庆幸自己记了这些。我第一轮实验没记,后来想复现某个效果好的配置,翻了两天日志才找回来。
另外还有个后续可以做的事:把这套流程里的人工盲评部分也做成半自动的,用一个固定的评分模板加多个裁判模型投票,减少对人的依赖。这个我在试,目前一致性还不够理想,等跑通了再单独写一篇。