Skip to content

第16章 LoRA 适配器热切换

"Don't change the whole model — just teach it a new trick."

本章要点

  • 理解 LoRA 在推理中的作用:一个基座模型服务多个任务
  • 掌握 vLLM 的 LoRA 加载与管理机制
  • 理解多 LoRA 并发服务的调度策略
  • 认识 LoRA 对 KV Cache 和前缀缓存的影响

16.1 一个基座,多个技能

LoRA(Low-Rank Adaptation)在训练中已经广泛使用。但它在推理时的价值同样巨大:

一个 70B 的基座模型加载一次就行(140 GB 显存)。当请求 A 需要客服场景时,加载 LoRA-A(几十 MB);请求 B 需要代码生成时,加载 LoRA-B。多个 LoRA 可以同时活跃,不同请求在同一步推理中使用不同的 LoRA。

16.2 LoRA 请求与加载

在 vLLM 中,每个请求可以通过 LoRARequest 指定使用哪个 LoRA:

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

llm = LLM(model="meta-llama/Llama-2-7b-hf", enable_lora=True)

# 请求 A 使用客服 LoRA
output_a = llm.generate(
    "你好,我想退货",
    lora_request=LoRARequest("customer-service", 1, "/path/to/lora-a"),
)

# 请求 B 使用代码 LoRA
output_b = llm.generate(
    "write a quicksort in Python",
    lora_request=LoRARequest("code-gen", 2, "/path/to/lora-b"),
)

源码vllm/lora/models.py

vLLM 的 LoRA 模型表示定义在 LoRAModelmodels.py:61)中:

python
# vllm/lora/models.py:61-87 (简化)
class LoRAModel(AdapterModel):
    """A LoRA fine-tuned model."""
    def __init__(self, lora_model_id: int, rank: int,
                 loras: Dict[str, LoRALayerWeights], ...):
        self.id = lora_model_id
        self.rank = rank
        self.loras = loras  # module_name → LoRA weights (A, B matrices)

    def clone(self, lora_model_id: int) -> "LoRAModel":
        """复制模型但共享底层张量——用于同一 LoRA 的多实例"""
        return self.__class__(lora_model_id, rank=self.rank,
                              loras=self.loras.copy())

注意 clone 方法:它创建 LoRA 模型的浅拷贝,共享底层权重张量。这意味着同一个 LoRA 被多个请求使用时,GPU 上只有一份权重,而非 N 份。

LoRAModelManagermodels.py:304)管理活跃 LoRA 的生命周期,LRUCacheLoRAModelManagermodels.py:711)在此基础上添加了 LRU 缓存——当 LoRA 数量超过 GPU 容量时,最近最少使用的 LoRA 会被自动卸载。

WorkerLoRAManagervllm/lora/worker_manager.py)负责 LoRA 的加载和管理。它的职责链条比想象中复杂:

加载流程

当一个请求携带了 LoRARequest,WorkerLoRAManager 需要确保对应的 LoRA 权重已经在 GPU 显存中就绪。加载分为三步:

步骤 1:定位权重LoRAResolvervllm/lora/resolver.py)是一个可插拔的解析器,支持从本地路径、S3、HuggingFace Hub 等来源获取 LoRA 权重。默认的解析器直接读本地文件;企业部署中可以实现自定义解析器,从模型仓库或对象存储中动态拉取。

步骤 2:加载到 CPU。LoRA 权重文件通常是 safetensors 格式,包含 A 矩阵和 B 矩阵。对于 rank=16 的 LoRA 应用于 Llama-2-7B 的所有注意力层,权重大小约为 4 × 32 × (4096 × 16 + 16 × 4096) × 2 bytes ≈ 33 MB——非常小。

步骤 3:传输到 GPU。LoRA 权重被放置到 GPU 上的专用缓冲区。vLLM 预分配了一块 LoRA 权重缓冲区(大小由 --max-lora-rank--max-loras 决定),不同的 LoRA 共享同一块缓冲区的不同"槽位"。

LRU 驱逐策略

GPU 上同时能容纳的 LoRA 数量有限(由 --max-loras 配置,默认 1)。当需要加载一个新 LoRA 但槽位已满时,WorkerLoRAManager 按 LRU(最近最少使用) 策略驱逐最久未被任何活跃请求使用的 LoRA。

驱逐的代价很低——只需要将新 LoRA 的权重覆盖到已释放的槽位。但如果请求模式频繁切换(每个请求都用不同的 LoRA),频繁的加载/驱逐会成为瓶颈。因此 --max-loras 应该设为实际同时活跃的 LoRA 数量,而非 LoRA 总数。

16.3 LoRA 的数学原理回顾

要理解 vLLM 对 LoRA 的工程处理,需要回顾 LoRA 的核心思想。

标准的微调会修改模型的全部权重 W。LoRA 的洞察是:微调过程中权重的变化 ΔW 是低秩的——它可以分解为两个小矩阵的乘积。

