Prhub

#56639 [Pooling] Support prompt embeddings in MRV2 decoder pooling

原始 PR 作者 taneem-ibrahim 合并时间 2026-09-13 13:49 文件变更 2 提交数 2 评论 2 代码增减 +2 / -2

执行摘要

MRV2 池化支持 prompt_embeds,token id 断言下沉到任务级

PR body 明确指出这是 MRV1 与 MRV2 之间的回归:MRV2 的 prompt-embedding 状态本就支持预计算嵌入,但池化请求路径无条件断言 prompt_token_ids 存在,于是“Decoder pooling requests that supplied prompt_embeds therefore crashed before inference, even though most pooling tasks do not need token IDs”。触发场景是可复现的:开启 enable_prompt_embeds 后把 {prompt_embeds: torch.randn(4, hidden_size)} 与普通字符串放在同一批请求里,main 分支报 AssertionError 与 EngineDeadError,而大多数池化任务(embedding、rerank)本身并不需要 token id。修复目标是在不改变既有行为的前提下,让 token id 只在声明需要它的任务上被强制要求。

值得精读,但不必花大量时间:diff 极小,重点看两处设计决策——(1) 为什么把 prompt_token_ids 放宽为 list[int] | None 而不是在 model_runner 里为 embeds 请求加特例;(2) 为什么断言放在 if pooling_params.requires_token_ids: 分支内而不是 _get_pooling_metadata 里。这两点体现了“输入校验跟随能力声明、在真正使用点失败”的模式,可直接迁移到其他 MRV2 迁移修复中。若你在维护池化任务或新增 pooler,请额外关注 requires_token_ids 的声明是否与实际实现一致,并建议补一条 MRV2 下 embeds 与 token 输入混合 batch 的回归用例,把本次手工验证固化进 CI。

讨论亮点

本轮没有逐行评审意见(review_comments_count = 0),唯一实质动作是 njhill 先评论 /ci run 触发 Buildkite CI #88602(commit 835c1975f9d2),随后以 APPROVED 无正文放行;PR 也因此以“修复 commit + 合并 main 的 merge commit”两个 commit 落地。claude[bot] 只留下一条流程性说明:这是来自 fork 的 PR,自动化审查被禁用,需要维护者评论 @claude review 才会执行一次性审查,本次未触发。因此“断言放在调用方还是使用侧”这一设计取舍没有被公开辩论,结论完全由作者在 PR 描述中给出并被维护者默认接受;此外,缺少自动化回归用例的疑虑也没有在讨论中被提出。

实现拆解

本 PR 只有 2 个文件、+2/-2,却落在 MRV1 → MRV2 迁移的请求入队契约上,可按三层理解。

  1. 调用侧放宽契约(vllm/v1/worker/gpu/model_runner.py):add_requests 遍历 scheduler_output.scheduled_new_reqs 时,原本在 if self.pooling_runner is not None: 分支内先执行 assert new_req_data.prompt_token_ids is not None,再调用 self.pooling_runner.add_request(req_id, req_index, new_req_data.pooling_params, new_req_data.prompt_token_ids)。该断言隐含“凡池化请求必有 token id”的假设,与 MRV2 允许 prompt-embeds-only 输入的事实冲突,于是请求在入队阶段就崩溃。本次直接删除该断言,把判定权交给下游。
  2. 消费侧按能力声明校验(vllm/v1/worker/gpu/pool/pooling_runner.py):add_request 的 prompt_token_ids 参数由 list[int] 改为 list[int] | None,并在 if pooling_params.requires_token_ids: 分支内新增 assert prompt_token_ids is not None。这样“是否需要 token id”由任务自身的 requires_token_ids 声明驱动:token 分类、SPLADE 等任务在缺 token id 时仍快速失败,embedding / rerank 等任务允许 None 入队。
  3. 下游兼容性确认:同文件的 _get_pooling_metadata 早已用 if any(params.requires_token_ids for params in pooling_params): 决定是否构造 prompt_token_ids_cpu,remove_request 也用 self.prompt_token_ids.pop(req_index, None) 做删除,因此 self.prompt_token_ids 字典缺项不会破坏取用逻辑——这正是断言可以安全下沉到 requires_token_ids 分支的前提。
  4. 测试与配套改动:本次没有新增或修改测试文件(changed_files 均为源码)。验证依赖 PR 描述中的手工 reproducer(runner_v2 True outputs 2 embedding_dims [1024, 1024])以及既有用例:tests/v1/worker/test_prompt_embeds_state.py(4 passed)、VLLM_USE_V2_MODEL_RUNNER=1 下的 tests/models/language/pooling/test_all_pooling_plus_chunked_prefill.py(3 passed)、test_splade_sparse_pooler.py(12 passed)、test_jina_reranker_v3.py::test_offline(1 passed),整体 29 tests passed;精度对比表显示 prompt embeds 与 token id 两条路径的最大绝对差为 0、余弦相似度为 1.0。
