@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 967572dd5f
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 7, 16:30 UTC

fix(reasoning): Qwen3ReasoningParser returns truncated output as reasoning (#35230)

Signed-off-by: stakeswky <stakeswky@users.noreply.github.com> Co-authored-by: stakeswky <stakeswky@users.noreply.github.com>

不做了睡大觉 committed Feb 27, 2026 at 04:30 UTC 967572dd5f8da947aa4344f0e75516b6ee0ede9b
2 files changed +97 -10
tests/reasoning/test_qwen3_reasoning_parser.py
+77 -5
@@ -9,6 +9,7 @@ from tests.reasoning.utils import (
9 run_reasoning_extraction,
10 run_reasoning_extraction_streaming,
11 )
12 +from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
13 from vllm.reasoning import ReasoningParser, ReasoningParserManager
14
15 parser_name = "qwen3"
@@ -58,12 +59,14 @@ WITH_THINK_STREAM = {
59 "content": "This is the rest",
60 }
61
61 -# --- No think tokens at all (thinking disabled) ---
62 +# --- No think tokens at all (thinking enabled, truncated) ---
63
64 +# With thinking enabled (default), no think tokens means the output was
65 +# truncated before </think> could be generated. All output is reasoning.
66 WITHOUT_THINK = {
67 "output": "This is the rest",
65 - "reasoning": None,
66 - "content": "This is the rest",
68 + "reasoning": "This is the rest",
69 + "content": None,
70 }
71 # In streaming, the parser cannot distinguish "thinking disabled" from
72 # "reasoning in progress" when no think tokens have appeared yet.
@@ -87,10 +90,12 @@ MULTILINE_REASONING = {
90 "reasoning": "This is a reasoning\nsection",
91 "content": "This is the rest\nThat",
92 }
93 +# Truncated output: <think> present but no </think> (thinking enabled).
94 +# Everything is reasoning because the output was cut off mid-thought.
95 ONLY_OPEN_TAG = {
96 "output": "<think>This is a reasoning section",
92 - "reasoning": None,
93 - "content": "This is a reasoning section",
97 + "reasoning": "This is a reasoning section",
98 + "content": None,
99 }
100
101 ONLY_OPEN_TAG_STREAM = {
@@ -99,6 +104,20 @@ ONLY_OPEN_TAG_STREAM = {
104 "content": None,
105 }
106
107 +# Truncated output without <think> prefix (Qwen3.5 style where <think>
108 +# is in the prompt). No </think> means truncation — all is reasoning.
109 +TRUNCATED_NO_START_TOKEN = {
110 + "output": "This is a reasoning section",
111 + "reasoning": "This is a reasoning section",
112 + "content": None,
113 +}
114 +
115 +TRUNCATED_NO_START_TOKEN_STREAM = {
116 + "output": "This is a reasoning section",
117 + "reasoning": "This is a reasoning section",
118 + "content": None,
119 +}
120 +
121 TEST_CASES = [
122 pytest.param(
123 False,
@@ -170,6 +189,16 @@ TEST_CASES = [
189 ONLY_OPEN_TAG_STREAM,
190 id="only_open_tag_stream",
191 ),
192 + pytest.param(
193 + False,
194 + TRUNCATED_NO_START_TOKEN,
195 + id="truncated_no_start_token",
196 + ),
197 + pytest.param(
198 + True,
199 + TRUNCATED_NO_START_TOKEN_STREAM,
200 + id="truncated_no_start_token_stream",
201 + ),
202 ]
203
204
@@ -249,3 +278,46 @@ def test_reasoning_streaming_multi_token_deltas(
278
279 assert reconstructor.reasoning == expected_reasoning
280 assert (reconstructor.other_content or None) == expected_content
281 +
282 +
283 +# --- Tests for enable_thinking=False (thinking explicitly disabled) ---
284 +
285 +
286 +THINKING_DISABLED_CASES = [
287 + pytest.param(
288 + "This is plain content",
289 + None,
290 + "This is plain content",
291 + id="thinking_disabled_plain_content",
292 + ),
293 + pytest.param(
294 + "Some output without think tokens",
295 + None,
296 + "Some output without think tokens",
297 + id="thinking_disabled_no_think_tokens",
298 + ),
299 +]
300 +
301 +
302 +@pytest.mark.parametrize(
303 + "output, expected_reasoning, expected_content", THINKING_DISABLED_CASES
304 +)
305 +def test_reasoning_thinking_disabled(
306 + output: str,
307 + expected_reasoning: str | None,
308 + expected_content: str | None,
309 + qwen3_tokenizer,
310 +):
311 + """When enable_thinking=False, output without </think> is all content."""
312 + parser: ReasoningParser = ReasoningParserManager.get_reasoning_parser(parser_name)(
313 + qwen3_tokenizer,
314 + chat_template_kwargs={"enable_thinking": False},
315 + )
316 +
317 + reasoning, content = parser.extract_reasoning(
318 + model_output=output,
319 + request=ChatCompletionRequest(messages=[], model="test-model"),
320 + )
321 +
322 + assert reasoning == expected_reasoning
323 + assert content == expected_content
vllm/reasoning/qwen3_reasoning_parser.py
+20 -5
@@ -11,6 +11,7 @@ from vllm.entrypoints.openai.responses.protocol import (
11 ResponsesRequest,
12 )
13 from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser
14 +from vllm.tokenizers import TokenizerLike
15
16
17 class Qwen3ReasoningParser(BaseThinkingReasoningParser):
@@ -33,6 +34,14 @@ class Qwen3ReasoningParser(BaseThinkingReasoningParser):
34 it is stripped before extraction (non-streaming) or skipped (streaming).
35 """
36
37 + def __init__(self, tokenizer: TokenizerLike, *args, **kwargs):
38 + super().__init__(tokenizer, *args, **kwargs)
39 +
40 + chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {}
41 + # Qwen3 defaults to thinking enabled; only treat output as
42 + # pure content when the user explicitly disables it.
43 + self.thinking_enabled = chat_kwargs.get("enable_thinking", True)
44 +
45 @property
46 def start_token(self) -> str:
47 """The token that starts reasoning content."""
@@ -54,8 +63,11 @@ class Qwen3ReasoningParser(BaseThinkingReasoningParser):
63 If <think> is present (e.g. from a different template), it is
64 stripped before extraction.
65
57 - When thinking is disabled (no </think> in output), returns
58 - (None, model_output) to indicate all output is content.
66 + When thinking is explicitly disabled and no </think> appears,
67 + returns (None, model_output) — all output is content.
68 + Otherwise (thinking enabled, default), a missing </think> means
69 + the output was truncated and everything is reasoning:
70 + returns (model_output, None).
71
72 Returns:
73 tuple[Optional[str], Optional[str]]: reasoning content and content
@@ -68,9 +80,12 @@ class Qwen3ReasoningParser(BaseThinkingReasoningParser):
80 )
81
82 if self.end_token not in model_output:
71 - # No end token means thinking is disabled or the model
72 - # did not produce reasoning. Treat everything as content.
73 - return None, model_output
83 + if not self.thinking_enabled:
84 + # Thinking explicitly disabled — treat everything as content.
85 + return None, model_output
86 + # Thinking enabled but no </think>: output was truncated.
87 + # Everything generated so far is reasoning.
88 + return model_output, None
89
90 # Extract reasoning content from the model output.
91 reasoning, _, content = model_output.partition(self.end_token)