Prhub

#48617 [Render] Add round trip parity test and docs for derender

原始 PR 作者 hickeyma 合并时间 2026-07-17 13:02 文件变更 7 提交数 4 评论 11 代码增减 +397 / -2

执行摘要

新增 derender 往返测试与文档,修复工具调用 content 为 null 的 bug

确保 derender 端点的解析逻辑与标准聊天补全路径完全一致,防止因路径分裂导致的输出差异(如工具调用时的 content 缺失)。同时为 derender API 建立完整的文档说明,降低用户在分离式部署中的集成成本。参考 issue #42729 中提出的 'derender 应为 render 的精确逆转' 要求。

该 PR 值得精读,尤其是以下几点:

  1. 测试设计(使用同一生成的 token IDs 对比两个解析路径)可复用于其他分离式服务的验证。
  2. 文档中采用 --8<-- snippet 直接嵌入模型定义,保证单一事实来源,是编写 API 文档的推荐模式。
  3. 团队有关代码复用的讨论(derender vs coupled 路径)为后续重构提供了清晰的方向。
讨论亮点

主要围绕文档改进和代码复用风险。

  • sagearc 建议用 snippet 替代字段表,避免文档与模型定义脱节。hickeyma 采纳并在协议模型中添加了 docstrings 和 --8<-- 标记。
  • sagearc 指出管道图仅展示 token_ids,而 derender 还需要 chat_requestprompt_tokens。hickeyma 更新了图描述。
  • sagearc 建议移除关于负载上限的实现细节,让运行时错误自己说明。hickeyma 遵循。
  • sagearc 指出 bug 修复只是临时修补,derender_chat 重复了 ChatMessage 组装逻辑,建议后续合并为共同路径。hickeyma 同意并记录为 RFC #42729 的后续工作。

实现拆解

  1. 新增往返测试套件:创建 tests/entrypoints/scale_out/derender/test_derender_parity.py,使用 RemoteOpenAIServer 启动同时挂载 chat completions 和 derender 端点的服务。核心逻辑是通过 return_token_ids=True 从 coupled 响应中提取输出 token ID,然后送入 derender 路径,通过 _assert_parity 断言两个响应的 contentreasoningtool_callsfinish_reasonusage 字段完全一致。覆盖 5 种组合:纯文本、推理(<think>)、工具调用(可选和强制)、推理+工具调用、logprobs。

  2. 修复强制工具选择时的 content 为 null 问题:在 vllm/renderers/online_derenderer.pyderender_chat 方法中,当 tool_choiceChatCompletionNamedToolChoiceParam 对象或字符串 "required" 时,将 contentNone 转换为空字符串 ""。因为 coupled 路径在强制工具选择时始终返回空字符串,而 derender 路径原本返回 None,导致 _assert_parity 失败。

  3. 增强协议模型的文档标注:在 vllm/entrypoints/scale_out/token_in_token_out/protocol.pyDerenderChatRequestDerenderCompletionRequest 类中添加字段级 docstring,并插入 # --8<-- [start:...] 标记,便于文档通过 snippet 反引用保持单一事实来源。

  4. 编写 derender API 文档:新增 docs/serving/online_serving/derenderer.md,包含管道图、端点列表、请求格式嵌入(使用 snippet)、完整 Python 示例(render → generate → derender 闭环)以及安全相关说明(负载上限拒绝、prompt 注入保护)。同步更新 README.mdrenderer.mdsecurity.md 中的交叉引用链接。

文件 模块 状态 重要度
tests/entrypoints/scale_out/derender/test_derender_parity.py 解渲染 added 8.05
vllm/renderers/online_derenderer.py 解渲染器 modified 6.13
vllm/entrypoints/scale_out/token_in_token_out/protocol.py 协议 modified 4.86
docs/serving/online_serving/derenderer.md 文档 added 4.72
docs/serving/online_serving/README.md 文档 modified 2.1
docs/serving/online_serving/renderer.md 文档 modified 1.32
docs/usage/security.md 文档 modified 1.32

关键符号

_assert_parity _run_parity_case test_parity_plain test_parity_reasoning test_parity_tool test_parity_reasoning_tool test_parity_logprobs OnlineDerenderer.derender_chat

关键源码片段

tests/entrypoints/scale_out/derender/test_derender_parity.py test-coverage

新增整个往返测试套件,是 PR 的核心变更,覆盖 5 种场景,确保 derender 路径与 coupled 路径解析完全一致。

from tests.utils import RemoteOpenAIServerMODEL = 'deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B'
ARGS = [
    '--enable-auto-tool-choice',
    '--tool-call-parser',
    'hermes',
    '--reasoning-parser',
    'deepseek_r1',
]
TOOLS = [
    {
        'type': 'function',
        'function': {
            'name': 'get_weather',
            'description': 'Get weather for a city',
            'parameters': {
                'type': 'object',
                'properties': {'city': {'type': 'string'}},
            },
        },
    }
]
FORCE_WEATHER_TOOL = {'type': 'function', 'function': {'name': 'get_weather'}}
​
​
def _assert_parity(coupled: dict, disagg: dict) -> None:
    """Both paths saw the same tokens, so they must agree unconditionally."""
    c, d = coupled['choices'][0], disagg['choices'][0]
    # 检查 content、reasoning、tool_calls(通过标准化签名)和 finish_reason
    assert d['message']['content'] == c['message']['content']
    assert d['message'].get('reasoning') == c['message'].get('reasoning')
    assert _tool_sig(d) == _tool_sig(c)
    assert d['finish_reason'] == c['finish_reason']
    # usage 字段:prompt_tokens 直接相等,completion_tokens 通过 token_ids 长度验证
    assert disagg['usage']['prompt_tokens'] == coupled['usage']['prompt_tokens']
    assert disagg['usage']['completion_tokens'] == len(c['token_ids'])
