Skip to content

[Bugfix][Parser] qwen3: start in CONTENT when the prompt closed the think block - #53302

Open
vineethsaivs wants to merge 1 commit into
vllm-project:mainfrom
vineethsaivs:fix/qwen3-prompt-closed-think-block
Open

[Bugfix][Parser] qwen3: start in CONTENT when the prompt closed the think block#53302
vineethsaivs wants to merge 1 commit into
vllm-project:mainfrom
vineethsaivs:fix/qwen3-prompt-closed-think-block

Conversation

@vineethsaivs

Copy link
Copy Markdown
Contributor

Purpose

Fixes #53284.

With --reasoning-parser qwen3, a request whose rendered prompt already ends with a closed think block gets its whole answer returned as message.reasoning with content: null, and loses the answer entirely under include_reasoning=false.

Qwen3Parser.__init__ decides the engine's initial state from chat_template_kwargs["enable_thinking"] alone, and nothing looks at the prompt:

self.thinking_enabled = chat_kwargs.get("enable_thinking", True)
...
initial_state=ParserState.REASONING if thinking else ParserState.CONTENT

A chat template is free to close the think block for other reasons. The widely used community templates for Qwen3.5/3.6/3.8 do it for chat_template_kwargs: {"reasoning_effort": "none"}, for an inline <|think_off|> tag in the user message, and for auto_disable_thinking_with_tools=true. In every one of those cases the prompt ends with

<|im_start|>assistant
<think>

</think>

so the model has nothing left to close and emits its answer as plain text. The engine, still in REASONING, turns every token into a REASONING_CHUNK, and finish() only appends REASONING_END.

This is the same hook Gemma4 already overrides for the opposite polarity: a prompt ending inside an open reasoning block pre-initialises to REASONING (#45834). Qwen3 had no override for "prompt ends with a closed block", which is why the base no-op in ParserEngine.adjust_initial_state_from_prompt applied.

Changes

Qwen3Parser.adjust_initial_state_from_prompt resets the engine to CONTENT when the prompt already ended reasoning, and latches _streaming_initialized so a later default initialize_streaming() cannot restore the configured state. That is the same three-line shape as Gemma4Parser's override.

The predicate needs no new logic. is_reasoning_end already scans back to the last think marker, so:

prompt tail is_reasoning_end initial state
<think>\n (thinking on) False REASONING, unchanged
<think>\n\n</think>\n\n (template closed it) True CONTENT
earlier turn's </think>, then this turn's <think> False REASONING, unchanged
paired <tool_call>...</tool_call> before the generation <think> False REASONING, unchanged

Scope, and what this does not cover

This fixes the engine path, which is --reasoning-parser qwen3 together with --tool-call-parser qwen3_xml or qwen3_coder, the recommended Qwen3.x configuration: ParserManager.get_parser returns the Qwen3Parser engine itself, and ParserEngine.parse_delta calls the prompt hook.

It does not fix the non-streaming half of #53284, and I want to be explicit about that rather than let it read as a complete fix. Parser.parse and ParserEngine.parse take no prompt_token_ids, so nothing can seed them from the prompt today. #50015 is already adding exactly that plumbing for the Gemma4 case, so I have deliberately not duplicated it here. Worth flagging for whoever reviews both: #50015 calls the hook only when not is_reasoning_end(prompt_token_ids), which is the Gemma4 polarity, so this Qwen3 case would still be skipped on the non-streaming path even after it lands. That guard likely wants to widen once both are in, but that is a call for #50015, not something to pre-empt from here. This change is complementary either way and touches no file #50015 touches.

--reasoning-parser qwen3 alone already streams correctly, because Parser.parse_delta checks is_reasoning_end(prompt_token_ids) before the first delta and marks reasoning ended. That path does not reach this hook, so it is unaffected.

Test Plan

Four tests in tests/parser/engine/test_qwen3_reasoning.py under TestPromptClosedThinkBlock, driving Qwen3Parser.parse_delta with prompt_token_ids against the file's existing mock tokenizer:

  1. prompt ending in a closed think block streams the answer as content, with reasoning None (fails without this change)
  2. control: an open <think> tail still streams as reasoning
  3. control: a previous turn's </think> in the history does not disable the new turn
  4. control: no prompt_token_ids keeps the configured initial state

Test Result

I could not run the suite locally and do not want to imply otherwise. This is a macOS box with no vLLM build; import vllm.parser.qwen3 segfaults here (exit 139) before any test collects, so tests/parser/engine/test_qwen3_reasoning.py never ran on my machine. CI is the test runner for this change, and that file is the one to watch.

What I did verify locally, by reading the code rather than executing it:

  • ruff check and ruff format --check are clean on both touched files.
  • Both files parse (ast.parse).
  • The is_reasoning_end table above is read off ParserEngine.is_reasoning_end (backwards scan, first of </think> / <think> wins) and Qwen3Parser.is_reasoning_end (unpaired <tool_call>, returning False as soon as it sees the reasoning start token). The existing tests in TestIsReasoningEnd already pin every row of it.

The runtime symptom in #53284 was reproduced by a third party on Qwen/Qwen3.8-27B-FP8; neither the reporter nor I ran a GPU server for it.

AI assistance was used in preparing this change.

…hink block

`Qwen3Parser.__init__` decides the engine's initial state from
`chat_template_kwargs["enable_thinking"]` alone. Nothing looks at the
prompt. A chat template is free to render a closed `<think>\n\n</think>\n\n`
for other reasons, and the widely used community templates for Qwen3.x do
exactly that for `reasoning_effort: "none"`, for an inline `<|think_off|>`
tag, and for `auto_disable_thinking_with_tools=true`.

In that state the engine starts in REASONING while the model, with nothing
left to close, emits its answer as plain text. Every token becomes a
REASONING_CHUNK, so the response comes back with the whole answer in
`reasoning` and `content` set to null, and disappears entirely when
`include_reasoning=false`.

Override `adjust_initial_state_from_prompt` to reset the engine to CONTENT
when the prompt already ended reasoning. `is_reasoning_end` is the right
test and needs no new logic: it scans back to the last think marker, so a
thinking-on prompt, whose tail is an open `<think>`, stays False even with
preserved history. This mirrors Gemma4Parser, which overrides the same hook
for the opposite polarity.

Signed-off-by: Vineeth Sai <vineethsai4444@gmail.com>
Assisted-by: Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@mergify mergify Bot added qwen Related to Qwen models tool-calling bug Something isn't working labels Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working qwen Related to Qwen models tool-calling

Projects

Status: No status

1 participant