Prhub

#43778 [Rust Frontend] Add dynamic LoRA endpoints

原始 PR 作者 Xunzhuo 合并时间 2026-06-03 15:55 文件变更 24 提交数 14 评论 38 代码增减 +1079 / -47

执行摘要

Rust 前端新增动态 LoRA 管理端点

PR body 指出:This adds Rust frontend support for dynamic LoRA adapter management on the OpenAI-compatible server path. 原先前端 LoRA 需通过启动参数静态加载,该 PR 使管理员可在不重启服务的情况下动态管理适配器,提升生产灵活性。

该 PR 安全设计充分,并发控制合理,值得团队精读。建议后续补充 CI 中端到端测试,并关注路径验证的 TOCTOU 缓解。

讨论亮点

Review 中核心讨论:

  • 路径遍历与安全:Copilot 建议 canonicalize 防止 .. 绕过,BugenZhao 认可。depthfirst 指出 looks_like_local_lora_path 漏判裸相对路径,作者增加了 Component::ParentDir 并改用规范化比较。
  • lora_int_id 校验:Copilot 要求要么移除 unload 端的 lora_int_id 字段,要么实现验证。作者后来实现了匹配检查。
  • 状态拆分:BugenZhao 建议将 LoRA 状态从 AppState 分离到独立文件中,作者创建了 lora.rs
  • 原子性问题:Copilot 指出获取模型名称列表和 LoRA 请求不是同一锁快照可能导致竞态,BugenZhao 建议传递整个 LoraModelResolution,作者采纳。
  • 命名风格:BugenZhao 指出 LoRARequest 应改为 LoraRequest 以符合 Rust 惯例,作者已修改。

实现拆解

实现拆解如下:

  1. 协议定义:在 rust/src/engine-core-client/src/protocol/lora.rs 新增 LoraRequest 结构体,使用 serde_tuple 对齐 Python msgspec array_like 格式。
  2. 核心管理器:在 rust/src/server/src/lora.rs 创建 LoraManager,内部以 RwLock<BTreeMap> 存储已注册适配器,AtomicU64 分配 ID,Mutex 序列化引擎调用。提供 load_lora(含名称冲突检查)、unload_lora(含 ID 校验)、served_model_namesresolve_model 方法。
  3. 状态集成:修改 rust/src/server/src/state.rsAppState 新增 lora_manager 字段,暴露委托方法。
  4. HTTP 路由:在 rust/src/server/src/routes/lora.rs 新建 load_lora_adapterunload_lora_adapter 处理函数,通过 validate_lora_path_access 执行路径安全检查(仅允许 HF repo ID 或配置前缀下的绝对路径)。路由在 routes.rs 中根据环境变量条件注册。
  5. Request lowering 集成:修改 chat/completions/generate 的 convert.rs,在构建 EngineCoreRequest 时从 LoraModelResolution 中提取可选 lora_request
  6. 模型列表展示:修改 routes/openai/models.rs,在 /v1/models 响应中包含动态 LoRA 名称。
  7. 测试:在 tests.rs 中添加集成测试,mock engine 验证加载/转发、名称冲突、引擎失败、卸载 ID 校验等场景。CLI 测试验证 --enable-lora 参数传递。
文件 模块 状态 重要度
rust/src/server/src/routes/lora.rs 路由层 added 9.08
rust/src/server/src/lora.rs Lora 管理器 added 8.95
rust/src/server/src/state.rs 状态层 modified 7.81
rust/src/engine-core-client/src/protocol/lora.rs 协议 added 7.23
rust/src/server/src/routes/tests.rs 测试 modified 8.65
rust/src/server/src/routes.rs 路由工厂 modified 7.15

关键符号

validate_lora_path_access load_lora_adapter unload_lora_adapter LoraManager::load_lora LoraManager::unload_lora LoraManager::resolve_model AppState::served_model_names_with_loras AppState::resolve_model_with_loras

关键源码片段

rust/src/server/src/routes/lora.rs entrypoint

新增的 HTTP 路由文件,实现动态 LoRA 加载 / 卸载端点,包含路径验证等核心逻辑。

// 判断是否为本地路径:绝对路径、~、.、..
fn looks_like_local_lora_path(lora_path: &str) -> bool {
    let path = Path::new(lora_path);
    path.is_absolute()
        || lora_path.starts_with('~')
        || lora_path.starts_with('.')
        || path.components().any(|c| matches!(c, Component::ParentDir))
}// 路径访问验证:Ok(None) 表示 HF repo ID,Ok(Some(canonical)) 表示允许的本地路径
fn validate_lora_path_access(
    lora_path: &str,
    allowed_prefixes: Option<&[PathBuf]>,
) -> Result<Option<String>, ApiError> {
    let path = Path::new(lora_path);
    if !looks_like_local_lora_path(lora_path) && !path.exists() {
        return Ok(None);
    }
    let Some(prefixes) = allowed_prefixes else {
        return Err(ApiError::invalid_request(
            format!("Local LoRA adapter paths require {} to be configured.", RUNTIME_LORA_ALLOWED_PATH_PREFIXES_ENV),
            Some("lora_path"),
        ));
    };
    if !path.is_absolute() {
        return Err(ApiError::invalid_request(
            format!("Local LoRA adapter paths must be absolute and under the prefixes configured by {}.", RUNTIME_LORA_ALLOWED_PATH_PREFIXES_ENV),
            Some("lora_path"),
        ));
    }
    let canonical_path = path.canonicalize().map_err(|_| {
        ApiError::invalid_request("Local LoRA adapter path must exist and be accessible.", Some("lora_path"))
    })?;
    let canonical_prefixes: Vec<_> = prefixes.iter().map(|p| {
        p.canonicalize().map_err(|_| ApiError::server_error(
            format!("configured {} path prefix must exist and be accessible", RUNTIME_LORA_ALLOWED_PATH_PREFIXES_ENV))
        )
    }).collect::<Result<_, _>>()?;
    if !canonical_prefixes.iter().any(|p| canonical_path.starts_with(p)) {
        return Err(ApiError::invalid_request(
            "Local LoRA adapter path is outside the configured allowed prefixes.",
            Some("lora_path"),
        ));
    }
    Ok(Some(canonical_path.to_string_lossy().into_owned()))
}
rust/src/server/src/lora.rs core-logic

