LoRA 热加载与 adapter 路由#

LoRA(Low-Rank Adaptation)是当前主流的模型微调方法之一,它不修改基础模型权重,而是通过插入小型的低秩矩阵来实现对特定任务的适配。SGLang 支持在运行时动态加载 LoRA adapter、在不同请求间切换 adapter,以及同时维护多个 adapter 的活跃状态。

这一节回答三件事:

  1. LoRA 的权重结构是什么,以及它在推理时如何工作;
  2. SGLang 怎样在单次 forward pass 中同时处理多个使用不同 adapter 的请求;
  3. LoRA adapter 的生命周期(加载、路由、卸载)在代码里落在哪里。

LoRA 的权重结构#

LoRA 不修改原有参数,而是为特定层(通常是 attention 的 Q、K、V、O projection 和 MLP 的 gate/up/down projection)添加两个小矩阵:

W_adapted = W_base + A × B

其中:
- W_base: [d_out, d_in],冻结不变
- A: [d_out, r],低秩矩阵,r << d_out
- B: [r, d_in],低秩矩阵
- A × B: [d_out, d_in],和 W_base 维度相同

r 是 rank(秩),通常取 8、16 或 32。当 r=16,d_in=d_out=4096 时,每层 Q projection 的参数量从 4096×4096 = 16M 降到 4096×16 + 16×4096 = 131K,压缩了约 122 倍。

推理时不需要提前把 A×B 加到 W_base 上,而是通过分离计算实现:

output = W_base × input + scale × (A × (B × input))
       ≈ base_output + lora_output

这样 W_base 可以跨所有 adapter 共享,只有 A 和 B 需要按 adapter 各自存储。

多 adapter 的 Batch 执行#

SGLang 最重要的 LoRA 特性是:同一个 batch 里可以包含使用不同 adapter 的请求,而不需要把 batch 按 adapter 分拆。

朴素做法的问题:如果每个请求用的 adapter 不同,最简单的做法是把 batch 按 adapter 分组,每组单独跑。但这会把 batch 切碎,GPU 利用率下降。

SGLang 的做法:batched LoRA 计算

base_output = W_base @ X_batch         # [batch, seq, d_out],所有请求共享

# 对每个 adapter,找到使用它的请求子集
for adapter_id, req_indices in batch.lora_groups.items():
    X_sub = X_batch[req_indices]        # [n_sub, seq, d_in]
    lora_out = A[adapter_id] @ (B[adapter_id] @ X_sub)
    base_output[req_indices] += scale * lora_out

这样 W_base 的矩阵乘法仍然是全 batch 一次,LoRA 部分是按 adapter 分组的小矩阵乘法,总体上保持了高 GPU 利用率。

adapter 在 Req 中的字段#

当请求指定 lora_path 时,它会被 TokenizerManager 在请求进入 scheduler 之前解析成一个 lora_id

if obj.lora_path:
    lora_id = self.lora_manager.get_or_register_adapter(obj.lora_path)
else:
    lora_id = None  # 使用基础模型

Req 对象里有 lora_id 字段,Scheduler 在组 batch 时会把各请求的 lora_id 收集到 ScheduleBatch。到了 ForwardBatch 层,这些信息变成一个 per-request 的 adapter 索引数组。

Adapter 路由:从请求到 forward#

ForwardBatch 里,LoRA 信息以这种形式存在:

lora_batch_info = LoraBatchInfo(
    lora_ids=[...],       # 每个请求对应的 adapter id,None 表示不用 LoRA
    lora_a_strides=[...], # 各 adapter 的 A 矩阵在内存里的位置
    lora_b_strides=[...], # 各 adapter 的 B 矩阵在内存里的位置
)

ModelRunner.forward() 把这个对象传给模型,模型在每个 LoRA 层里读取当前请求对应的 adapter 权重并执行 lora_output 的计算。

Adapter 的生命周期#

加载(Load)

adapter 在第一次被请求使用时加载。加载过程:

  1. 读取 adapter 的 .safetensors 文件(或从 HuggingFace Hub 拉取)
  2. 验证 adapter 的 rank 和目标层与当前模型兼容
  3. 把 A、B 矩阵加载到 GPU 显存,注册到 LoRA 管理器

显存占用:每个 adapter 仅需存 A、B 矩阵。rank=16 的 Llama-3 8B 的完整 LoRA adapter 约 30–60 MB,远小于基础模型的 16 GB。

并发维护多个 adapter

SGLang 允许同时在 GPU 显存里保留多个 adapter。通过 --max-loras-per-batch 控制单次 batch 里能同时出现的 adapter 数量,通过 --max-lora-rank 控制能接受的最大 rank。

卸载(Eviction)

如果加载的 adapter 数量超过预设上限,SGLang 会按 LRU 策略卸载最久未使用的 adapter。被卸载的 adapter 下次再被请求时需要重新从磁盘加载。

调试 LoRA 时先看哪里#

现象:指定了 lora_path 但输出和基础模型一样

  1. 确认请求里的 lora_path 是否被正确解析成了 lora_id
  2. 检查 ForwardBatch.lora_batch_info 里这个请求的 lora_id 是否为 None;
  3. 确认 adapter 文件路径是否正确,adapter 是否成功加载到 GPU。

现象:LoRA 请求比普通请求慢很多

  1. 检查 --max-loras-per-batch 是否设置过小(频繁切换导致 batch 分组过细);
  2. 检查 adapter 的 rank 是否过大(rank=128 的开销是 rank=16 的 8 倍);
  3. /metrics 检查是否频繁触发 adapter eviction(磁盘加载很慢)。

现象:多个不同 adapter 的请求在同一 batch 里但输出都像同一个 adapter

这说明 LoraBatchInfo 的 per-request adapter 路由可能出现了问题。检查 ScheduleBatch 里各请求的 lora_id 是否被正确传递到 ForwardBatch

小结#

  • LoRA 通过 A×B 低秩矩阵在推理时插入,不修改基础模型权重;
  • SGLang 用 batched LoRA 计算让同一 batch 里的不同 adapter 共享 W_base 的矩阵乘法;
  • adapter 以 lora_id 的形式从 Req 传递到 ForwardBatch,在 forward 阶段被 per-request 地应用;
  • adapter 的生命周期由 LoRA 管理器控制,支持 LRU 卸载和多 adapter 并发。

理解了这层机制,再看多 adapter 场景下的性能分析和调试,就不会把 LoRA 开销误归到基础模型本身。