Prhub

#54407 [Bugfix][Frontend] Truncate prompt_token_offsets with the prompt

原始 PR 作者 Hotragn 合并时间 2026-08-31 14:13 文件变更 3 提交数 2 评论 4 代码增减 +106 / -8

执行摘要

修复 prompt_token_offsets 未随截断同步导致并行数组长度漂移

prompt_token_offsets 是每个 token 对应的 (start, end) 字符区间,TokensPrompt(vllm/inputs/llm.py:118-122)和 render API 的 GenerateRequest(vllm/entrypoints/scale_out/token_in_token_out/protocol.py:97-102)都明确文档化其长度等于 token 序列长度。但 apply_post_tokenization 截断 prompt_token_ids 时从未处理 offsets。触发条件为 return_token_offsets + truncate_prompt_tokens + 显式 truncation_side 三者同时出现:get_encode_kwargs(vllm/renderers/params.py:315-340)在显式 truncation_side 下刻意禁用 tokenizer 级截断以避免两侧语义不一致,随后 _token_truncation 只切片 token 列表。最终 POST /v1/completions/render 返回 200 OK,但 4 个 token id 对应 13 个 offsets,客户端按 token 索引回溯字符范围时读到的是已丢弃 token 的偏移,且无声无息。作者在 PR body 中强调:"The offsets describe the whole prompt while the token ids are the final four tokens... A client zipping the two lists maps every kept token to the wrong characters, with no error to signal it."

值得精读,尤其适合关注 API 数据契约与"并行数组一致性"问题的工程师。三个值得学习的点:

  1. 把"决策"与"执行"分离的设计:_truncation_slice 将截断的判定逻辑提取为可复用的 slice 工厂,使 prompt_token_ids 与 prompt_token_offsets 从"各自实现截断"变为"共享同一个决策"。这是一种比"复制一份截断代码"更抗腐化的结构。
  2. PR 的论证方式:作者对触发条件的可达性推演(get_encode_kwargs 为何禁用 tokenizer 截断 → 截断落到 _token_truncation → 只有 token ids 被切)非常清晰;在 reviewer 要求 e2e 证据时立即用 CPU-only server 落成测试,是高效的技术沟通范例。
  3. 测试断言的设计层次:单测与 e2e 都不满足于 len(offsets) == len(token_ids),而是进一步验证"保留的是正确一端"(full_offsets[-keep:] 与 offsets[-1][1] == len(prompt)),能抓住"长度对但内容错"的假修复——这是测试设计的高阶意识。

无需额外行动项;该 PR 已合并且测试覆盖充分。

讨论亮点

本 PR 的 review 讨论非常精简,但关键交锋质量很高:

  • Reviewer DarkLight1337 要求 e2e 证据:"Are you able to provide an e2e example where this is actually a problem?" 这是一个对"理论上存在不一致"的质疑——因为默认路径下 tokenizer 级截断会先发生、offsets 也随之截断,只有三条参数组合同时出现才会触发。
  • 作者 Hotragn 的回应:强调 render server 是 CPU-only,因此可以端到端运行而非从代码推演。他将复现压成一个测试(commit edd5a15b6d),贴出了 main 上返回 200 OK 但 4 个 token id 对应 13 个 offsets 的实际输出,以及 assert 13 == 4 的失败现场。这个回应直接把质疑从"理论路径"落到了"可复现的 bug",是说服 reviewer 的关键。
  • 结论:DarkLight1337 批准("Thanks for fixing!"),随后触发 Buildkite CI。

值得注意的未解决疑虑几乎为零——PR body 中作者主动报告了分支上多出的两个 HF Hub 相关测试失败,并分析为 Hub 限流(HfHubHTTPError 与配置拉取为空导致的 ModelConfig 校验错误)而非行为变更,用 432 passed 平局与"分支多收集两个测试"的事实论证无回归。这种主动披露而非隐藏差异的做法体现了较高的工程透明度。

实现拆解

