一、问题背景:为什么要自己微调

先说结论:不是所有场景都需要微调。RAG能解决的,优先用RAG。但这次的需求有几个硬性约束,逼得我不得不动手微调。

需求是做一个垂直领域的客服助手,输入是用户口语化的产品问题,输出要严格遵循公司的话术规范和字段格式。我试过三种方案:

  1. 纯Prompt:把话术规范塞进system prompt。效果勉强,但格式遵循率只有70%左右,长尾问题经常跑偏。
  2. RAG:检索知识库再拼接。知识准确率上去了,但输出格式依然不稳定,而且检索延迟叠加LLM推理,首token延迟到了2.3s。
  3. 微调:把话术规范和格式内化到模型权重里。

基线的准确率我测过,Qwen2.5-7B-Instruct在500条评测集上,领域问答准确率54%,JSON格式遵循率71%。这个数字对生产环境来说不可接受。

全量微调7B?单卡4090想都别想,光是优化器状态就要吃掉几十GB。所以LoRA是唯一现实的选择。为了进一步压显存,我用了QLoRA(4bit量化基座+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: RTX 4090 24GB
CUDA: 12.1

硬件就一张4090,没有多卡,所以训练脚本是单卡配置。如果你用A100 40G或80G,可以把batch size和序列长度往上调。

三、方案设计

核心思路:

  • 基座模型:Qwen2.5-7B-Instruct。选它是因为中文能力强、社区生态好、chat template规范。
  • 量化:4bit NF4量化加载基座,double quant开启,计算dtype用bfloat16。
  • LoRA配置:target_modules覆盖所有线性层(q/k/v/o/gate/up/down),rank=16,alpha=32,dropout=0.05。
  • 训练:3 epoch,lr=2e-4,cosine调度,warmup_ratio=0.03,per_device_batch=2,gradient_accumulation=8,等效batch=16。
  • 序列长度:cutoff_len=1024。我的数据大部分在600 token以内,1024够用且省显存。

LoRA的alpha/rank比例我设成2:1,这是比较稳妥的经验值。dropout设0.05是为了防止8000条数据在3个epoch下过拟合。

四、核心实现

4.1 数据准备

数据格式我统一成Alpaca风格,但实际训练时用的是Qwen的chat template。原始数据是JSONL,每行一个样本:

{"instruction": "用户问:你们的会员怎么退订?", "output": "{\"intent\":\"cancel_membership\",\"reply\":\"...\",\"need_human\":false}"}

我写了个预处理脚本,把数据转成conversation格式并套用chat template:

import json
from transformers import AutoTokenizer

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

SYSTEM = "你是一个专业的客服助手,必须严格输出JSON格式,字段包括intent、reply、need_human。"

def build_sample(item):
    messages = [
        {"role": "system", "content": SYSTEM},
        {"role": "user", "content": item["instruction"]},
        {"role": "assistant", "content": item["output"]},
    ]
    text = tokenizer.apply_chat_template(
        messages, tokenize=False, add_generation_prompt=False
    )
    return {"text": text}

with open("raw_data.jsonl", encoding="utf-8") as f, \
     open("train.jsonl", "w", encoding="utf-8") as out:
    for line in f:
        item = json.loads(line)
        out.write(json.dumps(build_sample(item), ensure_ascii=False) + "\n")

数据划分:8000条训练,500条验证,500条测试(测试集和训练集无重叠,且来自不同时间段,避免数据泄漏)。

4.2 训练脚本

用peft + trl的SFTTrainer,这是目前最省心的组合。完整可运行代码如下:

import torch
from datasets import load_dataset
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_use_double_quant=True,
    bnb_4bit_compute_dtype=torch.bfloat16,
)

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

model = AutoModelForCausalLM.from_pretrained(
    MODEL_PATH,
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True,
    torch_dtype=torch.bfloat16,
)
model = prepare_model_for_kbit_training(model)
model.config.use_cache = False

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.5273

train_ds = load_dataset("json", data_files="train.jsonl", split="train")
eval_ds = load_dataset("json", data_files="val.jsonl", split="train")

args = TrainingArguments(
    output_dir="./qwen2.5-7b-lora",
    per_device_train_batch_size=2,
    per_device_eval_batch_size=2,
    gradient_accumulation_steps=8,
    num_train_epochs=3,
    learning_rate=2e-4,
    lr_scheduler_type="cosine",
    warmup_ratio=0.03,
    logging_steps=10,
    eval_strategy="steps",
    eval_steps=100,
    save_steps=200,
    save_total_limit=2,
    bf16=True,
    gradient_checkpointing=True,
    gradient_checkpointing_kwargs={"use_reentrant": False},
    optim="paged_adamw_8bit",
    report_to="none",
    max_grad_norm=0.3,
)

trainer = SFTTrainer(
    model=model,
    args=args,
    train_dataset=train_ds,
    eval_dataset=eval_ds,
    tokenizer=tokenizer,
    dataset_text_field="text",
    max_seq_length=1024,
    packing=False,
)

