Prhub

#45025 [Bugfix][Rust Frontend] Stop unescaping XML-style tool-call parameter values

原始 PR 作者 Sunt-ing 合并时间 2026-06-11 10:05 文件变更 6 提交数 2 评论 0 代码增减 +36 / -120

执行摘要

移除 Rust 工具解析器中错误的 XML 转义解码

PR描述指出渲染器(DeepSeek DSML renderer、MiniMax-M2 chat template等)将参数值原样输出,但解析器却对值进行 XML 解码,导致 &amp; 变成 &&lt; 变成 <,与 Python 前端行为不一致。例如 write_file 参数 Tom &amp; Jerry &lt;3 被错误解码为 Tom & Jerry <3。需要移除解码以保持原始内容。

值得精读,尤其是理解前端渲染-解析一致性的重要性。该 PR 展示了如何识别和修复因渲染与解析协议不匹配导致的隐蔽数据损坏。设计决策(移除而非修复unescape)经过充分验证。

讨论亮点

审阅者 BugenZhao 评论:"I agree that as long as there's no escaping in the chat template, we should not assume that the model will escape the arguments spontaneously. Removing unescaping in tool parser LGTM." 没有任何争议或未解决疑虑。

实现拆解

  1. rust/src/tool-parser/src/utils.rs 中删除 xml_unescapedecode_xml_entity 函数及其相关的五个单元测试,同时移除不再需要的 use std::borrow::Cow 导入。
  2. 在四个解析器文件(minimax_m2.rsglm_xml/mod.rsqwen_coder.rsdeepseek_dsml/mod.rs)中移除对 xml_unescape 的导入,并删除参数值解析链中的 .map(xml_unescape) 调用,直接使用 take_until 返回的原始字符串转换为 String
  3. 更新 deepseek_dsml/deepseek_v32.rs 中的参数解析调用,改用 str::to_string 替代 xml_unescape
  4. minimax_m2.rsglm_xml/mod.rsqwen_coder.rsdeepseek_dsml/deepseek_v32.rs 中修改对应的测试用例:将原本断言 unescape 后内容的测试改为断言原始实体文字(如 &amp;&lt;)被保留,并新增 preserves_raw_entities 测试验证文字实体不转义。
文件 模块 状态 重要度
rust/src/tool-parser/src/utils.rs 工具解析器 modified 8.34
rust/src/tool-parser/src/minimax_m2.rs 工具解析器 modified 7.37
rust/src/tool-parser/src/glm_xml/mod.rs 工具解析器 modified 6.53
rust/src/tool-parser/src/qwen_coder.rs 工具解析器 modified 6.53
rust/src/tool-parser/src/deepseek_dsml/deepseek_v32.rs 工具解析器 modified 5.69
rust/src/tool-parser/src/deepseek_dsml/mod.rs 工具解析器 modified 5.23

关键符号

xml_unescape decode_xml_entity minimax_m2::parameter glm_xml::parse_parameter qwen_coder::parameter deepseek_dsml::parse_parameter

关键源码片段

rust/src/tool-parser/src/utils.rs core-logic

核心文件,删除 xml_unescape 和 decode_xml_entity 函数及其测试,是整个修复的基础。

// rust/src/tool-parser/src/utils.rs
// 删除 xml_unescape 和 decode_xml_entity 后,不再需要 std::borrow::Cow
use winnow::error::{ContextError, ErrMode, ModalResult, Needed, StrContext, StrContextValue};
use winnow::stream::{Offset, Partial, Stream};use super::Result;// (partial_prefix_len、safe_text_len 等函数保持不变 )/// Streaming lexical state for a top-level JSON object.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(super) struct JsonObjectScanState {
    // ...
}// [ 删除约 120 行,包括 xml_unescape 和 decode_xml_entity 函数以及五个测试 ]
rust/src/tool-parser/src/minimax_m2.rs core-logic

MiniMax-M2 解析器,参数值解析中移除了 xml_unescape,并更新测试验证原始实体保留。

// rust/src/tool-parser/src/minimax_m2.rs/// 解析 MiniMax M2 参数块:不再对参数值进行 xml_unescape,保持原始内容
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.to_string()))
}// 测试:确认原始实体(如 &amp;)被保留,不转义
#[test]
fn minimax_m2_parse_complete_preserves_raw_entities_in_parameter_value() {
    let mut parser = MinimaxM2ToolParser::new(&test_tools());
    let output = parser
        .parse_complete(&build_tool_block(&[(
            "get_weather",
            vec![("city", "Tom &amp; Jerry &lt;3")],
        )]))
        .unwrap();
    let args: Value = serde_json::from_str(&output.calls[0].arguments).unwrap();
    assert_eq!(args["city"], json!("Tom &amp; Jerry &lt;3"));
}
rust/src/tool-parser/src/glm_xml/mod.rs core-logic

GLM 解析器,参数值解析中移除 xml_unescape,更新对应测试。

// rust/src/tool-parser/src/glm_xml/mod.rs/// 解析 GLM 参数键值对:不再对参数值进行 xml_unescape
fn parse_parameter(input: &mut &str) -> ModalResult<(String, String)> {
    let (key, value) = seq!(
        _: literal(ARG_KEY_START),
        take_until(1.., ARG_KEY_END),
        _: literal(ARG_KEY_END),
        _: ws0,
        _: literal(ARG_VALUE_START),
        take_until(0.., ARG_VALUE_END).map(str::trim), // 仅修剪空白,不转义
        _: literal(ARG_VALUE_END),
    )
    .parse_next(input)?;
    Ok((key.trim().to_string(), value.to_string()))
}

评论区精华

移除 unescape 的合理性 正确性

审阅者 BugenZhao 指出只要 chat template 没有转义,就不应假设模型自发转义参数,移除 unescape 正确。

结论:同意 PR 变更,认为修复正确。 · 已解决

风险与影响

变更非常安全:只移除了错误的 unescape 操作,不引入新功能或修改其他路径。四个解析器的参数值提取逻辑改为直接保留原始字符串,与Python前端行为一致,不存在回归风险。但需确保上游chat template确实不进行 XML 转义(当前已验证)。

影响范围仅限使用 Rust 前端的用户,且仅涉及 minimax_m2、qwen_coder、glm_xml 和 deepseek_dsml 四种工具调用解析。修复后参数值将保持模型输出的原始文本,不再被错误解码;与Python前端行为统一。用户无需修改代码,变更无缝生效。

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论