Prhub

#37220 [Rust] Split and rename embedded server components

原始 PR 作者 merrymercy 合并时间 2026-09-01 03:28 文件变更 41 提交数 10 评论 7 代码增减 +3409 / -3293

执行摘要

拆解 Rust 服务器模块并统一 MM 命名

PR body 原话:"Make the embedded Rust server easier to review and maintain without changing its runtime behavior. This is PR 1 of 4 in stack #37223."。具体动机包括:template.rs(1200+ 行)与 to_scheduler.rs(1100+ 行)混合了类型定义、FSM 逻辑、校验、渲染与测试夹具,单文件评审负担过重;Python 侧 managers/rust_server.py 单文件同时承载生命周期、MM pipeline 与配置构建三类职责;Native 术语有歧义(易被误读为“模型原生”或“Python 原生”),统一为 Rust 以明确指代 Rust worker 池管线;作为 4-PR 栈的第一发,先理清模块边界,后续 3 个 PR 才能小步、可验证地推进。

值得精读,尤其适合想理解 embedded Rust server 架构或准备做大规模安全重构的工程师。看点:(1) 平铺兄弟模块(不用嵌套目录与 mod.rs)的组织取舍;(2) #[cfg(test)] 控制测试专属导出,防止生产代码依赖只存在于测试构建的路径;(3) 术语统一(Native → Rust)如何降低跨语言维护成本;(4) non_mechanical_provable 提交拆分纪律——每个 commit 单独可验证;(5) to_scheduler_validation.rscheck_total_tokens 对 FSM 顺序限制的注释(Python 先校验后 verify,Rust 只能在截断处补断言)。若只关心推理功能,可跳过本 PR 的功能细节。

讨论亮点

该 PR 没有任何 inline review 评论(review_comments_count = 0),评审重心完全落在 CI 与编译验证上。merrymercy 在 PR 内评论 “approve” 并自行合并,rainj-me 给予 APPROVED(空 body)。值得注意的是三次 /rerun-test 的过程:首轮 test_run_rust_tests.py(ubuntu-latest)与 test_srt_endpoint.py(1-gpu-5090)失败;二轮全部通过;末轮 1-gpu-5090 与 1-gpu-h100(test_rust_native_mm_e2e.py / test_rust_native_mm_mmmu.py)再次失败,但未阻断合并——失败模式与本次纯重构无直接关联,倾向环境抖动或既有 flaky 用例。另外 10 个 commit 消息统一携带 non_mechanical_provable 后缀,说明作者对每一步拆分都做了“非机械、可证明”的验证,是值得借鉴的提交纪律。

实现拆解

  1. 拆分 template.rs(Rust 对话模板):commit 1、8。新文件 template_legacy.rsLegacyFormatter / LegacySpec 与全部 sep_style 渲染分支)、template_loader.rsload_chat_formatterinfer_legacy_template_from_model_pathparse_legacy_template)、template_builtins.rs(内置模板注册表);template.rs 只保留 ChatFormatter 枚举、TemplateError 与少量受控 re-export,测试专属导出用 #[cfg(test)] pub(super) use 限定,避免生产代码误用仅测试构建可见的路径。
  2. 拆分 to_scheduler.rs(Rust 调度入口):commit 2。新文件 to_scheduler_validation.rsvalidate / check_total_tokens,160 行)、to_scheduler_types.rsLimits / MmFrom<&ServerArgs> 转换,50 行)、to_scheduler_tests.rs(892 行测试夹具与用例,含 make_intake 系列与 every_abort_source_deregisters_and_stops_the_scheduler);to_scheduler.rs 保留 Intake FSM 骨架与 MAX_RID_LEN 常量。
  3. Python 集成迁入多文件包:commit 3、4、5。旧 managers/rust_server.py 整体删除,新包 sglang.srt.rust_server/server.pyRustServer 生命周期)、multimodal.py(MM spec / processor)、config.py_build_server_args / _partition_cores)与 __init__.py 组成;_build_server_args 继续使用 resolving_view 解析声明(与 #37195 同机制),preferred_sampling_params 仍在 launch 时拒绝启动以保持原行为。
  4. 命名统一 Native → Rust:commit 6、9。NativeMmSpec → RustMmSpecNativeMmHost → RustMmProcessorNATIVE_MM_FAMILIES → RUST_MM_FAMILIES;Rust 侧 take_mm → take_mm_resultlib.rs)、process_native_mm → process_mmrust/sglang-mm/src/qwen_vl/mod.rs);测试同步更名(test_build_native_mm.py → test_build_rust_mm_output.pytest_native_mm_gate.py → test_rust_mm_gate.pytest_native_mm_host.py → test_rust_mm_processor.pyTestRustNativeMmMMMU → TestRustMmMMMU 等 6+ 处)。
  5. 配套调整与验证:commit 7、10。SamplingParams 按用途分组声明、MmEncodeResult 字段分组加注释且 wire 顺序不变;新增 rust/sglang-server/README.md;验证覆盖 cargo test(server 与 mm 两个 crate)、cargo clippy -D warnings、rustfmt、Ruff / isort、Python 字节码编译,并在 PR 评论中多次触发 4 组聚焦注册测试。
