一、为什么我要做这次微调
先说背景。我们内部有一个客服问答场景,基座模型用的是 Qwen2.5-7B-Instruct。直接用的效果是"能答,但答得不对味":回答太啰嗦、喜欢加"作为AI助手"这类前缀、对内部产品术语(比如某个功能模块的代号)完全不认识,经常答非所问。
全量微调7B?单卡24GB想都别想,至少得A100 80G起步。所以LoRA是唯一现实的选择。又因为显存实在紧张(batch size开不大),我顺手上了QLoRA的4bit量化,把基座模型量化后加载,LoRA adapter仍然用bf16训练。
最终目标很明确:让模型学会"简洁回答 + 认识内部术语",而不是让它变成一个通用更强的模型。这一点很重要,决定了后面数据怎么造。
二、环境与版本
环境这块踩过坑,先把版本钉死:
- GPU:RTX 4090 24GB(单卡)
- CUDA:12.1
- PyTorch:2.3.1
- transformers:4.44.2
- peft:0.12.0
- bitsandbytes:0.43.3
- trl:0.9.6
- accelerate:0.33.0
特别提醒:peft 和 transformers 的版本耦合比较紧,我一开始用 transformers 4.40 + peft 0.12 直接报 AttributeError: 'PeftModel' object has no attribute ...,升级 transformers 到 4.44 才正常。bitsandbytes 建议 0.43 以上,低版本在 4090 上加载 4bit 模型会有兼容性问题。
三、方案设计
整体思路:
- 基座量化加载:用
BitsAndBytesConfig做 NF4 4bit 量化,双重量化开启,计算类型 bf16。 - LoRA 注入:只对 attention 的 q/k/v/o 和 MLP 的 gate/up/down 七个投影层加 adapter,r=16,alpha=32,dropout=0.05。
- 数据格式:统一转成 chat 模板(system/user/assistant 三段),用
trl的SFTTrainer直接吃。 - 训练策略:单卡 batch=1 + 梯度累积16,等效 batch=16;cosine 调度,warmup 比例 0.03,学习率 2e-4。
为什么 r 选 16 而不是 8 或 64?8 试过,学内部术语学不进去(loss 降不下去);64 参数量上去了但效果提升不明显,还更容易过拟合。16 是我们这个数据量(3k 条)下的甜点。
四、核心实现
4.1 数据准备
数据是这次微调里最花时间的部分。原始数据是客服聊天记录导出,脏得离谱:有 HTML 标签、有重复、有超长闲聊。我写了个脚本做清洗和格式化:
```python
import json
import re
from datasets import Dataset
def clean_text(s: str) -> str:
s = re.sub(r"]+>", "", s) # 去 HTML 标签
s = re.sub(r"\s+", " ", s).strip() # 压缩空白
return s
def build_sample(row):
# 统一成 chat 格式,system 固定,用于约束回答风格
return {
"messages": [
{"role": "system",
"content": "你是XX产品的客服助手,回答要简洁,不超过三句话,不要使用'作为AI'等表述。"},
{"role": "user", "content": clean_text(row["question"])},
{"role": "assistant", "content": clean_text(row["answer"])},
]
}
raw = [json.loads(l) for l in open("raw_customer.jsonl", encoding="utf-8")]
过滤:答案长度 10~500 字符,去掉重复问题
seen, cleaned = set(), []
for r in raw:
q = clean_text(r["question"])
if q in seen or not (10 数据数量,清洗和 system prompt 约束比调参重要得多;
- QLoRA 的 4bit 量化对这类"风格 + 术语"微调几乎无损,但推理速度会慢 30% 左右,线上部署要权衡;
- 验证 loss 抬头就停,别硬跑满 epoch。
下一步想试试把 adapter 合并回基座再量化部署,看看推理延迟能压到多少。有进展再写一篇。