Prhub

#38939 [Rust] Use Dynamo native renderers when chat templates are missing

原始 PR 作者 sherlockwu 合并时间 2026-09-15 08:29 文件变更 9 提交数 10 评论 5 代码增减 +222 / -47

执行摘要

为无模板模型启用 Dynamo 原生渲染,DSV4 走通 chat API

PR body 明确说明:Models such as DeepSeek V4 ship no chat_template, so the Rust frontend disabled /v1/chat/completions for them。DeepSeek V4 的 prompt 构造逻辑在 Python 代码里按 architecture 选择(encoding_dsv4.py / resolve_chat_encoding_spec),而 Rust 服务器只有模板路径,导致 load_chat_formatter 返回 TemplateError::Missing 后 load_chat_support 直接关闭 chat 端点。该 PR 的目标是让无模板模型在 Rust 服务器上获得与 Python 前端一致的 chat 能力。

建议精读。这个 PR 展示了三个值得借鉴的设计决策:一是"专用方案收敛为通用 fallback"的演进路径——从为 DSV4 定制 encoder 重构为 native_formatter_for 通用回退;二是用 TemplateRequest 包装结构体适配 OAIChatLikeRequest trait,在不改协议类型的前提下透传 chat_template_kwargs;三是用 starts_in_reasoning 解决"prefill 已输出思考标记导致流式阶段无起始边界"的 reasoning 解析问题。对后续维护者,建议重点关注 prompt.ends_with(" thinking") 的耦合点,以及在 dynamo-renderer 升级时同步回归模板缺失场景。

讨论亮点

本 PR 没有产生 review 评论,仅有 APPROVE(rainj-me)。值得记录的讨论来自 issue 评论与 commit 迭代:

  • rainj-me 连续触发 /rerun-group rust-server 与 /rerun-test(test_run_rust_tests、test_srt_endpoint、test_rust_native_mm_e2e、test_rust_native_mm_mmmu),说明 rust-server 相关测试首轮失败,重跑后全部通过;合并前 hnyls2002 再次 /tag-and-rerun-ci 确认最终状态。
  • commit 历史显示设计经历了明显迭代:最初是"为 DeepSeek-V4-Flash 定制内置编码器",随后演进为"通用 native formatter 回退"(a602b9d),并删除了 Python/Rust 渲染一致性测试(04a52d5),说明作者把一致性校验从测试代码下沉到了 dynamo-renderer 的单一实现中,避免双端漂移。
  • 初始实现引入过独立的 Native 变体和 request shim,最终提交保留 HuggingFace 变体名并去掉冗余层,说明设计收敛到了更小接口面。

实现拆解

