qwen3 reasoning parser: streamed content starts with "\n\n" after </think> and streamed reasoning keeps its trailing newline; non-streaming strips both

Đang mở
#712 0 bình luận 0 reaction 0 người được giao Xem trên GitHub

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
Loại issue
Lỗi
Độ rõ ràng
Đặc tả rõ ràng
Mức độ hoạt động
Sôi nổi
Công nghệ
python
Lĩnh vực
backend

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:134 and :151-153: BaseReasoningParser.detect_and_parse (non-streaming) strips the whole text and then normal_text.strip().
  • python/freetoken/server/reasoning_parser.py:176-183: parse_streaming_increment returns the text after </think> unstripped. Its rstrip() 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:285 sends each ContentDelta as 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

  1. Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
  2. 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.
  3. Fork repository và làm thay đổi trên một nhánh.
  4. Mở pull request có tham chiếu số hiệu của issue.

Issue khác của FlashML-org/FreeToken

Tất cả issue của FlashML-org/FreeToken

Issue tương tự

Thêm issue về Python

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.