Prhub

#44267 [Refactor] Unify reasoning + tool-call parsing behind Parser.parse()

原始 PR 作者 sfeng33 合并时间 2026-06-02 15:11 文件变更 5 提交数 1 评论 6 代码增减 +409 / -152

执行摘要

统一推理与工具调用解析到 Parser.parse()

PR body 指出此前 OpenAIServingChat.chat_completion_full_generator 执行了两步独立的操作:reasoning_parser.extract_reasoning 和 OpenAIServing._parse_tool_calls_from_content,目的是将 reasoning 提取和 tool call 提取整合到单个统一入口,简化代码减少重复。

值得精读,因为统一解析入口是前端架构重构的关键步骤,为后续支持更多解析组合打下基础。需关注作者关于“匹配 streaming”的设计决策及其潜在的兼容性影响。

讨论亮点
  • Parser is None 时 tool_calls 处理:depthfirst-app[bot] 指出当 parser 为 None 时新代码将 tool_calls 无条件设为 [],而旧代码会处理 named/required tool_choice,可能漏掉结构化调用。作者回复这是为了匹配 streaming 路径的行为。
  • _extract_tool_calls 提前返回:同样由 bot 指出在 tool_parser 为 None 时提前返回 [],绕过了 named/required tool_choice 处理。作者再次确认匹配 streaming。

实现拆解

  1. 在 Parser 抽象基类中新增抽象的 parse() 方法,返回 (reasoning, content, tool_calls) 元组(位于 vllm/parser/abstract_parser.py)。
  2. 在 DelegatingParser 中实现 parse() 方法,依次调用 extract_reasoning_extract_tool_calls;新增 _extract_tool_calls 方法,将原本在 OpenAIServing._parse_tool_calls_from_content 中的逻辑移动过来并适配为实例方法(涉及 vllm/parser/abstract_parser.py 的导入和符号调整)。
  3. 删除 OpenAIServing._parse_tool_calls_from_content 静态方法,清理相关导入(vllm/entrypoints/openai/engine/serving.py 中删除约 126 行)。
  4. 修改 chat_completion_full_generator 方法,接受 Parser 对象替代 reasoning_parser,并在其中调用 parser.parse() 替代原先的两步处理(vllm/entrypoints/openai/chat_completion/serving.py)。
  5. _create_chat_completion 中实例化 parser 对象并传递给 full_generator;添加条件判断,仅在 self.parser_cls 不为 None 时构建 parser(chat_completion/serving.py)。
  6. 测试配套:新增 tests/parser/test_parse.py 覆盖纯文本、reasoning、tool call 及其组合的各种解析场景;调整 tests/entrypoints/openai/test_tool_choice_content_none.py 改用 _extract_tool_calls 测试。
文件 模块 状态 重要度
vllm/parser/abstract_parser.py 解析器 modified 8.15
vllm/entrypoints/openai/engine/serving.py 服务层 modified 7.66
vllm/entrypoints/openai/chat_completion/serving.py 前端入口 modified 6.29
tests/parser/test_parse.py 测试 added 7.76
tests/entrypoints/openai/test_tool_choice_content_none.py 测试 modified 5.23

关键符号

Parser.parse DelegatingParser._extract_tool_calls DelegatingParser.parse OpenAIServing._parse_tool_calls_from_content

关键源码片段

tests/parser/test_parse.py test-coverage

新增完整测试文件,覆盖统一解析的各种场景(纯文本、推理、工具调用及组合),验证新接口的正确性。

# SPDX-License-Identifier: Apache-2.0import json
import pytestfrom vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
from vllm.parser.abstract_parser import _WrappedParser
from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser
from vllm.tool_parsers.hermes_tool_parser import Hermes2ProToolParser
​
​
class ThinkReasoningParser(BaseThinkingReasoningParser):
    """测试用的简化 reasoning parser"""
    @property
    def start_token(self) -> str:
        return "<think>"
    @property
    def end_token(self) -> str:
        return "</think>"
