一套服务托管多个 LoRA:适配器加载、租户隔离与热切换实战

企业把同一个基础模型微调成客服、合同抽取、SQL 生成和设备诊断等多个版本后,最直接的部署方式是"一个微调模型一个服务"。这个方案边界清楚,却会重复加载几乎相同的基础权重。模型越来越多,GPU 显存被重复占用,镜像和服务实例也随之膨胀。LoRA 只保存低秩增量,让多个任务共享基础模型,天然适合把适配器按请求挂到同一推理引擎。

但"能同时加载多个 LoRA"不等于"已经实现多租户平台"。vLLM 负责把请求关联到某个适配器并高效执行,不知道企业中的租户、套餐、审批和数据边界。如果客户端可以随意填写 adapter 名称,甲租户就可能调用乙租户的模型;如果动态加载接口暴露给普通用户,对方甚至可能要求服务读取任意路径或远程权重。适配器更新时直接覆盖同名目录,还会让一批请求在无法审计的时间点切到新版本。

本文从一个具体目标出发:一个基础模型服务托管多个经过审核的 LoRA,每个请求只能使用所属租户被授权的版本;控制面可以灰度发布和回滚,数据面不能自行加载模型;任何结果都能追溯到基础模型、适配器摘要和路由版本。示例聚焦机制,不宣称某个模型或硬件有固定性能收益,容量要通过真实请求压测确定。

1. LoRA 共享了什么,又没有共享什么

全量微调会为每个任务得到一套完整权重。LoRA 冻结基础权重,在选定线性层旁增加低秩矩阵,推理时把增量作用到基础层输出。适配器文件远小于完整模型,因此同一基础模型上挂多个 LoRA,通常比启动多个完整副本节省权重存储和显存。

共享的是基础模型、分词器和推理引擎;不共享的是每个适配器的低秩参数、版本、质量结论和授权关系。两个适配器都基于"同名模型"训练,也可能来自不同基础模型提交、不同词表或不同目标模块,不能仅靠名称判断兼容。

LoRA 也不是任意组合插件。当前 vLLM 要求模型实现 SupportsLoRA,并受最大秩、目标模块、额外词表和模型架构等约束。一次普通请求选择一个适配器,不应假设可以把多个任务 LoRA 随意叠加后获得两种能力。多模态默认 LoRA 在当前文档中同样存在"一次提示只应用一个"的限制。

适配器名称只是服务接口中的选择键,不是安全主体。真正的授权判断必须发生在可信网关或业务服务:认证当前调用者,根据租户和业务能力解析到内部部署名,再把内部部署名传给 vLLM。不能让外部用户直接决定本地路径、仓库地址或动态加载参数。

2. 推荐的控制面与数据面