原始: Y = X × W
LoRA: Y = X × (W + ΔW) = X × W + X × (A × B)

其中 W 是 [d, d] 的原始权重(如 d=4096),A 是 [d, r],B 是 [r, d],r 是秩(通常 8-64,远小于 d)。

推理时的计算:LoRA 不需要真正修改 W。而是在前向传播中,将 LoRA 的贡献作为一个加法旁路叠加到原始输出上:

python
# 简化的 LoRA 前向传播
def forward_with_lora(x, W, A, B, scaling):
    base_output = x @ W           # 原始路径
    lora_output = (x @ A) @ B     # LoRA 旁路
    return base_output + scaling * lora_output

这种设计有两个关键优势:

  1. 基座权重不变——多个 LoRA 共享同一份基座权重,切换 LoRA 只需要换 A 和 B 矩阵
  2. 计算开销极小——r 通常只有 16 或 32,x @ A[batch, d] × [d, r])的计算量只有原始矩阵乘的 r/d ≈ 0.4%

16.4 LoRA 与 KV Cache

LoRA 修改了注意力层的权重,这意味着相同的 Prompt 在不同 LoRA 下产生不同的 KV Cache

这对前缀缓存有直接影响:块的哈希必须包含 LoRA 标识作为 extra_key。使用 LoRA-A 的请求和使用 LoRA-B 的请求,即使 Prompt 完全相同,它们的 KV Cache 块也不能共享。

python
# BlockHash 构造中包含 LoRA 名
block_hash = hash(
    parent_hash,
    token_ids,
    extra_keys=(lora_name, ...)  # LoRA 名作为缓存隔离键
)

16.5 多 LoRA 并发

同一批次中可以包含使用不同 LoRA 的请求。GPU 内核通过批量索引处理:

Batch = [req_A(LoRA-1), req_B(LoRA-2), req_C(LoRA-1), req_D(LoRA-3)]
lora_indices = [0, 1, 0, 2]  # 每个请求对应的 LoRA 编号

注意力层在计算时,根据 lora_indices 选择对应的 LoRA 权重。这比为每个 LoRA 单独做一次前向传播高效得多。

批量 LoRA 的 GPU 内核

朴素的实现是为每个 LoRA 分别执行矩阵乘法。但如果批次中有 100 个请求使用了 3 个不同的 LoRA,就需要 3 次额外的矩阵乘法,效率不高。

vLLM 使用分组 GEMM(Grouped GEMM)——将同一 LoRA 的请求分在一组,一次内核调用处理一组。对于上面的例子,只需要 3 次小矩阵乘法(每次处理一组请求),配合基座模型的 1 次大矩阵乘法。

由于 LoRA 的 rank 很小(通常 16-64),旁路计算的 FLOPs 占比不到总计算量的 1%。批量 LoRA 的额外开销几乎可以忽略。

16.6 量化 LoRA(QLoRA)

vLLM 还支持量化 LoRA(QLoRA)——在量化的基座模型上应用 LoRA。这意味着基座模型用 INT4/FP8 节省显存,LoRA 的 A/B 矩阵保持 FP16/BF16 精度。

这种组合在多租户场景下非常有价值:基座模型量化到 4-bit 只占 35 GB(70B 参数),留出大量显存给 KV Cache 和多个 LoRA 适配器。一张 80 GB 的 A100 可以同时服务 10+ 个不同的 LoRA 任务。

16.7 生产部署建议

参数选择

参数说明建议值
--enable-lora启用 LoRA 支持需要时开启
--max-lorasGPU 同时活跃的 LoRA 数实际并发 LoRA 数,通常 2-8
--max-lora-rank最大 LoRA 秩与训练时一致,通常 16-64
--lora-extra-vocab-sizeLoRA 额外词表大小0(除非 LoRA 扩展了词表)

显存规划:LoRA 权重的显存占用 = max_loras × num_layers × 2 × (d × r + r × d) × dtype_size。对于 Llama-2-7B、rank=16、max_loras=4,约 130 MB——相对于模型权重本身(14 GB FP16)微不足道。

冷启动优化:如果 LoRA 权重在远程存储(S3)上,首次加载可能耗时数秒。建议在服务启动时预加载常用的 LoRA,或将 LoRA 权重存放在本地 SSD 上。

16.8 本章小结

  • 一基座多任务——LoRA 让一个大模型同时服务多种场景
  • 热切换——WorkerLoRAManager 管理 LoRA 的加载/卸载/切换
  • KV Cache 隔离——不同 LoRA 的 KV Cache 通过 extra_key 隔离
  • 批量并发——同一批次中不同请求可以使用不同的 LoRA

源码导航

  • LoRA 管理:vllm/lora/worker_manager.py
  • LoRA 请求:vllm/lora/request.py
  • LoRA 解析器(远程加载):vllm/lora/resolver.py

基于 VitePress 构建