Prhub

#51426 [Rust Frontend] Fix GLM-5.2 chat template rendering parity

原始 PR 作者 WoosukKwon 合并时间 2026-08-18 11:16 文件变更 5 提交数 5 评论 11 代码增减 +106 / -6

执行摘要

MiniJinja 2.24 修复 GLM-5.2 模板渲染及工具字段顺序

PR 目标是修复 GLM-5.2 chat 模板在 Rust 前端无法编译、以及渲染结果与 Python 不一致的问题。MiniJinja 2.18 会在 mid-chain dotted integer lookup(如 values.0.name)处报 unexpected float, expected identifier or integer,直接拒绝 GLM-5.2 自带模板;升级到 2.22 虽能编译,但工具 struct 序列化后的字段顺序变成 description、name、parameters,与 Python 前端不一致,且缺失 description 时字段被整体丢弃,而 Python 的 model_dump() 会保留 description=null。这些差异会导致相同请求在 Rust 与 Python 前端渲染出不同的 prompt 与 token 序列。

值得精读。三个设计决策值得借鉴:一是借上游修复解决序列化顺序问题,而不是在本地用手写 map 绕过,维护成本最低;二是用 serde 属性把“缺失、null、false”三态语义精确映射到 OpenAI 协议;三是用真实模型 roundtrip 加 SHA-256 比对来锁定 prompt 一致性,比单测更有说服力。后续在 Rust 前端接入新模型时,可直接沿用这套验证模板。

