Prhub

#44311 [Rust Frontend] Fix several hf chat template rendering issues

原始 PR 作者 BugenZhao 合并时间 2026-06-03 16:04 文件变更 9 提交数 4 评论 3 代码增减 +183 / -56

执行摘要

修复 HF chat template 渲染的数字精度和字段影子问题

HF chat template 在 Python Jinja2 中可以正常工作,但迁移到基于 MiniJinja 的 Rust 实现后出现了两个兼容性差距:启用任意精度数字时,serde_json::Value 通过 MiniJinja 序列化会泄漏 $serde_json::private::Number 内部结构(issue #641);MiniJinja 的默认 Serde map 路径会优先解析 map 中的同名字段,导致 dict.items() 方法调用被字段值覆盖(issue #903)。这两个问题影响 MiniMax M2.5 等依赖 _args.items() 的模型模板,造成渲染错误。

本 PR 值得精读,尤其关注以下要点:(1)如何在不修改 MiniJinja 核心的前提下,通过自定义 Object 和 pycompat 后门解决字段与方法冲突的设计模式;(2)全局 serde_json 特性调整时的依赖影响评估思路;(3)测试用例设计中对渲染精度妥协的明确标注。对于同样使用 MiniJinja 渲染 HF 模板的其他项目,此方案具有直接参考价值。

讨论亮点

Review 中关键讨论围绕 arbitrary_precision 移除的副作用:Codex 机器人指出该变更会影响其他依赖 serde_json::Value 的 Rust crate,例如 DeepSeek V4 的助手工具调用渲染路径中数字 1.00 会变成 1.0。PR 作者未直接回应此评论,但最终合并表明团队认为在统一序列化行为和避免 bug 之间的取舍可以接受。此外,njhill 在批准时提到希望未来能在 MiniJinja 上游彻底修复这两个兼容问题。

实现拆解

  1. 回退任意精度数字:在 rust/Cargo.toml 中移除 serde_jsonarbitrary_precision 特性,数字统一由标准 serde_json 序列化处理。之后调整 tojson.rsparameters.rs 中的测试期望值,确保不再检测原始数字格式,并增加 serialized_json_numbers_do_not_leak_serde_private_representation 测试以验证泄漏已被消除。

  2. 引入自定义 Object 类型:新增 rust/src/chat/src/renderer/hf/value.rs,定义 TemplateValue 透明包装类型和 TemplateMap 结构体。TemplateMap 实现 Object trait 时,其 call_method 始终返回 UnknownMethod,从而将 dict 方法调用导向 MiniJinja 的 pycompat unknown_method_callback,避免字段名与方法名冲突。

  3. 集成到渲染器:在 rust/src/chat/src/renderer/hf/mod.rs 中将模板上下文中的 argumentsparameters 字段类型由 serde_json::Value 改为 TemplateValue,并在构造处调用 to_template_value 进行递归转换。同时添加 chat_template_tool_call_argument_items_method_is_not_shadowed_by_field 测试用例,验证工具调用参数中包含 items 键时模板仍能正确渲染。

  4. 调整配套测试:更新 rust/src/chat/tests/roundtrip.rs 中的测试数据,为工具调用参数显式加入 items 字段,检验完整轮转中该键不会被误解析为方法调用。

文件 模块 状态 重要度
rust/src/chat/src/renderer/hf/value.rs HF 渲染器 added 8.68
rust/src/chat/src/renderer/hf/mod.rs 渲染集成 modified 7.05
rust/src/chat/src/renderer/hf/tojson.rs 序列化测试 modified 6.65
rust/src/chat/tests/roundtrip.rs 轮转测试 modified 5.05
rust/src/tool-parser/src/parameters.rs 工具解析 modified 5.37
rust/Cargo.toml 依赖配置 modified 3.17

关键符号

to_template_value TemplateMap::call_method chat_template_tool_call_argument_items_method_is_not_shadowed_by_field tojson_uses_standard_serde_json_number_spelling serialized_json_numbers_do_not_leak_serde_private_representation

关键源码片段

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

核心新增文件,定义 TemplateValue 和 TemplateMap,解决字段影子问题

// rust/src/chat/src/renderer/hf/value.rsuse std::sync::Arc;use indexmap::IndexMap;
use minijinja::value::{Enumerator, Object, ObjectExt, ObjectRepr};
use minijinja::{Error as TemplateError, ErrorKind as TemplateErrorKind, State};
use serde::Serialize;
use serde_json::Value as JsonValue;/// A transparent wrapper around `minijinja::Value` constructed via `to_template_value`.
/// It ensures that map objects use `TemplateMap` to avoid field-method shadowing.
#[derive(Debug, Serialize)]
#[serde(transparent)]
pub(super) struct TemplateValue(minijinja::Value);/// Recursively convert a `serde_json::Value` into a `TemplateValue`.
/// Arrays and primitives are passed through directly; maps are wrapped in `TemplateMap`.
pub(super) fn to_template_value(value: JsonValue) -> TemplateValue {
    TemplateValue(match value {
        JsonValue::Array(values) => values
            .into_iter()
            .map(to_template_value)
            .map(|value| value.0)
            .collect::<minijinja::Value>(),
        JsonValue::Object(values) => minijinja::Value::from_object(TemplateMap(
            values
                .into_iter()
                .map(|(key, value)| (key, to_template_value(value).0))
                .collect(),
        )),
        // For primitive values, use `from_serialize` which now uses standard number representation.
        value => minijinja::Value::from_serialize(value),
    })
}// Custom Object implementation that forwards field access but always returns UnknownMethod
// for method calls, ensuring that pycompat's unknown_method_callback is used instead.
#[derive(Debug)]
struct TemplateMap(IndexMap<String, minijinja::Value>);impl Object for TemplateMap {
    fn repr(self: &Arc<Self>) -> ObjectRepr {
        ObjectRepr::Map
    }    fn get_value(self: &Arc<Self>, key: &minijinja::Value) -> Option<minijinja::Value> {
        self.0.get(key.as_str()?).cloned()
    }    fn get_value_by_str(self: &Arc<Self>, key: &str) -> Option<minijinja::Value> {
        self.0.get(key).cloned()
    }    fn enumerate(self: &Arc<Self>) -> Enumerator {
        self.mapped_rev_enumerator(|this| {
            Box::new(this.0.keys().map(|key| minijinja::Value::from(key.as_str())))
        })
    }    fn enumerator_len(self: &Arc<Self>) -> Option<usize> {
        Some(self.0.len())
    }    fn call_method(
        self: &Arc<Self>,
        _state: &State<'_, '_>,
        _method: &str,
        _args: &[minijinja::Value],
    ) -> std::result::Result<minijinja::Value, TemplateError> {
        // Always return UnknownMethod so that Python dict methods like `items()` are handled
        // by MiniJinja's pycompat rather than being shadowed by a map field with the same name.
        Err(TemplateError::from(TemplateErrorKind::UnknownMethod))
    }
}
rust/src/chat/src/renderer/hf/tojson.rs core-logic

测试模块更新,验证数字序列化不再泄漏内部表示并调整期望值

// rust/src/chat/src/renderer/hf/tojson.rs (tests 模块 )#[test]
fn tojson_uses_standard_serde_json_number_spelling() {
    let payload = serde_json::from_str(r#"{"x":2,"y":1.00}"#).unwrap();
    let rendered = render("{{ payload|tojson }}", payload);
    // 启用了 arbitrary_precision 时会被序列化为 {"x": 2, "y": 1.00},
    // 但现在使用标准序列化,`1.00` 被规范化为 `1.0`。
    assert_eq!(rendered, r#"{"x": 2, "y": 1.0}"#);
}#[test]
fn serialized_json_numbers_do_not_leak_serde_private_representation() {
    let payload: serde_json::Value = serde_json::from_str(r#"{"x":2,"y":1.00}"#).unwrap();
    let rendered = render("{{ payload }}", payload);
    // 确保不再泄漏 `$serde_json::private::Number` 内部结构。
    assert!(!rendered.contains("$serde_json::private::Number"));
    assert_eq!(rendered, r#"{"x": 2, "y": 1.0}"#);
}

评论区精华

移除 arbitrary_precision 的副作用与作用域 设计

Codex 机器人指出该变更会影响其他依赖 serde_json::Value 的 crate(如 DeepSeek V4),建议限定作用域。

结论:PR 已合并,说明团队接受了当前全局移除的权衡。 · 已解决

对上游 MiniJinja 修复的期望 other

njhill 在批准时表示希望 Codex 能帮助 MiniJinja 上游修复这两个兼容性问题。

结论:未直接解决,已作为后续跟踪。 · unresolved

风险与影响

核心风险在于移除 arbitrary_precision 属于全局依赖变更,可能影响所有通过 serde_json::Value 进行序列化的 Rust crate,包括 DeepSeek V4 等非 HF 渲染路径。具体表现为数字精度丢失(如 1.00 变为 1.01e0 变为 1.0),如果某些模型依赖精确的数字文本格式,渲染结果可能与 Python 不一致。此外,TemplateMap 属于新代码,虽然测试覆盖,但仍存在未发现的边界情况。测试用例中注释掉的部分(如精度保留断言)表明已知的妥协。

影响范围:Rust 前端的 HF chat template 渲染模块,涉及所有使用 Rust 渲染器的部署(目前主要影响 vLLM v1 架构的 Rust 重写路径)。影响用户:使用 Hugging Face 格式 chat template 且包含工具调用的模型,特别是 MiniMax M2.5、Qwen 系列等依赖 arguments.items() 的模型。影响程度:修复了阻碍正常使用的 bug,故对受影响模型是正向影响;数字格式标准化后无功能性断变,仅文本表示略有调整,预计用户无感知。

全局依赖变更 精度丢失 可能影响其他渲染器 测试中妥协

关联 Issue

#641 minjinja does not seem to support `serde_json` arbitrary-precision number types
#903 Python dict methods are shadowed by same-named map keys in `Object::call_method`

完整报告

参与讨论