生产系统至少分成三个角色。模型制品库保存不可变适配器目录和摘要;控制面负责校验、注册、发布、回滚与审计;数据面只接收已经解析好的模型名并执行推理。业务网关位于调用方与数据面之间,负责身份认证、配额、租户授权和别名解析。
#mermaid-svg-CFtzWXWw3DYA2YXx{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-CFtzWXWw3DYA2YXx .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-CFtzWXWw3DYA2YXx .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-CFtzWXWw3DYA2YXx .error-icon{fill:#552222;}#mermaid-svg-CFtzWXWw3DYA2YXx .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-CFtzWXWw3DYA2YXx .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-CFtzWXWw3DYA2YXx .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-CFtzWXWw3DYA2YXx .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-CFtzWXWw3DYA2YXx .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-CFtzWXWw3DYA2YXx .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-CFtzWXWw3DYA2YXx .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-CFtzWXWw3DYA2YXx .marker{fill:#333333;stroke:#333333;}#mermaid-svg-CFtzWXWw3DYA2YXx .marker.cross{stroke:#333333;}#mermaid-svg-CFtzWXWw3DYA2YXx svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-CFtzWXWw3DYA2YXx p{margin:0;}#mermaid-svg-CFtzWXWw3DYA2YXx .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-CFtzWXWw3DYA2YXx .cluster-label text{fill:#333;}#mermaid-svg-CFtzWXWw3DYA2YXx .cluster-label span{color:#333;}#mermaid-svg-CFtzWXWw3DYA2YXx .cluster-label span p{background-color:transparent;}#mermaid-svg-CFtzWXWw3DYA2YXx .label text,#mermaid-svg-CFtzWXWw3DYA2YXx span{fill:#333;color:#333;}#mermaid-svg-CFtzWXWw3DYA2YXx .node rect,#mermaid-svg-CFtzWXWw3DYA2YXx .node circle,#mermaid-svg-CFtzWXWw3DYA2YXx .node ellipse,#mermaid-svg-CFtzWXWw3DYA2YXx .node polygon,#mermaid-svg-CFtzWXWw3DYA2YXx .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-CFtzWXWw3DYA2YXx .rough-node .label text,#mermaid-svg-CFtzWXWw3DYA2YXx .node .label text,#mermaid-svg-CFtzWXWw3DYA2YXx .image-shape .label,#mermaid-svg-CFtzWXWw3DYA2YXx .icon-shape .label{text-anchor:middle;}#mermaid-svg-CFtzWXWw3DYA2YXx .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-CFtzWXWw3DYA2YXx .rough-node .label,#mermaid-svg-CFtzWXWw3DYA2YXx .node .label,#mermaid-svg-CFtzWXWw3DYA2YXx .image-shape .label,#mermaid-svg-CFtzWXWw3DYA2YXx .icon-shape .label{text-align:center;}#mermaid-svg-CFtzWXWw3DYA2YXx .node.clickable{cursor:pointer;}#mermaid-svg-CFtzWXWw3DYA2YXx .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-CFtzWXWw3DYA2YXx .arrowheadPath{fill:#333333;}#mermaid-svg-CFtzWXWw3DYA2YXx .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-CFtzWXWw3DYA2YXx .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-CFtzWXWw3DYA2YXx .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CFtzWXWw3DYA2YXx .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-CFtzWXWw3DYA2YXx .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CFtzWXWw3DYA2YXx .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-CFtzWXWw3DYA2YXx .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-CFtzWXWw3DYA2YXx .cluster text{fill:#333;}#mermaid-svg-CFtzWXWw3DYA2YXx .cluster span{color:#333;}#mermaid-svg-CFtzWXWw3DYA2YXx div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-CFtzWXWw3DYA2YXx .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-CFtzWXWw3DYA2YXx rect.text{fill:none;stroke-width:0;}#mermaid-svg-CFtzWXWw3DYA2YXx .icon-shape,#mermaid-svg-CFtzWXWw3DYA2YXx .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CFtzWXWw3DYA2YXx .icon-shape p,#mermaid-svg-CFtzWXWw3DYA2YXx .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-CFtzWXWw3DYA2YXx .icon-shape rect,#mermaid-svg-CFtzWXWw3DYA2YXx .image-shape rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CFtzWXWw3DYA2YXx .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-CFtzWXWw3DYA2YXx .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-CFtzWXWw3DYA2YXx :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 业务模型别名
内部部署名
校验后加载/发布
租户应用
认证与路由网关
vLLM 数据面
共享基础模型
LoRA A v3
LoRA B v7
适配器制品库
模型控制面
版本注册表
审计与指标

控制面与数据面的网络入口应分离。推理端点可以由业务网关访问,动态加载和卸载端点只允许管理网络中的控制面访问。即使 vLLM 配置了 API Key,也要确认代理实际保护了哪些路径;不能把一个通用密钥同时发给所有终端用户。

注册表维护逻辑别名与不可变部署版本。例如外部只知道 contract-review,内部当前解析为 tenant-a.contract-review.v3。新版本先以 v4 加载并验证,灰度完成后原子切换别名。这样每个正在进行的请求都携带明确版本,不会因为同名目录内容变化而失去可追溯性。

3. 一个适配器进入生产前要保存哪些信息

最小清单不只是 adapter_config.json 和权重文件,还要有基础模型身份、训练信息和验证结论。基础模型应记录仓库与不可变提交摘要,而不是容易漂移的 latest 路径;适配器记录自身 SHA-256、LoRA rank、alpha、目标模块、权重格式、训练代码版本和许可证。

服务侧还需要业务元数据:所属租户、能力标识、允许的输入类型、数据等级、审批人、创建时间、过期时间、当前状态以及替代版本。质量结果至少覆盖任务准确性或格式合法率、安全回归和基础能力回归。适配器很小不代表风险小,它仍然能显著改变输出行为。

一个实用状态机可以包含 uploaded、validated、staged、canary、active、retired 和 rejected。只有 active 或按灰度策略选中的 canary 能被普通业务请求解析;uploaded 文件绝不能因目录恰好存在就自动暴露。rejected 保留失败原因和摘要,避免同一制品换个名字再次进入。

注册表和制品库要把"名字"与"内容"分开。逻辑版本 v3 指向确定摘要,摘要对应只读目录。若必须修改权重,应生成新摘要和新版本。覆盖文件会让缓存中的权重、磁盘上的权重与审计记录互相矛盾。

4. 先用静态加载建立正确基线

