强曰为道

与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

07 - LoRA 动态适配

07 - LoRA 动态适配

在不重启服务的情况下动态加载和切换 LoRA 适配器,实现一个基础模型服务多个垂直场景。


7.1 LoRA 基础概念

7.1.1 什么是 LoRA?

LoRA(Low-Rank Adaptation)是一种高效的模型微调方法。它冻结预训练模型的原始权重,仅训练少量的低秩分解矩阵,从而以极低的参数量实现模型适配。

原始权重矩阵 W (frozen): [d × d]
    │
    │  原始推理: h = W · x
    │
    │  LoRA 推理: h = W · x + ΔW · x = W · x + B · A · x
    │                           ↑
    │                    LoRA 增量(可训练)
    │
    │  A: [d × r]  (降维矩阵,r << d)
    │  B: [r × d]  (升维矩阵)
    │
    │  参数量: 2 × d × r(远小于 d × d)

示例(d=4096, r=16):
  原始参数: 4096 × 4096 = 16,777,216
  LoRA 参数: 2 × 4096 × 16   = 131,072
  压缩比: 0.78%(仅增加 0.78% 的参数)

7.1.2 LoRA 在 vLLM 中的优势

优势说明
零停机切换切换 LoRA 适配器无需重启服务
多 LoRA 并行同一请求中不同 token 使用不同 LoRA
显存高效基础模型只加载一份,LoRA 适配器很小
快速适配几分钟内即可训练新的 LoRA 适配器
多租户不同租户使用不同的 LoRA

7.2 使用 LoRA 适配器

7.2.1 基础用法

# lora_basic.py
"""LoRA 基础使用示例"""

from vllm import LLM, SamplingParams

# 启用 LoRA 支持
llm = LLM(
    model="Qwen/Qwen2.5-7B-Instruct",
    enable_lora=True,           # 启用 LoRA
    max_lora_rank=64,           # 最大 LoRA rank
    max_loras=4,                # 最大同时加载的 LoRA 数量
    max_model_len=4096,
    gpu_memory_utilization=0.9,
)

# 加载并使用 LoRA 适配器
from vllm.lora.request import LoRARequest

lora_adapter = LoRARequest(
    lora_name="medical-lora",          # 适配器名称
    lora_int_id=1,                      # 整数 ID
    lora_path="/data/lora/medical-lora", # 适配器路径
)

sampling_params = SamplingParams(temperature=0.7, max_tokens=256)

# 使用 LoRA 生成
outputs = llm.generate(
    ["患者出现持续性头痛,可能是什么原因?"],
    sampling_params,
    lora_request=lora_adapter,
)

print(outputs[0].outputs[0].text)

7.2.2 在线服务中使用 LoRA

# 启动带 LoRA 支持的 API 服务
vllm serve Qwen/Qwen2.5-7B-Instruct \
    --enable-lora \
    --max-lora-rank 64 \
    --max-loras 4 \
    --lora-modules \
        medical-lora=/data/lora/medical-lora \
        legal-lora=/data/lora/legal-lora \
        finance-lora=/data/lora/finance-lora

7.2.3 API 请求中指定 LoRA

# 使用指定 LoRA 发送请求
curl http://localhost:8000/v1/chat/completions \
    -H "Content-Type: application/json" \
    -d '{
        "model": "qwen-7b",
        "messages": [{"role": "user", "content": "诊断:慢性胃炎的治疗方案"}],
        "max_tokens": 300,
        "temperature": 0.7,
        "model_extra": {
            "lora_name": "medical-lora"
        }
    }'
# Python 客户端使用 LoRA
from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1", api_key="none")

# 指定 LoRA 适配器
response = client.chat.completions.create(
    model="qwen-7b",
    messages=[{"role": "user", "content": "分析这个合同条款的风险。"}],
    max_tokens=300,
    extra_body={"lora_name": "legal-lora"},
)
print(response.choices[0].message.content)

7.3 多 LoRA 并行

7.3.1 架构原理

多个请求同时使用不同 LoRA 适配器:

请求1(医疗)──┐
请求2(法律)──┤    ┌─────────────────────────┐
请求3(金融)──┼───→│     vLLM Engine          │
请求4(无LoRA)┘    │                         │
                    │  基础模型权重(共享)       │
                    │       │                  │
                    │  ┌────▼────┐             │
                    │  │ LoRA A  │← 请求1,请求4 │
                    │  │ LoRA B  │← 请求2      │
                    │  │ LoRA C  │← 请求3      │
                    │  └─────────┘             │
                    └─────────────────────────┘

一个前向推理批次中,不同 token 可能使用不同的 LoRA。
vLLM 通过 LoRA 权重矩阵的拼接和分段计算来高效处理。

7.3.2 多 LoRA 配置

# multi_lora.py
"""多 LoRA 同时加载"""

from vllm import LLM, SamplingParams
from vllm.lora.request import LoRARequest

llm = LLM(
    model="Qwen/Qwen2.5-7B-Instruct",
    enable_lora=True,
    max_lora_rank=64,
    max_loras=8,            # 最多同时加载 8 个 LoRA
    max_model_len=4096,
)