trainer.train()
trainer.save_model("./qwen2.5-7b-lora/final")

跑起来后显存占用约14.2GB,训练速度约2.1 step/s,3个epoch总共约2小时35分钟。

4.3 推理脚本

微调完后,LoRA适配器可以合并回基座,也可以动态加载。我推荐动态加载,方便A/B对比:

import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel

base = "Qwen/Qwen2.5-7B-Instruct"
adapter = "./qwen2.5-7b-lora/final"

tokenizer = AutoTokenizer.from_pretrained(base, trust_remote_code=True)
model = AutoModelForCausalLM.from_pretrained(
    base, torch_dtype=torch.bfloat16, device_map="auto", trust_remote_code=True
)
model = PeftModel.from_pretrained(model, adapter)
model.eval()

def chat(user_input):
    messages = [
        {"role": "system", "content": "你是一个专业的客服助手,必须严格输出JSON格式。"},
        {"role": "user", "content": user_input},
    ]
    text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
    inputs = tokenizer(text, return_tensors="pt").to(model.device)
    with torch.no_grad():
        out = model.generate(**inputs, max_new_tokens=256, do_sample=False,
                             temperature=None, top_p=None)
    return tokenizer.decode(out[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True)

print(chat("你们的会员怎么退订?"))

五、踩坑与优化

坑1:gradient_checkpointing和use_cache冲突。 一开始没关model.config.use_cache,报了一堆warning,训练速度还慢。记得手动设False。

坑2:padding_side设错。 Qwen是decoder-only,训练时padding_side必须是"right",否则loss计算会错位。我一开始用了left,loss曲线抖动特别厉害,排查了半天。

坑3:packing的诱惑。 SFTTrainer支持packing,能把多个短样本拼成一条长序列提升吞吐。但我的数据格式要求严格,packing后跨样本注意力会污染格式学习,最后关掉了。吞吐降了约18%,但效果更稳。

坑4:学习率。 一开始用1e-4,收敛太慢,3个epoch loss只到0.95。调到2e-4后明显改善。QLoRA因为基座被量化,梯度信号弱,学习率可以比全量微调大一档。

坑5:eval_steps太密。 一开始设50,eval本身要跑几百条,拖慢训练。改成100后整体时间省了约15分钟。

六、效果数据

6.1 Loss曲线

训练loss从step 0的1.82,到step 1000(约1个epoch)降到0.94,step 2000降到0.72,最终step 3000停在0.63。验证loss最低点出现在step 2600左右,0.71,之后轻微回升到0.74——这是典型的轻微过拟合信号,所以最终我选了step 2600的checkpoint,而不是最后一个。

6.2 推理效果对比

在500条测试集上,我用同一套prompt和greedy解码对比:

指标 基线Qwen2.5-7B LoRA微调后
领域问答准确率 54.2% 86.4%
JSON格式遵循率 71.0% 98.6%
平均输出token数 187 142
首token延迟 0.42s 0.43s
通用能力(MMLU抽样200题) 68.5% 67.8%

几个观察:

  • 格式遵循率提升最明显,从71%到98.6%,基本消灭了格式错误。
  • 输出变短了,说明模型学会了更精炼的话术,不再啰嗦。
  • 首token延迟几乎无变化,因为LoRA适配器参数量小,推理开销可忽略。
  • 通用能力掉了0.7个点,在可接受范围内。如果掉超过2个点,就要考虑加通用数据做正则化。

6.3 一个具体case

输入:"我想把上个月买的那个会员退了,怎么弄?"

基线输出(格式错、话术生硬):

您可以联系客服进行退订,或者在我的页面找到退订按钮。

微调后输出:

{"intent":"cancel_membership","reply":"您可以在「我的-会员中心-管理订阅」中自助退订,退款将在1-3个工作日原路返回。如需人工协助,回复「转人工」。","need_human":false}

格式、话术、字段全部符合规范。

七、总结

这次LoRA微调实践下来,几个关键结论:

  1. 7B模型+QLoRA在24GB单卡上完全可行,显存14GB左右,训练2.5小时,成本极低。
  2. 数据质量比数量重要。8000条高质量、格式统一的数据,效果远好于3万条脏数据。
  3. rank=16、alpha=32、lr=2e-4是一组稳妥的起点,垂直领域任务基本够用。如果任务更复杂,可以试试rank=32或64。
  4. 一定要留验证集并早停。我用最后checkpoint的话,验证loss会高0.03,测试准确率掉约1.2个点。
  5. 通用能力退化要监控。垂直微调必然带来灾难性遗忘,如果退化严重,混入5%-10%的通用指令数据能有效缓解。

LoRA不是银弹,但在"格式约束强、领域明确、算力有限"的场景下,它是我目前用过性价比最高的方案。如果你也在做类似的事,希望这篇记录能帮你少踩几个坑。