文件 模块 状态 重要度
vllm/v1/worker/gpu/pool/pooling_runner.py 池化执行器 modified 5.39
vllm/v1/worker/gpu/model_runner.py 模型运行器 modified 4.79

关键符号

add_request add_requests

关键源码片段

vllm/v1/worker/gpu/pool/pooling_runner.py core-logic

池化请求的注册入口,本次真正的契约变更点:prompt_token_ids 放宽为可选,断言下沉到 requires_token_ids 分支,决定了哪些任务允许没有 token id。

    # 文件:vllm/v1/worker/gpu/pool/pooling_runner.py
    def add_request(
        self,
        req_id: str,
        req_index: int,
        pooling_params: PoolingParams,
        # 改动点 1:prompt_token_ids 由必填 list[int] 放宽为 Optional。
        # MRV2 允许请求只带预计算的 prompt_embeds(Qwen3-Embedding 等 decoder pooling),
        # 这类请求本就没有 token id,属于合法输入而非错误。
        prompt_token_ids: list[int] | None,
    ) -> None:
        task = pooling_params.task
        if task not in self.supported_tasks:
            raise ValueError(
                f"Unsupported task: {task!r}. "
                f"Supported tasks: {sorted(self.supported_tasks)}"
            )
        # 先让 pooler 按任务类型改写参数,再做后续登记,保证 pooling_params 已定型
        self.model.pooler.get_pooling_updates(task).apply(pooling_params)
        self.late_interaction_runner.register_request(req_id, pooling_params)
        self.pooling_params[req_index] = pooling_params
        self.pooling_states[req_index] = PoolingStates()
​
        # 改动点 2:把 token id 的强制校验从调用方(model_runner)下移到真正使用它的分支。
        # 只有声明 requires_token_ids 的任务(token 分类、SPLADE 等)才要求 token id 非空;
        # embedding / rerank 等任务即使 prompt_token_ids 为 None 也能正常入队。
        # 之所以安全:同文件 _get_pooling_metadata 用 any(params.requires_token_ids ...)
        # 作为是否构造 prompt_token_ids_cpu 的开关,remove_request 也用 pop(req_index, None)
        # 删除条目,因此下游不会读到未登记的 None。
        if pooling_params.requires_token_ids:
            assert prompt_token_ids is not None
            self.prompt_token_ids[req_index] = torch.tensor(
                prompt_token_ids, dtype=torch.int64
            )
vllm/v1/worker/gpu/model_runner.py data-contract

池化请求的统一入队点,本次删除了对 prompt_token_ids 的无条件断言;正是这行断言把 prompt-embeds-only 请求变成 EngineDeadError。

    # 文件:vllm/v1/worker/gpu/model_runner.py(保留本次改动附近的核心分支)
    def add_requests(self, scheduler_output: SchedulerOutput) -> None:
        for new_req_data in scheduler_output.scheduled_new_reqs:
            assert new_req_data.prefill_token_ids is not None
            req_id = new_req_data.req_id
​
            # 中间省略:流式输入更新先 _remove_request,再登记 req_states / req_index、
            # 自适应投机校验状态等既有逻辑。
​
            if self.pooling_runner is not None:
                assert new_req_data.pooling_params is not None
                # 本次删除了这一行:assert new_req_data.prompt_token_ids is not None
                # 原因是它把“是否需要 token id”的判断强加给所有池化请求,
                # 使 prompt_embeds-only 的请求在入队阶段就抛 AssertionError,
                # 最终以 EngineDeadError 的形式暴露给用户。
                # 现在该判断交由 pooling_runner 依据 pooling_params.requires_token_ids 完成。
                self.pooling_runner.add_request(
                    req_id,
                    req_index,
                    new_req_data.pooling_params,
                    new_req_data.prompt_token_ids, # 允许为 None
                )

评论区精华