文件 模块 状态 重要度
python/sglang/srt/rust_server/server.py 嵌入式服务 added 8.98
python/sglang/srt/rust_server/multimodal.py 多模态集成 added 8.98
python/sglang/srt/rust_server/config.py 启动配置 added 8.01
python/sglang/srt/managers/rust_server.py 嵌入式服务 removed 8.5
rust/sglang-server/src/api_server/openai/template_loader.rs 对话模板 added 8.88
rust/sglang-server/src/tokenizer_manager/to_scheduler_validation.rs 调度入口 added 7.46
rust/sglang-server/src/tokenizer_manager/to_scheduler_tests.rs 调度入口 added 8.6
rust/sglang-server/src/lib.rs 服务入口 modified 6.89

关键符号

RustServer.launch RustServer.drain RustMmProcessor.resolve_spec RustMmProcessor.build_output check_total_tokens validate load_chat_formatter infer_legacy_template_from_model_path take_mm_result make_intake

关键源码片段

rust/sglang-server/src/api_server/openai/template_loader.rs entrypoint

从 1200 行 template.rs 拆出的模板加载与模型路径推断模块,load_chat_formatter 的优先级逻辑(内置名 → 路径推断 → HF 模板)是拆分后仍需保持语义一致的关键入口。

//! Chat-template 加载与模型路径推断。
//! 本文件从原 `template.rs` 拆出,原文件只保留 `ChatFormatter`、
//! `TemplateError` 与少量受控 re-export。/// 模板选择优先级(与 Python `load_chat_template` 对齐):
/// 1. `--chat-template` 传内置注册名 → 直接命中,不碰文件系统;
/// 2. 未传该参数时按模型路径推断 legacy 模板;
/// 3. 其余情况一律围绕 tokenizer 配置构建 HF renderer。
pub(super) fn load_chat_formatter(
    config_file: Option<&str>,
    model_path: Option<&str>,
    chat_template_arg: Option<&str>,
) -> Result<ChatFormatter, TemplateError> {
    // Python 先查注册表再查文件系统:内置名在缺失 `tokenizer_config.json`
    // 时也能工作,这里保持同样的顺序。
    if let Some(argument) = chat_template_arg
        && let Some(spec) = builtin_template(argument)
    {
        return Ok(ChatFormatter::Legacy(Box::new(LegacyFormatter { spec })));
    }    // 未传 `--chat-template` 时先从模型路径推断 legacy 模板,
    // 保证 config 中缺失 `chat_template` 的 legacy 模型也能拿到模板。
    if chat_template_arg.is_none()
        && let Some(model_path) = model_path
        && let Some(spec) = infer_legacy_template_from_model_path(model_path)
    {
        tracing::info!(%model_path, "inferred legacy chat template from model path");
        return Ok(ChatFormatter::Legacy(Box::new(LegacyFormatter { spec })));
    }    // 剩余来源都围绕 tokenizer 配置构建 HF renderer。
    let Some(config_file) = config_file else {
        return Err(TemplateError::MissingConfig);
    };    let config_path = Path::new(config_file);
    let config_text = read_to_string(config_path, "tokenizer config")?;
    let mut config = parse_json(&config_text, config_path, "tokenizer config")?;    let Some(argument) = chat_template_arg else {
        return formatter_from_config(&config);
    };    let path = Path::new(argument);
    if !path.exists() {
        return Err(TemplateError::NotFound { path: path.to_path_buf() });
    }
    if !path.is_file() {
        return Err(TemplateError::NotFile { path: path.to_path_buf() });
    }    // `.jinja` 文件作为模板文本注入 config;JSON 文件则可能是 HF 风格
    // (携带 `chat_template`)或 legacy SGLang 风格(携带 Conversation 字段)。
    if path.extension().and_then(|extension| extension.to_str()) == Some("jinja") {
        let template = read_to_string(path, "chat template")?;
        set_chat_template(
            &mut config,
            Value::String(template.trim_matches('\n').replace("\\n", "\n")),
        )?;
        return formatter_from_config(&config);
    }    let template_text = read_to_string(path, "chat template")?;
    let template = parse_json(&template_text, path, "chat template")?;
    if let Some(chat_template) = template.get("chat_template") {
        set_chat_template(&mut config, chat_template.clone())?;
        formatter_from_config(&config)
    } else {
        // legacy 文件翻译成 `LegacyFormatter`,逐字段校验。
        Ok(ChatFormatter::Legacy(Box::new(LegacyFormatter {
            spec: parse_legacy_template(&template, path)?,
        })))
    }
}
rust/sglang-server/src/tokenizer_manager/to_scheduler_validation.rs core-logic

