LoRA微调实战:使用QLoRA在单卡GPU上微调70亿参数大模型
LoRA微调实战:使用QLoRA在单卡GPU上微调70亿参数大模型

LoRA微调实战:使用QLoRA在单卡GPU上微调70亿参数大模型

朋友联系我,他们是个初创公司,想用大模型处理客服对话,但预算只够买一张4090显卡,问我能不能在单卡上微调个70亿参数的模型。我当时第一反应是:这不太可能吧?7B模型全量微调,显存至少得40G往上,4090才24G。

但话不能说死,我挂了电话就开始琢磨。这项目得用点心思来,不能瞎搞。

业务场景

他们的核心需求其实挺明确的:有个在线商城,每天大概5000条客服对话。现在的客服机器人太死板,回答都是固定模板,朋友不满意。他们想用大模型来理解用户意图,生成更灵活、更贴切的回复。

但约束条件也很硬:

  1. 硬件只有一台服务器,单张NVIDIA RTX 4090(24G显存)。
  2. 数据不能出公司,得本地训练。
  3. 两周内要上线试运行。

我当时跟朋友开了个会,把需求对清楚了。验收标准就两条:第一,模型能在4090上稳定训练,显存不爆;第二,生成的客服回复比原来的机器人强,人工评估通过率得超过80%。

架构设计

定了需求,接下来就是技术选型。全量微调肯定没戏,显存不够,那就LoRA把,挺简单的。

我对比了几个方案:

  • 标准LoRA:能省显存,但训练速度慢,而且用的8bit量化,显存占用还是偏高。
  • QLoRA:4bit量化,NF4类型,显存能再砍一半,但兼容性得测试。
  • 其他压缩方法:像Adapter、Prefix Tuning也看过,但社区支持没LoRA好,文档也少。

最后选了QLoRA,主要是看中它的显存优势。基础模型后面有,参数差不多,能接受。训练框架用Hugging Face的PEFT + bitsandbytes,推理时再把LoRA权重合并回去。

容量方面,单卡4090跑QLoRA,我预估显存占用能控制在12G左右,留点余量给系统。扩展性嘛,说实话,这架构要扩展只能加显卡,但预算就那样,先跑起来再说。

数据说明

朋友给了5000条客服对话数据,但格式乱七八糟——有的JSON,有的纯文本,还有的带HTML标签。我拿到数据的时候头都大了,这得清洗到什么时候?

数据来源是他们的客服系统导出,包含用户提问和客服回复。但问题很多:

  1. 重复对话大概有10%,可能是系统bug导致的。
  2. 有些回复太短,就一两个字,比如“嗯”、“好的”,这种没训练价值。
  3. 格式不统一,有的字段名是“question”,有的是“query”,得对齐。

我花了大概3个小时写清洗脚本。核心逻辑就几步:

# 数据清洗脚本(简化版)
import json
import hashlib
from typing import List, Dict

def load_data(file_path: str) -> List[Dict]:
    """加载原始数据,支持JSON和文本格式"""
    # 实际代码会根据不同格式做解析,这里省略
    pass

def clean_text(text: str) -> str:
    """清洗文本:去掉HTML标签、多余空格"""
    import re
    text = re.sub(r'<[^>]+>', '', text)  # 去HTML标签
    text = re.sub(r'\s+', ' ', text).strip()  # 合并空格
    return text

def deduplicate(data: List[Dict]) -> List[Dict]:
    """基于MD5去重"""
    seen = set()
    unique_data = []
    for item in data:
        # 把对话内容拼接起来生成MD5
        content = item['question'] + item['answer']
        content_hash = hashlib.md5(content.encode()).hexdigest()
        if content_hash not in seen:
            seen.add(content_hash)
            unique_data.append(item)
    return unique_data

def filter_by_length(data: List[Dict], min_len: int = 5) -> List[Dict]:
    """过滤掉太短的回复"""
    filtered = []
    for item in data:
        if len(item['answer'].strip()) >= min_len:
            filtered.append(item)
    return filtered

# 主流程
raw_data = load_data("customer_service_raw.json")
cleaned_data = []
for item in raw_data:
    item['question'] = clean_text(item['question'])
    item['answer'] = clean_text(item['answer'])
    cleaned_data.append(item)

deduped_data = deduplicate(cleaned_data)  # 去重后剩4500条
final_data = filter_by_length(deduped_data, min_len=5)  # 过滤后剩4200条

# 保存成标准格式
with open("cleaned_data.json", "w", encoding="utf-8") as f:
    json.dump(final_data, f, ensure_ascii=False, indent=2)

这里我踩了个坑:一开始没做去重,结果训练时模型老是重复生成相似的回复。后来加了个基于MD5的简单去重,才解决。数据清洗这块,真不能偷懒。

实现步骤

环境搭建我用的Ubuntu 22.04,Python 3.10,PyTorch 2.1.0。依赖包主要就三个:transformers、peft、bitsandbytes。装bitsandbytes的时候有点麻烦,得从源码编译,我折腾了快一个小时才搞定。

模型加载的代码是关键,QLoRA的配置都在这里:

# 这是最终可运行的模型加载代码
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig, get_peft_model
import torch
from transformers import BitsAndBytesConfig

# 关键配置:4bit量化,用NF4类型
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",  # 一定要用NF4,别用FP4
    bnb_4bit_compute_dtype=torch.float16,  # 计算用float16加速
    bnb_4bit_use_double_quant=True  # 双层量化,再省点内存
)

# 加载基础模型
model = AutoModelForCausalLM.from_pretrained(
    "THUDM/chatglm2-6b",
    quantization_config=bnb_config,
    device_map="auto",  # 自动分配设备
    trust_remote_code=True
)
tokenizer = AutoTokenizer.from_pretrained("THUDM/chatglm2-6b", trust_remote_code=True)