vLLM 支持启动时通过 --lora-modules 声明适配器,请求再把适配器名放入 OpenAI 兼容接口的 model 字段。静态加载最容易审计,也避免把模型管理端点暴露出来,适合适配器数量有限、变更不频繁的第一版。

bash 复制代码
vllm serve /models/base \
  --host 0.0.0.0 \
  --port 8000 \
  --served-model-name base-model \
  --enable-lora \
  --max-loras 4 \
  --max-cpu-loras 12 \
  --max-lora-rank 64 \
  --lora-modules \
    '{"name":"tenant-a.sql.v3","path":"/models/lora/sha256-a1","base_model_name":"base-model"}' \
    '{"name":"tenant-b.support.v7","path":"/models/lora/sha256-b7","base_model_name":"base-model"}'

--max-loras 表示一个批次中可同时存在的 LoRA 数量上限,不是注册表能保存多少业务模型;--max-cpu-loras 控制 CPU 侧可保留数量,并要求不小于 max_loras;--max-lora-rank 要覆盖计划加载适配器的最大秩。官方文档特别提醒,不要为了"兼容未来"把最大秩设得远高于实际值,因为预留过大可能浪费内存并影响性能。

请求适配器时,model 填内部部署名:

json 复制代码
{
  "model": "tenant-a.sql.v3",
  "messages": [
    {"role": "user", "content": "根据给定表结构生成只读查询"}
  ],
  "temperature": 0,
  "max_tokens": 256
}

生产调用者不应看到 tenant-a.sql.v3 这一内部名。它提交业务别名 sql-generator,网关根据认证租户解析。直接把 vLLM 的 /v1/models 返回给所有客户,也可能泄露其他租户适配器名称和基础模型信息,应由网关提供经过过滤的能力列表。

5. 在网关执行租户隔离

多租户授权需要回答三个问题:谁在调用、属于哪个租户、是否被允许使用该能力。来自请求正文的 tenant_id 不能作为可信依据,租户必须从已经验证的令牌、双向 TLS 身份或服务端会话取得。模型别名同样需要严格格式和长度限制,防止把路径、URL 或控制字符传入下游。

下面是一个不依赖 Web 框架的核心路由示例。真实项目可把 resolve 函数放进现有网关;关键点是注册表返回固定部署名,调用方永远不能提交文件路径。不可变 Mapping 每次整体替换,避免热更新时读到一半新、一半旧的数据。

python 复制代码
# tenant_model_router.py
from __future__ import annotations

from dataclasses import dataclass
from types import MappingProxyType
from typing import Mapping


class RouteDenied(Exception):
    pass


@dataclass(frozen=True)
class Principal:
    subject: str
    tenant_id: str
    scopes: frozenset[str]


@dataclass(frozen=True)
class Deployment:
    public_alias: str
    internal_model: str
    required_scope: str
    adapter_sha256: str


class Router:
    def __init__(self, routes: Mapping[tuple[str, str], Deployment]) -> None:
        self._routes = MappingProxyType(dict(routes))

    def replace(self, routes: Mapping[tuple[str, str], Deployment]) -> "Router":
        return Router(routes)

    def resolve(self, principal: Principal, alias: str) -> Deployment:
        if not alias or len(alias) > 80 or any(c in alias for c in "/\\:"):
            raise RouteDenied("invalid model alias")
        deployment = self._routes.get((principal.tenant_id, alias))
        if deployment is None:
            raise RouteDenied("model is not available for this tenant")
        if deployment.required_scope not in principal.scopes:
            raise RouteDenied("missing model scope")
        return deployment


def demo() -> None:
    deployment = Deployment(
        public_alias="sql-generator",
        internal_model="tenant-a.sql.v3",
        required_scope="models:sql",
        adapter_sha256="a" * 64,
    )
    router = Router({("tenant-a", "sql-generator"): deployment})
    alice = Principal("alice", "tenant-a", frozenset({"models:sql"}))
    bob = Principal("bob", "tenant-b", frozenset({"models:sql"}))
    assert router.resolve(alice, "sql-generator") == deployment
    try:
        router.resolve(bob, "sql-generator")
    except RouteDenied:
        pass
    else:
        raise AssertionError("cross-tenant route must be denied")


if __name__ == "__main__":
    demo()

授权失败最好统一返回"该模型不可用",不要区分"模型存在但属于其他租户"和"模型不存在",以免形成枚举接口。服务端日志可以记录内部原因,但不能记录完整敏感提示词、访问令牌或本地适配器路径。

