Prhub

#56593 [Bugfix][Rust Frontend] Honor `add_generation_prompt` in DeepSeek V3.2/V4/V4.1 renderers

原始 PR 作者 YeonwooSung 合并时间 2026-09-15 18:03 文件变更 5 提交数 7 评论 12 代码增减 +217 / -24

执行摘要

修复 Rust 前端 DeepSeek 渲染器在关闭 generation prompt 时仍追加 后缀的 bug,覆盖 V3.2/V4/V4.1 三个模型系。

Rust 前端 DeepSeek 渲染器此前总是无条件地在最后一个 user-like 轮次后追加 及 thinking/response 标签,即使 API 调用方显式设置了add_generation_prompt=false`。HF、Kimi K3、Inkling 等渲染器均已正确遵守该标志。这导致意图用于续写或评估的请求被注入了多余的模型输入,与 Python 端 DeepSeek 行为不一致。PR 作者在正文中明确指出:'The Rust DeepSeek chat renderers always appended after a last user-like turn even when add_generation_prompt is false',并说明这不是 #46257(Python 端 tokenizer 修复)的重复工作。

值得精读。该 PR 展示了如何将一次看似微小的 bug 修复做成跨三个模型版本的一致行为修正:核心不在于改了几行代码,而在于'判断下一个渲染条目'这一抽象的正确建模,以及用测试锁定每一个分支。对关注 Rust 前端渲染一致性或 DeepSeek 系列 agentic 行为(tool calling、thinking 标签)的开发者有直接参考价值。

讨论亮点

评审中 BugenZhao 提出核心意见:'Would you also cover DSV4/4.1 in this PR? I believe they have the same issue.' 作者随即扩展了改动范围,并解释了选择 next-rendered 判定而非 raw index 的原因:V4 会把尾部 tool result 合并进当前 user block,直接比较索引会误判。Codex 自动审查未发现重大问题。整体上没有激烈的争议,讨论聚焦在行为一致性和边界 case 覆盖。

实现拆解

变更分四步落地:

  1. 在 render_request 入口读取配置并逐层传递:deepseek_v32/encoding.rs 的 render_request 增加 let add_generation_prompt = request.chat_options.add_generation_prompt();,将其作为参数传入 render_message,再由 render_message 转发给 render_developer_message / render_user_message;deepseek.rs 中 V4/V4.1 共用路径则在主循环内直接读取该标志。
  2. V3.2 的后缀逻辑修正:write_user_like_suffix 新增 add_generation_prompt: bool 参数,在 false 时直接 return 不再写 与 thinking/ response;render_message里同样用let add_generation_prompt = message_index + 1 != messages.len() || add_generation_prompt;` 确保非最后一条消息时始终追加,最后一条消息时遵从显式设置。
  3. V4/V4.1 的下一渲染条目判定重构:deepseek.rs 将原先返回 bool 的 next_rendered_entry_is_assistant_or_end 重构为返回 Option<&ChatMessage> 的 next_rendered_entry,跳过被合并的尾部 tool result 与历史 developer 消息后取下一个真实渲染条目,据此判断应写 transition(下一个是 assistant)还是不写(下一个是结尾且 flag 关闭)还是返回 false(下一个是其他类型)。
  4. 测试补充:三个测试文件新增 9 个用例,覆盖默认追加、禁用不追加、user 后接 assistant 仍追加、V4 尾部 system、合并 tool result、developer 消息,以及 V4.1 的 mid-system 与 chat 模式场景,全部为 expect! 精确断言。
文件 模块 状态 重要度
rust/src/chat/src/renderer/deepseek.rs 渲染器 modified 7.63
rust/src/chat/src/renderer/deepseek_v32/encoding.rs V3.2 渲染 modified 6.79
rust/src/chat/src/renderer/deepseek_v4/tests.rs 渲染测试 modified 7.87
rust/src/chat/src/renderer/deepseek_v41/tests.rs 渲染测试 modified 7.25
rust/src/chat/src/renderer/deepseek_v32/tests.rs 渲染测试 modified 7.03

关键符号

write_user_like_suffix render_message render_request next_rendered_entry

关键源码片段

rust/src/chat/src/renderer/deepseek.rs core-logic

V4/V4.1 共用渲染逻辑所在文件,核心重构点:将 next_rendered_entry_is_assistant_or_end 改为返回 Option<&ChatMessage> 的 next_rendered_entry,使 add_generation_prompt 的语义在最后一个渲染条目上生效。

/// 返回 message_index 之后的下一个渲染条目。
///
/// 跳过被合并的 user 内容尾部和被丢弃的历史 developer 消息,
/// 以便调用方区分后续 assistant 轮次与会话结束两种情况。
fn next_rendered_entry(
    messages: &[ChatMessage],
    message_index: usize,
    drop_historical_developers: bool,
    last_user_like_message_index: Option<usize>,
) -> Option<&ChatMessage> {
    let mut next_index = message_index + 1;
    // 跳过紧随其后的连续 user 内容块,以及被策略丢弃的历史 developer 消息
    while next_index < messages.len()
        && (is_following_user_content(messages, next_index)
            || (drop_historical_developers
                && is_historical_developer(
                    &messages[next_index],
                    next_index,
                    last_user_like_message_index,
                )))
    {
        next_index += 1;
    }
​
    messages.get(next_index)
}
​
// 主渲染循环中对每个 user-like 入口的判定:
if is_user_like_entry(message, current_render_index as usize, dialect)
    || (dialect == DsDialect::V4 && matches!(message, ChatMessage::System { .. }))
{
    // 仅当下一个渲染条目是 assistant 时总是写 transition;是会话末尾时
    // 取决于 add_generation_prompt 配置;是其他类型时不写
    let should_write_transition = match next_rendered_entry(
        request.messages.as_slice(),
        message_index,
        drop_historical_developers,
        last_user_like_message_index,
    ) {
        Some(ChatMessage::Assistant { .. }) => true,
        None => request.chat_options.add_generation_prompt(),
        Some(_) => false,
    };
    if should_write_transition {
        write_assistant_transition(
            &mut out,
            thinking_mode,
            drop_thinking,
            current_render_index >= last_user_render_index,
        );
    }
}
rust/src/chat/src/renderer/deepseek_v32/encoding.rs core-logic

