Prhub

#43582 [Rust Frontend] Add reasoning/tool parser & renderer roundtrip tests

原始 PR 作者 BugenZhao 合并时间 2026-05-27 08:49 文件变更 11 提交数 6 评论 0 代码增减 +671 / -43

执行摘要

为 Rust 聊天前端添加 HF 模板往返测试,并修复解析器和数值精度

PR 描述指出:添加 Rust 聊天文本级别往返集成覆盖,以验证渲染器、输出处理器和解析器流水线的正确性。通过覆盖真实 HF 模板修复了 JSON 对象插入顺序和任意精度数值保留,以及 MiniMax M2 工具解析器对模板空白和流式分割的容忍性。

该 PR 值得精读,特别是往返测试的设计模式和解析器重构手法。对于涉及工具调用和推理标签的模型集成,展示了如何构建端到端的测试验证。

讨论亮点

PR 没有收到人类审核者的评论,只有 gemini-code-assist[bot] 的自动评论概述了变更,无具体讨论。

实现拆解

  1. 新增往返测试框架rust/src/chat/tests/roundtrip.rs 定义 RoundtripCase 结构体,配置模型 ID、助手停止后缀、解析器和 JSON 格式。使用宏生成多个测试用例,涵盖推理+内容混合和工具调用混合场景。
  2. 修复 MiniMax M2 工具解析器rust/src/tool-parser/src/minimax_m2.rs 中将参数解析从 invoke_event 内联拆分为独立 parse_invoke_params 函数,使用 take_until 捕获完整 invoke body 再解析参数,新增 partial_attr_value 处理流式分割的属性值。
  3. 修复 JSON 数值精度rust/src/tool-parser/src/parameters.rs 中修改 convert_number 优先使用 serde_json::from_str::<Number> 解析以保留原始拼写(如 1.00),降级解析仅作为回退。
  4. 添加 trim 辅助rust/src/chat/src/event.rsAssistantContentBlockAssistantMessage 添加 trim 方法,清理前后空白,为空块返回 None
  5. 修复 tojson filterrust/src/chat/src/renderer/hf/tojson.rs 中修改 hf_tojson_filter 接受 ViaDeserialize<JsonValue> 并启用 preserve_orderarbitrary_precision 特性,以保持对象键顺序和数值拼写。
  6. 启用依赖并调整测试:在多个 Cargo.toml 中添加 pasteserial_test 等依赖,启用 preserve_orderarbitrary_precision。取消一些原被忽略的测试(如 Qwen3 生成默认值测试)。
文件 模块 状态 重要度
rust/src/chat/tests/roundtrip.rs 测试 added 8.05
rust/src/tool-parser/src/minimax_m2.rs 工具解析器 modified 7.96
rust/src/tool-parser/src/parameters.rs 工具解析器 modified 7.42
rust/src/chat/src/event.rs 聊天事件 modified 6.62
rust/src/chat/src/renderer/hf/tojson.rs 聊天渲染器 modified 6.58
rust/src/text/src/lower.rs 文本后端 modified 5.78
rust/Cargo.toml 构建配置 modified 3.41

关键符号

RoundtripCase::qwen3 RoundtripCase::qwen35 RoundtripCase::minimax_m25 RoundtripCase::deepseek_v4 RoundtripCase::glm47 invoke_event parse_invoke_params parameter attr_value partial_attr_value convert_number AssistantContentBlock::trim AssistantMessage::trim hf_tojson_filter

关键源码片段

rust/src/chat/tests/roundtrip.rs test-coverage

新增的往返测试文件,是 PR 的核心部分,定义了测试框架和针对 6 个模型的 12 个测试用例。

//! Text-level roundtrip tests for the real chat-template and output-processor pairing.
//! The invariant under test is that a structured assistant message rendered as history can be
//! parsed from the generated assistant completion and then rendered back to the exact same text./// One model/parser configuration used to run the fixed roundtrip fixtures.
struct RoundtripCase {
    /// Hugging Face model id resolved through the production backend loader.
    model_id: &'static str,
    /// Final assistant-history suffix rendered by the chat template but not
    /// generated by the model body (consumed by the output processor).
    assistant_stop_suffix: &'static str,
    /// Tool parser selection used by the output processor.
    tool_call_parser: ParserSelection,
    /// Reasoning parser selection used by the output processor.
    reasoning_parser: ParserSelection,
    /// JSON formatting expected after this model's template has materialized tool-call arguments.
    json_fmt: JsonFmt,
}impl RoundtripCase {
    /// Qwen3 XML tool-call format with `qwen3` reasoning tags.
    fn qwen3() -> Self {
        Self {
            model_id: "Qwen/Qwen3-0.6B",
            assistant_stop_suffix: "<|im_end|>\n",
            tool_call_parser: ParserSelection::Auto,
            reasoning_parser: ParserSelection::Auto,
            json_fmt: spaced_json_fmt(), // Qwen3 uses spaced JSON
        }
    }
    // Similar cases for qwen35, minimax_m25, deepseek_v4, glm47, kimi_k25
}// Macro to generate test functions for each (case, fixture) pair.
macro_rules! roundtrip_tests {
    ($($case:ident => [$($fixture:ident),* $(,)?]),+ $(,)?) => {
        paste::paste! {
            $(
                $(
                    #[tokio::test]
                    #[file_serial([<hf_ $case>])]
                    async fn [<roundtrip_ $case _ $fixture>]() -> Result<()> {
                        [<run_roundtrip_ $fixture>](RoundtripCase::$case()).await
                    }
                )*
            )+
        }
    };
}roundtrip_tests! {
    qwen3 => [reasoning_and_content, tool_call_mix],
    qwen35 => [reasoning_and_content, tool_call_mix],
    minimax_m25 => [reasoning_and_content, tool_call_mix],
    deepseek_v4 => [reasoning_and_content, tool_call_mix],
    glm47 => [reasoning_and_content, tool_call_mix],
}
rust/src/tool-parser/src/minimax_m2.rs core-logic

