Prhub

#52529 [Bugfix][Frontend] Only echo the assistant turn in batched chat completions

原始 PR 作者 Kaif10 合并时间 2026-08-30 00:44 文件变更 2 提交数 2 评论 12 代码增减 +106 / -1

执行摘要

批量聊天 echo 不再回显用户 prompt

PR body 明确指出:在批处理路径中 echo=true 会把 conversation[-1]["content"] 无条件拼接到模型回答前,而默认 add_generation_prompt=true 时最后一条消息是用户的 prompt,导致响应变成 "USER PROMPTASSISTANT ANSWER",预期应为 "ASSISTANT ANSWER"。非批处理路径 serving.py 已有角色守卫,批处理路径缺失,此 PR 用于恢复两条路径的行为一致性。

值得精读。变更虽只有一行核心条件,但包含两点学习价值:一是"两处语义相同的代码因漏改产生行为漂移"的典型修复案例,可与 serving.py 对照阅读;二是测试设计巧妙——用 new 绕过初始化、以 stub async generator 驱动真实生产方法,实现秒级且免 GPU 的回归,适合在 vLLM 前端测试中推广。

讨论亮点

DarkLight1337 在评论中质疑"这是否与 OpenAI 官方 API 行为一致",作者 Kaif10 澄清:echo 根本不是 OpenAI Chat Completions 参数(仅遗留 /v1/completions 端点有),它是 vLLM 扩展字段,对照的是 vLLM 自身文档契约——protocol.py 中描述为"prepended with the last message if they belong to the same role";且非批处理路径的流式与非流式分支早已实现该契约,本 PR 只是让批处理路径与之对齐,而非引入新规则。DarkLight1337 随后认可解释并批准("Thanks for the explanation and LGTM")。

实现拆解

  1. 定位差异:对比 batch_serving.py 的 chat_completion_full_generator_batch 与 serving.py 的单条路径实现,发现批处理 echo 分支只检查 conversation 非空且最后一条消息含 content,未检查其 role;而 serving.py 的流式与非流式两个分支均使用 conversation[-1].get("role") == role 守卫。
  2. 修复核心:在 batch_serving.py 的 echo 分支条件中追加 role 比较。role 的取值逻辑与单条路径一致:add_generation_prompt=True 时取 self.response_role(即 assistant),否则取最后一条消息的 role。这样默认场景下 user 消息不再被回显,而 continue_final_message=true 时末位 assistant 前缀仍能正确回显。
  3. 测试配套:在 test_batched_chat_completions.py 新增 101 行、两个回归测试(test_batched_echo_does_not_prepend_user_prompt 与 test_batched_echo_prepends_matching_assistant_prefix)。测试通过 OpenAIServingChatBatch.new 绕过需要引擎的 init,配合 stub async generator 直接驱动真实生产方法 chat_completion_full_generator_batch,免 GPU 秒级运行;同时新增 _make_request_output 与 _generator 辅助构造 RequestOutput 流。
  4. 验证与合并:修复前 1 失败 1 通过,修复后 2 通过;ruff check 与 pre-commit 通过;Buildkite 主 CI 全绿后由 DarkLight1337 批准、vllm-bot 合并。既有 5 个 e2e 测试需要 RemoteOpenAIServer 与 GPU,未改动。
文件 模块 状态 重要度
vllm/entrypoints/openai/chat_completion/batch_serving.py 批处理服务 modified 5.59
tests/entrypoints/openai/chat_completion/test_batched_chat_completions.py 批处理测试 modified 6.83

关键符号

chat_completion_full_generator_batch _make_request_output _generator test_batched_echo_does_not_prepend_user_prompt test_batched_echo_prepends_matching_assistant_prefix

关键源码片段

vllm/entrypoints/openai/chat_completion/batch_serving.py core-logic

批处理聊天补全的响应组装核心文件,本 PR 在此补充 echo 的角色守卫,是行为修复的落点。

