问题背景

手上有一个垂直领域的问答场景,基座模型用的是Qwen2.5-7B-Instruct。直接prompt工程能覆盖大部分通用问题,但涉及领域术语、内部规范和固定输出格式时,基座模型的表现就不太稳定——要么答非所问,要么格式跑偏。全量微调7B模型对显存和成本要求太高(单卡A100 40GB跑全参微调基本没戏),所以选择了LoRA这条路线。

目标很明确:用1万条左右的领域数据,让模型在保持通用能力的前提下,显著提升领域问答的准确率和格式遵循度。本文记录整个落地过程,包括数据准备、训练配置、loss曲线、推理对比,以及中间踩过的坑。

环境与版本

环境这块我尽量写清楚,因为版本不一致导致的坑实在太多了:

  • GPU:单卡 A100 40GB
  • CUDA:12.1
  • Python:3.10.13
  • PyTorch:2.3.1 + cu121
  • transformers:4.44.2
  • peft:0.12.0
  • trl:0.9.6
  • bitsandbytes:0.43.3(本次最终没用QLoRA,但装了备用)
  • accelerate:0.33.0
  • datasets:2.20.0

这里有个小提醒:transformers 4.44 和 peft 0.12 的组合比较稳,再往上版本(比如transformers 4.45+)在加载某些7B模型时会出现 attn_implementation 相关的警告甚至报错,建议锁版本。

方案设计

整体思路分三块:

1. 数据格式:统一成对话格式(system / user / assistant),用trl的SFTTrainer直接吃。数据来源是内部FAQ + 人工改写,共1.2万条,按 9:1 划分训练集和验证集。

2. LoRA配置:目标模块选 q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj(全attention+MLP),r=16,alpha=32,dropout=0.05。之所以不选更大的r,是因为1万条数据量下r=16已经够用,r=32反而容易过拟合,实测后面会提到。

3. 训练策略:bf16混合精度,batch size 4 + 梯度累积 4(等效16),学习率2e-4,cosine调度,warmup 100步,训练3个epoch。序列长度截断到2048。

为什么不用QLoRA?因为40GB显存跑bf16的LoRA(7B模型权重约15GB + 优化器状态 + 激活)是够的,实测峰值显存约28GB。QLoRA的4bit量化会带来额外精度损失,在当前显存条件下没必要。但如果只有24GB卡(比如3090/4090),就得切QLoRA了。

核心实现

数据准备

先把原始数据转成 messages 格式,写成一个jsonl:

import json
import random

def build_sample(system, user, assistant):
    return {
        "messages": [
            {"role": "system", "content": system},
            {"role": "user", "content": user},
            {"role": "assistant", "content": assistant},
        ]
    }

# 假设 raw_data 是 [(user, assistant), ...] 的列表
SYSTEM_PROMPT = "你是一个领域助手,回答需严格遵循给定格式,不确定时明确说明。"
samples = [build_sample(SYSTEM_PROMPT, u, a) for u, a in raw_data]
random.seed(42)
random.shuffle(samples)

split = int(len(samples) * 0.9)
with open("train.jsonl", "w", encoding="utf-8") as f:
    for s in samples[:split]:
        f.write(json.dumps(s, ensure_ascii=False) + "\n")
with open("val.jsonl", "w", encoding="utf-8") as f:
    for s in samples[split:]:
        f.write(json.dumps(s, ensure_ascii=False) + "\n")

注意:SFTTrainer 用 chat template 时,要确保 tokenizer 的 chat_template 存在。Qwen2.5 自带,直接 tokenizer.apply_chat_template 即可。

训练配置与代码

核心训练脚本如下,用 peft + trl:

import torch
from datasets import load_dataset
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer, SFTConfig

MODEL_PATH = "Qwen/Qwen2.5-7B-Instruct"

tokenizer = AutoTokenizer.from_pretrained(MODEL_PATH, trust_remote_code=True)
tokenizer.pad_token = tokenizer.eos_token

# bf16 全精度加载(40GB卡够用);若显存不足改用下方4bit配置
model = AutoModelForCausalLM.from_pretrained(
    MODEL_PATH,
    torch_dtype=torch.bfloat16,
    device_map="auto",
    trust_remote_code=True,
)

# —— 若使用QLoRA,替换为以下加载方式 ——
# 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_PATH, quantization_config=bnb_config,
#     device_map="auto", trust_remote_code=True,
# )
# model = prepare_model_for_kbit_training(model)

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"],
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 输出:trainable params: 40,370,176 || all params: 7,655,986,688 || trainable%: 0.527

train_dataset = load_dataset("json", data_files="train.jsonl", split="train")
eval_dataset = load_dataset("json", data_files="val.jsonl", split="train")

