Prhub

#54509 [Bugfix][Frontend] Truncate prompt_is_token_ids with the prompt

原始 PR 作者 Hotragn 合并时间 2026-08-31 17:29 文件变更 2 提交数 1 评论 3 代码增减 +54 / -2

执行摘要

截断 prompt 时同步截断 prompt_is_token_ids 掩码

EmbedsPrompt.prompt_is_token_ids 是逐位置掩码,与 prompt_token_ids 逐元素配对,决定每个位置读取真实 token id 还是 prompt_embeds 的一行。契约已写明两者必须等长(vllm/inputs/llm.py 中 "Must be the same length as prompt_token_ids when both are set")。但 TokenizeParams.apply_post_tokenization 截断 prompt_token_ids 与 prompt_embeds 时从不触碰掩码,设置 truncate_prompt_tokens 后两者描述不同位置。PR body 给出两个实际受害点:vllm/v1/worker/gpu_input_batch.py 把更长掩码广播进截断后的槽位会抛 ValueError;vllm/v1/request.py 中 zip 截断配对导致左截断后 embed 占位符不再被清零,占位 token 带着越界 embedding 行进入引擎。该路径从 OpenAI API 直通可达:render_chat_async -> render_messages_async -> tokenize_prompts_async -> apply_post_tokenization。

值得精读,属于"小改动、高分析质量"的教科书式 bugfix。建议重点学习两点:一是用单一 _truncation_slice 作为所有并行数组的统一截断入口,避免各数组各自实现截断造成漂移;二是测试断言"掩码内容"而非仅"掩码长度",这类对齐缺陷用长度断言必然漏测。可作为 frontend 输入处理链路的参考实现。

讨论亮点

本 PR 没有实质技术讨论线程,合流过程如下:

  • claude[bot] 自动审查因 fork PR 被禁用,提示维护者可评论 @claude review 触发一次性审查,最终未触发。
  • mergify[bot] 曾报告合并冲突,作者在 #54407 合并后 rebase,冲突解决。
  • 维护者 DarkLight1337 直接批准("Thanks"),说明修复方案风险低、无未决疑虑。

实现拆解

变更入口是 vllm/renderers/params.py 的 TokenizeParams.apply_post_tokenization,它是 token 化后所有 prompt 并行数组的统一收口点。

  1. 定位缺陷来源:apply_post_tokenization 对 prompt_token_ids 与 prompt_embeds 通过 _validate_tokens 链路完成截断;prompt_token_offsets 已在 #54407 中补上同一截断逻辑;经逐一核对,prompt_is_token_ids 是最后一个未被截断的并行数组。该掩码仅在混合模态(prompt_embeds)路径由 _build_mixed_prompt_embeds 生成,契约要求其长度与 prompt_token_ids 一致。
  2. 复用统一截断窗口:在 apply_post_tokenization 末尾新增对 prompt_is_token_ids 的处理,用 _truncation_slice(tokenizer, len(is_token_ids)) 计算与 token 完全相同的截断窗口并套用,天然支持 truncation_side 的 left/right 语义;同时扩展 _truncation_slice 的 docstring,把 prompt_is_token_ids 显式列入"必须用同一 slice 缩减的并行数组",从约定层面防止后续新增数组再次漏截。
  3. 内容级回归测试:tests/renderers/test_chat_utils_prompt_embeds.py 新增 test_truncation_keeps_the_mixed_mask_aligned_with_the_prompt,构造 10 个真实 token、5 个 embed 占位、5 个真实 token 的混合 prompt,truncation_side="left" 保留最后 8 个位置(从 embed span 中段切过),断言存活掩码内容为 [False] * 3 + [True] * 5 而非仅断言长度——长度对而内容错的实现(如从错误方向截断掩码)同样会失败,从而真正钉住对齐关系;测试同时校验 prompt_token_ids 与 prompt_embeds 被同样截断。
  4. 验证配套:作者在无 GPU 的 CPU runner 上跑通整个 tests/renderers 套件(本分支 437 passed),4 个失败在 main 与本分支逐字节一致,均为环境问题(cohere_melody 可选包缺失、被门禁的 Hugging Face repo),非本 PR 引入;ruff、mypy、typos 与 SPDX 钩子均通过。
文件 模块 状态 重要度
vllm/renderers/params.py 渲染器 modified 5.88
tests/renderers/test_chat_utils_prompt_embeds.py 混合嵌入 modified 5.58

关键符号

apply_post_tokenization _truncation_slice test_truncation_keeps_the_mixed_mask_aligned_with_the_prompt

关键源码片段

vllm/renderers/params.py core-logic

修复的落点:apply_post_tokenization 新增 prompt_is_token_ids 截断分支,并扩展 _truncation_slice docstring,是全部逻辑变更所在。

