执行摘要
- 一句话:截断 prompt 时同步截断 prompt_is_token_ids 掩码
- 推荐动作:值得精读,属于"小改动、高分析质量"的教科书式 bugfix。建议重点学习两点:一是用单一 _truncation_slice 作为所有并行数组的统一截断入口,避免各数组各自实现截断造成漂移;二是测试断言"掩码内容"而非仅"掩码长度",这类对齐缺陷用长度断言必然漏测。可作为 frontend 输入处理链路的参考实现。
功能与动机
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。
实现拆解
变更入口是 vllm/renderers/params.py 的 TokenizeParams.apply_post_tokenization,它是 token 化后所有 prompt 并行数组的统一收口点。
- 定位缺陷来源: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 一致。
- 复用统一截断窗口:在 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 缩减的并行数组",从约定层面防止后续新增数组再次漏截。
- 内容级回归测试: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 被同样截断。
- 验证配套:作者在无 GPU 的 CPU runner 上跑通整个 tests/renderers 套件(本分支 437 passed),4 个失败在 main 与本分支逐字节一致,均为环境问题(cohere_melody 可选包缺失、被门禁的 Hugging Face repo),非本 PR 引入;ruff、mypy、typos 与 SPDX 钩子均通过。
关键文件:
vllm/renderers/params.py(模块 渲染器;类别 source;类型 core-logic;符号 apply_post_tokenization, _truncation_slice): 修复的落点:apply_post_tokenization 新增 prompt_is_token_ids 截断分支,并扩展 _truncation_slice docstring,是全部逻辑变更所在。
tests/renderers/test_chat_utils_prompt_embeds.py(模块 混合嵌入;类别 test;类型 test-coverage;符号 test_truncation_keeps_the_mixed_mask_aligned_with_the_prompt): 新增内容级对齐测试,断言掩码内容而非长度,能钉住 left 截断下 embed 行存活的对齐关系;同时补齐 TokenizeParams 导入。
关键符号:apply_post_tokenization, _truncation_slice, test_truncation_keeps_the_mixed_mask_aligned_with_the_prompt
关键源码片段
vllm/renderers/params.py
修复的落点: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
评论区精华
本 PR 没有实质技术讨论线程,合流过程如下:
风险与影响
- 风险:
- 回归面窄但需确认类型安全:新增分支用 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 与测试双重钉住,降低后续新增并行数组时再次漏截的概率。
- 风险标记:混合模态路径变更, 并行数组截断一致性, 左截断边界, 执行期崩溃点消除
关联脉络
- PR #54407 [Bugfix][Frontend] Truncate prompt_token_offsets with the prompt: 直接前驱,修复同一类"并行数组未随 prompt 截断"缺陷;本 PR 是它的 follow-up,并 rebase 在其合并之后。
- PR #54364 [Bugfix][Frontend] Truncate pooling prompts before padding them: 同文件(vllm/renderers/params.py)的另一处截断顺序缺陷,同属 frontend 截断正确性系列。
参与讨论