实现拆解如下:

  1. 契约扩展:Python 向 Rust 传递 model_type。rust/sglang-server/src/message/config.rs 中 ModelConfig 结构体新增 pub model_type: Option<String> 字段,py_new 签名同步增加关键字参数,默认值为 None;python/sglang/srt/rust_server/config.py 的 _build_server_args 通过 getattr(mc.hf_config, "model_type", None) 读取 HF 模型类型并传入,为原生编码器选择提供依据。

  2. 模板回退链:native formatter 兜底。rust/sglang-server/src/api_server/openai/template_loader.rs 的 load_chat_formatter 新增 model_type 参数,定义 native_formatter 闭包:仅在未显式指定 --chat-template 时调用 dynamo_renderer::native_formatter_for(model_type 与 model_path 均转小写后匹配)。回退路径有三条:无 tokenizer config 文件时返回 native formatter;有 config 但 chat_template 缺失(TemplateError::Missing)时回退 native formatter;显式指定模板或模板存在时保持原逻辑。rust/sglang-server/src/api_server/openai.rs 的 load_chat_support 把 server_args.model_config.model_type 传入该函数。

  3. 渲染接口升级:适配 OAIChatLikeRequest。rust/sglang-server/Cargo.toml 将 dynamo-renderer 从 5.0.0 升级到 5.1.2,并新增 minijinja 依赖——新版本渲染接口从具体请求类型改为 trait 对象,且模板变量以 minijinja::Value 传递。rust/sglang-server/src/api_server/openai/template.rs 新增 ChatTemplateKwargs = HashMap<String, serde_json::Value>,ChatFormatter::render 增加 kwargs 参数,并新增 TemplateRequest 包装结构体实现 OAIChatLikeRequest,把 CreateChatCompletionRequest 的各个字段委托给底层请求,通过 chat_template_args() 返回额外变量。

  4. 请求层透传:chat_template_kwargs 进入模板。rust/sglang-server/src/api_server/openai/chat.rs 新增 ChatRequest 结构体,用 #[serde(flatten)] 展开 CreateChatCompletionRequest 并追加可选 chat_template_kwargs 字段;chat_completions 解析该字段后传入 prepare_chat_request 与 formatter.render,使模板渲染能拿到 thinking 等模型特定变量。

  5. 流式推理初始化:V4 无起始标记场景。chat.rs 计算 starts_in_reasoning:当 reasoning_parser 为 deepseek-v4 相关名称且渲染出的 prompt 以 thinking 结尾时为 true,并透传至 chat_event_stream。reasoning.rs 的 ReasoningStreamSplitter 新增 starts_in_reasoning 字段与构造参数,parser 惰性构建时调用 set_in_reasoning(true),使流式输出的前序 token 直接按思考内容解析。

  6. 测试配套。template.rs 内新增 chat_template_kwargs_reach_the_template 与 missing_template_falls_back_to_native_formatter 两个单元测试,覆盖 kwargs 注入、缺失模板、缺失配置、未知架构四种场景;reasoning.rs 内新增 v4_streaming_separates_prefilled_reasoning 验证思考内容与答案内容正确分离。未新增独立集成测试文件,依赖现有 Rust 测试套件与 CI 重跑验证。

文件 模块 状态 重要度
rust/sglang-server/src/api_server/openai/template.rs 模板渲染 modified 8.68
rust/sglang-server/src/api_server/openai/chat.rs 聊天端点 modified 7.05
rust/sglang-server/src/api_server/openai/template_loader.rs 模板加载 modified 6.39
rust/sglang-server/src/api_server/openai/reasoning.rs 推理分流 modified 6.88
rust/sglang-server/src/message/config.rs 服务配置 modified 5.23
rust/sglang-server/src/api_server/openai.rs 路由入口 modified 4.63
python/sglang/srt/rust_server/config.py 配置接入 modified 4.35
rust/sglang-server/Cargo.toml 依赖配置 modified 3.17
rust/Cargo.lock 依赖锁定 modified 2.85

关键符号

load_chat_formatter native_formatter_for ChatFormatter::render TemplateRequest chat_completions prepare_chat_request ReasoningStreamSplitter::new load_chat_support

关键源码片段

rust/sglang-server/src/api_server/openai/template.rs core-logic

核心改动文件:新增 ChatTemplateKwargs 类型与 TemplateRequest 适配层,实现 OAIChatLikeRequest trait,render 方法支持 kwargs 透传,并补充两个关键单元测试(kwargs 注入与 native formatter 回退)。

//! ChatFormatter 的渲染入口:HuggingFace 变体现在接受 OAIChatLikeRequest trait。
//! 由于 dynamo-renderer 5.1.2 的渲染接口要求 trait 对象,而协议类型本身不携带
//! chat_template_kwargs,因此用 TemplateRequest 包装层补上这块数据。
​
/// 额外变量集:Jinja 模板渲染时可通过 chat_template_kwargs 注入 thinking 等字段。
pub type ChatTemplateKwargs = HashMap<String, serde_json::Value>;
​
/// 协议请求 + 额外变量的组合,实现 dynamo-renderer 定义的渲染接口。
struct TemplateRequest<'a> {
    request: &'a CreateChatCompletionRequest,
    kwargs: Option<&'a ChatTemplateKwargs>,
}
​
impl OAIChatLikeRequest for TemplateRequest<'_> {
    fn model(&self) -> String {
        self.request.model()
    }
    fn messages(&self) -> minijinja::Value {
        self.request.messages()
    }
    fn typed_messages(&self) -> Option<&[ChatCompletionRequestMessage]> {
        self.request.typed_messages()
    }
    fn tools(&self) -> Option<minijinja::Value> {
        self.request.tools()
    }
    fn tool_choice(&self) -> Option<minijinja::Value> {
        self.request.tool_choice()
    }
    fn response_format(&self) -> Option<minijinja::Value> {
        self.request.response_format()
    }
    fn reasoning_effort(&self) -> Option<minijinja::Value> {
        self.request.reasoning_effort()
    }
    fn should_add_generation_prompt(&self) -> bool {
        self.request.should_add_generation_prompt()
    }
    // 关键桥接:把 HTTP 层解析出的 chat_template_kwargs 交给模板引擎。
    fn chat_template_args(&self) -> Option<&ChatTemplateKwargs> {
        self.kwargs
    }
    fn extract_text(&self) -> Option<TextInput> {
        self.request.extract_text()
    }
    fn mm_processor_kwargs(&self) -> Option<&serde_json::Value> {
        self.request.mm_processor_kwargs()
    }
}
rust/sglang-server/src/api_server/openai/chat.rs entrypoint

请求入口改造:新增 ChatRequest 包装结构体(serde flatten 支持 chat_template_kwargs),计算 starts_in_reasoning 并透传到流式推理分流器。

/// HTTP 请求包装:serde(flatten) 展开标准 OpenAI 请求,同时捕获
/// chat_template_kwargs(OpenAI 协议没有该字段,属于 SGLang 扩展)。
#[derive(Deserialize)]
struct ChatRequest {
    #[serde(flatten)]
    request: CreateChatCompletionRequest,
    chat_template_kwargs: Option<ChatTemplateKwargs>,
}
​
// 在 chat_completions 入口解析后传给 prepare_chat_request。
let (request, prompt) =
    match prepare_chat_request(&state, request, chat_template_kwargs.as_ref()).await {
        Ok(prepared) => prepared,
        Err(response) => return response,
    };
​
// V4 模型的 prefill 阶段已经输出了 " thinking" 起始标记,因此流式阶段
// 不会再出现新的 <think> 起始边界;必须告诉 reasoning splitter 从
// 思考状态开始,否则前序 token 会被误判为正常答案文本。
let starts_in_reasoning = matches!(
    reasoning_parser.as_deref(),
    Some("deepseek-v4" | "deepseek_v4" | "deepseekv4")
) && prompt.ends_with(" thinking");
​
// 该标记随后传入 chat_event_stream,用于初始化每个 choice 的
// ReasoningStreamSplitter(见 reasoning.rs 的 starts_in_reasoning 字段)。
rust/sglang-server/src/api_server/openai/template_loader.rs core-logic

实现核心回退逻辑:native_formatter 闭包与三条回退路径,决定无模板模型是否能加载 chat 支持。

pub(super) fn load_chat_formatter(
    config_file: Option<&str>, // tokenizer_config.json 路径
    model_path: Option<&str>, // 模型目录
    model_type: Option<&str>, // Python 侧传入的 HF model_type
    chat_template_arg: Option<&str>, // 命令行 --chat-template 参数
) -> Result<ChatFormatter, TemplateError> {
    // 1. 注册表名称优先:builtin 模板不依赖任何文件。
    if let Some(argument) = chat_template_arg
        && let Some(spec) = builtin_template(argument)
    {
        return Ok(ChatFormatter::Legacy(Box::new(LegacyFormatter { spec })));
    }
​
    // 2. 模型路径推断 legacy 会话模板,与 Python 的 load_chat_template 对齐。
    if chat_template_arg.is_none()
        && let Some(model_path) = model_path
        && let Some(spec) = infer_legacy_template_from_model_path(model_path)
    {
        return Ok(ChatFormatter::Legacy(Box::new(LegacyFormatter { spec })));
    }
​
    // 3. 原生 formatter 回退:DeepSeek V4 等模型不带 chat_template,
    // Dynamo 内置了按架构编码的渲染器(native_formatter_for),
    // 仅在未显式指定 --chat-template 时参与,避免覆盖用户意图。
    let native_formatter = || match chat_template_arg {
        None => native_formatter_for(
            &model_type.map(str::to_lowercase),
            &model_path.unwrap_or_default().to_lowercase(),
        )
        .map(ChatFormatter::HuggingFace),
        Some(_) => None,
    };
​
    // 4. 没有 tokenizer_config.json 时只能依赖原生编码器。
    let Some(config_file) = config_file else {
        return native_formatter().ok_or(TemplateError::MissingConfig);
    };
​
    // 5. 读取 tokenizer 配置交给 HF 渲染器。
    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")?;
​
    // 6. 未显式指定模板时:模板缺失(Missing)则回退原生编码器,
    // 否则走正常 HuggingFace 模板渲染路径。
    let Some(argument) = chat_template_arg else {
        return match formatter_from_config(&config) {
            Err(TemplateError::Missing) => native_formatter().ok_or(TemplateError::Missing),
            result => result,
        };
    };
​
    // 7. 显式指定 --chat-template 时的 jinja/JSON 文件处理逻辑保持不变。
    // ...(原逻辑:校验路径、注入模板、构造 formatter)
}