推理请求还要带内部 request_id、tenant_id、deployment、base_revision 和 adapter_sha256,写入结构化日志与指标标签。tenant_id 标签数量可控,用户标识和原始提示词不适合作为 Prometheus 标签,否则会造成高基数和隐私泄露。

6. 适配器校验不能只看文件能否加载

模型控制面接收新制品后,先在隔离任务中检查目录结构、文件大小、摘要和 adapter_config。不要允许归档解压覆盖目标目录,也不要跟随制品中的符号链接读取外部文件。若权重来自外部仓库,应固定提交并在受控下载器中完成,不让在线推理服务按用户输入访问互联网。

配置层至少核对 base_model_name_or_path、r、target_modules 和 peft_type。基础模型名称可能只是训练时字符串,仍需由发布清单声明不可变提交并通过实际兼容测试。rank 不得超过服务 max_lora_rank,目标模块必须属于当前模型支持范围;额外词表、分类头和 MoE 权重格式还各有专门限制。

下面的校验器计算目录摘要,并检查最关键字段。它没有解析 safetensors 的张量形状,因此只能作为入库第一道门;后续必须在与生产一致的 vLLM 版本上实际加载,并运行推理回归。

python 复制代码
# validate_adapter.py
from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class AdapterPolicy:
    allowed_base_models: frozenset[str]
    allowed_target_modules: frozenset[str]
    max_rank: int
    max_total_bytes: int


def directory_sha256(root: Path) -> tuple[str, int]:
    digest = hashlib.sha256()
    total = 0
    files = sorted(path for path in root.rglob("*") if path.is_file())
    if not files:
        raise ValueError("adapter directory is empty")
    for path in files:
        if path.is_symlink():
            raise ValueError(f"symbolic link is forbidden: {path}")
        relative = path.relative_to(root).as_posix().encode("utf-8")
        digest.update(len(relative).to_bytes(4, "big"))
        digest.update(relative)
        with path.open("rb") as stream:
            while chunk := stream.read(1024 * 1024):
                total += len(chunk)
                digest.update(chunk)
    return digest.hexdigest(), total


def validate(root: Path, policy: AdapterPolicy) -> dict:
    config_path = root / "adapter_config.json"
    if not config_path.is_file():
        raise ValueError("adapter_config.json is missing")
    config = json.loads(config_path.read_text(encoding="utf-8"))
    if config.get("peft_type") != "LORA":
        raise ValueError("only LoRA adapters are allowed")
    if config.get("base_model_name_or_path") not in policy.allowed_base_models:
        raise ValueError("base model is not approved")
    rank = config.get("r")
    if not isinstance(rank, int) or not 0 < rank <= policy.max_rank:
        raise ValueError("LoRA rank is outside policy")
    modules = config.get("target_modules")
    if not isinstance(modules, list) or not modules:
        raise ValueError("target_modules must be a non-empty list")
    unknown = set(modules) - policy.allowed_target_modules
    if unknown:
        raise ValueError(f"unsupported target modules: {sorted(unknown)}")
    sha256, total = directory_sha256(root)
    if total > policy.max_total_bytes:
        raise ValueError("adapter exceeds size limit")
    return {"sha256": sha256, "bytes": total, "rank": rank}


if __name__ == "__main__":
    policy = AdapterPolicy(
        allowed_base_models=frozenset({"org/base-model"}),
        allowed_target_modules=frozenset(
            {"q_proj", "k_proj", "v_proj", "o_proj"}
        ),
        max_rank=64,
        max_total_bytes=2 * 1024**3,
    )
    print(validate(Path("adapter"), policy))

摘要算法把相对路径和内容一起纳入,避免两个文件拼接产生歧义;大小限制在读取时累计,防止先信任制品声明。真实控制面还要设置文件数量、单文件大小、JSON 深度和处理超时,并把校验放在无生产凭证的隔离环境。

7. 静态加载、按需解析与动态接口怎么选

静态加载的优点是最小攻击面和确定启动状态,缺点是变更需要重启或滚动发布。适配器不多时,这是优先方案:生成新启动清单,拉起新实例,通过健康检查和质量冒烟后切流,再下线旧实例。基础模型本来就需要较长初始化,明确的蓝绿发布通常比在旧进程里做复杂变更更好排障。

vLLM 也提供 /v1/load_lora_adapter 和 /v1/unload_lora_adapter,并要求显式设置 VLLM_ALLOW_RUNTIME_LORA_UPDATING=True。官方安全文档明确警告,动态加载不是面向不可信客户端的安全操作;相关端点只能由受信管理员通过反向代理或网络策略访问,不能向终端用户开放。

