返回 Expert 笔记
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_alpha2rscaling = α/r,控制更新强度
lora_dropout0.0-0.1小数据加 dropout 防过拟合
target_modulesq,k,v,o + gate,up,down全 attention + MLP 通常最好
learning_rate1e-4 ~ 3e-4LoRA 比 full FT lr 高 5-10x
epochs1-3多了过拟合
batch_size看显存4-32, gradient_accumulation 凑
lr_schedulercosinewarmup 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+ A100A100 40G18 min$1.50
RunPod 1xA100 80GA100 80G13 min$1.80
RunPod 1xH100H100 80G7 min$2.10
自有 A10018 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 latency320 ms
月成本(H100 持续运行 $2.0/h)$1450
月可服务请求量~ 50M
每请求成本$0.000029

vs claude-haiku-4-5 API(短任务):~ $0.0008/req → 自托管便宜 27x(前提:流量足够大)


五、金融领域应用

  1. 客户邮件意图分类:稳定 10 类,日 1M+ 量,LoRA 自托管 ROI 显著
  2. 合规规则匹配:内部规则术语,base model 不熟,LoRA 学懂术语
  3. 结构化抽取:KYC 字段、申请单字段,LoRA 让模型 100% 输出 JSON
  4. 风险事件分类:操作风险/信用风险/市场风险细分类,LoRA 用历史标注
  5. 银行内部术语翻译:把客户口语翻译为系统术语("账单逾期" → "DPD30+")
  6. 报告生成风格统一:让模型用银行特定写作模板(LoRA 风格迁移)

金融场景额外要求

  • 训练数据脱敏 + 审计
  • 模型权重视作 PII 容器,权限管控
  • 监管要求"模型可解释",LoRA 通常不影响可解释(base 仍是开源已知)
  • 模型版本号清晰:base_model + lora_id + train_data_hash

六、生产经验与陷阱

  1. 数据质量 > 数据量:800 条精标 > 8000 条粗标。bad label 让模型学到错的东西
  2. train/test leak:去重必须做(subject + body hash);数据按时间切(不是随机),避免 future leak
  3. 过拟合 60% 任务:LoRA r 太大 + epochs 多就过拟合。先 r=16, 1-2 epochs,看 val_loss 决定
  4. chat template 不对:Llama-3.2 用 <|begin_of_text|> 格式,写错训练全废。一定看官方
  5. 学习率太低:LoRA 比 full FT 学习率高 5-10x(2e-4 vs 2e-5),太低收敛不动
  6. target_modules 太少:只给 q/v_proj 不够,加上 gate/up/down 效果好很多
  7. merge 后掉点:QLoRA merge 时 nf4 反量化误差,掉 0.5-2 个点。要的精度临界,用 LoRA-as-adapter 模式
  8. LoRA hot-swap 时 latency 抖动:vLLM --max-loras 4 加载新 adapter 占显存,配置上限
  9. 合规审查:FT 模型上线前要走"模型变更"评审,跟 base model 上线一样
  10. 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
trlSFTTrainer
bitsandbytes量化
vllm部署 + LoRA hot-swap

八、面试题

  1. LoRA 与 full FT 的本质区别?什么时候 LoRA 不够要 full FT?

    • LoRA 只更新低秩残差,参数量 < 1% full FT;当任务需要大幅改变模型分布(如换语言、跨领域)full FT 更好;通常金融分类 / 抽取 / 风格 LoRA 足矣
  2. QLoRA 的 4-bit 量化会不会掉性能?

    • NF4 在 LoRA fine-tune 后通常 < 1% 准确率掉。merge 时反量化有误差,需 eval 验证。生产用 LoRA-as-adapter(不 merge)能保持 base 精度
  3. 怎么决定 r?

    • 起 8,看 val 不够强加 16/32;> 64 通常过拟合或没必要;任务复杂度 / 数据量 → r 大小
  4. LoRA 模型在生产怎么部署?

    • vLLM --enable-lora 同 base 上多个 adapter;或 merge 后单模型部署;金融多业务线推荐第一种(一套 base、多 LoRA)
  5. 训练数据合规怎么做?

    • PII 脱敏(regex + NER);数据来源审计;训练日志保留 7 年;模型权重审计访问;FT 数据样本人工 SME 抽审 1%

明日预告

Day 174:Safety & Guardrails NeMo Guardrails Colang DSL + Anthropic constitutional AI + LLM Guard 多层防御,给 agent 加全套安全护栏。