执行摘要
- 一句话:拆解 Rust 服务器模块并统一 MM 命名
- 推荐动作:值得精读,尤其适合想理解 embedded Rust server 架构或准备做大规模安全重构的工程师。看点:(1) 平铺兄弟模块(不用嵌套目录与
mod.rs)的组织取舍;(2) #[cfg(test)] 控制测试专属导出,防止生产代码依赖只存在于测试构建的路径;(3) 术语统一(Native → Rust)如何降低跨语言维护成本;(4) non_mechanical_provable 提交拆分纪律——每个 commit 单独可验证;(5) to_scheduler_validation.rs 中 check_total_tokens 对 FSM 顺序限制的注释(Python 先校验后 verify,Rust 只能在截断处补断言)。若只关心推理功能,可跳过本 PR 的功能细节。
功能与动机
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 才能小步、可验证地推进。
实现拆解
- 拆分
template.rs(Rust 对话模板):commit 1、8。新文件 template_legacy.rs(LegacyFormatter / LegacySpec 与全部 sep_style 渲染分支)、template_loader.rs(load_chat_formatter、infer_legacy_template_from_model_path、parse_legacy_template)、template_builtins.rs(内置模板注册表);template.rs 只保留 ChatFormatter 枚举、TemplateError 与少量受控 re-export,测试专属导出用 #[cfg(test)] pub(super) use 限定,避免生产代码误用仅测试构建可见的路径。
- 拆分
to_scheduler.rs(Rust 调度入口):commit 2。新文件 to_scheduler_validation.rs(validate / check_total_tokens,160 行)、to_scheduler_types.rs(Limits / Mm 与 From<&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 常量。
- Python 集成迁入多文件包:commit 3、4、5。旧
managers/rust_server.py 整体删除,新包 sglang.srt.rust_server/ 由 server.py(RustServer 生命周期)、multimodal.py(MM spec / processor)、config.py(_build_server_args / _partition_cores)与 __init__.py 组成;_build_server_args 继续使用 resolving_view 解析声明(与 #37195 同机制),preferred_sampling_params 仍在 launch 时拒绝启动以保持原行为。
- 命名统一 Native → Rust:commit 6、9。
NativeMmSpec → RustMmSpec、NativeMmHost → RustMmProcessor、NATIVE_MM_FAMILIES → RUST_MM_FAMILIES;Rust 侧 take_mm → take_mm_result(lib.rs)、process_native_mm → process_mm(rust/sglang-mm/src/qwen_vl/mod.rs);测试同步更名(test_build_native_mm.py → test_build_rust_mm_output.py、test_native_mm_gate.py → test_rust_mm_gate.py、test_native_mm_host.py → test_rust_mm_processor.py、TestRustNativeMmMMMU → TestRustMmMMMU 等 6+ 处)。
- 配套调整与验证: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(模块 嵌入式服务;类别 source;类型 rename-or-move;符号 RustServer, launch, wait_request, drain): 嵌入式 Rust 服务器生命周期主类迁入新包的核心文件,drain 路径改用 take_mm_result 并委托 RustMmProcessor.build_output 组装多模态输出,是包搬迁与命名统一的交汇点。
python/sglang/srt/rust_server/multimodal.py(模块 多模态集成;类别 source;类型 rename-or-move;符号 RustMmSpec, feature_dim, rust_json, RustMmFamily): 旧 managers/rust_server.py 中 NativeMm* 系列重命名并独立成模块的产物(RustMmSpec / RustMmFamily / RustMmProcessor),是术语统一策略最完整的展示。
python/sglang/srt/rust_server/config.py(模块 启动配置;类别 source;类型 rename-or-move;符号 _build_server_args, _partition_cores): 从旧单文件拆出的启动配置模块:_build_server_args 构建 typed ServerArgs 交接,_partition_cores 实现 NUMA 感知的 CPU 核心划分,是包搬迁后职责切分的样本。
python/sglang/srt/managers/rust_server.py(模块 嵌入式服务;类别 source;类型 rename-or-move;符号 NativeMmSpec, NativeMmFamily, native_mm_family_for, NativeMmHost): 被整体删除的 869 行旧单文件,其职责(生命周期 / MM pipeline / 配置)分别迁入 sglang.srt.rust_server 包三个模块,是本 PR 最大单一删除。
rust/sglang-server/src/api_server/openai/template_loader.rs(模块 对话模板;类别 source;类型 entrypoint;符号 load_chat_formatter, infer_legacy_template_from_model_path, read_model_type, parse_legacy_template): 从 1200 行 template.rs 拆出的模板加载与模型路径推断模块,load_chat_formatter 的优先级逻辑(内置名 → 路径推断 → HF 模板)是拆分后仍需保持语义一致的关键入口。
rust/sglang-server/src/tokenizer_manager/to_scheduler_validation.rs(模块 调度入口;类别 source;类型 core-logic;符号 validate, check_total_tokens): 从 to_scheduler.rs 拆出的请求校验模块(validate / check_total_tokens),承载 rid 上限、词表范围与上下文窗口检查,是调度入口拆分后最核心的行为逻辑。
rust/sglang-server/src/tokenizer_manager/to_scheduler_tests.rs(模块 调度入口;类别 test;类型 test-coverage;符号 make_intake, make_intake_with_abort, make_intake_with, make_intake_inner): 892 行测试夹具与用例从 to_scheduler.rs 独立成文件,make_intake 系列夹具与 abort 路径测试是拆分后保障调度入口行为不变的关键测试资产。
rust/sglang-server/src/lib.rs(模块 服务入口;类别 source;类型 core-logic;符号 take_mm_result): FFI 边界上的 MM 结果取出 API 由 take_mm 更名为 take_mm_result,是 Native → Rust 命名统一在 Rust 侧的落地点,牵动 Python drain 调用方。
关键符号: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
从 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
从 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(())
}
评论区精华
该 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 后缀,说明作者对每一步拆分都做了“非机械、可证明”的验证,是值得借鉴的提交纪律。
- 聚焦注册测试在 GPU runner 上的间歇失败 (testing): 未在合并前定位失败根因,PR 仍被合并;失败模式与本次纯结构重构无直接关联,倾向环境抖动或既有 flaky 用例,但需后续 PR 持续观察。
- 零 review comment 的纯结构重构评审 (other): 评审重心完全落在编译、clippy 与注册测试上;无设计争议或未解决问题。
风险与影响
- 风险:
- 跨语言 API 重命名:
take_mm → take_mm_result(rust/sglang-server/src/lib.rs)、process_native_mm → process_mm(rust/sglang-mm/src/qwen_vl/mod.rs)等符号改名的调用点横跨 Rust FFI 与 Python rust_server/server.py 的 drain 路径;Rust 侧遗漏引用会直接编译失败,相对安全,但 Python 侧任何漏改的调用点会在运行时炸,依赖注册测试覆盖。
- 核心调度入口重构:
to_scheduler.rs 的 validate / check_total_tokens 是请求进入调度器前的最后校验(rid 上限、词表范围、上下文窗口),拆出后若 import 关系或可见性(pub(super))调整出错,会影响整条请求路径的 400/200 行为。
- 测试文件大量重命名:6+ 个测试文件路径变更,若 CI 配置或外部脚本硬编码旧路径会静默丢测试;本 PR 通过多次 rerun 验证了注册测试路径,但未全绿。
- GPU runner 间歇失败:末轮 rerun 中 1-gpu-5090(
test_srt_endpoint.py)与 1-gpu-h100(VLM MM e2e / mmmu)失败但未定位根因即合并,存在环境抖动或既有 flaky 用例的可能。
- 行为不变声明依赖现有测试背书:拆分过程未新增端到端行为回归测试,若拆分时无意改变了错误消息文本或校验顺序,现有断言不一定全覆盖(例如
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 测试间歇失败, 行为不变依赖现有测试背书
关联脉络
- PR #37221 [Rust] Derive server address and accept signed env values: 同一 #37223 栈的 2/4,直接基于本 PR 拆分后的结构继续演进,并共用 python/sglang/srt/rust_server/server.py 与 rust/sglang-server 模块。
- PR #37222 [Rust] Keep sampling and scheduler wire schemas in sync: 栈 3/4,延续 sampling / scheduler 结构梳理,并新增双语言 lockstep 测试,落在本次拆出的 message 与 to_scheduler 模块边界上。
- PR #37226 [Rust] Simplify request defaults and document batch header ABI: 栈 4/4,完成请求默认值精简与 batch header ABI 文档化,与本 PR 的 README 与结构整理一脉相承。
- PR #37195 fix(config): retain pre-engine resolution declarations: rust_server/config.py 的 _build_server_args 依赖 resolving_view 解析声明机制,与本 PR 共享同一配置解析链路。
参与讨论