LoRAResolver 可以在请求出现未知模型名时,从本地目录或远程存储解析并加载适配器。它适合受控平台减少预加载数量,却把"请求模型"与"拉取代码路径"连在一起。官方文档也明确说明远程下载解析器不适合生产不可信环境。企业场景更稳妥的做法是控制面提前拉取、扫描并放入只读本地缓存,数据面只解析审核过的部署名。

动态能力不是适配器数量多时的唯一答案。可以按租户或业务域把适配器分片到多个池,每个池静态加载一组高频版本;低频版本经过控制面预热后再路由。这样故障范围更小,也避免一个全局进程维护过多租户制品。

8. 热切换不要等同于覆盖同名目录

最安全的更新流程是"新名字加载、验证、切别名、排空、卸载旧版本"。例如现网为 tenant-a.sql.v3,新版本注册为 tenant-a.sql.v4。控制面加载 v4 后先请求 /v1/models 确认可见,再执行固定冒烟集;网关将少量租户请求稳定哈希到 v4;指标满足门槛后,注册表把 sql-generator 的默认目标切到 v4。
指标平台 业务网关 vLLM 控制面 指标平台 业务网关 vLLM 控制面 #mermaid-svg-WSSiZKpyFpgMJzBT{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WSSiZKpyFpgMJzBT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WSSiZKpyFpgMJzBT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WSSiZKpyFpgMJzBT .error-icon{fill:#552222;}#mermaid-svg-WSSiZKpyFpgMJzBT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WSSiZKpyFpgMJzBT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WSSiZKpyFpgMJzBT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WSSiZKpyFpgMJzBT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WSSiZKpyFpgMJzBT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WSSiZKpyFpgMJzBT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WSSiZKpyFpgMJzBT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WSSiZKpyFpgMJzBT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WSSiZKpyFpgMJzBT .marker.cross{stroke:#333333;}#mermaid-svg-WSSiZKpyFpgMJzBT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WSSiZKpyFpgMJzBT p{margin:0;}#mermaid-svg-WSSiZKpyFpgMJzBT .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-WSSiZKpyFpgMJzBT text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-WSSiZKpyFpgMJzBT .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-WSSiZKpyFpgMJzBT .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-WSSiZKpyFpgMJzBT .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-WSSiZKpyFpgMJzBT .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-WSSiZKpyFpgMJzBT #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-WSSiZKpyFpgMJzBT .sequenceNumber{fill:white;}#mermaid-svg-WSSiZKpyFpgMJzBT #sequencenumber{fill:#333;}#mermaid-svg-WSSiZKpyFpgMJzBT #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-WSSiZKpyFpgMJzBT .messageText{fill:#333;stroke:none;}#mermaid-svg-WSSiZKpyFpgMJzBT .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-WSSiZKpyFpgMJzBT .labelText,#mermaid-svg-WSSiZKpyFpgMJzBT .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-WSSiZKpyFpgMJzBT .loopText,#mermaid-svg-WSSiZKpyFpgMJzBT .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-WSSiZKpyFpgMJzBT .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-WSSiZKpyFpgMJzBT .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-WSSiZKpyFpgMJzBT .noteText,#mermaid-svg-WSSiZKpyFpgMJzBT .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-WSSiZKpyFpgMJzBT .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-WSSiZKpyFpgMJzBT .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-WSSiZKpyFpgMJzBT .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-WSSiZKpyFpgMJzBT .actorPopupMenu{position:absolute;}#mermaid-svg-WSSiZKpyFpgMJzBT .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-WSSiZKpyFpgMJzBT .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-WSSiZKpyFpgMJzBT .actor-man circle,#mermaid-svg-WSSiZKpyFpgMJzBT line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-WSSiZKpyFpgMJzBT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 加载 tenant-a.sql.v4 冒烟与契约测试 发布 5% 稳定灰度规则 部分请求使用 v4 版本化质量与性能指标 原子切换默认别名到 v4 排空后卸载 v3

"原子切换"发生在网关路由快照,不是修改磁盘软链接。每个请求开始时解析一次部署并写入上下文,重试也沿用原部署,避免同一业务操作第一次走 v3、超时重试却走 v4。长对话是否固定版本要由产品决定;通常在会话创建时绑定,直到会话结束或显式迁移。

当前 vLLM 文档还提供 load_inplace,可用相同名称替换权重,常用于异步强化学习等连续更新场景。它减少名称管理,却让审计、灰度和并发语义更难:名称相同但内容已经变化,正在执行或排队的请求属于哪个版本必须实测并明确。普通企业模型发布更适合不可变版本名;只有业务确实需要高频同名更新,并能接受、测试其语义时才考虑原位替换。

