Skip to content

Reasoning parsers re-enter reasoning on a <think> after </think> when streaming, and delete it when not (qwen3, glm, minimax, deepseekv32, gemma4, minimax_m3) #711

Open

Description

@gdevenyi

What happened

With thinking on (the default for qwen3), the chat template opens the think block and the model emits only </think>. After that, a later <think> in the answer is plain text, for example inside a tool-call argument when an agent greps for <think> or edits code that contains the markers. The two modes handle it differently, and both modes corrupt the call:

grep call with pattern="<think>" edit argument START = "<think>", END = "</think>"
Expected pattern is <think> the argument text unchanged
Streaming the rest of the call, </parameter></function></tool_call>, streams as reasoning_content. The tool parser never sees the call close, so its streamed arguments stop at {"pattern":" (invalid JSON). "\nEND = " moves into reasoning_content. Both markers are stripped and the argument becomes START = ""
Non-streaming pattern is empty <think> is deleted and </think> is kept: START = ""\nEND = "</think>"

Streaming treats the first <think> it sees as a new reasoning block, even after reasoning has ended. Non-streaming deletes every <think> in the text and splits at the first </think>. Every parser built on BaseReasoningParser is affected in streaming: qwen3, glm and minimax (ThinkReasoningParser), deepseekv32 and gemma4 (with its own markers), and minimax_m3 (<mm:think>). minimax_m3 already keeps the markers verbatim in non-streaming, so its two modes disagree. gpt_oss and muse_glimmer override all three methods and are not covered here.

Minimal reproduction (CPU only, PYTHONPATH=python):

from freetoken.server.reasoning_parser import ReasoningParser as RP
def both(name, toks):
    p = RP(name, force_reasoning=True); R = C = ""
    for t in toks + [None]:
        r, c = p.flush() if t is None else p.parse_stream_chunk(t); R += r; C += c
    print(name, "stream:", (R, C)); print(name, "non-stream:", RP(name, force_reasoning=True).parse_non_stream("".join(toks)))
both("qwen3", ["plan", "</think>", "\n\n", "<tool_call>\n<function=grep>\n<parameter=pattern>\n",
               "<think>", "\n</parameter>\n</function>\n</tool_call>"])
marks = {"qwen3": ("<think>", "</think>"), "glm": ("<think>", "</think>"), "minimax": ("<think>", "</think>"),
         "deepseekv32": ("<think>", "</think>"), "gemma4": ("<|channel>thought\n", "<channel|>"),
         "minimax_m3": ("<mm:think>", "</mm:think>")}
for name, (o, c) in marks.items():
    toks = ["plan", c, "\n\n", "<tool_call>\n<function=edit>\n<parameter=new>\n",
            'START = "', o, '"\nEND = "', c, '"\n', "</parameter>\n</function>\n</tool_call>"]
    p = RP(name, force_reasoning=True); R = C = ""
    for t in toks + [None]:
        r, d = p.flush() if t is None else p.parse_stream_chunk(t); R += r; C += d
    nr, nc = RP(name, force_reasoning=True).parse_non_stream("".join(toks))
    arg = lambda s: s.split("<parameter=new>\n")[1].split("\n</parameter>")[0]
    print(f"{name:12} stream: reasoning={R!r:22} arg={arg(C)!r}\n{'':12} non-stream: reasoning={nr!r:18} arg={arg(nc)!r}")

Output on main 9b68e00:

qwen3 stream: ('plan\n</parameter>\n</function>\n</tool_call>', '\n\n<tool_call>\n<function=grep>\n<parameter=pattern>\n')
qwen3 non-stream: ('plan', '<tool_call>\n<function=grep>\n<parameter=pattern>\n\n</parameter>\n</function>\n</tool_call>')
qwen3        stream: reasoning='plan"\nEND = "'       arg='START = ""'
             non-stream: reasoning='plan'             arg='START = ""\nEND = "</think>"'
glm          stream: reasoning='plan"\nEND = "'       arg='START = ""'
             non-stream: reasoning='plan'             arg='START = ""\nEND = "</think>"'
minimax      stream: reasoning='plan"\nEND = "'       arg='START = ""'
             non-stream: reasoning='plan'             arg='START = ""\nEND = "</think>"'
deepseekv32  stream: reasoning='plan"\nEND = "'       arg='START = ""'
             non-stream: reasoning='plan'             arg='START = ""\nEND = "</think>"'
gemma4       stream: reasoning='plan"\nEND = "'       arg='START = ""'
             non-stream: reasoning='plan'             arg='START = ""\nEND = "<channel|>"'
minimax_m3   stream: reasoning='plan"\nEND = "'       arg='START = ""'
             non-stream: reasoning='plan'             arg='START = "<mm:think>"\nEND = "</mm:think>"'

With thinking off (force_reasoning=False), a <think>…</think> that the model quotes in its answer is also taken as reasoning: "Wrap it as <think>x</think> then answer." streams as reasoning 'x'. Non-streaming returns reasoning 'Wrap it as x', so the prose before the opener is moved into reasoning as well.

Cause (current main, 9b68e00)

  • python/freetoken/server/reasoning_parser.py:168-172 (BaseReasoningParser.parse_streaming_increment): the first complete think_start_token in the buffer is stripped and sets _in_reasoning = True whenever _stripped_think_start is still False. With force_reasoning=True no opener was ever stripped, so the first opener after </think> re-enters reasoning. The next </think> (lines 176-183) then ends it again and is stripped too. _split_trailing_partial (lines 222-224) also holds back partial openers for the same reason.
  • python/freetoken/server/reasoning_parser.py:130-151 (detect_and_parse): in_reasoning is true if the opener appears anywhere, text.replace(self.think_start_token, "") deletes every opener, and the text is split at the first closer. The two code paths share no logic, so they disagree.
  • MiniMaxM3ReasoningParser.detect_and_parse (lines 452-492) already treats later markers as data, but its streaming path calls the base increment (line 518), so it re-enters there.
  • python/freetoken/server/generation.py:398 defaults force_reasoning to on for qwen3, so this is the default path for Qwen3 agent traffic.

Proposed fix

  • Reasoning is one block. Once it closes (at the first closer, or at the tool marker for deepseekv32 and minimax_m3), the parser stays in content for the rest of the turn, and any later opener or closer is literal content in both modes.
  • With force_reasoning=False, an opener starts reasoning at most once, and the text before it stays content. minimax_m3 already does this in non-streaming.
  • Define detect_and_parse as feeding the whole text to a fresh instance's streaming path and then calling flush(), as MuseGlimmerReasoningParser.detect_and_parse already does. Then the modes agree by construction, and a parity test over chunkings, with markers quoted in content and tool arguments, covers every family.

Environment

Seen serving nvidia/Qwen3.8-Flash-Next-NVFP4 on an RTX 5080 16 GB; the reproduction above is CPU-only on main 9b68e00.

Related: #712 (whitespace around </think> also differs between the two modes).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions