一、问题背景:为什么我要微调一个7B模型

先说场景。我这边有一个垂直领域的问答需求,用户提问大多是行业术语+口语化表达混在一起,比如“这个料号交期能不能压到两周内”。直接拿Qwen2.5-7B-Instruct去跑,回答格式不稳定,有时候给一堆通用建议,有时候干脆答非所问。

试过几种方案:

  • Prompt工程:写了接近600字的system prompt,效果提升有限,而且token成本上去了。
  • RAG:检索能补知识,但改不了“说话方式”和输出结构。
  • 全量微调:7B模型全参训练,光优化器状态就爆显存,单卡A100 40G根本放不下。

所以最终选了LoRA。目标很明确:让模型学会这个领域的回答风格和固定输出结构,而不是灌大量新知识。数据量不大,1.2万条,LoRA正好合适。

二、环境与版本

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

Python          3.10.13
torch           2.4.0+cu121
transformers    4.46.2
peft            0.13.2
bitsandbytes    0.44.1
trl             0.12.1
datasets        3.0.1
accelerate      1.0.1
GPU             A100 40GB * 1

重点提醒:peft 0.13.x 和 transformers 4.46 搭配是稳的,我之前用peft 0.11 + transformers 4.44,加载Qwen2.5时报target_modules找不到,折腾了半天。另外bitsandbytes在Windows上基本别想,直接Linux。

三、方案设计:QLoRA + LoRA,rank怎么选

我最终用的是QLoRA,也就是4-bit量化基座 + LoRA适配器。原因很简单:40G显存虽然能跑fp16的LoRA,但batch size只能开到1,训练太慢。4-bit量化后显存直接砍到15G左右,batch size能上到8,速度反而更快。

关键参数:

参数 取值 说明
lora_rank 16 垂域风格适配够用
lora_alpha 32 一般取2*r
lora_dropout 0.05 防过拟合
target_modules q/k/v/o/gate/up/down 全注意力+MLP
learning_rate 2e-4 LoRA常用区间
batch_size 8 单卡
grad_accum 4 等效batch 32
epochs 2 多了会过拟合
max_length 1024 覆盖99%样本

rank我试过8、16、32。8的时候loss降不下去,32和16差距很小但显存多占1.8G,所以16是性价比最高的点。

四、核心实现

4.1 数据准备

数据格式我统一成Alpaca风格,然后用手写的chat template拼。这里要注意Qwen2.5有自己的特殊token,别自己乱拼。

import json
from datasets import Dataset

def build_sample(item):
    system = "你是一个行业助手,回答要简洁、分点、给出可执行建议。"
    user = item["question"]
    assistant = item["answer"]
    text = (
        f"system\n{system}\n"
        f"user\n{user}\n"
        f"assistant\n{assistant}"
    )
    return {"text": text}

with open("data/train.json", "r", encoding="utf-8") as f:
    raw = json.load(f)

ds = Dataset.from_list([build_sample(x) for x in raw])
ds = ds.train_test_split(test_size=0.02, seed=42)
print(ds)
# DatasetDict({train: 11760, test: 240})

数据清洗我做了三件事:去掉长度超过1024的样本(约120条)、去掉答案里带“作为AI”这类模板话术的、把重复问题去重。最后剩1.2万条。

4.2 训练脚本

import torch
from transformers import (
    AutoModelForCausalLM, AutoTokenizer,
    BitsAndBytesConfig, TrainingArguments
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer

model_path = "Qwen/Qwen2.5-7B-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,
)

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

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"],
)

args = TrainingArguments(
    output_dir="./output_qwen_lora",
    per_device_train_batch_size=8,
    gradient_accumulation_steps=4,
    num_train_epochs=2,
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    logging_steps=10,
    save_strategy="epoch",
    eval_strategy="epoch",
    bf16=True,
    optim="paged_adamw_8bit",
    report_to="none",
    gradient_checkpointing=True,
)

trainer = SFTTrainer(
    model=model,
    args=args,
    train_dataset=ds["train"],
    eval_dataset=ds["test"],
    peft_config=lora_config,
    tokenizer=tokenizer,
    dataset_text_field="text",
    max_seq_length=1024,
    packing=False,
)

trainer.train()
trainer.save_model("./output_qwen_lora/final")

跑起来显存峰值14.6G,单步约1.9秒,2个epoch总共用时约3小时10分钟。

五、踩坑与优化

坑1:packing开了loss反而抖。 我一开始开了packing=True,想提升吞吐,结果loss曲线锯齿特别大。原因是把多条短样本拼进同一条序列后,attention mask处理不干净,跨样本注意力泄漏。垂域数据长度分布不均,直接关掉。

坑2:eval_loss比train_loss低。 第一次跑完发现eval_loss 0.79 数据数量。1.2万条精标数据,比5万条脏数据效果好得多。
2.
rank 16 + alpha 32 是垂域风格适配的甜点区,别一上来就上64。
3.
QLoRA不是精度妥协,在风格和格式任务上,和fp16 LoRA差距在1%以内,但显存省一半。
4.
loss曲线要看趋势不看绝对值**,平滑下降、eval不反弹,基本就稳了。

下一步我打算试试把LoRA权重合并回基座,再量化成GPTQ部署,看看推理延迟能压到多少。有进展再写一篇。