qwen3 reasoning parser: streamed content starts with "\n\n" after </think> and streamed reasoning keeps its trailing newline; non-streaming strips both
Maintainer thường phản hồi trong vòng 1 ngày
Chưa có ai nhận issue này.
Đánh giá
- Độ khó
- 3/5
- Thời gian dự kiến
- 1-2 ngày
- Mức phù hợp với người mới
- 62/100
Hướng nghiên cứu
Start in python/freetoken/server/reasoning_parser.py: compare BaseReasoningParser.detect_and_parse with parse_streaming_increment, where the whitespace around </think> is handled differently. Run the issue's CPU reproduction with PYTHONPATH=python to see the stream and non-stream outputs. Done when both paths return the same reasoning and content for the text-answer and tool-call-only token sequences, with a test that covers both.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
What happened
With thinking on (--reasoning-parser qwen3), the model writes …reasoning\n</think>\n\n<answer or tool call>. Non-streaming strips the whitespace on both sides of </think>. Streaming does not, so the same turn looks different on /v1/chat/completions depending on stream:
stream: false |
stream: true (joined deltas) |
|
|---|---|---|
content, text answer |
"Answer." |
"\n\nAnswer." |
content, tool-call-only turn |
"" |
"\n\n", sent as a content delta before the tool_calls delta |
reasoning_content |
"Think." |
"Think.\n" |
So a thinking-on streamed answer starts with a blank line, and a tool-call-only turn sends a whitespace-only content delta. A client that creates a text part when the first content delta arrives therefore shows an empty assistant message before each tool call. The /v1/messages and /v1/responses streams go through the same path (generate_events), so their text blocks get the same "\n\n".
Minimal reproduction (CPU only, PYTHONPATH=python):
from freetoken.server.reasoning_parser import ReasoningParser
from freetoken.server.function_call_parser import FunctionCallParser
from freetoken.server.api_models import Tool
toks = ["Think", ".", "\n", "</think>", "\n\n", "Answer", "."]
p = ReasoningParser("qwen3", force_reasoning=True)
d = [p.parse_stream_chunk(t) for t in toks] + [p.flush()]
print("stream: reasoning=%r content=%r" % ("".join(r for r, _ in d), "".join(c for _, c in d)))
r, c = ReasoningParser("qwen3", force_reasoning=True).parse_non_stream("".join(toks))
print("non-stream: reasoning=%r content=%r" % (r.strip(), c)) # .strip(): generation.py:823
# tool-call-only turn, streamed through the qwen3_coder detector as generate_events does
tools = [Tool(type="function", function={"name": "ls", "parameters": {"type": "object", "properties": {"path": {"type": "string"}}}})]
toks = ["Plan", "\n", "</think>", "\n\n", "<tool_call>", "\n<function=ls>\n<parameter=path>\n.\n</parameter>\n</function>\n</tool_call>"]
rp, fp, text = ReasoningParser("qwen3", force_reasoning=True), FunctionCallParser(tools, "qwen3_coder"), []
for t in toks:
_, c = rp.parse_stream_chunk(t)
if c: text += [e[1] for e in fp.parse_stream_events(c) if e[0] == "text"]
print("stream content deltas before the call:", text)
_, c = ReasoningParser("qwen3", force_reasoning=True).parse_non_stream("".join(toks))
print("non-stream content:", repr(FunctionCallParser(tools, "qwen3_coder").parse_non_stream(c).normal_text))
Output on main 9b68e007:
stream: reasoning='Think.\n' content='\n\nAnswer.'
non-stream: reasoning='Think.' content='Answer.'
stream content deltas before the call: ['\n\n']
non-stream content: ''
Cause (current main, 9b68e007)
python/freetoken/server/reasoning_parser.py:134and:151-153:BaseReasoningParser.detect_and_parse(non-streaming) strips the whole text and thennormal_text.strip().python/freetoken/server/reasoning_parser.py:176-183:parse_streaming_incrementreturns the text after</think>unstripped. Itsrstrip()applies only to reasoning in the same chunk as</think>. When the"\n"arrives in an earlier delta than</think>(the usual case, since</think>is a single added token), it has already been streamed as reasoning by then.python/freetoken/server/generation.py:823: the non-streaming result also.strip()s the reasoning. The streaming loop (:706-723) forwards the deltas as they are. In the tool path,suppress_ws(:667) only drops whitespace after a call has closed, not before the first one.openai_api.py:285sends eachContentDeltaas is.
The code is in BaseReasoningParser, so the qwen3, glm, minimax and deepseekv32 parsers all inherit it.
Proposed fix
Make the streaming path follow the non-streaming one in BaseReasoningParser:
- After
</think>, drop leading whitespace from content until the first non-whitespace character. Flush a whitespace-only remainder at the end as nothing. - While reasoning, hold back a trailing run of whitespace instead of streaming it. Emit it if more reasoning follows. Drop it when
</think>closes the block.
Both are small, bounded holds, so streaming latency does not change. A stream-vs-non-stream test over the same token sequence (text answer and tool-call-only turn) would keep the two paths in line.
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: #711 (same parser, re-entry on a later <think>), #719 (chat logprobs design, which depends on both modes agreeing).
- Ngôn ngữ chính
- Python
- Star
- 14.2k
- Fork
- 1.4k
- Merge trung bình
- 1 ngày 1 giờ
- Pull request đã merge (30 ngày)
- 102
Chuẩn bị môi trường
Dự án này không cung cấp dev container, Dockerfile hay hướng dẫn đóng góp, nên bạn cần tự thiết lập môi trường: hãy bắt đầu từ README và xem hướng dẫn đóng góp lần đầu của chúng tôi để biết các bước chung.
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của FlashML-org/FreeToken
-
Desktop feature
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 72/100
FlashML-org/FreeToken#800 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
test_gemv_path_matches_the_transcription fails on sm_120 (bf16 rounding tie exceeds the tolerance)Có thể đã có người làm @void-mckenzie đã nhận 26 ngày trước. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 77/100
FlashML-org/FreeToken#649 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
FlashML-org/FreeToken#648 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
FlashML-org/FreeToken#646 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 88/100
FlashML-org/FreeToken#645 · 1 bình luận ·
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của FlashML-org/FreeToken
Issue tương tự
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 66/100
Maintainer thường phản hồi trong vòng 1 ngày
-
[Bug] vllm-sr config migrate drops providers.models[].deploymentCó thể đã có người làm @Bevisy đã nhận hôm nay. Đang mởneeds-acceptance wg/developer-experience-ecosystem
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 86/100
vllm-project/semantic-router#5996 ·
Maintainer thường phản hồi trong vòng 2 ngày
-
bugfix low-priority plugin:abstract plugin:leyline small-effort source:discussion
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 66/100
athola/claude-night-market#1132 ·
-
Add support for Sinhala languageĐang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 77/100
WeblateOrg/language-data#2773 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
[yugabyted] xcluster set_up and add_to_replication fail for database names containing - . or (Đang mởkind/bug priority/medium qa_automation
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 78/100
yugabyte/yugabyte-db#37324 ·
Maintainer thường phản hồi trong vòng 1 ngày