Expert Day 173
LoRA / QLoRA 实战 — Unsloth + 金融数据集
### 1.1 LoRA 数学(必背)
2026-10-21
Phase 3 - 生产基础设施与评估 (Day 163-176)LoRAQLoRAUnslothPEFTFineTuningLlama
日期: 2026-10-21 方向: AI系统工程 / Fine-tuning / PEFT 阶段: Phase 3 - 生产基础设施与评估 (Day 163-176) 标签: #LoRA #QLoRA #Unsloth #PEFT #FineTuning #Llama
今日目标
| 类型 | 内容 |
|---|---|
| 学习 | LoRA 数学(low-rank adaptation)、QLoRA 4-bit 量化、PEFT 库、Unsloth 加速、超参选择(rank/alpha/lr)、过拟合检测 |
| 实操 | 用 Unsloth 在 Llama-3.2-3B 上做 LoRA fine-tune,金融客户邮件分类,从原始数据到 vLLM 部署完整流程 |
| 产出 | lora_demo.py:可运行训练脚本 + 部署 + eval |
一、核心概念
1.1 LoRA 数学(必背)
冻结 base 模型权重 W,给每个 linear 加一个低秩残差:
W_new = W + ΔW
ΔW = B · A
│ │
d×r r×k (r << min(d, k),通常 r=8/16/32)
参数量对比(Llama 3 70B 的 attention q_proj,d=k=8192):
- Full FT:W_new 全更新,67M 参数 / 层
- LoRA r=16:B (8192×16) + A (16×8192) = 262K 参数 / 层,节省 256x
整模型:Llama 70B 全 FT 70B params;LoRA r=16 → ~50-200M params
1.2 QLoRA:4-bit 量化 + LoRA
- 把 base 模型用 NF4(4-bit normal float)量化加载
- LoRA 适配器仍是 fp16/bf16
- 70B 模型显存:fp16 = 140GB → nf4 = 38GB
- 单张 H100 80G 训 70B 成为可能(不行的)→ 但够 13B/34B
1.3 关键超参
| 超参 | 推荐 | 含义 |
|---|---|---|
r(rank) | 8 / 16 / 32 | 越大表达力越强,参数越多 |
lora_alpha | 2r | scaling = α/r,控制更新强度 |
lora_dropout | 0.0-0.1 | 小数据加 dropout 防过拟合 |
target_modules | q,k,v,o + gate,up,down | 全 attention + MLP 通常最好 |
learning_rate | 1e-4 ~ 3e-4 | LoRA 比 full FT lr 高 5-10x |
epochs | 1-3 | 多了过拟合 |
batch_size | 看显存 | 4-32, gradient_accumulation 凑 |
lr_scheduler | cosine | warmup 5-10% |
1.4 Unsloth 是什么
- 开源加速库,比 HF + PEFT 快 2-5x,省 50%+ 显存
- 支持 Llama / Mistral / Gemma / Qwen 等主流
- API 几乎与 HF 一致
1.5 过拟合检测
- train_loss 一直降,eval_loss 上升 → 过拟合
- 在 OOD(不同时期、不同业务线)测准确率显著低于 in-distribution → 过拟合
- 信号:train acc=99% but eval acc=82%
二、生产架构图
原始数据(CSV / DB)
│
▼
┌──────────────────┐
│ Data preparation │ PII 脱敏、去重、Train/Val/Test 切分
└──────────────────┘
│ Instruction format
▼
┌──────────────────┐
│ Unsloth + PEFT │ LoRA r=16, lr=2e-4, epochs=2
│ 1xA100/H100 │
└──────────────────┘
│
┌────┴────┐
▼ ▼
Adapter Eval (golden test)
│
▼
merge_and_unload() ← 选项:合并到 base 简化部署
│
▼
┌──────────────────┐
│ vLLM serving │ --model = ft-finance-v1
└──────────────────┘
│
▼
生产 API(OpenAI 兼容)
三、代码实现(完整端到端)
3.1 安装
pip install "unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git"
pip install --no-deps trl peft accelerate bitsandbytes
pip install datasets pandas
3.2 数据准备
"""data_prep.py — 把客户邮件 CSV 转 instruction 格式"""
import pandas as pd
import json
import random
import re
from sklearn.model_selection import train_test_split
# 假设 raw.csv: subject, body, intent (10 类)
df = pd.read_csv("emails.csv")
# PII 脱敏(金融场景必做)
def mask(text: str) -> str:
text = re.sub(r"\b1[3-9]\d{9}\b", "[PHONE]", text)
text = re.sub(r"\b\d{17}[\dX]\b", "[ID]", text)
text = re.sub(r"\b\d{16,19}\b", "[CARD]", text)
text = re.sub(r"[\w.+-]+@[\w-]+\.[\w.-]+", "[EMAIL]", text)
return text
df["body"] = df["body"].apply(mask)
df["subject"] = df["subject"].apply(mask)
# 去重
df = df.drop_duplicates(subset=["body"]).reset_index(drop=True)
# 平衡(每个 intent 取 min(N, available))
target_per_intent = 200
balanced = []
for intent, g in df.groupby("intent"):
n = min(target_per_intent, len(g))
balanced.append(g.sample(n=n, random_state=42))
df = pd.concat(balanced).reset_index(drop=True)
# Train / Val / Test 切分(按时间或随机)
train, temp = train_test_split(df, test_size=0.3, stratify=df["intent"], random_state=42)
val, test = train_test_split(temp, test_size=0.5, stratify=temp["intent"], random_state=42)
# Instruction 格式
def to_instruct(row):
return {
"instruction": "请把以下金融客户邮件分类到合适的 intent 类别。仅输出类别英文,不解释。",
"input": f"Subject: {row['subject']}\n\nBody: {row['body']}",
"output": row["intent"],
}
for name, dset in [("train", train), ("val", val), ("test", test)]:
with open(f"data/{name}.jsonl", "w", encoding="utf-8") as f:
for _, row in dset.iterrows():
f.write(json.dumps(to_instruct(row), ensure_ascii=False) + "\n")
print(f"train={len(train)}, val={len(val)}, test={len(test)}")
3.3 LoRA 训练脚本
"""lora_demo.py — Unsloth + LoRA 训练 Llama-3.2-3B"""
import torch
from datasets import load_dataset
from unsloth import FastLanguageModel
from trl import SFTTrainer
from transformers import TrainingArguments
MAX_SEQ = 2048
BASE_MODEL = "meta-llama/Llama-3.2-3B-Instruct"
# 1. 加载量化 base + LoRA wrapper
model, tokenizer = FastLanguageModel.from_pretrained(
model_name=BASE_MODEL,
max_seq_length=MAX_SEQ,
dtype=None, # auto bf16
load_in_4bit=True, # QLoRA
)
model = FastLanguageModel.get_peft_model(
model,
r=16,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
lora_alpha=32,
lora_dropout=0.05,
bias="none",
use_gradient_checkpointing="unsloth",
random_state=42,
use_rslora=False, # rank-stabilized LoRA(可选)
loftq_config=None,
)
# 2. 加载数据 + 格式化
def fmt(example):
return {
"text": (
"<|begin_of_text|><|start_header_id|>system<|end_header_id|>\n\n"
f"{example['instruction']}<|eot_id|>"
"<|start_header_id|>user<|end_header_id|>\n\n"
f"{example['input']}<|eot_id|>"
"<|start_header_id|>assistant<|end_header_id|>\n\n"
f"{example['output']}<|eot_id|>"
)
}
train_ds = load_dataset("json", data_files="data/train.jsonl", split="train").map(fmt)
val_ds = load_dataset("json", data_files="data/val.jsonl", split="train").map(fmt)
# 3. SFTTrainer
trainer = SFTTrainer(
model=model,
tokenizer=tokenizer,
train_dataset=train_ds,
eval_dataset=val_ds,
dataset_text_field="text",
max_seq_length=MAX_SEQ,
dataset_num_proc=4,
packing=False,
args=TrainingArguments(
output_dir="outputs",
per_device_train_batch_size=4,
gradient_accumulation_steps=4, # 有效 batch=16
warmup_ratio=0.05,
num_train_epochs=2,
learning_rate=2e-4,
fp16=False, bf16=True,
logging_steps=20,
eval_strategy="steps",
eval_steps=50,
save_strategy="steps",
save_steps=200,
save_total_limit=3,
optim="adamw_8bit",
weight_decay=0.01,
lr_scheduler_type="cosine",
seed=42,
report_to="tensorboard",
),
)
# 4. 训练
trainer_stats = trainer.train()
print(trainer_stats)
# 5. 保存
model.save_pretrained("ft-finance-classifier-v1")
tokenizer.save_pretrained("ft-finance-classifier-v1")
# 6. (可选)merge LoRA 到 base,方便部署
merged = model.merge_and_unload()
merged.save_pretrained("ft-finance-classifier-v1-merged", safe_serialization=True)
tokenizer.save_pretrained("ft-finance-classifier-v1-merged")
3.4 训练后评测
"""eval_lora.py"""
import json
from unsloth import FastLanguageModel
model, tokenizer = FastLanguageModel.from_pretrained(
"ft-finance-classifier-v1",
max_seq_length=2048,
load_in_4bit=True,
)
FastLanguageModel.for_inference(model)
def predict(text: str) -> str:
msgs = [
{"role": "system", "content": "请把以下金融客户邮件分类到合适的 intent 类别。仅输出类别英文,不解释。"},
{"role": "user", "content": text},
]
inputs = tokenizer.apply_chat_template(msgs, return_tensors="pt", add_generation_prompt=True).to(model.device)
out = model.generate(input_ids=inputs, max_new_tokens=20, do_sample=False, temperature=0.0)
text = tokenizer.decode(out[0][inputs.shape[1]:], skip_special_tokens=True)
return text.strip().lower()
# 跑 test 集
test = [json.loads(l) for l in open("data/test.jsonl")]
correct = 0
errors = []
for c in test:
pred = predict(c["input"])
if pred == c["output"]:
correct += 1
else:
errors.append((c["output"], pred, c["input"][:80]))
print(f"Accuracy: {correct/len(test)*100:.1f}% ({correct}/{len(test)})")
print(f"\nSample errors:")
for gold, pred, inp in errors[:10]:
print(f" gold={gold}, pred={pred}, input={inp}")
3.5 部署到 vLLM
# 用 merged 版本(最简单)
python -m vllm.entrypoints.openai.api_server \
--model ./ft-finance-classifier-v1-merged \
--max-model-len 2048 \
--gpu-memory-utilization 0.85 \
--dtype bfloat16 \
--port 8000
# 或者用 LoRA adapter 模式(base + adapter,可热切多个 adapter)
python -m vllm.entrypoints.openai.api_server \
--model meta-llama/Llama-3.2-3B-Instruct \
--enable-lora \
--lora-modules "finance-classifier=./ft-finance-classifier-v1" \
--max-loras 4 \
--port 8000
3.6 调用 + 性能测试
"""bench_ft.py"""
import time
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="x")
def call(text: str):
r = client.chat.completions.create(
model="finance-classifier", # LoRA name
messages=[
{"role": "system", "content": "请把以下金融客户邮件分类到合适的 intent 类别。仅输出类别英文,不解释。"},
{"role": "user", "content": text},
],
max_tokens=20,
temperature=0,
)
return r.choices[0].message.content
# Bench
import statistics
times = []
for _ in range(100):
t0 = time.time()
call("我账户余额还有多少?")
times.append(time.time() - t0)
print(f"P50: {statistics.median(times)*1000:.0f}ms, P95: {sorted(times)[95]*1000:.0f}ms")
四、Cost & Performance 实测数据
4.1 训练(Llama-3.2-3B, 800 train, r=16, 2 epochs)
| 平台 | GPU | 时长 | 成本 |
|---|---|---|---|
| Colab Pro+ A100 | A100 40G | 18 min | $1.50 |
| RunPod 1xA100 80G | A100 80G | 13 min | $1.80 |
| RunPod 1xH100 | H100 80G | 7 min | $2.10 |
| 自有 A100 | — | 18 min | $0(电费忽略) |
4.2 训练 Llama 4 70B LoRA(QLoRA r=16)
| 配置 | 时长 | 成本 |
|---|---|---|
| 1× H100 + QLoRA + 1K samples | ~ 6h | $36 |
| 2× H100 + QLoRA + 10K samples | ~ 18h | $216 |
4.3 推理(Llama-3.2-3B-merged on H100)
| 指标 | 数字 |
|---|---|
| 单卡 H100 throughput | ~ 4500 tok/s |
| P50 latency(20 tok output) | 180 ms |
| P95 latency | 320 ms |
| 月成本(H100 持续运行 $2.0/h) | $1450 |
| 月可服务请求量 | ~ 50M |
| 每请求成本 | $0.000029 |
vs claude-haiku-4-5 API(短任务):~ $0.0008/req → 自托管便宜 27x(前提:流量足够大)
五、金融领域应用
- 客户邮件意图分类:稳定 10 类,日 1M+ 量,LoRA 自托管 ROI 显著
- 合规规则匹配:内部规则术语,base model 不熟,LoRA 学懂术语
- 结构化抽取:KYC 字段、申请单字段,LoRA 让模型 100% 输出 JSON
- 风险事件分类:操作风险/信用风险/市场风险细分类,LoRA 用历史标注
- 银行内部术语翻译:把客户口语翻译为系统术语("账单逾期" → "DPD30+")
- 报告生成风格统一:让模型用银行特定写作模板(LoRA 风格迁移)
金融场景额外要求:
- 训练数据脱敏 + 审计
- 模型权重视作 PII 容器,权限管控
- 监管要求"模型可解释",LoRA 通常不影响可解释(base 仍是开源已知)
- 模型版本号清晰:
base_model + lora_id + train_data_hash
六、生产经验与陷阱
- 数据质量 > 数据量:800 条精标 > 8000 条粗标。bad label 让模型学到错的东西
- train/test leak:去重必须做(subject + body hash);数据按时间切(不是随机),避免 future leak
- 过拟合 60% 任务:LoRA r 太大 + epochs 多就过拟合。先 r=16, 1-2 epochs,看 val_loss 决定
- chat template 不对:Llama-3.2 用
<|begin_of_text|>格式,写错训练全废。一定看官方 - 学习率太低:LoRA 比 full FT 学习率高 5-10x(2e-4 vs 2e-5),太低收敛不动
- target_modules 太少:只给 q/v_proj 不够,加上 gate/up/down 效果好很多
- merge 后掉点:QLoRA merge 时 nf4 反量化误差,掉 0.5-2 个点。要的精度临界,用 LoRA-as-adapter 模式
- LoRA hot-swap 时 latency 抖动:vLLM
--max-loras 4加载新 adapter 占显存,配置上限 - 合规审查:FT 模型上线前要走"模型变更"评审,跟 base model 上线一样
- persistent prompt injection:FT 数据被攻击者污染(恶意 sample),可植后门。数据来源必审
七、关键速查
| 任务 | 推荐配置 |
|---|---|
| 分类(10 类,1K data) | r=8, lr=3e-4, epochs=2 |
| 抽取(structured output) | r=16, lr=2e-4, epochs=2-3 |
| 风格 / 风格 | r=32, lr=1e-4, epochs=3 |
| 推理 / 思维链 | LoRA 不推荐,用 RAG / prompt |
| 库 | 用途 |
|---|---|
unsloth | 加速 + QLoRA |
peft | 标准 LoRA |
trl | SFTTrainer |
bitsandbytes | 量化 |
vllm | 部署 + LoRA hot-swap |
八、面试题
-
LoRA 与 full FT 的本质区别?什么时候 LoRA 不够要 full FT?
- LoRA 只更新低秩残差,参数量 < 1% full FT;当任务需要大幅改变模型分布(如换语言、跨领域)full FT 更好;通常金融分类 / 抽取 / 风格 LoRA 足矣
-
QLoRA 的 4-bit 量化会不会掉性能?
- NF4 在 LoRA fine-tune 后通常 < 1% 准确率掉。merge 时反量化有误差,需 eval 验证。生产用 LoRA-as-adapter(不 merge)能保持 base 精度
-
怎么决定 r?
- 起 8,看 val 不够强加 16/32;> 64 通常过拟合或没必要;任务复杂度 / 数据量 → r 大小
-
LoRA 模型在生产怎么部署?
- vLLM
--enable-lora同 base 上多个 adapter;或 merge 后单模型部署;金融多业务线推荐第一种(一套 base、多 LoRA)
- vLLM
-
训练数据合规怎么做?
- PII 脱敏(regex + NER);数据来源审计;训练日志保留 7 年;模型权重审计访问;FT 数据样本人工 SME 抽审 1%
明日预告
Day 174:Safety & Guardrails NeMo Guardrails Colang DSL + Anthropic constitutional AI + LLM Guard 多层防御,给 agent 加全套安全护栏。