旧版本不能在切换后立刻卸载。控制面先停止新流量,再等待活跃请求归零和重试窗口结束;若无法直接取得按适配器活跃数,可由网关维护有界计数并设置保守排空时间。卸载失败不应影响默认路由,但要告警并防止显存或 CPU 缓存长期泄漏。

9. 多 LoRA 批处理为何可能影响性能

共享基础模型避免了重复加载,但每个批次中的活跃适配器数量、rank 和目标模块都会影响内核工作与内存布局。max_loras 控制单批可并存的适配器数量;设得太小会限制混合租户批处理,设得太大则可能增加预留和调度成本。max_cpu_loras 影响可驻留缓存,冷适配器切换还可能带来加载延迟。

流量均匀分散到很多低频适配器时,连续批处理的局部性可能变差;一个热门适配器占据大部分请求时,表现又不同。容量测试必须使用真实的"适配器热度分布",不能只让所有请求调用同一个 LoRA,然后声称支持大量租户。

rank 越高通常表示更多增量计算和存储,但实际影响还与目标层数量、数据类型、模型架构和后端有关。max_lora_rank 应匹配已批准适配器的最大值,而不是盲目预留 256。若不同业务确实需要差异很大的 rank,可以考虑分池,避免少数高 rank 适配器抬高整个服务配置。

vLLM 当前还提供针对活跃 LoRA 数量的 CUDA Graph 特化选项,可能改善变化模式下的性能,但会增加启动时间和内存。它属于测量后选择的高级开关,不应在没有基线时一起启用。每次只改变一个主要变量,才能知道收益来自多 LoRA、本身的批处理参数还是图捕获。

10. 怎样做可复现容量测试

先建立基础模型无 LoRA 的基线,再分别测单 LoRA、多 LoRA 混合与冷热切换。所有组使用同一目标模型、相同输入输出长度分布、采样参数、并发、vLLM 版本和硬件。每轮预热后记录 TTFT、TPOT、端到端 P50/P95、有效 Token 吞吐、GPU 显存、KV Cache 使用、队列长度和错误率。

多 LoRA 组至少设计三种分布:单一热门适配器;少数适配器按生产比例混合;大量适配器低频轮换。并发要覆盖日常和峰值。冷加载测试应把"控制面下载与校验""数据面加载""第一次请求"分开计时,不能把提前缓存后的结果当成完整上线时延。

输出质量也要按适配器验证。每个版本都有独立的任务集和安全集,同时保留一组基础能力题,防止微调过拟合或破坏通用指令。相同问题分别请求旧版和新版,结果由确定性规则、人工 Rubric 或经过校准的评估器判断;不能只验证 HTTP 200。

报告不写无法复现的固定倍数,而是保存容器摘要、GPU、驱动、基础模型提交、适配器摘要、启动参数、请求集版本和原始结果。只有在给定服务等级目标下提高稳定吞吐,或者在相同吞吐下改善尾延迟,才算服务层收益。

11. 灰度指标要同时看质量与系统

系统指标包括按 deployment 分组的请求数、错误率、TTFT、TPOT、总时延、排队、Token 数和适配器加载失败。质量指标由任务决定:SQL 生成可以看语法通过、只读约束和执行正确率;抽取可以看字段完整率与 schema 合法率;客服可以看知识引用、拒答和转人工。

适配器名称作为指标标签时要使用受控 deployment,而不是用户原始 model 字符串。适配器版本很多时,退役版本应停止产生新序列,避免时序系统标签无限增长。更细的摘要和请求关系放进日志或追踪系统,不全部塞进指标。

灰度比较要使用稳定分流,同一租户或会话固定版本,避免用户在两个口吻和能力间跳动。流量少的租户无法快速获得统计结论时,可以先跑回放集和影子请求;影子输出不能触发外部工具或业务写操作,也不应双倍保存敏感输入。

自动回退条件在发布前定义,例如关键任务格式错误率上升、P95 超过门槛、加载失败或安全回归命中。回退只需把别名切回旧版本,前提是旧适配器尚未卸载、基础模型和路由契约保持兼容。这也是保留不可变版本名的直接价值。

12. 最容易踩的六类故障

第一类是基础模型不匹配。适配器能被读到,不代表输出正确;训练基座提交、词表或目标层改变都可能导致加载错误或静默质量异常。必须用清单和真实推理双重校验。

第二类是 rank 或目标模块超出服务配置。不要看到错误后直接把 max_lora_rank 拉到极大;先确认制品是否符合训练规范,再调整到实际最大值。错误适配器应在控制面被拒绝,而不是让生产进程反复尝试。