讨论亮点
  1. 字段顺序问题的定位与决策:BugenZhao 在 review 中指出“serializing the Rust tool structs changed the function-field order to description, name, parameters, while the OpenAI request and Python frontend preserve name, description, parameters”,并确认 minijinja v0.24 已修复此问题(mitsuhiko/minijinja#920),因此选择继续使用 typed struct 而非手写 map。

  2. 关于 description 缺失语义的 P1 意见:Codex review 指出早期 manual-map 实现会在 description 缺失时整体丢弃该字段,而 Python 前端(vllm/renderers/online_renderer.py:180 的 model_dump()、vllm/entrypoints/openai/engine/protocol.py:299-314)会保留 description=null,影响 tool_chat_template_functiongemma.jinja 这类区分 undefined 与 null 的模板。最终改为 typed projection 后,description=null 与省略 strict 两种语义同时成立。

  3. 元讨论:WoosukKwon 说明 @codex review 请求本身也是 Codex 发出的(“This was written by Codex btw”),BugenZhao 回应其上一次 review 请求同样由 Codex 发送,反映该 PR 以自动化 review 协作为主。

实现拆解

  1. 依赖升级(rust/Cargo.toml 与 rust/Cargo.lock):将 minijinja 与 minijinja-contrib 从 2.22 提升到 2.24,继续启用 preserve_order 特性。上游 2.24 修复了 mitsuhiko/minijinja#920(模板迭代是否保留 Serde struct 定义顺序),这是字段顺序方案成立的前提;Cargo.lock 同步更新两个 crate 的版本与 checksum。

  2. 模板编译回归(rust/src/chat/src/renderer/hf/template.rs):在 tests 模块新增 test_midchain_dotted_integer_lookup,用 {{ values.0.name }} 验证 mid-chain dotted integer lookup 可被稳定编译与求值。该语法正是 GLM-5.2 模板中导致旧版 MiniJinja 编译失败的构造,测试将版本行为固化下来。

  3. 工具字段序列化语义(rust/src/chat/src/renderer/hf/mod.rs):TemplateToolDefinition 维持 typed struct(name/description/parameters/strict),仅在 strict 上追加 #[serde(skip_serializing_if = "Option::is_none")]。这样 description 为 None 时按 serde 默认行为序列化为 null,parameters 作为 JsonValue 原样保留 null,strict 缺失时省略、显式 strict: false 时保留,与 Python FunctionDefinition 的 model_dump() 语义逐字段对齐。

  4. 字段级回归测试(rust/src/chat/src/renderer/hf/mod.rs):新增 chat_template_preserves_openai_tool_field_order(断言遍历顺序为 name|description|parameters|)与 chat_template_preserves_python_optional_tool_fields(断言 description=null、parameters=null、strict 省略、strict=false 保留两组语义)。

  5. 真实模型往返验证(rust/src/chat/tests/roundtrip.rs):新增 RoundtripCase::glm52(zai-org/GLM-5.2-FP8),注册 reasoning_and_content 与 tool_call_mix 两个 fixture。测试会加载真实 tokenizer 与自带模板,渲染固定对话、解析 assistant 输出、追加历史、再渲染下一轮,最终以 980 字符、243 token、SHA-256 b3d3bfd... 与 Python 渲染结果精确一致。

文件 模块 状态 重要度
rust/src/chat/src/renderer/hf/mod.rs 模板渲染 modified 7.28
rust/src/chat/src/renderer/hf/template.rs 模板渲染 modified 6.37
rust/src/chat/tests/roundtrip.rs 往返测试 modified 5.17
rust/Cargo.toml 依赖配置 modified 3.25
rust/Cargo.lock 依赖锁文件 modified 2.69

关键符号

to_template_tools test_midchain_dotted_integer_lookup chat_template_preserves_openai_tool_field_order chat_template_preserves_python_optional_tool_fields glm52

关键源码片段

rust/src/chat/src/renderer/hf/mod.rs core-logic

核心实现文件:为 TemplateToolDefinition.strict 增加 skip_serializing_if 属性,使缺失 strict 被省略而 strict=false 保留;同时新增两个字段级回归测试锁定字段顺序与缺失语义。

// TemplateToolDefinition 是工具函数在 Jinja 模板上下文中的投影。
// 字段声明顺序即模板 items() 遍历顺序:name -> description -> parameters -> strict,
// 依赖 MiniJinja 2.24 的 preserve_order 特性与上游对 Serde struct 顺序的修复。
#[derive(Debug, Serialize)]
struct TemplateToolDefinition {
    name: String,
    // 缺失的 description 序列化为 null 而非丢字段,与 Python model_dump() 行为对齐
    description: Option<String>,
    // parameters 保留原始 JSON 值,null 参数维持 null
    parameters: JsonValue,
    // 缺失的 strict 直接省略;显式 strict: false 时保留,二者均与 Python 语义一致
    #[serde(skip_serializing_if = "Option::is_none")]
    strict: Option<bool>,
}/// 回归测试:模板遍历工具字段时,迭代顺序必须严格为 name|description|parameters|
#[test]
fn chat_template_preserves_openai_tool_field_order() {
    let mut request = sample_request(vec![ChatMessage::text(ChatRole::User, "hello")]);
    let tools = vec![ChatTool {
        name: "get_weather".to_string(),
        description: Some("Get weather".to_string()),
        parameters: serde_json::json!({"type": "object"}),
        strict: None,
    }];
    request.tool_context = crate::request::ResolvedToolContext::new(
        &request.messages,
        tools,
        Some(ChatToolChoice::Auto),
        true,
    )
    .expect("tool context should resolve");    let rendered = render(
        Some("{% for key, value in tools[0].function.items() %}{{ key }}|{% endfor %}"),
        &request,
    )
    .unwrap();    assert_eq!(rendered, "name|description|parameters|");
}
rust/src/chat/src/renderer/hf/template.rs core-logic

模板编译链路所在文件,新增 mid-chain dotted integer lookup 回归测试,固化 MiniJinja 2.24 对该语法的支持,防止 GLM-5.2 模板编译期回退。

/// 回归测试:模板中段出现的点号后整数索引(values.0.name)语法。
/// MiniJinja 2.18 会在此处报 unexpected float, expected identifier or integer,
/// 导致 GLM-5.2 自带模板编译失败;该测试把语法能力固化在 2.24 版本上。
#[test]
fn test_midchain_dotted_integer_lookup() {
    let template = CompiledChatTemplate::new(
        "{{ values.0.name }}".to_string(),
        ChatTemplateContentFormatOption::Auto,
    )
    .unwrap();
    let mut kwargs = HashMap::new();
    kwargs.insert("values".to_string(), serde_json::json!([{"name": "first"}]));    let result = template
        .apply(TemplateContext {
            template_kwargs: Some(&kwargs),
            ..Default::default()
        })
        .unwrap();    assert_eq!(result, "first");
}
rust/src/chat/tests/roundtrip.rs test-coverage

集成测试:新增 GLM-5.2 真实模型往返用例,覆盖 reasoning+content 与混合工具调用两种 fixture,验证渲染、解析、历史追加的完整链路与 Python 一致。

/// GLM-5.2 XML-like 工具参数格式,带 `thinking` 推理标签。
/// 与 GLM-4.5/4.7 同族,但内嵌模板依赖 mid-chain dotted integer lookup 语法。
fn glm52() -> Self {
    Self {
        model_id: "zai-org/GLM-5.2-FP8",
        assistant_stop_suffix: "",
        tool_call_parser: ParserSelection::Auto,
        reasoning_parser: ParserSelection::Auto,
        thinking_behavior: ThinkingBehavior::Toggleable { default: true },
        json_fmt: compact_json_fmt(),
        sort_json_keys: false,
    }
}// 注册到宏生成的 roundtrip_glm52 测试:覆盖纯文本 + 推理内容、
// 以及混合工具调用两种 fixture,渲染固定对话、解析输出、追加历史再渲染下一轮。
roundtrip_tests! {
    // ...
    glm52 => [reasoning_and_content, tool_call_mix],
    // ...
}

评论区精华

工具字段顺序与 Python 前端不一致 设计

BugenZhao 指出序列化 Rust tool struct 后字段顺序变成 description、name、parameters,而 OpenAI 请求与 Python 前端保持 name、description、parameters。

结论:确认 minijinja v0.24 已修复(mitsuhiko/minijinja#920),升级依赖并继续使用 typed struct 而非手写 map。 · 已解决

缺失 description 应序列化为 null 而非丢字段 正确性

Codex review 提出 P1 意见:早期 manual-map 实现会在 description 缺失时丢弃字段,而 Python 前端 model_dump() 保留 description=null,影响 tool_chat_template_functiongemma.jinja 等区分 undefined 与 null 的模板。

结论:最终 typed projection 让 description=None 序列化为 null,同时省略缺失 strict,两种语义同时成立。 · 已解决

Codex 自动触发 review 的元讨论 other

WoosukKwon 澄清 @codex review 请求也是 Codex 自己发出的,BugenZhao 回应其上一次 review 请求同样由 Codex 发送。

结论:无代码影响,仅说明该 PR 的 review 均由自动化代理执行。 · 已结束

风险与影响

  1. 依赖升级面:MiniJinja 2.22 → 2.24 影响 rust/src/chat 下全部模板编译与渲染路径,不只 GLM-5.2。虽然 HF renderer 套件 76/76 通过,但其他模型的模板若依赖旧版本边界行为,可能在升级后产生细微差异。

  2. 字段顺序隐式依赖 struct 定义:preserve_order 按 Serde struct 声明顺序输出,若后续有人在 TemplateToolDefinition 中调整字段位置,会静默改变所有工具类模板的 prompt,需靠新增的字段顺序测试兜底。

  3. 序列化语义变更:缺失 description 从“丢弃字段”变为“输出 null”,对已依赖旧行为的模板是 prompt 变化;这是本 PR 有意为之,但接入方需要回归验证线上模板。

  4. 真实模型测试的稳定性:roundtrip_glm52 需要从 HF 下载 zai-org/GLM-5.2-FP8 的 tokenizer 与模板,CI 网络或缓存问题可能导致测试不稳定(与 kimi_k3 用例对 HF_HOME 的依赖类似)。

  5. 协议同步风险:字段顺序与 Python 的 tool model_dump() 对齐,但若 Python 侧协议字段定义顺序调整,Rust 侧需同步,目前靠测试约束而非自动校验。

影响范围集中在 Rust 前端的 chat 模板渲染链(rust/src/chat/src/renderer/hf)。直接收益是 GLM-5.2 在 Rust 前端可正常编译模板并产出与 Python 完全一致的 prompt(980 字符、243 token、SHA-256 相同),工具函数字段顺序与缺失语义在两种前端间可互换。对团队而言,本 PR 建立了“升级依赖 + 字段级回归 + 真实模型 roundtrip + 哈希比对”的渲染一致性验证范式,后续新增模型或升级 MiniJinja 时可复用。对系统整体影响较小,不涉及 Python 侧与核心调度路径。

依赖升级覆盖全部模板渲染路径 字段顺序依赖 struct 定义顺序 真实下载依赖导致 CI 不稳定 序列化语义变更影响既有模板

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论