Hacktoberfest 2026: le issue che i maintainer hanno segnato per ottobre, aperte e adatte ai principianti. Sfoglia le issue Hacktoberfest

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

Aperta
#711 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
4/5
Tempo stimato
3-5 giorni
Idoneità per principianti
28/100
Tipo di issue
Bug
Chiarezza
Specificata chiaramente
Stato di attività
Attiva
Stack tecnologico
python
Ambito
backend

Direzione di ricerca

Start with BaseReasoningParser.parse_streaming_increment and _split_trailing_partial in python/freetoken/server/reasoning_parser.py, then detect_and_parse around lines 130-151, and run the reproduction script from the report with PYTHONPATH=python. Done when streaming and non-streaming agree on reasoning and tool arguments for qwen3, glm, minimax, deepseekv32, gemma4 and minimax_m3, with markers quoted in content and tool arguments, and a parity test over chunkings passes.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

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 9b68e007:

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, 9b68e007)
  • 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 9b68e007.

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

Lingua principale
Python
Stelle
14.2k
Fork
1.4k
Merge medio
21h 17m
PR unite (30g)
97

Preparare l'ambiente

Questo progetto non fornisce container di sviluppo, Dockerfile né guida per i contributori, quindi l'ambiente è a tuo carico: parti dal suo README e consulta la nostra guida al primo contributo per i passaggi generali.

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di FlashML-org/FreeToken

Tutte le issue di FlashML-org/FreeToken

Issue simili

Altre issue su Python

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.