本 PR 的修复思路是让决定截断的唯一逻辑源可被复用,使 prompt_token_ids 与 prompt_token_offsets 永远应用同一个切片,从结构上杜绝漂移。整体分三步:

  1. 提取截断决策为独立函数:在 vllm/renderers/params.py 中新增 _truncation_slice(self, tokenizer, length) -> slice | None。它将原 _token_truncation 中的全部决策逻辑(truncate_prompt_tokens 的负数语义回退到 max_input_tokens、max_length == 0 返回空切片、truncation_side 的优先级为显式设置优先于 tokenizer 默认值、left/right 分别对应 slice(-max_length, None) 与 slice(0, max_length))从"对 token 列表操作"抽象为"产生一个切片"。返回 None 表示无需截断,这是关键设计:调用方不需要自行重复判断截断条件。

  2. 改造 _token_truncation 与 apply_post_tokenization:_token_truncation 简化为获取 slice 后直接 tokens[truncation];apply_post_tokenization 在完成 token ids 与 embeds 的校验后,新增对 prompt_token_offsets 的检查——若存在且 slice 非 None,则对 offsets 应用同一 slice。由于引入了 cast(TokensPrompt, prompt),相应在文件头部从 typing 增加 cast 导入。作者明确说明不将 padding 应用于 offsets:padding token 没有源字符,且 pad_prompt_tokens 是 pooling 专用参数而 return_token_offsets 仅可在 generate 端点设置,两者在实际请求中不会共现。

  3. 配套测试(两层):

    • 单元测试:在 tests/renderers/test_token_offsets.py 新增 TestTruncationKeepsOffsetsAligned 类,参数化 side ∈ {left, right}。测试先做一次无截断 tokenize 拿到完整 offsets,再断言截断结果等于 full_offsets[-keep:] 或 full_offsets[:keep]——不仅验证长度相等,还验证保留的是"正确那一端"的 offsets,能挡住"长度对但内容错"的假修复。
    • e2e 测试:在 tests/entrypoints/scale_out/render/test_render.py 新增 test_completion_render_truncated_token_offsets,通过 RemoteLaunchRenderServer + hmellor/tiny-random-LlamaForCausalLM 走真实 HTTP 路径复现。该测试在 main 上以 assert 13 == 4 失败,修复后通过;且额外断言 offsets[-1][1] == len(prompt) 与 offsets[0][0] > 0,确认 left 截断后保留的是 prompt 末尾的 offsets。由于 render server 是 CPU-only,该 e2e 无需 GPU 即可运行。
文件 模块 状态 重要度
vllm/renderers/params.py 渲染器 modified 7.53
tests/renderers/test_token_offsets.py 偏移测试 modified 6.17
tests/entrypoints/scale_out/render/test_render.py 渲染端点 modified 5.89

关键符号

_truncation_slice _token_truncation apply_post_tokenization test_explicit_truncation_side_truncates_offsets test_completion_render_truncated_token_offsets

关键源码片段

vllm/renderers/params.py core-logic

修复核心:新增 `_truncation_slice` 将截断决策与执行分离,并在 `apply_post_tokenization` 中对 `prompt_token_offsets` 应用同一 slice,从结构上保证 token 与 offsets 不再漂移。

# vllm/renderers/params.py
# 截断决策从 _token_truncation 中提取为独立的 slice 工厂,
# 使所有与 token 序列平行的数组(如 prompt_token_offsets)
# 都能复用同一个截断决策,避免两处逻辑各自演化而漂移。
def _truncation_slice(
    self, tokenizer: TokenizerLike | None, length: int
) -> slice | None:
    """返回对长度为 length 的序列应施加的截断 slice。
​
    None 表示无需截断;任何与 prompt tokens 平行的序列
    (如 prompt_token_offsets)都必须用同一个 slice 裁剪,
    以保持与 token 序列对齐。
    """
    max_length = self.truncate_prompt_tokens
    if max_length is not None and max_length < 0:
        # 负数语义:回退到全局最大输入长度
        max_length = self.max_input_tokens
​
    if max_length is None or max_length >= length:
        # 无需截断:返回 None 让调用方直接保留原序列
        return None
    if max_length == 0:
        return slice(0, 0) # 截断为空的特殊情况
​
    # 显式 truncation_side 优先,其次回退到 tokenizer 默认值
    side = self.truncation_side or (
        tokenizer.truncation_side if tokenizer is not None else None
    )
    if side == "left":
        # left 截断保留末尾 max_length 个元素
        return slice(-max_length, None)
​
    # 默认(right)截断保留开头 max_length 个元素
    return slice(0, max_length)
​
​
def _token_truncation(self, tokenizer: TokenizerLike | None, tokens: _S) -> _S:
    """对 prompt tokens 应用截断;现在是 _truncation_slice 的薄封装。"""
    truncation = self._truncation_slice(tokenizer, len(tokens))
    if truncation is None:
        return tokens
​
    return tokens[truncation]