sft_config = SFTConfig(
    output_dir="./lora_qwen7b",
    per_device_train_batch_size=4,
    per_device_eval_batch_size=4,
    gradient_accumulation_steps=4,
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_steps=100,
    num_train_epochs=3,
    logging_steps=10,
    eval_strategy="steps",
    eval_steps=100,
    save_strategy="steps",
    save_steps=300,
    save_total_limit=2,
    bf16=True,
    max_seq_length=2048,
    packing=False,
    gradient_checkpointing=True,
    report_to="none",
)

trainer = SFTTrainer(
    model=model,
    args=sft_config,
    train_dataset=train_dataset,
    eval_dataset=eval_dataset,
    tokenizer=tokenizer,
)
trainer.train()
trainer.save_model("./lora_qwen7b/final")

可训练参数约4037万,占总参数的0.527%——这也是LoRA的核心优势,优化器状态大幅减少,显存占用主要来自模型本体和激活。

踩坑与优化

坑1:显存OOM(第一次跑就炸)

第一次跑没开 gradient_checkpointing,batch size 设了8,直接OOM。A100 40GB在7B bf16 + LoRA下,不开梯度检查点时峰值显存能到38GB+。开启 gradient_checkpointing=True 后降到约28GB,代价是训练速度慢约20%。最终配置:batch 4 + 累积 4 + 梯度检查点,训练速度约 1.1 it/s。

坑2:loss前期震荡

前100步loss在1.8~2.3之间反复横跳,原因是warmup太短 + 学习率偏高。把warmup从50步提到100步、学习率从3e-4降到2e-4后,曲线明显平滑。另外 packing=False 时不同长度样本会引入padding,建议对长度差异大的数据开启packing,但packing会让样本边界模糊,看任务取舍。

坑3:验证集loss不降反升

第2个epoch末验证loss开始抬头(0.92→0.98),典型过拟合信号。数据量只有1万条,3个epoch已经是上限。后来试了 r=32,过拟合更早(第1.5个epoch就抬头),验证了前面的判断:这个数据规模下r=16是甜点。加 lora_dropout=0.05 有一定缓解。

坑4:eval时tokenizer告警

SFTTrainer 在eval时对 pad_token 处理有告警,手动设置 tokenizer.pad_token = tokenizer.eos_token 后消失,不影响训练。

效果数据

Loss曲线(每100步记录一次eval loss):

步数 train loss eval loss
100 1.62 1.71
300 1.18 1.24
600 0.96 1.02
900 0.87 0.94
1200 0.83 0.98(抬头)

最终取第900步的checkpoint(eval loss最低0.94),没有用最后一个epoch。训练总耗时约4.5小时(3 epoch,约1350步)。

推理效果对比:从验证集抽了200条做人工评估,指标是"回答正确且格式合规"的比例。

模型 准确率 格式合规率
基座 Qwen2.5-7B-Instruct 52% 68%
LoRA 微调后 81% 96%

典型case:问"XX流程的第三步需要哪些材料",基座模型会泛泛而谈甚至编造材料名称;微调后能准确列出内部规范里的3项材料并保持固定编号格式。通用能力方面,抽了50条通用问题做回归测试,基座和微调后的回答质量基本持平,没有明显退化——这也是LoRA相对全参微调的一个好处,对原有能力的破坏更小。

推理时合并LoRA权重或直接挂载adapter都可以,挂载方式更省事:

from peft import PeftModel
base = AutoModelForCausalLM.from_pretrained(MODEL_PATH, torch_dtype=torch.bfloat16, device_map="auto")
model = PeftModel.from_pretrained(base, "./lora_qwen7b/final")
model = model.merge_and_unload()  # 合并后推理速度与基座一致

总结

这次LoRA微调7B的实践,几个结论可以直接复用:

  1. 数据量1万条、r=16、3 epoch 是这个场景的合理配置,再多容易过拟合,验证集loss抬头就该早停。
  2. 显存:A100 40GB跑bf16 LoRA峰值约28GB,24GB卡必须上QLoRA(4bit + double quant),代价是精度略降。
  3. 超参:学习率2e-4、warmup 100步、cosine调度,是7B LoRA比较稳的起点;loss震荡优先查warmup和学习率。
  4. 效果:领域准确率52%→81%,格式合规率68%→96%,通用能力无明显退化。
  5. 取舍:全参微调没必要,LoRA在效果和成本之间平衡得很好;QLoRA是显存受限时的备选,不是默认选项。

后续打算再试试数据配比(混入一部分通用数据防退化)和DPO做一轮偏好对齐,有结果再写一篇。代码和配置基本就是上面这些,版本锁死基本能复现,有问题的欢迎评论区交流。