契约放宽的落点:断言留在调用方还是下沉到使用侧 设计

PR 没有逐行评论(review_comments_count = 0),但改动本身给出了取舍:与其在 model_runner.add_requests 保留无条件断言并为 prompt_embeds 请求开特例,不如把 prompt_token_ids 变为 Optional,并在 pooling_runner.add_request 内由 pooling_params.requires_token_ids 驱动校验,对应 PR 描述中的“enforces the token-ID requirement only for tasks that declare requires_token_ids”。

结论:方案被采纳;njhill 以 APPROVED 放行,无反对意见,现有 token 化请求与依赖 token id 的池化任务行为不变。 · 已解决

CI 触发与合并流程 other

njhill 在合并前评论“/ci run”,触发 Buildkite CI #88602(commit 835c1975f9d2),随后批准合并。PR 描述列举了本地验证:test_prompt_embeds_state.py 4 passed、test_all_pooling_plus_chunked_prefill.py 3 passed、test_splade_sparse_pooler.py 12 passed、test_jina_reranker_v3.py::test_offline 1 passed,整体 29 tests passed。

结论:CI 通过并由维护者批准,改动以修复 commit 加一次 main 合并提交落地。 · 已解决

fork PR 上自动化审查被跳过 other

claude[bot] 以 COMMENTED 状态说明“This pull request is from a fork — automated review is disabled”,需维护者评论 @claude review 才会执行一次性审查;本次未触发。

结论:这段放宽入队契约的改动只经维护者人工批准,没有自动化深度审查;对 4 行改动而言影响有限。 · 已解决

缺少 prompt_embeds 场景的自动化回归用例 测试

改动未包含任何测试文件变更(changed_files_count = 2,均为源码),验证依赖 PR 描述中的手工 reproducer 与既有用例清单;讨论中无人提出补齐测试。

结论:未在本次补齐;建议后续在 vllm/v1/worker 或 pooling 集成测试中加入 MRV2 下 prompt_embeds 与普通 token 输入混合 batch 的用例,防止同类回归。 · unresolved

风险与影响

1) 契约放宽带来的静默失败面:prompt_token_ids 变为可选后,校验完全依赖每个 pooler 的 requires_token_ids 声明。若某个池化任务实际需要 token id 却未声明该标志,错误会从入队期的断言(即时、可定位)推迟到 _get_pooling_metadata 或 pooler 内部,表现可能更隐蔽,例如构造出长度或内容不正确的输入。
2) 无自动化回归测试:修复仅由手工 reproducer 与既有用例覆盖,仓库中没有新增覆盖“MRV2 下 prompt_embeds 与普通 token 输入混合 batch”的用例,也没有断言 prompt_embeds-only 请求不崩溃的测试文件变更,同类回归存在再次出现的可能。
3) 混合批次路径需自查:self.prompt_token_ids 以 req_index 为键,批内可能同时存在需要与不需要 token id 的请求;当 any(params.requires_token_ids ...) 为真时,_get_pooling_metadata 会为整批构造 prompt_token_ids_cpu,此时批内缺失项如何对齐需要继续确认,属于本次改动后最值得复核的边界。
4) 影响范围受限于 opt-in 路径:MRV2 需显式 VLLM_USE_V2_MODEL_RUNNER=1,默认路径不受影响;但对该开关的已启用用户,原行为是崩溃级(EngineDeadError),因此这是修复而非新风险。

对用户:开启 V2 model runner 的解码器池化用户(Qwen3-Embedding-0.6B、BAAI/bge-multilingual-gemma2、intfloat/e5-mistral-7b-instruct)可以用预计算 prompt_embeds 直接取嵌入,不必再伪造 token id;PR 的精度表显示 embeds 路径与 token id 路径最大绝对差为 0,说明计算结果等价。对系统:改动收敛在 worker 侧入队契约,model_runner.add_requests → pooling_runner.add_request 的职责边界更清晰——调用方只负责传递数据,是否必须存在由任务能力声明决定,后续新增池化任务时不必再改 model_runner。对团队:这是 MRV1 → MRV2 迁移中一个典型的 parity gap,提示迁移验证清单需要显式覆盖“非 token 输入”(prompt embeds、多模态、缓存等)路径,而不能只跑文本 token 流程。影响程度整体为局部、低风险但修复价值明确。

无新增回归测试 请求入队契约放宽 仅影响 MRV2 可选路径 崩溃级回归修复

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论