​
​
# 包含 reasoning + tool call 的模型输出
MODEL_OUTPUT = (
    "<think>let me think about this</think>"
    '<tool_call>\n{"name": "get_weather", "arguments": {"city": "Dallas"}}\n</tool_call>'
)
PLAIN_TEXT = "The weather in Dallas is sunny and 75°F."
​
​
@pytest.fixture(scope="module")
def tokenizer():
    from vllm.tokenizers import get_tokenizer
    return get_tokenizer("Qwen/Qwen3-32B")
​
​
def make_request(**overrides):
    base = {"model": "test-model", "messages": [{"role": "user", "content": "hi"}]}
    base.update(overrides)
    return ChatCompletionRequest.model_validate(base)
​
​
TOOLS = [{"type": "function", "function": {"name": "get_weather", "parameters": {"type": "object", "properties": {}}}}]
​
​
def make_parser(tokenizer, reasoning=False, tool=False):
    # 通过设置类属性指定使用哪个 reasoning/tool parser
    _WrappedParser.reasoning_parser_cls = ThinkReasoningParser if reasoning else None
    _WrappedParser.tool_parser_cls = Hermes2ProToolParser if tool else None
    return _WrappedParser(tokenizer)
​
​
def test_parse_plain_text_with_reasoning_parser(tokenizer, reasoning, tool):
    parser = make_parser(tokenizer, reasoning=True, tool=True)
    request = make_request()
    # 统一解析入口,返回 (reasoning, content, tool_calls)
    r, content, tool_calls = parser.parse(PLAIN_TEXT, request)
    # 纯文本没有 tool calls
    assert r == PLAIN_TEXT
    assert content is None # reasoning parser 会消耗 content
    assert len(tool_calls) == 0
​
​
def test_parse_both_parsers(tokenizer):
    parser = make_parser(tokenizer, reasoning=True, tool=True)
    request = make_request(tools=TOOLS)
    reasoning, content, tool_calls = parser.parse(
        MODEL_OUTPUT, request, enable_auto_tools=True)
    assert reasoning is not None and "let me think about this" in reasoning
    assert len(tool_calls) == 1
    assert tool_calls[0].name == "get_weather"
    assert json.loads(tool_calls[0].arguments) == {"city": "Dallas"}

评论区精华

Parser is None 时 tool_calls 处理 正确性

depthfirst-app[bot] 指出当 parser 为 None 时新代码将 tool_calls 无条件设为 [],而旧代码会处理 named/required tool_choice,可能漏掉结构化调用。

结论:作者回复这是为了匹配 streaming 路径的行为。 · 已解决

_extract_tool_calls 提前返回跳过 named/required 正确性

depthfirst-app[bot] 指出当 tool_parser 为 None 时 _extract_tool_calls 直接返回,绕过了 named/required tool_choice 处理。

结论:作者确认这是为了匹配 streaming 路径的行为。 · 已解决

风险与影响

  1. 行为回归风险:当 parser 为 None(无 reasoning 也无 tool parser)时,非流式路径不再处理 namedrequiredtool_choice,而旧代码会通过 _parse_tool_calls_from_content 处理。这可能导致此类请求的 tool call 被错误地当作纯文本返回。
  2. 流式与非流式路径对齐风险:作者声明匹配 streaming 行为,但 streaming 路径本身对此类场景的处理是否合理需要验证。
  3. 依赖关系清理欠缺vllm/entrypoints/openai/engine/serving.py 中删除了大量导入和约 126 行代码,但未添加新的异常处理路径,可能遗漏边缘情况。

影响非流式 /v1/chat/completions 请求的解析路径,尤其涉及 tool_choicenamedrequired 且未配置任何 parsing 的场景。团队前端负责人和推理服务开发者需注意行为变化。整体影响范围有限(仅非流式路径)。

行为回归 streaming 对齐 工具解析回退缺失

关联 Issue

未识别关联 Issue

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

完整报告

参与讨论