# LoRA配置:只训练attention的qkv和输出投影
lora_config = LoraConfig(
    r=8,  # 秩,我试过4、8、16,8在这个任务上效果最好
    lora_alpha=32,
    target_modules=["query_key_value", "dense"],  # ChatGLM2的特殊结构
    lora_dropout=0.1,
    bias="none",
    task_type="CAUSAL_LM"
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()  # 输出:trainable params: 3,932,160 || all params: 6,248,320,000 || trainable%: 0.0629%

跑起来后,显存占用大概12G,4090完全扛得住。但训练参数调优又是个头疼的事。

参数说明

我一开始学习率设了2e-4,结果loss震荡得厉害,像坐过山车。后来调到5e-5才稳定下来。这里有个经验:QLoRA因为参数少,学习率要比全量微调设小一点。

参数 推荐值 我试过的范围 说明
学习率 5e-5 1e-4 ~ 1e-5 大了容易震荡,小了收敛慢
批大小 8 4 ~ 16 受显存限制,我最大只能到8
训练轮数 3 1 ~ 5 多了容易过拟合
LoRA秩(r) 8 4, 8, 16 8性价比最高
优化器 AdamW 默认的就行
学习率调度 cosine 慢慢下降,避免突变

训练了3个epoch,loss曲线是这样的:

Epoch 1: loss 2.34 -> 1.87
Epoch 2: loss 1.87 -> 1.45
Epoch 3: loss 1.45 -> 1.23

下降趋势还行,但到第3轮已经有点平了,再训练可能就过拟合了。我当时停在了第3轮,保存了checkpoint。

调用方式

训练完的模型不能直接拿来用,得把LoRA权重合并回原模型,不然加载慢,推理也麻烦。我用的是PEFT的merge_and_unload方法。

# 推理示例:合并权重并保存
from peft import PeftModel

# 加载基础模型(跟训练时一样的配置)
base_model = AutoModelForCausalLM.from_pretrained(
    "THUDM/chatglm2-6b",
    quantization_config=bnb_config,
    device_map="auto",
    trust_remote_code=True
)

# 加载LoRA权重
model = PeftModel.from_pretrained(base_model, "./lora_checkpoint")

# 合并权重
model = model.merge_and_unload()

# 保存合并后的模型
model.save_pretrained("./merged_model")
tokenizer.save_pretrained("./merged_model")

# 实际推理代码
input_text = "用户问:退货怎么处理?"
inputs = tokenizer(input_text, return_tensors="pt").to(model.device)
outputs = model.generate(**inputs, max_length=100, temperature=0.8)
response = tokenizer.decode(outputs[0], skip_special_tokens=True)
print(response)

合并后的模型就跟普通模型一样用了,部署起来也简单。我建议保存两个版本:一个是带LoRA权重的(方便继续训练),一个是合并后的(用于推理)。

上线后评估

模型训练完,得验收了。我没用那些复杂的BLEU、ROUGE指标——说实话,在客服对话场景,那些指标跟人工评价差距挺大的。我直接找了10个典型问题,让微调前后的模型都回答,人工对比。

测试用例比如:

  1. “退货怎么处理?”
  2. “订单一直没发货,怎么回事?”
  3. “商品有破损,能换货吗?”

原始模型的回答比较通用,比如“请提供订单号,我们将为您处理退货”。而微调后的模型会带上朋友具体的政策,比如“根据退货政策P-2023-001,请在收到商品7天内申请退货,并确保商品完好”。

我们团队3个人一起评估,每人给回复打分(1-5分),最后算平均分。微调前模型平均2.8分,微调后平均4.2分,通过率(4分以上)85%,超过了朋友要求的80%。

上线后监控也很重要。我让朋友在客服系统里加了个反馈按钮,用户可以对机器人回复打分。头一周的数据显示,满意度大概在70%左右,比之前的50%强多了。当然,还有优化空间,但第一期交付算达标了。

常见坑

整个项目做下来,踩的坑不少,我总结几个主要的:

  1. 量化类型选错:一开始用了FP4,效果比NF4差一截,loss下不去。后来查文档才发现,NF4是专门为神经网络优化的,FP4是通用量化。

  2. 数据清洗偷懒:没去重的那版模型,生成回复重复率高达30%,根本没法用。数据质量太关键了。

  3. 学习率设太高:QLoRA参数少,2e-4的学习率直接训飞了,loss上蹿下跳。调到5e-5才稳下来。

  4. 评估方法脱离业务:光看loss下降没用,得结合实际场景测试。人工评估虽然费时,但最靠谱。

  5. 忘记合并权重:第一次部署时直接加载了LoRA权重,推理速度慢不说,还多占显存。后来才想起来要merge_and_unload。

演进路线

这项目目前算是交付了,但长远看还有优化空间。我跟朋友聊过后续的演进路线:

  1. 短期(1个月):收集更多用户反馈数据,做第二轮微调,重点优化那些得分低的case。
  2. 中期(3个月):如果业务量增长,考虑升级显卡,或者用多卡并行训练更大的模型。
  3. 长期(6个月):探索全量微调或者更高级的微调方法,比如DoRA,但得看预算情况。

整个项目从需求对接到上线评估,花了大概两周时间。现在模型已经在朋友那边跑起来了,资源占用比预期低,效果也还行。当然,如果显卡再多点,我可能会试试更大的秩或者全量微调,但单卡环境下,QLoRA确实是目前最实用的方案了。做项目交付,关键是把边界画清楚,每个阶段都有明确的产出,这样才不容易翻车。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

9 + 1 =