核心 LoRA 管理器,封装适配器注册、ID 分配、并发控制及引擎调用。

pub(crate) struct LoraManager {
    requests: RwLock<BTreeMap<String, LoraRequest>>,
    id_counter: AtomicU64,
    update_lock: Mutex<()>, // 序列化引擎与本地表更新
}impl LoraManager {
    /// 加载 LoRA:检查名称冲突,调用引擎 add_lora,成功后插入本地表
    pub async fn load_lora(
        &self,
        engine: &EngineCoreClient,
        base_model_names: &[String],
        lora_name: String,
        lora_path: String,
        load_inplace: bool,
        is_3d_lora_weight: bool,
    ) -> Result<LoraRequest, LoadLoraError> {
        let _guard = self.update_lock.lock().await;
        if base_model_names.iter().any(|n| n == &lora_name) {
            return Err(LoadLoraError::BaseModelName { lora_name });
        }
        if !load_inplace && self.requests.read().await.contains_key(&lora_name) {
            return Err(LoadLoraError::AlreadyLoaded { lora_name });
        }
        let int_id = self.requests.read().await.get(&lora_name)
            .map(|r| r.lora_int_id)
            .unwrap_or_else(|| self.id_counter.fetch_add(1, Ordering::Relaxed) + 1);
        let req = LoraRequest::new(lora_name.clone(), int_id, lora_path, load_inplace, is_3d_lora_weight);
        if !engine.add_lora(&req).await.map_err(LoadLoraError::Engine)? {
            return Err(LoadLoraError::NotLoaded { lora_name });
        }
        self.requests.write().await.insert(lora_name, req.clone());
        Ok(req)
    }    /// 卸载 LoRA:引擎移除后清理本地表
    pub async fn unload_lora(
        &self,
        engine: &EngineCoreClient,
        lora_name: &str,
        requested_int_id: Option<u64>,
    ) -> Result<LoraRequest, UnloadLoraError> {
        let _guard = self.update_lock.lock().await;
        let req = self.requests.read().await.get(lora_name).cloned()
            .ok_or_else(|| UnloadLoraError::NotFound { lora_name: lora_name.into() })?;
        if let Some(actual) = requested_int_id && actual != req.lora_int_id {
            return Err(UnloadLoraError::IntIdMismatch {
                lora_name: lora_name.into(),
                expected: actual,
                actual: req.lora_int_id,
            });
        }
        if !engine.remove_lora(req.lora_int_id).await.map_err(UnloadLoraError::Engine)? {
            return Err(UnloadLoraError::NotRemoved { lora_name: lora_name.into(), lora_int_id: req.lora_int_id });
        }
        self.requests.write().await.remove(lora_name);
        Ok(req)
    }
}

评论区精华

路径遍历漏洞与路径验证改进 安全

Copilot 指出静态 starts_with 可能被 `..` 绕过,BugenZhao 建议 canonicalize。depthfirst 进一步指出 `looks_like_local_lora_path` 无法识别裸相对路径。

结论:作者最终实现 canonicalize 前缀匹配,并将规范化路径传递给引擎。 · 已解决

lora_int_id 校验:卸载时是否需要匹配 正确性

Copilot 指出 unload 请求接受 lora_int_id 但未使用,容易误导。

结论:作者在 LoraManager::unload_lora 中增加了 lora_int_id 匹配检查,不匹配时返回 IntIdMismatch 错误。 · 已解决

将 LoRA 状态抽离为独立 Manager 设计

BugenZhao 建议将 lora_requests 及相关方法从 AppState 移到单独文件。

结论:作者创建了 rust/src/server/src/lora.rs,将 LoraManager 及相关类型独立。 · 已解决

模型解析与 LoRA 查找的原子性 正确性

Copilot 指出获取 served_model_names 与 resolve_lora_request 是两次锁读取,可能不一致。BugenZhao 建议传递整个 LoraModelResolution。

结论:作者修改为一次性通过 state.resolve_model_with_loras 获取 LoraModelResolution,然后从中解构。 · 已解决

命名风格:LoraRequest vs LoRARequest style

BugenZhao 指出 Rust 惯例应将缩写视为单词,建议 LoraRequest。

结论:作者统一更名为 LoraRequest。 · 已解决

风险与影响

  • 安全风险:路径验证已用 canonicalize,但仍存在极小 TOCTOU 窗口(symlink swap),建议引擎侧二次验证。
  • 竞态条件LoraManagerresolve_model 未加 update_lock,可能读取到加载/卸载过程中的中间状态,但影响有限,因只读快照。
  • 内存泄漏:若引擎 remove_lora 失败,本地表可能残留,需后续同步。
  • 性能影响:每次请求增加一次 RwLock::read 和可选的 BTreeMap::get,非 LoRA 场景影响可忽略。

对管理员:可在生产环境动态管理 LoRA 适配器,避免重启服务。新端点默认不开放(需设置环境变量),现有用户不受影响。对系统:增加元数据内存开销,需引擎支持 add_lora/remove_lora。对团队:这是 Rust 前端 LoRA 功能的重要补充,为后续更多管理操作奠定基础。

路径遍历风险 TOCTOU 窗口 异步竞态 缺少卸载测试覆盖

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论