​
​
def apply_post_tokenization(
    self,
    tokenizer: TokenizerLike | None,
    prompt: TokensPrompt | EmbedsPrompt,
) -> TokensPrompt | EmbedsPrompt:
    """token 化后处理:先校验并变换 token ids / embeds,
    再对 prompt_token_offsets 施加与 token ids 完全相同的截断 slice,
    保证两个并行数组始终描述同一批 token。
    """
    if "prompt_token_ids" in prompt:
        prompt["prompt_token_ids"] = self._validate_tokens(
            tokenizer,
            prompt["prompt_token_ids"],
        )
    if "prompt_embeds" in prompt:
        prompt["prompt_embeds"] = self._validate_tokens(
            tokenizer,
            prompt["prompt_embeds"],
        )
​
    # 显式 truncation_side 会禁用 tokenizer 级截断(见 get_encode_kwargs),
    # 因此这里必须单独处理 offsets;padding 不适用,因为 pad token 无源字符
    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]
​
    return prompt

评论区精华

需要 e2e 证据证明问题真实存在 question

DarkLight1337 询问 "Are you able to provide an e2e example where this is actually a problem?" ——因为默认路径下 tokenizer 级截断会让 offsets 先被截断,问题只在 `return_token_offsets` + `truncate_prompt_tokens` + 显式 `truncation_side` 组合下出现。

结论:作者利用 render server 是 CPU-only 的特性,将复现压成 e2e 测试(commit `edd5a15b6d`),贴出 `main` 上 200 OK 但 4 个 token ids 对应 13 个 offsets 的失败输出,证明问题可复现且会静默产生错误数据。 · 已解决

CI 触发与回归确认 other

DarkLight1337 批准后触发 Buildkite CI #86211;PR body 中作者主动对比 `main` 与分支的整目录测试结果,报告分支多出的两个 HF Hub 失败并论证为限流而非行为变更。

结论:两层测试(单测 + e2e)均通过,审阅者确认修复并合并。 · 已解决

风险与影响

整体风险较低,但仍需关注以下点:

  1. offsets 与 token ids 的截断一致性依赖单一 slice 源:修复后两个数组由同一 _truncation_slice 产生切片,理论上不会漂移。但 prompt_token_offsets 的截断在 apply_post_tokenization 中独立执行,若未来有人在 _validate_tokens 链路上增加新的会改变长度的变换(如新的 padding 策略),需要记得同步 offsets;当前代码注释已提示"Anything parallel to the prompt tokens must be reduced with this same slice",但缺少机制性保障(如将 offsets 纳入 _validate_tokens 的回调链)。
  2. cast(TokensPrompt, prompt) 的类型安全:prompt 参数类型是 TokensPrompt | EmbedsPrompt,offsets 仅在 TokensPrompt 上存在。代码先判断 offsets is not None 再执行切片赋值,运行时安全,但 cast 依赖 get 的返回值判断,若未来在 EmbedsPrompt 上引入同名 prompt_token_offsets 字段且语义不同,可能被误截断。
  3. e2e 测试的模型依赖:test_completion_render_truncated_token_offsets 使用 hmellor/tiny-random-LlamaForCausalLM,与文件中既有测试共用同一 MODEL_NAME 夹具,无新增外部依赖;但该测试失败会在 CI 网络不稳定时产生干扰(尽管不是本 PR 引入的新风险)。
  4. 行为变更边界:修复改变了 POST /v1/completions/render 在截断场景下的响应字段长度,属于对"已文档化契约"的回归修复而非破坏性变更——此前返回的 offsets 本身是错的。依赖旧行为的客户端不存在(旧行为是静默错误)。

影响范围集中在 vLLM 的 render API(/v1/completions/render 与 /v1/chat/completions/render)及 TokenizeParams 后处理链路:

  • 对用户:任何组合使用 return_token_offsets、truncate_prompt_tokens 与显式 truncation_side 的调用方将获得与 token_ids 严格对齐的 token_offsets。此前这些调用方在无任何报错的情况下读到错误字符区间,可能已产生"token 与文本错位"的隐性污染(如 RAG 引用定位、高亮渲染、评估脚本)。
  • 对系统:改动仅涉及纯 Python 的渲染/后处理逻辑,不触碰推理内核、KV cache 或调度器,无性能影响;render server 为 CPU-only,CPU 环境即可回归验证。
  • 对团队:_truncation_slice 的引入为后续在 apply_post_tokenization 中同步处理其他与 token 平行的数组(类似 ServingRender.render_chat_request 已处理的 assistant_tokens_mask)提供了现成的复用点,降低了同类 bug 再次出现的概率。
  • 影响程度:中低。触发条件需要三个参数同时显式设置,属于边界组合;但后果是静默数据错位,对下游消费者的危害不容忽视。
并行数组对齐依赖约定而非机制 e2e 测试依赖外部模型下载 仅限特定参数组合触发

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论