Reasoning parsers re-enter reasoning on a `<think>` after `</think>` when streaming, and delete it when not (qwen3, glm, minimax, deepseekv32, gemma4, minimax_m3)
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
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 completethink_start_tokenin the buffer is stripped and sets_in_reasoning = Truewhenever_stripped_think_startis still False. Withforce_reasoning=Trueno 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_reasoningis 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:398defaultsforce_reasoningto on forqwen3, 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
deepseekv32andminimax_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_m3already does this in non-streaming. - Define
detect_and_parseas feeding the whole text to a fresh instance's streaming path and then callingflush(), asMuseGlimmerReasoningParser.detect_and_parsealready 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
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di FlashML-org/FreeToken
-
Desktop feature
Difficoltà 2/5 1-3 ore Idoneità per principianti 72/100
FlashML-org/FreeToken#800 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 84/100
FlashML-org/FreeToken#767 ·
I maintainer di solito rispondono entro 1 giorno
-
test_gemv_path_matches_the_transcription fails on sm_120 (bf16 rounding tie exceeds the tolerance)Forse già presa @void-mckenzie l’ha presa 16 giorni fa. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 77/100
FlashML-org/FreeToken#649 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
FlashML-org/FreeToken#648 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 76/100
FlashML-org/FreeToken#646 · 1 commento ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di FlashML-org/FreeToken
Issue simili
-
area/profiles comp/cli P3 type/feature
Difficoltà 2/5 1-3 ore Idoneità per principianti 74/100
NousResearch/hermes-agent#146372 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 65/100
I maintainer di solito rispondono entro 1 giorno
-
[oblt-aw][autodoc] Document obs-aw-automerge contract and fix automerge doc inconsistenciesForse già presa @github-actions l’ha presa oggi. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 76/100
I maintainer di solito rispondono entro 1 giorno
-
GeoSeries scale and skew tests fail with pytest 9.2.0Forse già presa @jiayuasu l’ha presa oggi. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 82/100
I maintainer di solito rispondono entro 1 giorno
-
admission-deferred priority: medium status: ready work-class: scoped
Difficoltà 2/5 1-3 ore Idoneità per principianti 70/100
melodic-software/claude-code-plugins#10887 · 4 commenti ·
I maintainer di solito rispondono entro 1 giorno