# 定义多个 LoRA 适配器
loras = {
    "medical": LoRARequest("medical", 1, "/data/lora/medical"),
    "legal": LoRARequest("legal", 2, "/data/lora/legal"),
    "finance": LoRARequest("finance", 3, "/data/lora/finance"),
    "code": LoRARequest("code", 4, "/data/lora/code"),
}

# 不同请求使用不同的 LoRA
prompts_and_loras = [
    ("头痛的常见原因有哪些?", loras["medical"]),
    ("分析合同中的违约条款。", loras["legal"]),
    ("评估这个投资方案的风险。", loras["finance"]),
    ("写一个快速排序算法。", loras["code"]),
    ("你好,今天天气怎么样?", None),  # 不使用 LoRA
]

sampling_params = SamplingParams(temperature=0.7, max_tokens=256)

for prompt, lora in prompts_and_loras:
    output = llm.generate([prompt], sampling_params, lora_request=lora)
    lora_name = lora.lora_name if lora else "base"
    print(f"[{lora_name}] {output[0].outputs[0].text[:100]}...")

7.4 LoRA 热切换

7.4.1 动态加载新 LoRA

vLLM 支持在运行时动态加载新的 LoRA 适配器,无需重启服务:

# hot_swap.py
"""运行时动态加载 LoRA"""

import time
from vllm import AsyncLLMEngine, AsyncEngineArgs, SamplingParams
from vllm.lora.request import LoRARequest

async def demo_hot_swap():
    # 创建引擎
    engine_args = AsyncEngineArgs(
        model="Qwen/Qwen2.5-7B-Instruct",
        enable_lora=True,
        max_lora_rank=64,
        max_loras=4,
    )
    engine = AsyncLLMEngine.from_engine_args(engine_args)
    
    sampling_params = SamplingParams(temperature=0.7, max_tokens=100)
    
    # 初始 LoRA
    lora_v1 = LoRARequest("model-v1", 1, "/data/lora/medical-v1")
    
    result = await engine.generate(
        "头痛的诊断流程是什么?",
        sampling_params,
        request_id="req-1",
        lora_request=lora_v1,
    )
    print(f"V1: {result.outputs[0].text[:100]}")
    
    # 热切换到新版本的 LoRA
    lora_v2 = LoRARequest("model-v2", 2, "/data/lora/medical-v2")
    
    result = await engine.generate(
        "头痛的诊断流程是什么?",
        sampling_params,
        request_id="req-2",
        lora_request=lora_v2,
    )
    print(f"V2: {result.outputs[0].text[:100]}")

7.4.2 LoRA 适配器管理

# lora_manager.py
"""LoRA 适配器管理"""

import os
from pathlib import Path

class LoRAManager:
    """管理 LoRA 适配器的加载和切换"""
    
    def __init__(self, lora_base_dir: str):
        self.base_dir = Path(lora_base_dir)
        self.loaded_adapters: dict[str, LoRARequest] = {}
        self._next_id = 1
    
    def discover_adapters(self) -> list[str]:
        """发现可用的 LoRA 适配器"""
        adapters = []
        for d in self.base_dir.iterdir():
            if d.is_dir() and (d / "adapter_config.json").exists():
                adapters.append(d.name)
        return adapters
    
    def load_adapter(self, name: str) -> LoRARequest:
        """加载指定的适配器"""
        if name in self.loaded_adapters:
            return self.loaded_adapters[name]
        
        adapter_path = self.base_dir / name
        if not adapter_path.exists():
            raise FileNotFoundError(f"适配器 {name} 不存在")
        
        lora_request = LoRARequest(
            lora_name=name,
            lora_int_id=self._next_id,
            lora_path=str(adapter_path),
        )
        self._next_id += 1
        self.loaded_adapters[name] = lora_request
        
        return lora_request
    
    def get_adapter(self, name: str) -> LoRARequest:
        """获取已加载的适配器"""
        if name not in self.loaded_adapters:
            return self.load_adapter(name)
        return self.loaded_adapters[name]

# 使用示例
manager = LoRAManager("/data/lora-adapters")
print("可用适配器:", manager.discover_adapters())

medical_lora = manager.load_adapter("medical-v3")

7.5 训练 LoRA 适配器

7.5.1 使用 PEFT 库训练

# train_lora.py
"""训练 LoRA 适配器示例"""

from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments
from peft import LoraConfig, get_peft_model, TaskType
from datasets import load_dataset
from trl import SFTTrainer

# 1. 加载基础模型
model_name = "Qwen/Qwen2.5-7B-Instruct"
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype="auto",
    device_map="auto",
)
tokenizer = AutoTokenizer.from_pretrained(model_name)

# 2. 配置 LoRA
lora_config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    r=16,                    # LoRA rank
    lora_alpha=32,           # 缩放因子
    lora_dropout=0.05,       # Dropout
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
                    "gate_proj", "up_proj", "down_proj"],
    bias="none",
)