# batch_serving.py —— chat_completion_full_generator_batch 内 echo 分支(修复后)
if request.echo:
    conversation = all_conversations[prompt_idx]
    last_msg_content: str | list[dict[str, str]] = ""
    # 角色守卫是本 PR 的核心:默认 add_generation_prompt=True 时 role 恒为
    # assistant,而 conversation[-1] 通常是 user 消息;不加此校验会把用户
    # 自己的 prompt 拼进助手回答。该守卫此前只存在于 serving.py 单条路径。
    if (
        conversation
        and "content" in conversation[-1]
        and conversation[-1].get("role") == role
    ):
        last_msg_content = conversation[-1]["content"] or ""
    # 多模态输入下 content 可能是 list[dict],需拼合各文本片段再回显
    if isinstance(last_msg_content, list):
        last_msg_content = "\n".join(
            msg["text"] for msg in last_msg_content
        )
    message.content = last_msg_content + (message.content or "")
tests/entrypoints/openai/chat_completion/test_batched_chat_completions.py test-coverage

新增两个回归测试(101 行),用免 GPU 的方式直接驱动生产代码,验证 user prompt 不回显且 assistant 前缀仍回显,是本 PR 的验证主体。

# 回归测试核心:绕过需要引擎的 __init__,直接用 __new__ 建对象,
# 再以手工 stub 的 async generator 驱动真实生产方法
# chat_completion_full_generator_batch,实现免 GPU 的秒级回归。
serving = OpenAIServingChatBatch.__new__(OpenAIServingChatBatch)
serving.response_role = "assistant"
serving.system_fingerprint = None
​
conversations = [[{"role": "user", "content": "USER PROMPT"}]]
request = BatchChatCompletionRequest(
    model="test-model",
    messages=conversations,
    echo=True, # 默认 add_generation_prompt=True,此时无合法回显前缀
)
​
response = await serving.chat_completion_full_generator_batch(
    request=request,
    generators=[_generator(0, "ASSISTANT ANSWER")],
    request_id="req-echo",
    model_name="test-model",
    all_conversations=conversations,
    tokenizer=None,
    request_metadata=RequestResponseMetadata(request_id="req-echo"),
)
​
# 修复前此断言失败(得到 "USER PROMPTASSISTANT ANSWER"),
# 修复后仅保留助手回答,用户 prompt 不再泄漏。
assert response.choices[0].message.content == "ASSISTANT ANSWER"

评论区精华

echo 行为是否与 OpenAI 官方 API 一致 question

DarkLight1337 提问:"Is this consistent with official OpenAI API behavior?"。Kaif10 回应:echo 不是 OpenAI Chat Completions 参数(仅遗留 /v1/completions 有),vLLM 的字段文档写明 "prepended with the last message if they belong to the same role",非批处理路径两处分支均已实现该契约,本 PR 只是让批处理路径对齐。

结论:以 vLLM 自身字段文档为基准而非 OpenAI 行为;DarkLight1337 认可解释并批准("Thanks for the explanation and LGTM")。 · 已解决

CI 触发与 pre-run-check 状态 other

作者询问 Buildkite 主 CI 通过后 pre-run-check 的失败记录是否需要重跑;DarkLight1337 两次 /ci retry,最终确认 pre-commit 通过且主 CI 在最近一次合并前已通过,可以合入。

结论:pre-commit 与主 CI 通过即满足合并条件,pre-run-check 的失败均早于 ready 标签,无需重跑。 · 已解决

风险与影响

行为变更风险:所有使用批处理接口且开启 echo 的调用方输出会变化——原本错误拼入的用户 prompt 前缀被移除。旧行为违反 vLLM 自身字段契约,但依赖旧输出的下游(如测试断言)可能受波及,属预期内的破坏性修正。
测试构造风险:通过 new 绕过 init、手工赋值 response_role 与 system_fingerprint 的方式依赖实例属性名与生产方法签名稳定,未来若构造函数或参数重构,该测试可能失配甚至误导。
覆盖缺口:作者无法本地运行依赖 RemoteOpenAIServer 与 GPU 的 5 个 e2e 测试,不过其未改动且 CI 已验证;多模态 content 为 list[dict] 时的拼合逻辑保持原样,不受角色守卫影响。

用户侧:使用 POST /v1/chat/completions/batch 且开启 echo=true 的用户,响应将不再包含用户自身 prompt 的重复内容,输出符合字段文档描述;continue_final_message 场景不受影响。
系统侧:仅前端响应组装逻辑,不触及生成与调度路径,无性能、内存或令牌数影响。
团队侧:补齐了批处理与单条路径的行为一致性缺口,并提供了一种免 GPU 快速回归前端生产代码的测试模式(new + stub generator),可复用于后续同类测试。

API 行为变更 测试构造依赖内部属性 缺少 GPU e2e 覆盖

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论