Prhub

#56366 [Bugfix][Rust Frontend][Multimodal] Align DeepSeek V4.1 and Kimi K3 media with rendered placeholders

原始 PR 作者 reidliu41 合并时间 2026-09-14 18:38 文件变更 18 提交数 4 评论 3 代码增减 +415 / -85

执行摘要

Rust 前端媒体提取改按渲染占位符顺序,修复图片错配工具结果

PR body 明确指出两个相互独立的顺序来源:"Rust chat rendering and multimodal extraction previously determined media order independently"。DeepSeek V4.1 与 Kimi K3 会把工具结果重排成 preceding assistant tool-call 的顺序,渲染后的图片占位符跟着 tool-call 顺序走,而媒体提取仍按原始请求顺序,于是 "images could be silently associated with the wrong tool results";另外 DeepSeek V4.1 可以省略历史 developer 消息,这些消息里的图片仍被提取,但没有对应占位符,导致 "multimodal placeholder expansion to fail"。维护者 BugenZhao 也在 review 中确认这是个 "good catch"。

值得精读,尤其适合维护 Rust 前端渲染器或做多模态请求准备的同学。重点看三处设计:一是「顺序产物挂在 RenderedPrompt 上、用 Option 表示默认行为」的接口设计,它把破坏性降到只影响真正会重排的渲染器;二是 extract_media_parts 用 Either 在零拷贝前提下统一「显式来源 / 请求顺序」两条路径,并把下标不一致显式变成错误;三是索引必须由渲染器在写占位符的同一处产生(write_chat_content 与 K3TokenWriter::image),这是避免再次出现「两处独立推导顺序」的关键。阅读时建议同时对照 deepseek.rs::render_user_content_block 的重排逻辑与两个 media_order_follows_reordered_tool_results 测试,验证下标语义;并留意 Test Result 为空这一事实,合入后需自行跑一遍多模态 e2e 确认。

讨论亮点

讨论量很小但方向明确。claude[bot] 因 "This pull request is from a fork — automated review is disabled" 未做自动评审;维护者 BugenZhao 直接 APPROVED 并评价 "Thanks @reidliu41 this is a good catch!",同时说明自己补了实现方式的改动:"I've added a commit to put media_order into RenderedPrompt and use None for default ordering."。这实际上是本 PR 最关键的设计决策——媒体顺序不作为渲染器的额外返回值旁路传递,而是作为 RenderedPrompt 的一等字段,且用 Option 表达「默认请求顺序」而非要求所有渲染器都改造。随后的 commit 2/3(均 Co-authored-by: Codex)分别完成了这一重构和空判定的 match 写法,第 4 个 commit 只是把 main 合入。没有未解决的争议线程,也没有逐行 review 评论。