从 to_scheduler.rs 拆出的请求校验模块(validate / check_total_tokens),承载 rid 上限、词表范围与上下文窗口检查,是调度入口拆分后最核心的行为逻辑。

//! 请求校验:从原 `to_scheduler.rs`(约 1100 行)拆出的独立模块。
//! 拆分后 `to_scheduler.rs` 只保留 `Intake` FSM 骨架。/// 上下文窗口校验:镜像 Python `TokenizerManager._validate_one_request`,
/// 让客户端拿到可行动的 400 而不是静默截断的 200。
pub(super) fn check_total_tokens(g: &mut GenerateRequest, limits: &Limits) -> Result<(), Error> {
    let max_req_len = limits.context_len;
    // Python 把保留槽位(如 eagle draft tokens)计入输入长度,
    // 因此 prompt 单独能放下时仍可能因保留槽位被拒绝。
    let input_len =
        g.input_ids.as_ref().map_or(0, |ids| ids.len()) as u64 + limits.num_reserved_tokens;    // 输入长度无条件检查:`max_new_tokens: null` 只表示 " 无生成上限 ",
    // 不等于 " 跳过检查 "。比较用 `>=`(与 Python 一致):正好填满窗口的
    // prompt 没有生成空间,必须拒绝。
    if input_len >= max_req_len {
        if !limits.allow_auto_truncate {
            return Err(Error::Validation(format!(
                "The input ({input_len} tokens) is longer than the model's context length ({max_req_len} tokens)."
            )));
        }
        if let Some(ids) = &mut g.input_ids {
            ids.truncate(max_req_len as usize);
        }
    }
    let input_len =
        g.input_ids.as_ref().map_or(0, |ids| ids.len()) as u64 + limits.num_reserved_tokens;    let Some(max_new_tokens) = g.sampling_params.max_new_tokens else {
        return Ok(()); // 没有请求上限 → 无需叠加检查
    };
    let total = input_len.saturating_add(max_new_tokens.max(0) as u64);
    if total <= max_req_len {
        return Ok(());
    }
    if !limits.allow_auto_truncate {
        return Err(Error::Validation(format!(
            "Requested token count exceeds the model's maximum context length of {max_req_len} tokens. You requested a total of {total} tokens: {input_len} tokens from the input messages and {max_new_tokens} tokens for the completion. Please reduce the number of tokens in the input messages or the completion to fit within the limit."
        )));
    }
    let clamped = max_req_len.saturating_sub(input_len) as i64;
    // 截断会破坏 `min_new_tokens <= max_new_tokens` 不变量:`verify` 已在
    // Normalizing 阶段跑过,且 `is_normalized: true` 会让调度器跳过重新校验,
    // 所以这里必须主动复查(Python 是先校验后 verify,Rust 的 FSM 顺序
    // 无法调整,只能在截断处补回这道断言)。
    if g.sampling_params.min_new_tokens > clamped {
        return Err(Error::Validation(format!(
            "min_new_tokens must be in [0, max_new_tokens({clamped})], got {}",
            g.sampling_params.min_new_tokens
        )));
    }
    g.sampling_params.max_new_tokens = Some(clamped);
    Ok(())
}