修复了 MiniMax M2 解析器以处理模板空白和流式分割的参数,核心逻辑变更。

/// Parse a complete MiniMax M2 invoke block.
fn invoke_event(input: &mut MinimaxM2Input<'_>) -> ModalResult<MinimaxM2Event> {
    let (name, body) = seq!(
        _: ws0,
        _: literal(INVOKE_START),
        _: (ws1, literal("name=")),
        partial_attr_value, // Use partial-aware version for streaming
        _: literal(">"),
        take_until(0.., INVOKE_END), // Take entire body for later parsing
        _: literal(INVOKE_END),
    )
    .parse_next(input)?;
    // Parse parameters from the complete body, handling whitespace around params
    let raw_params = parse_invoke_params(body)?;    Ok(MinimaxM2Event::Invoke {
        name: name.trim().to_string(),
        raw_params,
    })
}/// Parse all parameter blocks inside a complete MiniMax M2 invoke body.
fn parse_invoke_params(invoke_body: &str) -> ModalResult<Vec<(String, String)>> {
    let mut input = invoke_body;
    // Use `eof` to ensure we consume all input, tolerant of whitespace
    delimited(ws0, repeat(0.., terminated(parameter, ws0)), eof)
        .parse_next(&mut input)
}/// Parse a MiniMax M2 parameter block (on a non-partial &str, so take_until works on complete slice).
fn parameter(input: &mut &str) -> ModalResult<(String, String)> {
    let (name, value) = seq!(
        _: literal(PARAMETER_START),
        _: (ws1, literal("name=")),
        attr_value,
        _: literal(">"),
        take_until(0.., PARAMETER_END).map(xml_unescape),
        _: literal(PARAMETER_END),
    )
    .parse_next(input)?;
    Ok((name.trim().to_string(), value.into_owned()))
}/// Parse a quoted or unquoted XML attribute value from partial streaming input.
fn partial_attr_value<'i>(input: &mut MinimaxM2Input<'i>) -> ModalResult<&'i str> {
    alt((
        delimited(literal("\""), take_until(1.., "\""), literal("\"")),
        delimited(literal("'"), take_until(1.., "'"), literal("'")),
        take_until(1.., ">"), // unquoted attribute ends at '>'
    ))
    .parse_next(input)
}
rust/src/tool-parser/src/parameters.rs core-logic

修复了 JSON 数值转换以保留原始拼写,例如 `1.00` 保持为 `1.00` 而非 `1.0`。

/// Convert one raw string value to a JSON number.
fn convert_number(value: &str) -> Option<Value> {
    // First attempt: parse as serde_json::Number to preserve JSON number spelling
    // (e.g., "5.00" stays as "5.00", "1e0" becomes "1e+0")
    serde_json::from_str::<Number>(value)
        // Fallback to i64 for legacy compatibility (e.g., "+1" -> 1)
        .or_else(|_| value.parse::<i64>().map(Number::from))
        // Final fallback to f64 (e.g., large numbers with decimals that fit in f64)
        .or_else(|_| value.parse::<f64>().ok().and_then(Number::from_f64).ok_or(()))
        .ok()
        .map(Value::Number)
}#[test]
fn number_conversion_preserves_json_number_spelling_with_legacy_fallback() {
    let params = ToolSchema::from_schema(&json!({
        "type": "object",
        "properties": {
            "value": { "type": "number" }
        }
    }));    assert_eq!(converted_number_text(&params, "5"), "5");
    assert_eq!(converted_number_text(&params, "5.0"), "5.0");
    assert_eq!(converted_number_text(&params, "5.00"), "5.00"); // Preserved!
    assert_eq!(converted_number_text(&params, "1e0"), "1e+0");
    assert_eq!(converted_number_text(&params, "5."), "5.0");
    assert_eq!(converted_number_text(&params, "+1"), "1"); // Legacy fallback
    assert_eq!(converted_number_text(&params, "9223372036854775807.5"), "9223372036854775807.5"); // Large value preserved
}

评论区精华

无实质性讨论 other

PR 没有收到人工审阅者的评论,只有 gemini-code-assist[bot] 的自动概述,无具体反馈。

结论:PR 被批准合并,无未解决疑虑。 · 已解决

风险与影响

  1. 回归风险:MiniMax M2 解析器从内联参数解析改为两步(先取整块 body 再解析),如果 body 内容不符合预期可能导致解析失败,但已有新测试覆盖。
  2. 数值精度变化convert_number 优先使用 serde_json::from_str 可能改变之前版本中 +1 映射为 1 的行为(现在也是 1),但 5. 会变为 5.0,测试已断言。
  3. 性能影响:新增的 trim 和解析器重构可能带来轻微额外开销,但仅在解析完成时调用,不影响流式性能。
  4. 依赖增加:新增 pasteserial_test 等依赖,可能因版本兼容性问题影响构建。
  1. 用户影响:MiniMax 模型用户将受益于更鲁棒的工具调用解析;JSON 参数保留原始数值拼写可能改进下游兼容性。
  2. 系统影响:新增 500+ 行测试代码,包含网络依赖(HF 模型),但已通过 file_serial 序列化避免并发问题。
  3. 团队影响:提供了测试模式可供其他模型参考,降低了回归风险。
解析器行为变更 数值精度兼容性 新增网络依赖测试

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论