第三类是名称冲突。两个租户使用相同 public alias 很正常,但内部部署名必须全局唯一,至少包含租户、能力和不可变版本。LoRARequest 的内部整数标识在离线场景也要求全局唯一,不能用进程随机 hash 充当持久身份。

第四类是冷加载抖动。首个请求触发远程下载会把对象存储、网络和磁盘异常直接暴露给用户。生产发布应提前同步到本地、校验、加载并预热,按需解析也要有超时、并发抑制和失败缓存,防止大量相同请求同时下载。

第五类是过早卸载。路由已经切换不代表旧版没有在途请求和重试。控制面需要排空状态,而不是调用卸载后假设完成。若卸载失败,要保持可观测并限制后续加载,避免缓存占满。

第六类是把输出串租户误判成"LoRA 串权重"。真实原因也可能是网关缓存键遗漏 tenant_id、会话历史复用、前缀缓存标识错误或日志拼接。排障时沿 request_id 检查认证租户、解析部署、发送 model、vLLM 记录和响应版本,逐层证明,不要只重启服务。

13. 安全边界:适配器也是可执行行为的一部分

LoRA 权重不是传统脚本,但它会改变模型行为,可以削弱拒答、诱导泄密或稳定输出攻击内容。外部适配器必须像第三方制品一样进行来源、许可证、摘要、恶意行为和质量审查。优先使用 safetensors 等不依赖任意对象反序列化的格式,并在隔离环境处理。

动态加载端点、Resolver 插件和远程下载能力属于控制面权限。官方安全指南要求动态 LoRA 管理只对可信管理员开放。反向代理应按路径拒绝普通业务身份,网络层再限制来源;生产容器使用只读模型目录和最小权限账户,不能让推理进程扫描宿主机任意路径。

租户隔离还包括数据和缓存。请求日志、会话历史、结果缓存和评测样本都必须带服务端确认的租户键;缓存键至少包含租户、deployment、基础模型版本、采样参数和输入摘要。只用提示词摘要作键,会让相同问题跨租户共享结果。

适配器发布需要双人审批还是自动策略,取决于业务风险。涉及金融、医疗、权限决策或外部执行时,应把质量报告、安全报告和负责人写入发布记录。模型输出仍是不可信数据,调用工具前需要确定性参数校验和业务授权,不能因为 LoRA 专门训练过就绕过防线。

14. 备份、恢复与灾难演练

真正需要备份的是不可变制品、注册表、路由版本和评测结论,不是 GPU 中的临时缓存。制品库应跨故障域保存并定期校验摘要;注册表数据库备份要能恢复 alias 到 deployment 的历史关系。只有权重没有路由记录,恢复后无法知道哪个租户当时使用哪个版本。

灾难恢复流程可以从空白集群开始:部署固定基础模型,恢复注册表快照,按 active 与回滚所需版本同步适配器,重新计算摘要,启动 vLLM,执行冒烟和关键回归,再开放网关流量。演练要记录恢复时间和缺失项,而不是只验证对象存储里"文件还在"。

若某个适配器被确认有安全问题,控制面应先禁止别名解析和新请求,再排空或终止高风险在途任务,最后卸载并标记 revoked。只删除目录不够,因为其他节点、CPU 缓存和对象存储副本仍可能存在。撤销记录要包含摘要,使同一内容换名后仍会被拒绝。

15. 什么时候不该使用一套多 LoRA 服务

不同适配器基于不同基础模型、词表或架构时,无法靠一个引擎共享。某些租户要求物理隔离、独占容量或不同升级窗口,也应使用独立池。单个高价值适配器流量足够大时,独占实例可能获得更稳定的批处理和故障边界。

业务模型数量很少、更新频率低时,静态启动清单已经足够,不必引入动态加载平台。反过来,如果有成千上万个极低频个人适配器,单进程按需加载也未必合适,需要评估分片、缓存淘汰、冷启动和制品治理,而不是只提高 max_cpu_loras。

如果适配器之间真正需要的是不同工具、知识库或流程,LoRA 可能不是正确抽象。很多"租户定制"用系统提示词、检索权限和结构化工具就能完成,训练适配器会增加数据、评测和发布负担。只有评测证明微调带来稳定收益,才值得进入 LoRA 生命周期。

16. 一条最小可行落地路线

第一阶段只选一个基础模型和两三个低风险适配器,静态加载,网关做租户到内部部署名的白名单映射。建立不可变制品、摘要、任务评测和版本化日志,不开放任何动态管理端点。

