一、为什么要做这次微调

先交代背景。我手上有一个垂直领域的问答场景,基座是Qwen2.5-7B-Instruct。直接用原始模型测试,在领域术语理解和固定输出格式上表现一般:我构造了200条测试集,人工评估准确率只有54%,主要问题集中在三类——专业术语答错、输出格式不统一(有时带markdown有时不带)、以及偶尔"过度发挥"编造细节。

全量微调7B模型对我不现实:单卡4090,全参训练即使开ZeRO也要多卡,成本扛不住。所以选择LoRA。这里顺带说一句QLoRA:它通过4-bit量化基座进一步压缩显存,代价是训练速度变慢、精度略有损失。我的场景24GB显存跑LoRA(bf16)已经够用,就没上QLoRA,但后文会给出QLoRA的配置差异。

二、环境与版本

环境这块我踩过版本坑,先把确定可用的组合列出来:

  • GPU:RTX 4090 24GB,驱动 550.54
  • CUDA 12.1
  • Python 3.10.13
  • torch 2.3.1 + cu121
  • transformers 4.44.2
  • peft 0.12.0
  • trl 0.9.6
  • datasets 2.20.0
  • accelerate 0.33.0
  • bitsandbytes 0.43.3(QLoRA时需要)

重点提醒:peft 和 transformers 版本要匹配,我一开始用 peft 0.11 + transformers 4.44 出现过 target_modules 识别不到的问题,升到 0.12.0 后正常。

三、数据准备

数据格式我采用对话格式(messages),因为基座是Instruct模型,保持和它预训练对齐的模板最省事。每条样本长这样:

{
  "messages": [
    {"role": "system", "content": "你是一个XX领域助手,回答需简洁准确。"},
    {"role": "user", "content": "问题..."},
    {"role": "assistant", "content": "标准答案..."}
  ]
}

数据处理代码(截断 + 应用chat template):

from datasets import load_dataset
from transformers import AutoTokenizer

model_path = "Qwen/Qwen2.5-7B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)

def format_and_tokenize(example, max_len=1024):
    text = tokenizer.apply_chat_template(
        example["messages"],
        tokenize=False,
        add_generation_prompt=False
    )
    out = tokenizer(
        text,
        truncation=True,
        max_length=max_len,
        padding=False
    )
    return out

ds = load_dataset("json", data_files={"train": "train.jsonl", "valid": "valid.jsonl"})
ds = ds.map(format_and_tokenize, remove_columns=ds["train"].column_names)

数据量分配:训练集12000条,验证集800条。我特意控制了单条长度,95分位在780 token左右,max_len设1024足够,避免padding浪费。

一个容易忽略的点:assistant部分的loss才应该被计算。trl 的 SFTTrainer 在较新版本里支持 DataCollatorForCompletionOnlyLM,但用messages格式时更推荐直接用官方的对话collator,它会自动只对assistant token计算loss。我早期版本手动mask,写过一堆bug,后来直接交给trainer。

四、训练配置

LoRA的核心参数:rank=16,alpha=32,dropout=0.05。target_modules 我覆盖了 attention 的 q/k/v/o 和 MLP 的 gate/up/down,全线性层都挂上,效果比只挂q/v明显。

import torch
from transformers import AutoModelForCausalLM, TrainingArguments
from peft import LoraConfig, get_peft_model
from trl import SFTTrainer

model = AutoModelForCausalLM.from_pretrained(
    model_path,
    torch_dtype=torch.bfloat16,
    device_map="auto",
    trust_remote_code=True,
    attn_implementation="flash_attention_2",  # 需要装flash-attn
)
model.gradient_checkpointing_enable()
model.enable_input_require_grads()

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

可训练参数约4037万,占比0.527%。

训练参数:

args = TrainingArguments(
    output_dir="./qwen_lora_out",
    per_device_train_batch_size=2,
    gradient_accumulation_steps=8,     # 等效batch=16
    learning_rate=1e-4,
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    num_train_epochs=3,
    bf16=True,
    logging_steps=10,
    save_strategy="epoch",
    eval_strategy="epoch",
    gradient_checkpointing=True,
    optim="adamw_torch",
    report_to="tensorboard",
    max_seq_length=1024,
)

trainer = SFTTrainer(
    model=model,
    args=args,
    train_dataset=ds["train"],
    eval_dataset=ds["valid"],
)
trainer.train()

学习率1e-4是我试出来的:5e-5收敛太慢,3个epoch验证loss还在1.1以上;2e-4又震荡明显。1e-4配合cosine和3% warmup比较稳。

五、踩坑与优化

坑1:显存OOM。 一开始batch=4、没开gradient checkpointing,直接爆。开checkpointing后batch降到2,配合梯度累积8,显存峰值19.3GB,稳定。

坑2:loss不下降。 发现是数据里有大量重复样本(爬的问答有冗余),去重后从15000条降到12000条,loss曲线立刻正常。

坑3:推理时输出重复。 训练时没加 repetition_penalty,推理时加了1.1解决。

坑4:QLoRA的速度。 我对比过QLoRA(4-bit)配置,同样数据单epoch要2.6小时,LoRA(bf16)只要1.5小时,慢约70%。如果显存紧张再选QLoRA。

六、效果数据

loss曲线(训练集/验证集,节选关键点):

epoch train_loss eval_loss
0.5 1.18 1.21
1 0.96 1.02
2 0.79 0.90
3 0.74 0.87

验证loss从初始1.42降到0.87,没有明显过拟合(train和eval差距0.13,可接受)。

推理效果对比,用同一批200条领域测试集:

  • 基线Qwen2.5-7B-Instruct:准确率54%,格式合规率61%
  • LoRA微调后:准确率83%,格式合规率97%

举一个具体case:

问:"XX工艺的临界温度是多少?"
- 基线回答:给了一个泛泛的、数值错误的答案,还带了一段无关解释。
- 微调后:直接给出正确数值,两句话,格式统一。

推理代码(合并LoRA权重或直接加载adapter):

from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer

base = AutoModelForCausalLM.from_pretrained(
    model_path, torch_dtype=torch.bfloat16, device_map="auto"
)
model = PeftModel.from_pretrained(base, "./qwen_lora_out/checkpoint-2250")
model = model.merge_and_unload()  # 合并后可当普通模型用
tokenizer = AutoTokenizer.from_pretrained(model_path)

messages = [
    {"role": "system", "content": "你是一个XX领域助手,回答需简洁准确。"},
    {"role": "user", "content": "XX工艺的临界温度是多少?"},
]
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = tokenizer(text, return_tensors="pt").to(model.device)
out = model.generate(**inputs, max_new_tokens=256,
                     do_sample=False, repetition_penalty=1.1)
print(tokenizer.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True))

七、总结

这次LoRA微调整体符合预期:0.5%的可训练参数拿到了29个点的准确率提升,单卡4.5小时完成。几点经验:数据质量比数据量重要,去重和格式对齐收益最大;target_modules全挂线性层比只挂q/v效果好;学习率1e-4配cosine是个稳妥起点。QLoRA适合显存更紧的场景,但速度代价要提前评估。下一步我打算试试 rank=32 和更长序列(2048)看还有没有提升空间。