评论区精华

聚焦注册测试在 GPU runner 上的间歇失败 测试

merrymercy 先后三次发起 `/rerun-test test/registered/rust/test_run_rust_tests.py test/registered/core/test_srt_endpoint.py test/registered/vlm/test_rust_native_mm_e2e.py test/registered/vlm/test_rust_native_mm_mmmu.py`。首轮 ubuntu-latest 与 1-gpu-5090 失败;二轮全部通过;末轮 1-gpu-5090(test_srt_endpoint.py)与 1-gpu-h100(两个 VLM MM 测试)再次失败。

结论:未在合并前定位失败根因,PR 仍被合并;失败模式与本次纯结构重构无直接关联,倾向环境抖动或既有 flaky 用例,但需后续 PR 持续观察。 · 已解决

零 review comment 的纯结构重构评审 other

review_comments_count = 0;merrymercy 在 PR 内评论 "approve" 并最终自行合并,rainj-me 给予 APPROVED(空 body)。10 个 commit 均以 non_mechanical_provable 标注,说明每个拆分步骤都单独可验证。

结论:评审重心完全落在编译、clippy 与注册测试上;无设计争议或未解决问题。 · 已解决

风险与影响

  1. 跨语言 API 重命名take_mm → take_mm_resultrust/sglang-server/src/lib.rs)、process_native_mm → process_mmrust/sglang-mm/src/qwen_vl/mod.rs)等符号改名的调用点横跨 Rust FFI 与 Python rust_server/server.pydrain 路径;Rust 侧遗漏引用会直接编译失败,相对安全,但 Python 侧任何漏改的调用点会在运行时炸,依赖注册测试覆盖。
  2. 核心调度入口重构to_scheduler.rsvalidate / check_total_tokens 是请求进入调度器前的最后校验(rid 上限、词表范围、上下文窗口),拆出后若 import 关系或可见性(pub(super))调整出错,会影响整条请求路径的 400/200 行为。
  3. 测试文件大量重命名:6+ 个测试文件路径变更,若 CI 配置或外部脚本硬编码旧路径会静默丢测试;本 PR 通过多次 rerun 验证了注册测试路径,但未全绿。
  4. GPU runner 间歇失败:末轮 rerun 中 1-gpu-5090(test_srt_endpoint.py)与 1-gpu-h100(VLM MM e2e / mmmu)失败但未定位根因即合并,存在环境抖动或既有 flaky 用例的可能。
  5. 行为不变声明依赖现有测试背书:拆分过程未新增端到端行为回归测试,若拆分时无意改变了错误消息文本或校验顺序,现有断言不一定全覆盖(例如 check_total_tokens 的错误文案被多处测试断言引用)。

用户视角:无任何 API、协议或运行时行为变化(PR 声明 + 回归测试通过),请求路径、错误码、MM 输出格式均不变。系统视角:sglang.srt.rust_server 成为独立 Python 包,managers/rust_server.py 退役;Rust 侧 template*to_scheduler* 模块边界清晰化,为 stack 中后续 3 个 PR(#37221、#37222、#37226)提供了可直接落脚的模块结构。团队视角:“Native → Rust”术语统一降低了跨语言沟通歧义;测试文件路径变更要求 CI / 脚本同步;41 文件、约 6700 行变动的评审压力通过 10 个自洽 commit 得到缓解。

跨语言 API 重命名 核心调度入口重构 GPU runner 测试间歇失败 行为不变依赖现有测试背书

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论