[Bugfix][Parser] qwen3: start in CONTENT when the prompt closed the think block - #53302
Open
vineethsaivs wants to merge 1 commit into
Open
[Bugfix][Parser] qwen3: start in CONTENT when the prompt closed the think block#53302vineethsaivs wants to merge 1 commit into
vineethsaivs wants to merge 1 commit into
Conversation
…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
vineethsaivs
requested review from
aarnphm,
bbrowning,
chaunceyjiang and
sfeng33
as code owners
August 21, 2026 17:33
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Purpose
Fixes #53284.
With
--reasoning-parser qwen3, a request whose rendered prompt already ends with a closed think block gets its whole answer returned asmessage.reasoningwithcontent: null, and loses the answer entirely underinclude_reasoning=false.Qwen3Parser.__init__decides the engine's initial state fromchat_template_kwargs["enable_thinking"]alone, and nothing looks at the prompt: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 forauto_disable_thinking_with_tools=true. In every one of those cases the prompt ends withso the model has nothing left to close and emits its answer as plain text. The engine, still in
REASONING, turns every token into aREASONING_CHUNK, andfinish()only appendsREASONING_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 inParserEngine.adjust_initial_state_from_promptapplied.Changes
Qwen3Parser.adjust_initial_state_from_promptresets the engine toCONTENTwhen the prompt already ended reasoning, and latches_streaming_initializedso a later defaultinitialize_streaming()cannot restore the configured state. That is the same three-line shape asGemma4Parser's override.The predicate needs no new logic.
is_reasoning_endalready scans back to the last think marker, so:is_reasoning_end<think>\n(thinking on)FalseREASONING, unchanged<think>\n\n</think>\n\n(template closed it)TrueCONTENT</think>, then this turn's<think>FalseREASONING, unchanged<tool_call>...</tool_call>before the generation<think>FalseREASONING, unchangedScope, and what this does not cover
This fixes the engine path, which is
--reasoning-parser qwen3together with--tool-call-parser qwen3_xmlorqwen3_coder, the recommended Qwen3.x configuration:ParserManager.get_parserreturns theQwen3Parserengine itself, andParserEngine.parse_deltacalls 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.parseandParserEngine.parsetake noprompt_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 whennot 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 qwen3alone already streams correctly, becauseParser.parse_deltachecksis_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.pyunderTestPromptClosedThinkBlock, drivingQwen3Parser.parse_deltawithprompt_token_idsagainst the file's existing mock tokenizer:content, withreasoningNone (fails without this change)<think>tail still streams asreasoning</think>in the history does not disable the new turnprompt_token_idskeeps the configured initial stateTest 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.qwen3segfaults here (exit 139) before any test collects, sotests/parser/engine/test_qwen3_reasoning.pynever 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 checkandruff format --checkare clean on both touched files.ast.parse).is_reasoning_endtable above is read offParserEngine.is_reasoning_end(backwards scan, first of</think>/<think>wins) andQwen3Parser.is_reasoning_end(unpaired<tool_call>, returning False as soon as it sees the reasoning start token). The existing tests inTestIsReasoningEndalready 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.