Prhub

#52840 [Rust Frontend][gRPC] Add LoRA lifecycle control

原始 PR 作者 connorcarpenter15 合并时间 2026-08-25 12:13 文件变更 12 提交数 5 评论 11 代码增减 +471 / -82

执行摘要

为 Rust gRPC 控制服务新增 LoRA 加载 / 卸载 / 列表 API

PR body 明确提出目标:“Add runtime LoRA lifecycle operations to the Rust frontend's existing gRPC APIs”,并强调“Reuse the same LoraManager registry for HTTP and gRPC so adapter identity, duplicate-name handling, capacity checks, and load order remain consistent across transports”,即两个传输面必须共享同一状态,避免身份分叉;这也让 gRPC 客户端无需额外 listener 或独立注册表即可管理 adapter。作者在 review 中进一步澄清该接口面向 Dynamo sidecar 集成场景,但客户端显式指定 LoRA ID 的需求在进一步调查后被放弃,改为服务端统一分配。

值得精读。该 PR 展示了三个可复用设计:

1) 跨传输共享单一注册表(LoraManager)避免状态分叉;
2) 用 thiserror + 统一错误映射把 HTTP/gRPC 错误语义收敛到同一错误类型;
3) 把参数校验下沉到模型层 LoraRequest::new。它同时也是一个很好的安全审查案例:自动化 review 机器人抓住了 gRPC 新入口绕过既有路径限制的盲区,合入遗留的缺口应跟踪 follow-up PR。

讨论亮点