第二阶段测量真实混合流量,确定 max_loras、max_cpu_loras 和 max_lora_rank,并按服务等级验证容量。增加蓝绿实例发布和别名灰度,保留一键回退。此时即使没有热加载,也已经解决大部分资源复用问题。

第三阶段只有在重启成本或适配器变更频率确实成为瓶颈时,才启用受控动态加载。控制面独占管理网络,先下载校验、再调用加载端点;普通请求仍只能使用注册表中的业务别名。对高频更新场景再评估 load_inplace,且必须补充并发语义、审计和回滚测试。

这条路线把复杂度放在需求出现之后:共享基础权重依靠 vLLM 现成功能,租户隔离依靠现有认证网关,不自研推理内核,也不让数据面兼任模型仓库。每增加一步都有可测量的运维收益。

17. 上线检查表与责任边界

上线前可以用一张短检查表防止层级混乱。制品负责人确认来源、许可证、基础模型提交、摘要和权重格式;算法负责人确认任务集、安全集和基础能力回归;平台负责人确认 rank、目标模块、显存与混合流量容量;安全负责人确认管理端点不可被普通网络访问;业务负责人确认租户白名单、灰度比例与回退门槛。任一项缺失,适配器保持 staged,不因发布时间临近而绕过。

聊天模板也要作为版本化依赖。适配器可能使用特定指令格式训练,服务端若换成另一套模板,即使权重完全兼容,质量也会明显变化。注册表应记录模板标识和分词器提交,冒烟测试直接通过线上 Chat Completions 路径执行,而不是只在离线脚本里拼接一段不同格式的文本。

配额应在网关按租户执行,并区分请求数、输入 Token、输出 Token 和并发。多租户共享 GPU 时,一个租户的长输出会增加其他租户排队;仅限制每分钟请求数无法阻止这种资源占用。高优先级和低优先级请求是否进入同一批次,要依据服务等级设计,不能期待 LoRA 调度自动实现公平。

值班手册至少包含四条可执行路径:某版本质量异常时切回哪个 deployment;加载失败时怎样阻止继续灰度;显存压力过高时先卸载哪些 retired 版本;疑似越权时如何凭 request_id 还原认证租户、路由快照和实际适配器摘要。手册中使用稳定命令和查询,不依赖某位工程师记得内部目录。

最后明确责任边界:vLLM 保证其支持范围内的推理和适配器执行,业务网关保证身份、授权和配额,控制面保证制品与发布,模型团队保证质量。把这些职责都寄托在 model 字段或一个 API Key 上,系统表面简单,事故时却没有任何一层能回答"为什么这个租户调用了这个权重"。

小结

一套服务托管多个 LoRA 的真正难点,不是把 --enable-lora 加到启动命令,而是让适配器从制品到请求始终有确定身份。基础模型提交、适配器摘要、内部部署名、租户授权和路由版本要连成一条可审计链路;外部用户只使用业务别名,永远不能决定路径和加载动作。

vLLM 提供静态模块、按请求选择、动态端点、Resolver 和原位替换等能力,但能力越动态,安全与一致性责任越重。大多数团队应从静态加载和蓝绿切换开始,用不可变版本名完成灰度与回滚;只有变更频率证明值得时,再把动态加载放进隔离控制面。这样既能复用基础权重,也不会把资源优化变成跨租户风险。

参考资料

相关推荐
思考着亮1 小时前
14.Agentic RAG -3
人工智能
黑妹天下第一乖1 小时前
第 04 讲:阿加犀 AIMO 模型优化平台与 Model Farm 模型广场实战
人工智能·嵌入式硬件·矩阵·架构·iot
郝学胜_神的一滴1 小时前
AI 编程智能体 06:用Anaconda搞定Python多环境,彻底告别版本兼容灾难
人工智能·python
橘和柠1 小时前
显存计算与模型选择:你的显卡能跑多大的模型
人工智能
黑妹天下第一乖1 小时前
第08讲 · 视觉与相机流水:Spectra ISP 与实时检测
人工智能·嵌入式硬件·数码相机·机器人·接口隔离原则·iot
alonglong1 小时前
8,513 个向量、0.21 毫秒:给本地知识库搭一套语义检索,不引向量数据库
人工智能
数聚天成DeepSData1 小时前
OPUS 不是一份统一许可证的语料:子语料许可如何逐项管理?
人工智能·深度学习·机器学习·数据集·deepsdata
Rosanci1 小时前
Codex 下载与本地部署实战:从安装到运行全流程指南
开发语言·前端·算法·chatgpt·codex
2401_832298101 小时前
人工智能时代的变革机遇与社会发展新格局
人工智能·职场和发展