Prhub

#45919 [Render] Add reasoning/tool parsing to /derender + fix byte-fallback FFFD

原始 PR 作者 aoshen02 合并时间 2026-06-21 07:43 文件变更 4 提交数 6 评论 30 代码增减 +601 / -37

执行摘要

修复 derender 的 U+FFFD 并添加推理 / 工具解析

PR #45045 的推理/工具解析功能未合并到主分支,且 PR #43606 引入的字节回退处理导致 /derender 返回的 logprob token 中出现 U+FFFD 替换字符。社区用户报告了此回归问题(在 PR #45919 评论中有讨论)。为了使 RL 训练环能接收到结构化的推理和工具调用内容并修复回归问题,本 PR 将 parser 支持集成到 derender 端点并修复 FFFD。

建议精读 vllm/entrypoints/serve/render/serving.py 中的 _correct_decoded_token_resolve_logprobs 实现,理解利用 tokenizer.decode() 上下文修复 FFFD 的巧妙方法。同时关注 derender_chat_response 中的 parser 接入逻辑,可作为其他端点集成 parser 的参考。测试用例的 e2e roundtrip fixture 也值得学习。

讨论亮点
  • sfeng33 指出 chat_message_builder.py 中的 mistral 相关检查是废弃代码,应移除。aoshen02 同意并清除。
  • sfeng33 建议 derender parser 路径不需要复杂的 tool_choice 逻辑、finish_reason 计算和并行工具调用,假设 RL 场景为 auto。aoshen02 简化了实现。
  • 关于 reasoning_parser 参数:sfeng33 询问改为 args.reasoning_parser 是否等价;aoshen02 解释 init_render_app_state 不调用 create_engine_config()structured_outputs_config 字段始终默认,而 args.reasoning_parser 始终正确。sfeng33 接受。
  • sfeng33 询问某行 chat_kwargs 检查是否必要;aoshen02 解释 Harmony parser 不需要该参数,但 Qwen3 等需要,故保留。
  • cjackal 报告 streaming chat completion 的 FFFD 问题;sfeng33bbrowning 确认是引擎 parser 的 bug,不在本 PR 范围内。

实现拆解

  1. 新建聊天消息构建器:在 vllm/entrypoints/serve/utils/chat_message_builder.py 中新增 build_chat_message() 函数,作为聊天消息构造的唯一信源,集中处理 tool_choicetool_call_id 生成,被配合聊天路径和 /derender 端点共用。
  2. 增强 derender 响应主函数:修改 vllm/entrypoints/serve/render/serving.py
    • 新增 _parse_token_id_placeholder() 解析 token_id:N 占位符。
    • 新增 _correct_decoded_token(),利用前 4 个上下文 token 通过 tokenizer.decode() 逐步修复 U+FFFD,与 v1/engine/logprobs.py 中的方法对称。
    • 重写 _resolve_logprobs(),跟踪 context_token_ids,检测到 FFFD 时调用修复函数。
    • derender_chat_response() 中添加 parser 感知路径:当提供 chat_request 时,实例化 Parser 并解析整个输出 token 序列,提取 reasoning_contenttool_calls,构造 ChatCompletionResponseChoice
  3. 更新协议文档:在 vllm/entrypoints/serve/disagg/protocol.py 中为 DerenderChatRequestDerenderCompletionRequest 添加明确注释,强调仅支持非流式,并提及流式 derender 需要单独设计。
  4. 修复 API 服务初始化:在 vllm/entrypoints/openai/api_server.py 中将 init_render_app_state() 中的 reasoning_parser 参数来源从 args.structured_outputs_config.reasoning_parser 改为 args.reasoning_parser,因为渲染进程不经过 AsyncEngineArgs 后处理,原字段始终为默认值。
  5. 补充测试覆盖:在 tests/entrypoints/serve/render/test_derender.py 中新增基于 deepseek-ai/DeepSeek-R1-Distill-Qwen-1.5B 的端到端 roundtrip 测试,覆盖纯文本、推理内容、工具调用场景;新增辅助函数 _require_markers_survive 检查特殊标记在 encode-decode 回合中是否丢失。测试覆盖了 parser_server 和 parser_client 等 pytest fixture。