核心讨论有两条主线:
1)BugenZhao 质疑外部 LoRA ID 设计:“Our HTTP endpoint doesn't seem to support external LoRA ID... is this something we intentionally want in the gRPC interface?” 作者解释最初为了兼容 Dynamo 内部管理 ID 而加入,但经调查后确认 Dynamo 侧边车可以让 vLLM 自己分配 ID,于是移除该能力,与 HTTP 行为对齐(问题已解决)。
2)depthfirst-app[bot] 报告高严重性安全问题:gRPC load_lora 未调用 validate_lora_path_access(),绕过 VLLM_RUNTIME_LORA_ALLOWED_PATH_PREFIXES,可加载任意路径 adapter。作者承认问题并计划 follow-up PR 将路径校验迁移到 lora.rs 共享(已有 draft),该问题在合入时仍未修复。
BugenZhao 最终 APPROVE(“LGTM! I've just pushed some minor refactors to make error handling path a bit cleaner”),补充了 thiserror::Error 派生与 ensure_lora_enabled 统一门检两个小重构。

实现拆解

  1. proto 契约扩展:rust/proto/control.proto 新增 LoraAdapter 消息与 LoadLora / UnloadLora / ListLoras 三个 RPC(+30 行),rust/proto/inference.proto 扩展生成请求以支持按名字选 adapter(+3 行),rust/Cargo.lock 同步更新。
  2. 注册表与校验集中化:rust/src/server/src/lora.rs 为 LoadLoraError / UnloadLoraError 派生 thiserror::Error,新增 Disabled / InvalidRequest 变体与 LoraDisabledError;LoraManager::load_lora 在应用层统一完成重名检测、base model 冲突检测与服务端自增 ID 分配;LoraRequest::new 负责非空名称/路径及非零 ID 的传输无关校验。
  3. 统一能力门检:rust/src/server/src/state.rs 新增 ensure_lora_enabled(),通过 ready_response().supports_lora 判断引擎是否启用 LoRA,load/unload/list 三个入口统一先过门检;新增 list_loras() 返回按加载顺序排列的 adapter 快照(即 BugenZhao 提交的 “move lora disable check to a single place”)。
  4. gRPC handler:rust/src/server/src/grpc/control.rs 实现三个 RPC 与错误码映射(Disabled→FailedPrecondition、InvalidRequest→InvalidArgument、已存在/冲突→AlreadyExists、引擎错误→Internal);unload 先校验 name 非空,再基于 list 快照定位 adapter 后执行卸载;grpc/inference.rs 让生成请求按名称解析 adapter 并填入 EngineCoreRequest.lora_request。
  5. HTTP 路由重构:rust/src/server/src/routes/lora.rs 把内联 match 错误映射抽成 load_lora_api_error / unload_lora_api_error,删除手写的空名/空路径检查,行为由共享校验接管;路径前缀校验保留在 HTTP 侧,未与 gRPC 共享。
  6. 测试配套:rust/src/server/src/grpc/tests.rs 新增 reply_utility_bool 辅助函数及 control_lora_lifecycle_selects_adapter_for_generation、control_list_loras_requires_lora_enabled_engine 两个集成测试,覆盖 load→生成选中 adapter→unload 全链路、重复加载冲突、未启用 LoRA 引擎时 list 失败;routes/tests.rs 将 ready 参数化以支持 LoRA 启停场景。
文件 模块 状态 重要度
rust/src/server/src/grpc/control.rs gRPC 控制 modified 8.31
rust/src/server/src/lora.rs LoRA 管理 modified 7.07
rust/src/server/src/state.rs 应用状态 modified 6.85
rust/src/server/src/grpc/tests.rs gRPC 测试 modified 8.07
rust/src/server/src/routes/lora.rs HTTP 路由 modified 7.22
rust/src/server/src/grpc/inference.rs 生成服务 modified 5.62
rust/src/engine-core-client/src/protocol/lora.rs 客户端协议 modified 5.97
rust/proto/control.proto Proto 契约 modified 3.75

关键符号

lora_to_proto load_lora_status unload_lora_status list_loras_status load_lora unload_lora list_loras ensure_lora_enabled load_lora_api_error unload_lora_api_error reply_utility_bool control_lora_lifecycle_selects_adapter_for_generation control_list_loras_requires_lora_enabled_engine

关键源码片段

rust/src/server/src/grpc/control.rs core-logic

gRPC LoRA 生命周期 API 的核心实现:新增 load_lora/unload_lora/list_loras 三个 RPC,并将统一错误类型映射为 gRPC Status 码,是整个 PR 的主入口。

// 将内部 LoRA 请求转换为对外公开的 gRPC 协议对象。
// lora_int_id 是 u64,proto 字段是 int64,这里收敛到 i64::MAX 防止溢出。
fn lora_to_proto(adapter: &LoraRequest) -> pb::LoraAdapter {
    pb::LoraAdapter {
        lora_id: adapter.lora_int_id.min(i64::MAX as u64) as i64,
        lora_name: adapter.lora_name.clone(),
        source_path: adapter.lora_path.clone(),
    }
}
​
// 把统一的 LoadLoraError 映射为 gRPC Status 码。
// 明确区分“引擎未启用”“请求不合法”“已存在 / 冲突”“引擎内部错误”四类语义。
fn load_lora_status(error: LoadLoraError) -> Status {
    let code = match &error {
        LoadLoraError::Disabled(_) => Code::FailedPrecondition,
        LoadLoraError::InvalidRequest(_) => Code::InvalidArgument,
        LoadLoraError::AlreadyLoaded { .. } | LoadLoraError::BaseModelName { .. } => {
            Code::AlreadyExists
        }
        LoadLoraError::Engine { .. } | LoadLoraError::NotLoaded { .. } => Code::Internal,
    };
    Status::new(code, error.to_report_string())
}
​
// gRPC 控制服务的 LoRA 生命周期入口,与 HTTP 路由共享同一个 LoraManager。
// 注意:这里直接把 source_path 传给 state.load_lora,尚未接入 HTTP 侧
// 的 validate_lora_path_access 路径前缀校验,是已知的待跟进安全缺口。
#[tonic::async_trait]
impl pb::control_server::Control for ControlServiceImpl {
    async fn load_lora(
        &self,
        request: Request<pb::LoadLoraRequest>,
    ) -> Result<Response<pb::LoadLoraResponse>, Status> {
        let request = request.into_inner();
        // 统一走 AppState,复用 LoraManager 的 ID 分配、重名与容量检查;
        // gRPC 场景 load_inplace 与 is_3d_lora_weight 固定为 false,与 HTTP 默认一致。
        let adapter = self
            .state
            .load_lora(request.lora_name, request.source_path, false, false)
            .await
            .map_err(load_lora_status)?;
        Ok(Response::new(pb::LoadLoraResponse {
            adapter: Some(lora_to_proto(&adapter)),
        }))
    }
}
rust/src/server/src/lora.rs core-logic

注册表与错误模型改造核心:错误枚举派生 thiserror、新增 LoraDisabledError/InvalidRequest 变体、服务端自增 ID 分配与重名检查,是 HTTP/gRPC 行为一致性的基石。

// 引擎未启用 LoRA 时所有生命周期操作统一返回这个错误。
#[derive(Debug, Error)]
#[error("engine was not started with LoRA enabled")]
pub(crate) struct LoraDisabledError;
​
impl LoraManager {
    // 加载 adapter 的核心逻辑:先做名称与 ID 的校验和分配,再下发给引擎。
    pub(crate) async fn load_lora(
        // ... 参数省略
    ) -> Result<LoraRequest, LoadLoraError> {
        // 与已加载 adapter 或 base model 名称冲突时直接拒绝。
        if base_model_names.iter().any(|name| name == &lora_name) {
            return Err(LoadLoraError::BaseModelName { lora_name });
        }
​
        // 统一在应用层读取现有 ID:非 inplace 重名视为 AlreadyLoaded,
        // inplace 或新名称则复用已有 ID 或自增分配新 ID。
        let requests = self.requests.read().await;
        let existing_lora_int_id = requests.get(&lora_name).map(|request| request.lora_int_id);
        if !load_inplace && existing_lora_int_id.is_some() {
            return Err(LoadLoraError::AlreadyLoaded { lora_name });
        }
​
        let lora_int_id = existing_lora_int_id
            .unwrap_or_else(|| self.id_counter.fetch_add(1, Ordering::Relaxed) + 1);
​
        // LoraRequest::new 负责非空名称 / 路径与非零 ID 的传输无关校验,
        // 错误统一映射为 InvalidRequest,供 HTTP 与 gRPC 共用。
        let lora_request = LoraRequest::new(
            lora_name.clone(),
            lora_int_id,
            lora_path,
            load_inplace,
            // ... 其余参数省略
        )?;
        // ... 后续引擎调用与注册表更新省略
        Ok(lora_request)
    }
}
rust/src/server/src/state.rs core-logic

新增 list_loras 与 ensure_lora_enabled,把“引擎未启用 LoRA”检查收敛到单点,是 BugenZhao 重构提交的核心文件。

impl AppState {
    // 引擎启动时未开启 LoRA 支持,则所有生命周期 API 统一返回错误,
    // 避免各调用点各自判断导致行为不一致。
    fn ensure_lora_enabled(&self) -> Result<(), LoraDisabledError> {
        self.engine_core_client()
            .ready_response()
            .supports_lora
            .then_some(())
            .ok_or(LoraDisabledError)
    }
​
    // 返回当前注册的 adapter 快照,列表顺序即加载顺序。
    pub async fn list_loras(&self) -> Result<Vec<LoraRequest>, LoraDisabledError> {
        self.ensure_lora_enabled()?;
        Ok(self.lora_manager.served_lora_requests().await)
    }
​
    // 加载与卸载入口都先过统一门检,再委托给 LoraManager。
    pub async fn load_lora(
        &self,
        lora_name: String,
        lora_path: String,
        load_inplace: bool,
        is_3d_lora_weight: bool,
    ) -> Result<LoraRequest, LoadLoraError> {
        self.ensure_lora_enabled()?;
        self.lora_manager
            .load_lora(
                self.engine_core_client(),
                &self.served_model_names,
                lora_name,
                lora_path,
                load_inplace,
                is_3d_lora_weight,
            )
            .await
    }
}

评论区精华

gRPC 是否应支持客户端显式指定 LoRA ID 设计

BugenZhao 询问 HTTP 端点并不支持外部 LoRA ID,是否刻意在 gRPC 接口中支持。作者解释最初为兼容 Dynamo 内部管理 ID 而加入,但进一步调查后确认 Dynamo sidecar 可以让 vLLM 自己分配 ID。

结论:移除 gRPC 外部 ID 功能,改为服务端统一分配,与 HTTP 行为对齐。 · 已解决

gRPC load_lora 绕过路径前缀校验 安全

depthfirst-app[bot] 报告 HIGH 严重性问题:gRPC load_lora 直接透传 source_path,未调用 validate_lora_path_access,绕过 VLLM_RUNTIME_LORA_ALLOWED_PATH_PREFIXES,可加载任意路径 adapter。作者承认问题,建议将路径校验迁移到 lora.rs 共享,并已在 draft 中实现。

结论:该问题在合并时未修复,作者承诺 follow-up PR 共享路径校验。 · deferred

错误处理路径小重构 设计

BugenZhao 在 review 后直接 push 了两个小重构:为错误类型派生 thiserror::Error、把 LoRA 禁用检查收敛到 ensure_lora_enabled 单点,并 APPROVE 表示 LGTM。

结论:重构合入,错误处理路径更干净,HTTP/gRPC 共用同一错误模型。 · 已解决

风险与影响

1) 安全风险:gRPC load_lora 直接透传 source_path,缺少与 HTTP 等价的 validate_lora_path_access 前缀校验和 canonicalize,已认证客户端可绕过 VLLM_RUNTIME_LORA_ALLOWED_PATH_PREFIXES 读取任意文件系统路径的 adapter;该问题已确认存在并延期。
2) 错误语义差异:同一错误(如引擎未启用 LoRA)在 HTTP 侧映射为 400 invalid_request,在 gRPC 侧映射为 FailedPrecondition,两类客户端感知不一致。
3) 契约演进:proto 新增 RPC 并移除了早期设计中的外部 ID 字段,对未发布消费者有一定影响。
4) 回归面:HTTP 加载路径的手写校验删除后依赖 LoraRequest::new 统一校验,行为略有收紧,需要回归既有 HTTP LoRA 加载流程。

功能影响:gRPC 客户端获得完整的 LoRA 生命周期管理能力,并且与 HTTP 共享同一 LoraManager 注册表,避免双入口状态分叉;Dynamo 等外部框架可通过单一 gRPC 通道完成模型适配。系统影响:Control 服务新增 3 个 RPC,proto 契约扩展,生成路径支持按名选 adapter。团队影响:作者承诺的路径校验共享 follow-up 是关键后续工作,建议同时补充 HTTP/gRPC 错误码映射文档。

gRPC 路径校验绕过(安全) HTTP/gRPC 错误码语义差异 新 proto 契约需版本兼容 路径校验修复已延期

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论