​
​
async def _run_parity_case(
    client: httpx.AsyncClient, messages: list[dict], **extra
) -> tuple[dict, dict]:
    """Run the coupled request then feed its generated tokens into the
    disaggregated derender endpoint. Returns (coupled, disagg)."""
    coupled = await _coupled(client, messages, **extra)
    choice = coupled['choices'][0]
    disagg = await _disagg(
        client,
        output_ids=choice['token_ids'],
        prompt_tokens=coupled['usage']['prompt_tokens'],
        finish_reason=choice['finish_reason'],
        chat_request={
            'messages': messages,
            **extra,
        },
        logprobs=choice.get('logprobs'),
    )
    _assert_parity(coupled, disagg)
    return coupled, disagg

注意:代码块内使用单引号字符串以避免 JSON 转义,实际功能等价。

vllm/renderers/online_derenderer.py core-logic

修复强制工具选择时 content 为 null 的 bug,涉及工具选择分支控制流调整,是唯一的行为修正。

# 导入命名工具选择参数(用于检测强制工具选择)
from vllm.entrypoints.openai.chat_completion.protocol import (
    ChatCompletionNamedToolChoiceParam,
)# 在 derender_chat 方法内部,tool_calls 列表构建完成之后、ChatMessage 创建之前
# 修复:当工具选择为强制(命名或 required)时,确保 content 不为 None
is_named_tool_choice = (
    type(chat_request.tool_choice) is ChatCompletionNamedToolChoiceParam
)
is_required_tool_choice = chat_request.tool_choice == 'required'
if is_named_tool_choice or is_required_tool_choice:
    # 强制工具选择时,coupled 路径返回 content="",而 derender 返回 None
    # 此处将 None 替换为空字符串以保持一致
    content = content or ''message = ChatMessage(
    role='assistant',
    reasoning=reasoning,
    content=content,
    tool_calls=tc_items,
)

注意:代码块内使用单引号字符串以避免 JSON 转义,实际功能等价。

评论区精华

管道图是否应包含 chat_request 和 prompt_tokens? documentation

sagearc 指出管道图仅画了 token_ids 输入,但 derender 还需要原始 chat_request 和 prompt_tokens。hickeyma 更新了图描述以反映实际需要。

结论:文档已更新,在管道图下方补充说明 derender 也消费 chat_request 和 prompt_tokens。 · 已解决

使用 snippet 替代字段表保持单一事实来源 documentation

sagearc 建议用 `--8<--` 嵌入模型字段,而非手动维护字段表,避免文档与代码定义脱节。hickeyma 同意并在协议模型中添加了 docstrings 和 snippet 标记。

结论:已实现,文档现在通过 snippet 直接引用协议定义。 · 已解决

bug 修复暴露的代码复用风险 设计

sagearc 评论指出 `derender_chat` 重新实现了 `ChatMessage` 组装逻辑,与 coupled 路径 (`vllm/entrypoints/openai/chat_completion/serving.py`) 重复,可能导致更多不一致。hickeyma 认可并记录为后续工作。

结论:不阻塞此 PR;已记录到 RFC #42729 作为后续议题。 · 已解决

移除冗余的实现细节段落 documentation

sagearc 建议去掉关于负载上限的实现细节段落,让运行时错误本身负责提示。hickeyma 精简了这部分内容。

结论:已移除冗余说明。 · 已解决

风险与影响

  1. 测试稳定性:测试依赖真实 GPU 和固定的 DeepSeek 1.5B 模型。如果模型在不同环境中输出差异(即使 token_ids 相同,但解析路径可能有隐式状态),可能导致假阳性。但测试通过提取相同输出的 token_ids 消除了生成随机性,风险较低。
  2. 回归风险:bug 修复仅针对强制工具选择路径,但 derender_chat 方法与 coupled 路径的 ChatMessage 组装不共享代码,其他路径(如可选工具调用、无工具调用)可能仍存在隐藏的不一致,但测试覆盖了主要场景。
  3. 性能影响:无。
  4. 安全影响:文档新增了安全说明(负载上限拒绝),系统防线更明确。无新增攻击面。
  • 用户角度:self-hosting 用户现在可以查阅 derender API 的完整文档,包括请求格式和示例,降低了集成门槛。强制工具调用场景的 bug 修复使 derender 行为与 coupled 路径一致,避免解析异常。
  • 系统角度:新增的 e2e 测试在 CI 中运行,可以及早发现 derender 解析回归,提升代码库的鲁棒性。
  • 团队角度:协议模型 docstring 和 snippet 标记简化了文档维护,减少信息不一致。后续推动解析路径统一可能会降低代码重叠带来的维护成本。
测试依赖 GPU 和特定模型(DeepSeek 1.5B) 测试可能因模型输出不确定性导致假阴性?但通过提取同一生成消除了随机性 bug 修复仅在强制工具选择路径生效,其他路径可能仍存在隐藏不一致 拆分解析路径(derender vs coupled)可能导致后续不一致蔓延

关联 Issue

#42729 [RFC]: Derender Endpoints to Provide Detokenization for Disaggregated Serving

完整报告

参与讨论