实现拆解

  1. 建立「渲染媒体来源」契约(rust/src/chat/src/renderer/mod.rs、rust/src/chat/src/lib.rs):新增 MediaPartSource { message_index, content_part_index },并在 RenderedPrompt 上增加 media_order: Option<Vec<MediaPartSource>> 字段。语义被明确写进文档注释:None 表示沿用请求中的消息与 content-part 顺序(Jinja/HF 模板路径),Some 表示严格使用列出的来源,空列表意味着渲染器已丢弃全部媒体。MediaPartSource 从 rust/src/chat/src/lib.rs 重新导出。这也解释了 commit 2「Refactor rendered media order into RenderedPrompt」——顺序产物挂在渲染结果上,而不是通过旁路参数传递。

  2. 渲染侧边写占位符边记录来源(rust/src/chat/src/renderer/deepseek.rs、deepseek_v41/mod.rs):render_request 被拆成 render_request_with_media_order,返回 (String, Vec<MediaPartSource>),并把 message_index 与 &mut media_order 一路穿到 render_system_message、render_developer_message、render_user_content_block、write_tool_result、write_chat_content。只有 V4.1 方言在写入 DEEPSEEK_V41_IMAGE_PLACEHOLDER 时才 push 来源;工具结果写的是重排后的真实消息下标 sorted_index,因此记录顺序天然等于渲染顺序。DeepSeekV41ChatRenderer::render 改为返回 media_order: Some(media_order)。

  3. Kimi K3 渲染器同构改造(rust/src/chat/src/renderer/kimi_k3/encoding.rs、kimi_k3/mod.rs):K3TokenWriter 增加 media_order 字段与 image(message_index, content_part_index) 辅助方法(内部先 control(IMAGE_PLACEHOLDER) 再记录来源),finish() 从返回 Vec<u32> 改为返回 (Vec<u32>, Vec<MediaPartSource>)。write_content、write_role_message、write_tool_message 增加 message_index 参数,write_content 对 parts 使用 enumerate() 取得 content_part_index;工具结果沿用 pending_tool_run 中保存的真实消息下标,而不是重排后的 position。

  4. 提取侧按渲染顺序解析(rust/src/chat/src/multimodal.rs):extract_media_parts(request, media_order) 新增第二个参数,用 itertools::Either 统一两条分支——Some 分支按 MediaPartSource 定位消息与 content part,缺失时以 multimodal! 报错(而非静默错配);None 分支退化为旧的请求顺序扫描,并用 is_multimodal() 过滤纯文本。finalize_rendered_prompt 把「是否多模态」的判断从 request.has_multimodal() 改为先取 media_parts 再判空,媒体被渲染器丢弃时直接返回文本 Prompt。

  5. 入口侧空判定与测试配套(rust/src/chat/src/lib.rs、各 renderer 的 mod.rs 与 tests.rs):ChatRequestProcessor::finalize_rendered_prompt 先算 media_is_empty(Some 看列表是否为空,None 退回 !request.has_multimodal()),再决定是否在无 engine dtype 时报 UnsupportedMultimodalRenderer;commit 3 又把这段改成 match 写法。deepseek_v4/v32、harmony、hf、inkling 等保持现有行为的渲染器只补 media_order: None。测试方面新增 extract_media_parts_uses_request_order_or_explicit_selection(multimodal.rs,同时覆盖 None/显式顺序/空列表三种选择)、media_order_follows_reordered_tool_results 与 media_order_excludes_dropped_historical_developer_content(deepseek_v41/tests.rs)、media_order_follows_reordered_tool_results(kimi_k3/tests.rs),server 侧构造 RenderedPrompt 的测试同步补齐字段。本次没有配置、schema 或部署改动。

文件 模块 状态 重要度
rust/src/chat/src/multimodal.rs 多模态媒体 modified 8.23
rust/src/chat/src/renderer/mod.rs 渲染接口 modified 6.09
rust/src/chat/src/renderer/deepseek.rs 渲染器 modified 7.56
rust/src/chat/src/renderer/kimi_k3/encoding.rs 渲染器 modified 7.36
rust/src/chat/src/lib.rs 请求处理 modified 5.71
rust/src/chat/src/renderer/deepseek_v41/tests.rs 渲染器测试 modified 7.46
rust/src/chat/src/renderer/kimi_k3/tests.rs 渲染器测试 modified 6.98

关键符号

extract_media_parts finalize_rendered_prompt ChatRequestProcessor::finalize_rendered_prompt render_request_with_media_order write_chat_content write_tool_result render_user_content_block render_system_message render_developer_message K3TokenWriter::image K3TokenWriter::finish write_content write_role_message write_tool_message

关键源码片段

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

引入 `MediaPartSource` 数据结构并给 `RenderedPrompt` 增加 `media_order: Option<Vec<MediaPartSource>>`,这是整个 PR 的接口契约所在:`None` 表示沿用请求顺序,`Some` 表示严格按列表来源,空列表表示媒体已被丢弃。

/// 某个多模态 content part 在源 chat 请求中的位置。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MediaPartSource {
    /// 源请求中的零基消息下标。
    pub message_index: usize,
    /// 该消息内部零基的 content part 下标。
    pub content_part_index: usize,
}
​
/// 提交给文本后端的已渲染 chat prompt。
#[derive(Debug, Clone, PartialEq)]
pub struct RenderedPrompt {
    /// 渲染后的 prompt,可能是文本,也可能已经 tokenize 完成。
    pub prompt: Prompt,
    /// 按渲染后占位符顺序排列的媒体来源。
    ///
    /// `None` 表示沿用请求中的消息与 content-part 顺序(例如 Jinja 模板路径);
    /// `Some` 表示严格使用列出的来源,空列表意味着渲染器丢弃了全部媒体。
    /// 渲染器只要会重排工具结果或丢弃消息,就必须填写该字段,
    /// 否则媒体提取会与占位符顺序脱钩。
    pub media_order: Option<Vec<MediaPartSource>>,
    /// 应用服务端默认值、请求覆盖与推理控制后,对渲染器可见的模板参数。
    pub effective_template_kwargs: HashMap<String, Value>,
}
rust/src/chat/src/renderer/deepseek.rs core-logic