V3.2 渲染器入口,write_user_like_suffix 新增 add_generation_prompt 参数并在 false 时跳过后缀,是本 PR 修复的另一半核心逻辑。

/// Shared trailing wrapper used by both real user turns and native developer
/// turns after their content has already been written.
fn write_user_like_suffix(out: &mut String, opens_thinking: bool, add_generation_prompt: bool) {
    // 关闭 generation_prompt 时不再输出后缀,避免向模型注入多余的生成提示
    if !add_generation_prompt {
        return;
    }
    out.push_str("</|assistant|>");
    if opens_thinking {
        // 最后一条 user 轮次开启 thinking 模式时,追加 thinking 起始标记
        out.push_str(THINKING_START_TOKEN);
    } else {
        out.push_str(THINKING_END_TOKEN);
    }
}
​
fn render_message(
    // ... 省略前置参数 ...
    add_generation_prompt: bool,
) -> Result<()> {
    let render_index = message_index as isize + render_offset;
    let opens_thinking = render_index == last_user_render_index;
    let after_last_user_turn = render_index > last_user_render_index;
    // 非最后一条消息始终追加后缀,最后一条消息时才受 add_generation_prompt 约束
    let add_generation_prompt = message_index + 1 != messages.len() || add_generation_prompt;
​
    match message {
        ChatMessage::User { content } => render_user_message(
            out,
            content,
            thinking_mode == ThinkingMode::Thinking && opens_thinking,
            add_generation_prompt, // 将标志传递给后缀写入函数
        ),
        // 其他分支省略,逻辑保持一致
    }
}
rust/src/chat/src/renderer/deepseek_v4/tests.rs core-logic

V4 渲染器测试目录,新增多个用例验证 add_generation_prompt=false 时最后一个 user-like 轮次(含合并 tool result、developer、trailing system 场景)不再输出后缀。

#[test]
fn last_merged_tool_result_omits_generation_prompt_when_disabled() {
    let mut request = thinking_request(vec![
        ChatMessage::user("question"),
        // 尾部 tool result 会被合并进当前 user 块,因此不能依赖原始消息索引判断最后一个渲染条目
        ChatMessage::tool_response("result", "call-1"),
    ]);
    request.chat_options.generation_prompt_mode = GenerationPromptMode::NoGenerationPrompt;
​
    let rendered = render_request(&request);
​
    // 期望输出中没有 后缀,表示 transition 被正确省略
    expect!["用户问题\n\n<tool_result>result</tool_result>"].assert_eq(&rendered);
    assert!(!rendered.contains("</|assistant|>"));
}
​
#[test]
fn last_user_turn_omits_generation_prompt_when_disabled() {
    let mut request = thinking_request(vec![ChatMessage::user("Hello")]);
    request.chat_options.generation_prompt_mode = GenerationPromptMode::NoGenerationPrompt;
​
    let rendered = render_request(&request);
​
    // 最后一个 user 轮次时,add_generation_prompt=false 不再输出 与 thinking 标签
    assert!(rendered.ends_with("用户 Hello"));
    assert!(!rendered.contains("</|assistant|>"));
    assert!(!rendered.contains(" thinking"));
}

评论区精华

DSV4/4.1 是否也需要同样的修复 设计

BugenZhao 在评审中提出:'Would you also cover DSV4/4.1 in this PR? I believe they have the same issue.' 作者回应并扩展了改动范围,说明 V4/V4.1 共享 write_assistant_transition 且需要按 next-rendered 判定,因为 V4 把尾部 tool result 合并进当前 user block,不能按原始索引判断。

结论:确定将 V4/V4.1 纳入同一修复,并新增相应测试用例。 · 已解决

Codex 自动审查 other

Codex review 对 commit cbfa5c7 完成审查,结论为未发现重大问题,无异见。

结论:无后续动作。 · 已解决

风险与影响

主要风险集中在两点:一是行为变更范围覆盖所有使用 Rust 前端的 DeepSeek 系列用户,关闭 generation prompt 后 prompt 尾部不再出现 ],依赖该符号做输出的下游解析(评估脚本、客户端工具、日志)可能受到影响;二是next_rendered_entry` 的跳过逻辑(is_following_user_content 与 is_historical_developer 的组合)若理解有偏差,可能把本应继续对话的 assistant 轮次误判为会话末尾,导致后缀被错误省略。不过默认行为(add_generation_prompt=true 或继续对话场景)不受影响,且新增测试覆盖了主要分支,风险可控。

影响面为 vllm Rust 前端(LLM serve 场景)下 DeepSeek V3.2/V4/V4.1 的 prompt 渲染链路。对默认生成调用无行为变化;对显式设置 add_generation_prompt=false 的调用方,输出与 HF 及 Python tokenizer 对齐,消除了跨端不一致。团队维护上,后续新增 DeepSeek 系模型时需遵循同样的渲染模式,避免再次出现行为漂移。

行为变更影响全部 DeepSeek 系列用户 新增用例覆盖边界场景 回归风险低 与 Python 端对齐

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论