Appearance
第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 模型表示定义在 LoRAModel(models.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 份。
LoRAModelManager(models.py:304)管理活跃 LoRA 的生命周期,LRUCacheLoRAModelManager(models.py:711)在此基础上添加了 LRU 缓存——当 LoRA 数量超过 GPU 容量时,最近最少使用的 LoRA 会被自动卸载。
WorkerLoRAManager(vllm/lora/worker_manager.py)负责 LoRA 的加载和管理。它的职责链条比想象中复杂:
加载流程
当一个请求携带了 LoRARequest,WorkerLoRAManager 需要确保对应的 LoRA 权重已经在 GPU 显存中就绪。加载分为三步:
步骤 1:定位权重。LoRAResolver(vllm/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这种设计有两个关键优势:
- 基座权重不变——多个 LoRA 共享同一份基座权重,切换 LoRA 只需要换 A 和 B 矩阵
- 计算开销极小——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-loras | GPU 同时活跃的 LoRA 数 | 实际并发 LoRA 数,通常 2-8 |
--max-lora-rank | 最大 LoRA 秩 | 与训练时一致,通常 16-64 |
--lora-extra-vocab-size | LoRA 额外词表大小 | 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