DeepSeek V4/V4.1 共享渲染逻辑:`render_request` 拆出 `render_request_with_media_order`,并把 `message_index` 与 `&mut media_order` 贯穿到 `render_user_content_block` / `write_tool_result` / `write_chat_content`,是「占位符与来源同源产生」的关键实现。

/// 渲染一条 `<tool_result>` 内容,并传入原始请求中的消息下标,
/// 供 `write_chat_content` 记录媒体来源使用。
fn write_tool_result(
    out: &mut String,
    content: &ChatContent,
    dialect: DsDialect,
    message_index: usize,
    media_order: &mut Vec<MediaPartSource>,
) -> Result<()> {
    out.push_str("<tool_result>");
    write_chat_content(out, content, dialect, message_index, media_order)?;
    out.push_str("</tool_result>");
    Ok(())
}
​
/// V4.1 方言在每个 image part 的位置无条件内联图片占位符
/// (对应 Python 编码里的 `IMAGE_PLACEHOLDER`);其他方言拒绝多模态 part。
///
/// 这里的 `index` 与 `message_index` 组合出的 `MediaPartSource`
/// 必须与渲染顺序严格一致,chat 层才能把媒体还原到正确的工具结果上。
fn write_chat_content(
    out: &mut String,
    content: &ChatContent,
    dialect: DsDialect,
    message_index: usize,
    media_order: &mut Vec<MediaPartSource>,
) -> Result<()> {
    match content {
        ChatContent::Text(text) => out.push_str(text),
        ChatContent::Parts(parts) => {
            for (index, part) in parts.iter().enumerate() {
                match part {
                    ChatContentPart::ImageUrl { .. } if dialect == DsDialect::V41 => {
                        out.push_str(DEEPSEEK_V41_IMAGE_PLACEHOLDER);
                        // 写占位符与记录来源在同一处发生,杜绝两套顺序再次分叉
                        media_order.push(MediaPartSource {
                            message_index,
                            content_part_index: index,
                        });
                    }
                    _ => out.push_str(part.as_text()?),
                }
            }
        }
    }
    Ok(())
}
rust/src/chat/src/renderer/kimi_k3/encoding.rs core-logic

Kimi K3 的 K3TokenWriter 增加 `media_order` 字段与 `image()` 辅助方法,`finish()` 改为返回 `(Vec<u32>, Vec<MediaPartSource>)`,并在 `write_content`/`write_role_message`/`write_tool_message` 中补齐 `message_index`,是工具结果重排后顺序仍正确的关键。

/// K3 prompt 编码器:保持 Python 的分段 tokenize 边界,同时累积媒体来源。
pub(super) struct K3TokenWriter<'a> {
    tokenizer: &'a dyn Tokenizer,
    token_ids: Vec<u32>,
    /// 按占位符写出顺序记录的媒体来源,交由 `RenderedPrompt.media_order` 上报
    media_order: Vec<MediaPartSource>,
}
​
impl<'a> K3TokenWriter<'a> {
    /// 写出一个图片占位符并记录其来源,保证两者不会分叉。
    fn image(&mut self, message_index: usize, content_part_index: usize) -> Result<()> {
        self.control(IMAGE_PLACEHOLDER)?;
        self.media_order.push(MediaPartSource {
            message_index,
            content_part_index,
        });
        Ok(())
    }
​
    pub(super) fn finish(self) -> (Vec<u32>, Vec<MediaPartSource>) {
        (self.token_ids, self.media_order)
    }
}
​
/// 逐 part 写入消息内容;`message_index` 一路透传,
/// `content_part_index` 由 `enumerate()` 得到,正好是 chat 层回溯所需的下标。
fn write_content(
    out: &mut K3TokenWriter<'_>,
    message_index: usize,
    content: &ChatContent,
) -> Result<()> {
    match content {
        ChatContent::Text(text) => write_text_with_images(out, text),
        ChatContent::Parts(parts) => {
            for (content_part_index, part) in parts.iter().enumerate() {
                match part {
                    ChatContentPart::Text { text } => write_text_with_images(out, text)?,
                    ChatContentPart::ImageUrl { .. } => {
                        out.image(message_index, content_part_index)?;
                    }
                    ChatContentPart::VideoUrl { .. } => {
                        return Err(Error::UnsupportedMultimodalContent("video_url"));
                    }
                    // …其余 part 类型沿用既有处理
                }
            }
            Ok(())
        }
    }
}

评论区精华

媒体顺序产物的归属:旁路返回值还是 RenderedPrompt 字段 设计

作者最初在渲染器内部独立推导媒体顺序;维护者 BugenZhao 在批准时表示自己追加了实现方式改动:"I've added a commit to put `media_order` into `RenderedPrompt` and use `None` for default ordering."