文件 模块 状态 重要度
vllm/entrypoints/serve/render/serving.py 渲染端点 modified 8.33
tests/entrypoints/serve/render/test_derender.py 测试用例 modified 7.24
vllm/entrypoints/serve/disagg/protocol.py 协议定义 modified 4.43
vllm/entrypoints/openai/api_server.py API 启动 modified 4.7

关键符号

derender_chat_response _resolve_logprobs _correct_decoded_token _parse_token_id_placeholder build_chat_message

关键源码片段

vllm/entrypoints/serve/render/serving.py core-logic

核心变更文件:实现 parser 感知路径、FFFD 修复、token_id 占位符解析

def _correct_decoded_token(
    token_id: int, context_token_ids: list[int], tokenizer: TokenizerLike
) -> str:
    # 利用最多 4 个上下文 token 修复字节回退导致的 FFFD
    REPLACEMENT_CHAR = '\ufffd'
    max_ctx = min(len(context_token_ids), 4)
​
    for num_ctx in range(1, max_ctx + 1):
        context = context_token_ids[-num_ctx:]
        full_decoded = tokenizer.decode(context + [token_id])
​
        # 如果 decode 结果仍以替换字符结尾,尝试更多上下文
        if full_decoded.endswith(REPLACEMENT_CHAR):
            continue
​
        # 清理 context 中自身 decode 为替换字符的部分
        clean_end = len(context)
        for j in range(len(context) - 1, -1, -1):
            if tokenizer.decode([context[j]]).endswith(REPLACEMENT_CHAR):
                clean_end = j
            else:
                break
​
        clean_prefix = tokenizer.decode(context[:clean_end]) if clean_end > 0 else ''
​
        # 如果 full_decoded 以 clean_prefix 开头,直接截取 token 部分
        if full_decoded.startswith(clean_prefix):
            return full_decoded[len(clean_prefix):]
​
        # 否则计算公共前缀长度
        common_len = 0
        for a, b in zip(clean_prefix, full_decoded):
            if a != b:
                break
            common_len += 1
        return full_decoded[common_len:]
​
    return ''

评论区精华

移除 chat_message_builder 中的 mistral 废弃代码 设计

sfeng33 指出 mistral 相关检查是废弃代码,应移除

结论:aoshen02 同意并移除 · 已解决

简化 derender parser 路径的 tool_choice 和 finish_reason 逻辑 设计

sfeng33 建议 derender 路径不需要复杂 tool_choice 和 finish_reason 逻辑,假设 RL 场景为 auto

结论:aoshen02 简化实现 · 已解决

reasoning_parser 参数来源变更 正确性

sfeng33 问 args.reasoning_parser 是否等价于 args.structured_outputs_config.reasoning_parser

结论:aoshen02 解释 init_render_app_state 不调用 create_engine_config,原字段为空,等价 · 已解决

derender 仅支持非流式是否需要额外检查 设计

sfeng33 询问某处 chat_kwargs 检查是否必要;aoshen02 解释某些 parser 需要 chat_template_kwargs

结论:保留该检查,被接受 · 已解决

社区用户报告 streaming FFFD 问题,不在本 PR 范围 question

cjackal 报告 streaming chat completion 出现 FFFD,怀疑与本 PR 有关

结论:sfeng33 澄清是不同路径,引擎 parser 问题,本 PR 不解决 · 已解决

风险与影响

  1. 废弃代码残留chat_message_builder.py 中可能仍包含已废弃的 mistral 路径,虽经清理但仍需确认。
  2. Parser 实例化依赖:当提供 chat_request 时,derender_chat_response 会实例化 Parser,若请求上下文不完整可能抛异常。
  3. 非流式限制:Derender 端点明确非流式,调用方如期望流式输出将不工作。
  4. FFFD 修复不完全_correct_decoded_token 只回溯最多 4 个上下文 token,极端情况仍需更多上下文。
  5. 回归风险较低:改动集中于 derender 路径,不影响其他 API,且有新增测试覆盖。

用户影响:使用 /v1/chat/completions/derender 的用户可获得结构化的 reasoning_contenttool_calls,且 logprob token 不再出现 U+FFFD。
系统影响:新增 parser 实例化路径仅当提供 chat_request 时触发,不影响无 parser 的传统路径,性能影响可忽略。
团队影响build_chat_message() 作为共享函数减少了重复代码,为未来流式 derender 和 parser 扩展奠定基础。

废弃 mistral 代码残留 parser 实例化依赖完整配置 仅非流式 derender

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论