vLLM LoRA 实现深度解析
文档版本:2026-05-27 代码路径:
vllm/lora/覆盖文件:models.py,worker_manager.py,request.py,lora_weights.py,peft_helper.py
目录
-
[LoRA 数学原理](#LoRA 数学原理)
-
[多 LoRA 并发路由机制](#多 LoRA 并发路由机制)
-
[LRU 缓存策略](#LRU 缓存策略)
1. LoRA 数学原理
LoRA(Low-Rank Adaptation)的核心思想是:在原始权重矩阵 W₀ ∈ ℝ^(d×k) 旁边注入一个低秩分解的增量,而不修改原始权重:
W = W₀ + ΔW = W₀ + B · A
其中:
-
A ∈ ℝ^(r×k):下投影矩阵(lora_a),随机高斯初始化 -
B ∈ ℝ^(d×r):上投影矩阵(lora_b),初始化为零 -
r ≪ min(d, k):秩,控制参数量
前向传播公式:
h = W₀·x + (α/r) · B·A·x
其中 α(lora_alpha)是缩放超参数,scaling = α/r。
rsLoRA 变体
使用 Rank-Stabilized LoRA(rsLoRA)时,缩放因子改为:
scaling = α / √r
在 peft_helper.py 中实现:
if self.use_rslora:
self.vllm_lora_scaling_factor = self.lora_alpha / math.sqrt(self.r)
else:
self.vllm_lora_scaling_factor = self.lora_alpha / self.r
推理优化:预合并 scaling
为了减少推理时的乘法运算,vLLM 在激活 LoRA 时将 scaling 预乘进 lora_b:
# lora_weights.py
def optimize(self) -> "LoRALayerWeights":
if self.scaling == 1:
return self
self.lora_b *= self.scaling # 将 α/r 合并进 B
self.scaling = 1
return self
2. 整体架构
模块依赖关系
vllm/lora/
├── request.py # LoRARequest:请求描述符,携带名称/路径/整数ID
├── peft_helper.py # PEFTHelper:解析 adapter_config.json,校验 LoRA 配置
├── lora_weights.py # LoRALayerWeights / PackedLoRALayerWeights:单层权重封装
├── models.py # LoRAModel / LoRAModelManager / LRUCacheLoRAModelManager
├── worker_manager.py # WorkerLoRAManager:Worker 侧管理入口
├── layers.py # BaseLayerWithLoRA 及各线性层的 LoRA 替换实现
├── ops/ # Triton/CUDA 推理算子(lora_shrink, lora_expand)
└── punica_wrapper/ # token 级别多 LoRA 路由元数据管理
类层次结构
WorkerLoRAManager ← Worker 侧入口,每个 GPU Worker 一个实例
└── LoRAModelManager ← 管理 LoRA 模型的加载/激活/卸载
└── LRUCacheLoRAModelManager ← LRU 版本,支持多适配器动态换入换出
├── _registered_adapters: LoRALRUCache ← CPU 缓存
└── _active_adapters: LoRALRUCache ← GPU 激活槽
LoRAModel ← 一个完整的 LoRA 适配器
└── loras: dict[str, LoRALayerWeights] ← 模块名 → 该层的 A/B 矩阵
LoRARequest ← 客户端请求中携带的 LoRA 标识
├── lora_name: str ← 用户可见名称(如 "asr-v1")
├── lora_int_id: int ← 内部全局唯一整数 ID
└── lora_path: str ← 权重文件路径
3. 核心类详解
3.1 LoRARequest(request.py)
请求中携带的 LoRA 标识符,是连接前端请求与后端权重管理的桥梁。
class LoRARequest(msgspec.Struct):
lora_name: str # API 层用户指定的名称,如 "asr-v1"
lora_int_id: int # 全局唯一整数 ID,>0,用于 GPU 槽位索引
lora_path: str # 适配器权重磁盘/远程路径
设计要点 :使用 msgspec.Struct 实现高效序列化,array_like=True 支持跨进程传递。
3.2 PEFTHelper(peft_helper.py)
解析 LoRA 适配器目录下的 adapter_config.json,提供标准化的配置接口。
关键字段:
-
r:LoRA 秩 -
lora_alpha:缩放因子 -
target_modules:目标模块名列表(如["q_proj", "v_proj"]) -
use_rslora:是否使用 rsLoRA -
use_dora:是否使用 DoRA(vLLM 暂不支持) -
vllm_lora_scaling_factor:最终缩放值(由__post_init__计算)
3.3 LoRALayerWeights(lora_weights.py)
封装单个模型层的 LoRA 权重(A 矩阵 + B 矩阵)。
class LoRALayerWeights:
module_name: str # 所属模块名,如 "model.layers.0.self_attn.q_proj"
rank: int # LoRA 秩 r
lora_alpha: int # 缩放因子 α
lora_a: torch.Tensor # shape: (r, k),输入投影
lora_b: torch.Tensor # shape: (d, r),输出投影
scaling: float # α/r,optimize() 后变为 1
def optimize(self):
"""将 scaling 预乘进 lora_b,推理时无需额外乘法"""
self.lora_b *= self.scaling
self.scaling = 1
PackedLoRALayerWeights 是其子类,处理 qkv_proj 等打包层(多个子模块合并到一个矩阵)。
3.4 LoRAModel(models.py)
代表一个完整加载的 LoRA 适配器,包含所有层的权重。
class LoRAModel:
id: int # lora_int_id
rank: int # 全局秩
loras: dict[str, LoRALayerWeights] # 模块名 → 该层 LoRA 权重
加载入口 :LoRAModel.from_local_checkpoint(),支持以下格式:
| 文件名 | 格式 | 优先级 |
|---|---|---|
adapter_model.safetensors |
safetensors | 最高(推荐) |
adapter_model.bin |
PyTorch pickle | 次之 |
adapter_model.pt |
PyTorch pickle | 次之 |
3.5 LoRAModelManager(models.py)
管理 LoRA 模型的核心类,负责:
-
初始化时将目标模块替换为支持 LoRA 的版本
-
管理 CPU 缓存和 GPU 激活槽位
-
将 LoRA 权重写入 GPU buffer
关键属性:
_registered_adapters: dict[int, LoRAModel] # CPU 中已加载的 LoRA,以 lora_id 为 key
_active_adapters: dict[int, None] # 当前激活在 GPU 上的 LoRA
lora_index_to_id: list[Optional[int]] # GPU 槽位索引 → lora_id 映射,长度 = max_loras
modules: dict[str, BaseLayerWithLoRA] # 模型中被替换的 LoRA 层
punica_wrapper # token 级别的 LoRA 路由管理器
3.6 WorkerLoRAManager(worker_manager.py)
Worker 进程的 LoRA 管理入口。每次推理 batch 前,Scheduler 会调用其 set_active_adapters() 来确保所需 LoRA 已加载并激活。
class WorkerLoRAManager:
def set_active_adapters(self, requests, mapping):
self._apply_adapters(requests) # 加载未缓存的 LoRA
self._adapter_manager.set_adapter_mapping(mapping) # 更新路由
有两个子类:
-
WorkerLoRAManager:每次 batch 只保留当前需要的 LoRA,其余全部卸载 -
(对应
LRUCacheLoRAModelManager):LRU 策略,保留最近使用的 LoRA
4. 完整推理流程
客户端请求
│ POST /v1/chat model="asr-v1"
▼
API Server
│ 解析 model 名称 → 查找 lora_modules 注册表 → 创建 LoRARequest
│ LoRARequest(lora_name="asr-v1", lora_int_id=1, lora_path="/path/to/asr-v1")
▼
Scheduler(调度器)
│ 将 LoRARequest 附加到 SequenceGroup
│ batch 调度时收集所有 lora_requests 集合
▼
Worker.execute_model()
│ 传入 lora_requests: set[LoRARequest]
│ 传入 lora_mapping: LoRAMapping(token → lora_id 映射)
▼
WorkerLoRAManager.set_active_adapters(lora_requests, lora_mapping)
│
├─ _apply_adapters(lora_requests)
│ │
│ └─ for each lora_request:
│ │ lora_id not in registered?
│ │ YES → _load_adapter(lora_request)
│ │ │ 读取 adapter_config.json → PEFTHelper
│ │ │ 读取 adapter_model.safetensors → tensors
│ │ │ LoRAModel.from_lora_tensors() → LoRAModel
│ │ └─ LoRAModelManager.add_adapter(lora_model)
│ │
│ └─ LoRAModelManager.activate_adapter(lora_int_id)
│ │ 找空闲 GPU 槽位 index
│ │ lora_index_to_id[index] = lora_id
│ │ for each module:
│ │ module.set_lora(index, A, B, ...) ← 写入 GPU buffer
│ └─ _active_adapters[lora_id] = None
│
└─ set_adapter_mapping(lora_mapping)
│ punica_wrapper.update_metadata()
└─ token_0 → slot_0, token_1 → slot_0, token_2 → slot_1, ...
模型 forward pass
│ for each LoRA 层(已替换为 BaseLayerWithLoRA):
│ output = W₀·x ← 基础权重
│ + lora_b[slot]·lora_a[slot]·x ← LoRA 增量(slot 由 punica 路由)
▼
输出 token
5. 关键代码逐段解析
5.1 模型层替换 _create_lora_modules()
def _create_lora_modules(self):
for module_name, module in self.model.named_modules():
if not self._match_target_modules(module_name):
continue
# 过滤多模态模型中的视觉塔(仅对语言模型部分应用 LoRA)
if self._filter_unsupported_mm_module(module_name):
continue
# 将原始 nn.Linear 替换为 BaseLayerWithLoRA
# 内部维护 lora_a_stacked[max_loras, r, k] 和 lora_b_stacked[max_loras, d, r]
new_module = replace_submodule(
self.model, module_name,
from_layer(module, self.lora_slots, self.lora_config, ...)
)
# 绑定 punica_wrapper,用于推理时 token→lora 路由
new_module.set_mapping(self.punica_wrapper)
self.modules[module_name] = new_module
关键点 :替换后的层内部预分配了 max_loras 个槽位的 GPU buffer,运行前把不同 LoRA 的 A/B 矩阵分别写入不同槽位。
5.2 LoRA 加载 from_local_checkpoint()
@classmethod
def from_local_checkpoint(cls, lora_dir, expected_lora_modules, peft_helper, ...):
# 1. 校验模块名合法性
def check_unexpected_modules(modules):
for lora_module in modules.keys():
module_name, _, _ = parse_fine_tuned_lora_name(lora_module)
if module_name.split(".")[-1] not in expected_lora_modules:
unexpected_modules.append(module_name)
if unexpected_modules:
raise ValueError(f"Unexpected modules: {unexpected_modules}")
# 2. 加载权重(优先 safetensors)
with safetensors.safe_open(lora_tensor_path, framework="pt") as f:
check_unexpected_modules(f)
tensors = {module: f.get_tensor(module) for module in f.keys()}
# 3. 构建 LoRAModel
return cls.from_lora_tensors(
tensors=tensors, peft_helper=peft_helper, device=device, ...
)
5.3 GPU 槽位激活 activate_adapter()
def activate_adapter(self, lora_id: int) -> bool:
if lora_id in self._active_adapters:
return False # 已激活,跳过
# 找第一个空闲槽位
first_free_slot = next(
((i, lid) for i, lid in enumerate(self.lora_index_to_id) if lid is None),
None
)
if first_free_slot is None:
raise ValueError("No free lora slots")
index, _ = first_free_slot
self._active_adapters[lora_id] = None
lora_model = self._registered_adapters[lora_id]
logger.info("Activating LoRA. int id: %d, slot index: %d", lora_model.id, index)
self.lora_index_to_id[index] = lora_model.id
# 将 A/B 矩阵写入对应槽位的 GPU buffer
for module_name, module in self.modules.items():
module_lora = self._get_lora_layer_weights(lora_model, module_name)
if module_lora:
module_lora.optimize() # 预乘 scaling 进 B
module.set_lora(
index, # 槽位编号
module_lora.lora_a, # A 矩阵
module_lora.lora_b, # B 矩阵(已含 scaling)
module_lora.embeddings_tensor,
module_lora.bias,
)
else:
module.reset_lora(index) # 该层无 LoRA,置零
return True
5.4 打包模块处理 _create_merged_loras_inplace()
对于 qkv_proj 这类将 Q/K/V 合并到同一矩阵的层,需要将三个独立的 LoRA 权重合并为一个 PackedLoRALayerWeights:
def _create_merged_loras_inplace(self, lora_model: LoRAModel):
for module_name, new_module_names in self.packed_modules.items():
# module_name = "qkv_proj"
# new_module_names = ["q_proj", "k_proj", "v_proj"]
replacement_loras = []
for r in new_module_names:
lora = lora_model.get_lora(r) # 获取各子模块的 LoRA
replacement_loras.append(lora)
# 合并为 PackedLoRALayerWeights
lora_model.loras[module_name] = PackedLoRALayerWeights.pack(replacement_loras)
# 删除原子模块的 LoRA 条目
for module in replaced_module:
lora_model.loras.pop(module, None)
6. 多 LoRA 并发路由机制
vLLM 的一个核心能力是:同一个推理 batch 中,不同的 token 可以使用不同的 LoRA 适配器。这依赖 punica_wrapper 和 LoRAMapping。
LoRAMapping 结构
LoRAMapping:
- token_lora_mapping: [lora_slot_0, lora_slot_0, lora_slot_1, ...]
每个 token 对应的 GPU 槽位编号(而非 lora_int_id)
路由更新流程
Scheduler 构建 LoRAMapping
↓
WorkerLoRAManager.set_active_adapters(requests, mapping)
↓
LoRAModelManager.set_adapter_mapping(mapping)
↓
punica_wrapper.update_metadata(mapping, lora_index_to_id, ...)
将 lora_int_id 翻译为 GPU 槽位 index
↓
每层 BaseLayerWithLoRA 的 forward()
从 punica_wrapper 获取当前 token 对应的槽位
output += lora_b_stacked[slot] · lora_a_stacked[slot] · x
底层算子
位于 vllm/lora/ops/triton_ops/:
-
lora_shrink:计算A·x,将 hidden_size 降维到 lora_rank(shrink 阶段) -
lora_expand:计算B·(A·x),将 lora_rank 升维回 hidden_size(expand 阶段)
这两个 Triton 算子支持 batch 内混合多个 LoRA,通过 token_indices_sorted_by_lora_ids 按 LoRA ID 排列 token 以提升内存访问局部性。
7. LRU 缓存策略
当需要服务的 LoRA 适配器数量超过 GPU 能同时容纳的槽位数(max_loras)时,使用 LRUCacheLoRAModelManager。
两级缓存
┌─────────────────────────────────────────────┐
│ CPU 内存缓存(_registered_adapters) │
│ 容量 = max_cpu_loras(默认 = max_loras) │
│ 策略:LRU 淘汰 │
│ [asr-v1, asr-v2, asr-v3, ..., asr-v10] │
└─────────────────┬───────────────────────────┘
│ activate_adapter()(按需搬运到 GPU)
┌─────────────────▼───────────────────────────┐
│ GPU 激活槽(_active_adapters) │
│ 容量 = max_loras │
│ 策略:LRU 驱逐最久未使用的槽位 │
│ slot_0: asr-v1 │ slot_1: asr-v2 │
└─────────────────────────────────────────────┘
LRU 激活逻辑
# LRUCacheLoRAModelManager.activate_adapter()
def activate_adapter(self, lora_id: int) -> bool:
# GPU 槽满时,驱逐最旧的激活 LoRA
if lora_id not in self._active_adapters and len(self._active_adapters) >= self.lora_slots:
self._active_adapters.remove_oldest() # 从 GPU buffer 驱逐
result = super().activate_adapter(lora_id) # 写入 GPU buffer
# 更新 LRU 顺序
self._active_adapters.touch(lora_id)
return result
Pinning 机制
对于需要保证常驻 GPU 的高优先级 LoRA,可以调用 pin_adapter(lora_id):
def pin_adapter(self, lora_id: int) -> bool:
self._pin_lora_in_cpu_cache(lora_id) # CPU 缓存中固定
self._pin_lora_in_gpu_cache(lora_id) # GPU 激活槽中固定(不被 LRU 驱逐)
8. 配置参数说明
| 启动参数 | 配置字段 | 默认值 | 说明 |
|---|---|---|---|
--enable-lora |
--- | False | 开启 LoRA 支持 |
--max-loras |
lora_config.max_loras |
1 | GPU 同时激活的 LoRA 槽位数 |
--max-cpu-loras |
lora_config.max_cpu_loras |
= max_loras | CPU 内存缓存的 LoRA 总数 |
--max-lora-rank |
lora_config.max_lora_rank |
16 | 预分配 GPU buffer 时的最大秩 |
--lora-modules |
LoRARequest 注册表 | --- | 格式:name=path [name2=path2 ...] |
--lora-dtype |
lora_config.lora_dtype |
同模型 | LoRA 权重的精度 |
--enable-lora-bias |
lora_config.bias_enabled |
False | 是否支持 LoRA bias |
典型启动命令
python -m vllm.entrypoints.openai.api_server \
--model /path/to/base/model \
--enable-lora \
--max-loras 4 \
--max-cpu-loras 16 \
--max-lora-rank 64 \
--lora-modules \
asr-v1=/path/to/lora/asr-v1 \
asr-v2=/path/to/lora/asr-v2
9. 内存布局
GPU Buffer 结构(每个 BaseLayerWithLoRA)
lora_a_stacked: Tensor[max_loras, max_lora_rank, in_features]
slot 0 → asr-v1 的 A 矩阵
slot 1 → asr-v2 的 A 矩阵
slot 2 → None(空闲)
slot 3 → None(空闲)
lora_b_stacked: Tensor[max_loras, out_features, max_lora_rank]
同上结构
槽位映射表
lora_index_to_id = [1, 2, None, None]
↑ ↑
asr-v1 asr-v2(lora_int_id)
显存估算
单个 LoRA 适配器在 GPU 上占用的显存:
显存 = num_lora_layers × (in_features × rank + out_features × rank) × dtype_bytes × max_loras
例:Qwen2.5-7B,rank=64,bf16,32 个 LoRA 层,max_loras=4:
≈ 32 × (4096×64 + 4096×64) × 2 × 4 = 约 256MB
10. 日志与调试
关键日志点
| 文件 | 函数 | 日志内容 | 级别 |
|---|---|---|---|
models.py |
activate_adapter() |
Activating LoRA. int id: X, slot index: Y |
INFO |
models.py |
AdapterLRUCache._on_remove() |
Removing adapter int id: X |
DEBUG |
models.py |
add_adapter() |
Adding lora. Model id: X, int id: Y |
DEBUG |
peft_helper.py |
__post_init__() |
Loading LoRA weights trained with rsLoRA. |
INFO |
worker_manager.py |
_load_adapter() |
加载失败时的错误信息 | ERROR |
自定义日志(推荐添加位置)
在 worker_manager.py 的 set_active_adapters() 中添加 batch 级别日志:
def set_active_adapters(self, requests, mapping):
active_names = [r.lora_name for r in requests if r is not None]
if active_names:
logger.info("[LoRA] Batch 激活适配器: %s", active_names)
self._apply_adapters(requests)
if mapping is not None:
self._adapter_manager.set_adapter_mapping(mapping)
确认 LoRA 推理生效的检查清单
-
启动日志中出现
Activating LoRA. int id: X -
请求返回结果与基础模型有差异
-
/v1/models接口返回列表中包含 LoRA 名称 -
服务端 INFO 日志中出现
[LoRA] Batch 激活适配器
附录:关键数据流总结
客户端 model="asr-v1"
│
▼
LoRARequest(name="asr-v1", int_id=1, path="...")
│
▼
_load_adapter() → PEFTHelper.from_local_dir() → 读取 adapter_config.json
│ r=64, lora_alpha=16, target_modules=[...]
▼
LoRAModel.from_local_checkpoint()
│ → 读取 adapter_model.safetensors
│ → 按模块名组织 LoRALayerWeights
│ q_proj: {lora_a: (64,4096), lora_b: (4096,64), scaling: 0.25}
│ v_proj: {lora_a: (64,4096), lora_b: (4096,64), scaling: 0.25}
▼
LoRAModelManager.add_adapter(lora_model)
│ → _create_merged_loras_inplace() 合并 packed modules
│ → _registered_adapters[1] = lora_model
▼
LoRAModelManager.activate_adapter(lora_id=1)
│ → lora_index_to_id[0] = 1 (占用 slot 0)
│ → q_proj_layer.set_lora(0, A, B*scaling, ...)
│ → v_proj_layer.set_lora(0, A, B*scaling, ...)
▼
punica_wrapper.update_metadata(mapping)
│ → token_lora_mapping = [0, 0, 0, ...] (全部 token → slot 0)
▼
forward pass
│ h = W₀·x + lora_b_stacked[0] · lora_a_stacked[0] · x
▼
输出文本(含 LoRA 微调效果)