# vllm/renderers/params.py
# _truncation_slice 是唯一截断窗口来源:所有与 prompt token 平行的数组
# (token 偏移、嵌入掩码等)都必须用这个 slice 缩减,才能保证截断后
# 各序列仍在同一组位置上对齐。
​
def _truncation_slice(
    self, tokenizer: TokenizerLike | None, length: int
) -> slice | None:
    """The slice truncation applies to a sequence of `length` tokens.
​
    `None` means no truncation. Anything parallel to the prompt tokens
    (see `prompt_token_offsets` and `prompt_is_token_ids`) must be reduced
    with this same slice to stay aligned with them.
    """
    max_length = self.truncate_prompt_tokens
    if max_length is not None and max_length < 0:
        # 负数表示沿用 max_input_tokens
        max_length = self.max_input_tokens
​
    if max_length is None or max_length >= length:
        return None
    if max_length == 0:
        return slice(0, 0)
​
    # 截断方向默认跟随 tokenizer,left 用负索引保留尾部
    side = self.truncation_side or (
        tokenizer.truncation_side if tokenizer is not None else None
    )
    if side == "left":
        return slice(-max_length, None)
​
    return slice(0, max_length)
​
​
def apply_post_tokenization(
    self,
    tokenizer: TokenizerLike | None,
    prompt: TokensPrompt | EmbedsPrompt,
) -> TokensPrompt | EmbedsPrompt:
    """Ensure that the prompt meets the requirements set out by this config.
​
    This method is run after tokenization occurs.
    """
    if "prompt_token_ids" in prompt:
        prompt["prompt_token_ids"] = self._validate_tokens( # type: ignore[typeddict-unknown-key]
            tokenizer,
            prompt["prompt_token_ids"], # type: ignore[typeddict-item]
        )
    if "prompt_embeds" in prompt:
        prompt["prompt_embeds"] = self._validate_tokens( # type: ignore[typeddict-unknown-key]
            tokenizer,
            prompt["prompt_embeds"], # type: ignore[typeddict-item]
        )
​
    # 前序 PR `#54407` 已补上 offsets 的截断,本 PR 补上最后一块 mask;
    # 三者共用同一个 slice,天然保持逐位置对齐。
    offsets = cast(TokensPrompt, prompt).get("prompt_token_offsets")
    if offsets is not None:
        truncation = self._truncation_slice(tokenizer, len(offsets))
        if truncation is not None:
            prompt["prompt_token_offsets"] = offsets[truncation] # type: ignore[typeddict-unknown-key]
​
    is_token_ids = cast(EmbedsPrompt, prompt).get("prompt_is_token_ids")
    if is_token_ids is not None:
        truncation = self._truncation_slice(tokenizer, len(is_token_ids))
        if truncation is not None:
            prompt["prompt_is_token_ids"] = is_token_ids[truncation] # type: ignore[typeddict-unknown-key]
​
    return prompt

评论区精华

fork PR 自动 code review 被禁用 other

claude[bot] 说明来自 fork 的 PR 不自动执行审查,维护者可评论 @claude review 触发一次性审查。

结论:维护者未触发自动审查,直接人工批准。 · 已解决

合并冲突与 rebase other

mergify[bot] 报告 PR 存在合并冲突;作者在 #54407 合并后将其 rebase 到 main,PR body 说明 rebase 后改动收敛为只给最后一个并行数组套用现有 _truncation_slice。

结论:rebase 到已合并的 #54407 之后冲突解决,CI 通过。 · 已解决

维护者批准 other

DarkLight1337 直接批准并回复 Thanks,无技术分歧或未决疑虑。

结论:已合并。 · 已解决

风险与影响

  • 回归面窄但需确认类型安全:新增分支用 cast(EmbedsPrompt, prompt).get("prompt_is_token_ids") 取掩码,cast 仅影响类型检查;运行时对纯 TokensPrompt 返回 None 直接跳过,不会给普通文本请求引入任何额外路径。
  • 边界一致性:max_length == 0 时 _truncation_slice 返回 slice(0, 0),掩码与 token 都被清空,行为对齐无新增边界风险;截断起点由统一 slice 推导,两侧永远不会漂移。
  • 已知未覆盖点:PR 明确 padding 不适用(_token_padding 拒绝非 list 序列,embeds prompt 不会进入 padding);若未来放开 embed 输入的 padding,需要同步把掩码纳入 padding 逻辑。
  • 执行期崩溃消除:修复前,混合模态 + truncate_prompt_tokens 的请求在 v1 引擎核心 gpu_input_batch.py 广播赋值处直接抛 ValueError,是可达的服务级崩溃点;修复后该组合可正常服务。
  • 用户侧:开启 --enable-prompt-embeds 的服务,带 truncate_prompt_tokens 的 OpenAI 兼容 chat 请求从"必现报错或读到越界 embed"变为正常工作;对未使用混合模态的用户零影响。
  • 系统侧:消除了 v1 引擎核心路径的一个可触发崩溃点,同时避免左截断时 embed 占位 token 未被清零导致的潜在错误推理结果(越界读 embedding 行)。
  • 团队侧:确立了"所有与 prompt token 平行的数组必须共用 _truncation_slice"的代码约定,docstring 与测试双重钉住,降低后续新增并行数组时再次漏截的概率。
混合模态路径变更 并行数组截断一致性 左截断边界 执行期崩溃点消除

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论