结论:把 `media_order: Option<Vec<MediaPartSource>>` 提升为 `RenderedPrompt` 的一等字段,`None` 表示沿用请求顺序,从而让 Jinja/HF 等不重排的渲染器零改动保持原行为,只有 DeepSeek V4.1 与 Kimi K3 返回 `Some`。 · 已解决

多模态空判定应基于渲染结果而非原始请求 正确性

DeepSeek V4.1 会丢弃历史 developer 消息,其图片没有对应占位符;若仍按 `request.has_multimodal()` 判定,请求会走进媒体准备并因占位符数量不匹配而失败。commit 3「Use match for rendered media emptiness check」把该判定改写为 match 形式。

结论:`ChatRequestProcessor::finalize_rendered_prompt` 先算 `media_is_empty`:`Some` 看列表是否为空,`None` 退回 `!request.has_multimodal()`,被丢弃媒体的请求直接按纯文本放行。 · 已解决

fork PR 的自动化评审未执行 other

claude[bot] 给出 "This pull request is from a fork — automated review is disabled. A repository maintainer can comment `@claude review` to run a one-time review.";随后 BugenZhao 以 APPROVED 人工评审并自行补交 commit。

结论:未触发机器评审,也没有逐行 review 评论;本 PR 的评审证据主要是维护者的批准说明和 3 个后续 commit。 · 已解决

风险与影响

1) 渲染器与请求下标强耦合:MediaPartSource 使用 message_index/content_part_index 指向原始 ChatRequest.messages,任何渲染器「先过滤再渲染」的逻辑必须与索引保持一致。DeepSeek 的 render_user_content_block 用独立计算的 sorted_tool_response_indices 重排工具结果,这里显式传入重排后的 sorted_index 是正确的,但未来若再引入新的重排/过滤(例如继续新增「丢弃某类消息」的规则)却忘记同步索引,会在运行期触发 renderer reported missing content part ... 一类错误而非静默错配——属于可观测的失败,但会变成用户可见的 4xx/5xx。
2) 静默降级为纯文本:finalize_rendered_prompt 现在只要 media_parts.is_empty() 就直接返回文本 Prompt。对 DeepSeek V4.1 丢弃历史 developer 图片是预期行为,但如果某个渲染器误报空 media_order,请求会在缺失图片的情况下继续推理,属于「错误结果而非错误」的风险,现有测试只覆盖了 DeepSeek 的丢弃场景。
3) crate 公开 API 变更:RenderedPrompt 新增必填字段,属于对 vllm-chat 使用者的破坏性变更(workspace 内所有构造点已同步,含 rust/src/server/src/routes/tests.rs、http_client_tests.rs、grpc/tests.rs、rust/src/chat/tests/chat.rs)。若存在仓库外依赖,需要同步升级。
4) Kimi K3 的文本内嵌图片路径未在该契约内:write_content 对 ChatContent::Text 仍走 write_text_with_images,不记录 MediaPartSource;如果字符串内容格式中夹带占位符语义,需另行确认顺序一致性(材料中未见相关测试,属待验证项)。
5) 测试证据:PR body 的 Test Result 段为空,仅能从 Issue 评论看到 CI #88787 被触发,缺少最终绿灯结论;覆盖以 Rust 单元测试为主,虽有 rust/src/server 侧用例单行同步,但仓库级多模态 e2e 未在此次改动中新增。

用户侧:Rust OpenAI/http frontend 在「多工具调用 + 工具结果带图」以及「历史 developer 消息带图」的 DeepSeek V4.1 / Kimi K3 请求上,不再出现图片与工具结果错配或占位符扩展失败,多模态请求的语义正确性显著提升;对 Jinja/HF 渲染路径完全无行为变化。系统侧:影响集中在 vllm-chat 的渲染—媒体提取链路与 ChatRequestProcessor 的空媒体判定,属请求准备阶段的核心路径,但未触及 tokenizer、engine core 协议或 KV 传输。团队侧:为 Rust 前端建立了「渲染器必须报告媒体来源顺序」的统一契约,后续新增渲染器只需实现 RenderedPrompt.media_order 或在无重排时返回 None,可复用性较好;Python 前端与 Rust 前端在工具结果重排语义上的对齐度也更高。

核心请求准备路径变更 RenderedPrompt 新增必填字段(crate API 变更) 渲染器与请求下标强耦合 媒体被静默丢弃时降级为纯文本 Test Result 为空,缺最终 CI 结论

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论