model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
# 输出: trainable params: 13,107,200 || all params: 7,614,939,136 || trainable%: 0.172

# 3. 准备数据
dataset = load_dataset("json", data_files="train_data.jsonl")

# 4. 训练
training_args = TrainingArguments(
    output_dir="./lora-output",
    num_train_epochs=3,
    per_device_train_batch_size=4,
    gradient_accumulation_steps=4,
    learning_rate=2e-4,
    warmup_ratio=0.1,
    logging_steps=10,
    save_strategy="epoch",
    fp16=True,
)

trainer = SFTTrainer(
    model=model,
    args=training_args,
    train_dataset=dataset["train"],
    tokenizer=tokenizer,
    dataset_text_field="text",
    max_seq_length=2048,
)

trainer.train()

# 5. 保存 LoRA 适配器
model.save_pretrained("./medical-lora-adapter")
tokenizer.save_pretrained("./medical-lora-adapter")

7.5.2 LoRA 训练数据格式

{"messages": [{"role": "system", "content": "你是一个医疗助手。"}, {"role": "user", "content": "什么是高血压?"}, {"role": "assistant", "content": "高血压是指动脉血压持续升高..."}]}
{"messages": [{"role": "system", "content": "你是一个医疗助手。"}, {"role": "user", "content": "糖尿病的早期症状有哪些?"}, {"role": "assistant", "content": "糖尿病的早期症状包括..."}]}

7.6 LoRA 与量化组合

7.6.1 AWQ + LoRA

# awq_lora.py
"""AWQ 量化 + LoRA 组合使用"""

from vllm import LLM, SamplingParams
from vllm.lora.request import LoRARequest

# 使用 AWQ 量化模型作为基础模型
llm = LLM(
    model="Qwen/Qwen2.5-7B-Instruct-AWQ",
    quantization="awq",
    enable_lora=True,
    max_lora_rank=64,
    max_loras=4,
    max_model_len=4096,
)

lora = LoRARequest("medical", 1, "/data/lora/medical")
sampling_params = SamplingParams(temperature=0.7, max_tokens=256)

# 注意:LoRA 适配器需要在原始 FP16 模型上训练
# vLLM 会自动处理精度转换
outputs = llm.generate(
    ["患者症状:头痛、发热、乏力。请给出诊断建议。"],
    sampling_params,
    lora_request=lora,
)
print(outputs[0].outputs[0].text)

7.7 性能考量

7.7.1 LoRA 对推理性能的影响

配置吞吐量影响显存影响
无 LoRA(基线)100%基线
1 个 LoRA(rank=16)~97%+50MB
4 个 LoRA(rank=16)~93%+200MB
1 个 LoRA(rank=64)~95%+150MB
4 个 LoRA(rank=64)~88%+600MB

7.7.2 优化建议

# 性能优化配置
llm = LLM(
    model="model",
    enable_lora=True,
    max_lora_rank=32,       # 不要设太大,16-32 通常足够
    max_loras=4,            # 根据实际需求设置
    max_model_len=4096,     # 减小可增加并发
    gpu_memory_utilization=0.9,
    # LoRA 相关的额外显存需要预留
)

7.8 业务场景

场景一:多租户 SaaS 服务

              ┌─── 租户 A: LoRA(金融领域)
基础模型 ─────┤
(LLaMA-70B)  ├─── 租户 B: LoRA(医疗领域)
              │
              └─── 租户 C: LoRA(法律领域)

优势:
- 基础模型只加载一份(140GB)
- 每个 LoRA 仅 50-200MB
- 同一 GPU 服务所有租户

场景二:A/B 测试

# 同时运行两个版本的 LoRA,对比效果
lora_v1 = LoRARequest("model-v1", 1, "/lora/sentiment-v1")
lora_v2 = LoRARequest("model-v2", 2, "/lora/sentiment-v2")

# 50/50 分流
import random
for prompt in test_prompts:
    lora = lora_v1 if random.random() < 0.5 else lora_v2
    result = llm.generate([prompt], params, lora_request=lora)

场景三:多语言支持

基础模型: 英文大模型
    ├── LoRA: 中文增强
    ├── LoRA: 日文增强
    └── LoRA: 韩文增强

7.9 注意事项

训练一致性:LoRA 适配器必须在与基础模型相同版本的模型上训练。Qwen2.5-7B 上训练的 LoRA 不能用在 Qwen2-7B 上。

Rank 选择:rank 越大,表达能力越强但显存占用越多。一般 8-64 范围内选择,大多数场景 rank=16 足够。

目标模块:对所有线性层应用 LoRA(包括 gate_proj, up_proj, down_proj)通常效果最好。

最大 LoRA 数max_loras 参数决定同时加载的 LoRA 数量,超出的 LoRA 会使用 LRU 策略换出。

LoRA 权重合并:如果不需要动态切换,可以将 LoRA 权重合并到基础模型中,减少推理开销。


7.10 扩展阅读


上一章06 - 模型量化 | 下一章08 - 调度与批处理策略