评论区精华

rust-server 相关测试首轮失败与重跑 测试

rainj-me 在 issue 里连续发起 /rerun-group rust-server 与 /rerun-test(test_run_rust_tests、test_srt_endpoint、test_rust_native_mm_e2e、test_rust_native_mm_mmmu),说明 rust-server 组测试以及核心端点、多模态 e2e 测试曾出现失败。

结论:github-actions 反馈全部通过,PR 最终在合并前由 hnyls2002 再次 /tag-and-rerun-ci 确认。 · 已解决

合并前重新打标签并触发 CI other

hnyls2002 合并前调用 /tag-and-rerun-ci,说明需要更新 run-ci 标签并让 CI 以最终 head 状态重新运行。

结论:已执行,PR 正常合并。 · 已解决

风险与影响

主要风险如下:

  1. dynamo-renderer 接口升级风险:从 5.0.0 升级到 5.1.2,渲染接口从直接接收请求对象改为 OAIChatLikeRequest trait。所有走 HuggingFace 渲染器路径的模型(含多模态编码)都经过新适配层,若 trait 方法语义与旧接口不完全等价,可能出现渲染结果差异。好在单元测试覆盖了基础渲染场景,且 Python 侧行为不受影响。
  2. 字符串匹配脆弱性:chat.rs 中 prompt.ends_with(" thinking") 是硬编码字符串匹配,依赖 native formatter 渲染结果的精确格式。若 dynamo-renderer 后续调整 V4 prompt 尾部格式,starts_in_reasoning 会静默失效,流式推理内容可能被误判为正常文本。
  3. 行为变更面:native formatter 回退会让原本"禁用 chat 端点"的模型(只要 Dynamo 认识其架构)自动启用 chat API,这是一个行为语义变化。若 native_formatter_for 对某架构的编码实现有偏差,可能出现 prompt 格式错误但不再有显式报错。
  4. 依赖面扩大:Cargo.toml 新增 minijinja 直接依赖,且 Cargo.lock 中 dynamo-protocols 从 5.3.1 升到 5.4.3、dynamo-tokenizers 从 1.8.0 升到 1.8.1,均为连带升级,需关注后续 Rust crate 更新冲突。
  5. Python 侧取值兼容性:getattr(mc.hf_config, "model_type", None) 依赖 hf_config 对象存在 model_type 属性,若模型 config 加载失败此字段为 None,不影响原有行为,但可能掩盖配置异常。

影响范围集中在 Rust 服务器(rust/sglang-server)的 OpenAI chat 链路:

  • 用户侧:DeepSeek V4 系列模型(含 V4-Flash)在 Rust 服务器上首次获得 /v1/chat/completions 支持,且支持 chat_template_kwargs 传递,渲染结果与 Python 前端对齐;其他无模板模型的 chat 能力也从"禁用"变为"自动启用"。
  • 系统侧:ModelConfig 的 PyO3 数据结构新增字段,Python 与 Rust 的 Rust 扩展契约同步变更;OAIChatLikeRequest 适配层成为 HuggingFace 渲染的唯一入口。
  • 团队侧:Rust 服务器与 dynamo-renderer 的依赖关系更紧密,后续升级 dynamo 系列 crate 时需要同步回归模板渲染与流式推理拆分。影响程度为中等——不触及 Python 主调度路径、KV 缓存或内核层。
依赖升级且接口 trait 化 流式推理依赖字符串匹配 无模板模型行为自动变更 缺少独立集成测试文件

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论