Design: how chat logprobs should behave when a reasoning or tool parser is active
メンテナーはふだん 1 日以内に返信
まだ誰も着手していません。
評価
- 難易度
- 5/5
- 見積もり時間
- 1週間以上
- 初心者へのやさしさ
- 12/100
調査の方向性
Start with server/reasoning_parser.py (parse_stream_chunk, _split_trailing_partial, detect_and_parse) and server/generation.py (stream loop near lines 705-722, _make_reasoning_parser at 389-422). The repro script in the issue shows the text-versus-token mismatch on </think>. Done means the per-token state machine classifies marker ids, and streaming and non-streaming logprobs content lists match. This is design-heavy work, so discuss the proposed rules with maintainers before writing code.
索引モデルが issue の本文から書いたものです。
説明
What happened
On main, /v1/chat/completions ignores logprobs: true (#693). #224 adds logprobs and returns 400 whenever the server runs a reasoning parser or the request enables tool parsing, and leaves the combined case for later. So any deployment of a thinking model (for example Qwen3.8 with --reasoning-parser qwen3) has no chat logprobs, even with enable_thinking: false. Nothing defines yet what the entries should contain when the parsers hide or reshape tokens. This issue proposes rules for that.
Reference behaviour, from the current sources:
- vLLM (
entrypoints/openai/chat_completion/serving.py) builds chat logprobs from all outputtoken_ids, reasoning and tool-call tokens included. When streaming, it attaches each step's entries to whatever chunk that step produces. It drops them only wheninclude_reasoning=false. - SGLang (
entrypoints/openai/serving_chat.py) attaches a step's entries to the first chunk emitted that step (reasoning, tool call or content). When a parser is on, it flushes leftovers in a separate empty-delta chunk. - OpenAI documents
choices[].logprobs.contentas the message content tokens, with a separaterefusallist. Its reasoning models do not return logprobs.
Neither vLLM nor SGLang gives the meaning OpenAI documents. Clients that use logprobs for confidence or scoring of the answer then get reasoning and tool-markup tokens mixed into content.
Minimal reproduction (CPU only). It shows how the parser reshapes text compared with token boundaries, and that the markers are single token ids:
# PYTHONPATH=python MODEL=nvidia/Qwen3.8-Flash-Next-NVFP4 python repro.py
import os
from transformers import AutoTokenizer
from freetoken.server.reasoning_parser import ReasoningParser
tok = AutoTokenizer.from_pretrained(os.environ["MODEL"])
for m in ["<think>", "</think>", "<tool_call>", "</tool_call>", "<|im_end|>"]:
print(f"{m!r:16} -> {tok.encode(m, add_special_tokens=False)}")
text = "Let me think.</think>\n\nUse a <b> tag."
p = ReasoningParser("qwen3", force_reasoning=True)
for i in tok.encode(text, add_special_tokens=False):
piece = tok.decode([i]); r, c = p.parse_stream_chunk(piece)
print(f"{i:>7} {piece!r:12} reasoning={r!r:10} content={c!r}")
print("non-stream", ReasoningParser("qwen3", force_reasoning=True).parse_non_stream(text))
'<think>' -> [248068]
'</think>' -> [248069]
'<tool_call>' -> [248058]
'</tool_call>' -> [248059]
'<|im_end|>' -> [248046]
...
13 '.' reasoning='.' content=''
248069 '</think>' reasoning='' content=''
271 '\n\n' reasoning='' content='\n\n'
361 ' <' reasoning='' content=' '
65 'b' reasoning='' content='<b'
...
non-stream ('Let me think.', 'Use a <b> tag.')
Cause (current main, 9b68e007)
- The parsers work on text, not tokens.
server/reasoning_parser.pyholds back a partial marker (_split_trailing_partial, line 217). In the output above, the<of token' <'is sent with the next token.detect_and_parsestrips the text around</think>(lines 134, 153), but the streaming path does not, so\n\nstreams as content. server/generation.pyadds more reshaping on top: the stream loop (lines 705-722) and the tool-text router, which drops whitespace between tool calls (suppress_ws, lines 633-670).- To line entries up with the emitted text, both parsers would have to report character offsets. #224 notes the same: "needs the reasoning-parser interface to expose per-token classification".
- For the qwen3 and qwen3_coder formats, the markers are each exactly one token id. Each sampled id can be classified from the ids alone, without changing the parsers.
Proposed fix
Proposed rules for choices[].logprobs with a parser active:
logprobs.contenthas an entry for each sampled token that becomes visible message content. Reasoning tokens, tool-call tokens, marker tokens (<think>,</think>,<tool_call>,</tool_call>) and special/EOS ids get no entry. A turn that only calls tools hascontent: [].refusalisnull. When a stop string cuts a token, its entry is kept only if part of its text is still visible.top_logprobsstays the model's raw top-k at that position. An alternative such as</think>is fine there.- Streaming and non-streaming give the same list: the
contententries of all chunks, joined in order, equal the non-streaming list. Whitespace that one path strips (after</think>, between tool calls) is left out in both paths. - Each content entry goes with the first content delta sent at or after that token's step. A token whose text the parser holds back therefore goes with the later delta. A hidden token's entry is never attached to a visible delta.
- Entries still held at the end of the stream go on the
finish_reasonchunk, not dropped. - With
enable_thinking: falseandtool_choice: "none", the result is the same as with no parser.
Mechanism: classify each sampled token by its id, with a small state machine that runs alongside the existing text parsers. The states are reasoning, content and tool call, and the marker ids switch between them. The starting state is the same force_reasoning that _make_reasoning_parser uses (generation.py:389-422). Resolve the marker ids once from the tokenizer. Use a marker only if encode(marker) gives exactly one id that decodes back to it. Parser families whose state cannot be read from single marker ids keep the 400 (for example Harmony, where the channel name follows <|channel|> as plain text). One exception needs a signal from the text side: qwen3_coder also accepts a bare <function= with no <tool_call> (function_call_parser.py:1953). When the tool parser starts a call while the classifier is in content, the held entries should be relabelled as tool-call tokens.
Optional: a vLLM-style "all tokens" scope, or a separate reasoning_content logprob list behind a request flag, for clients that want reasoning confidence as well.
Environment
Seen serving nvidia/Qwen3.8-Flash-Next-NVFP4 on an RTX 5080 16 GB; the reproduction above is CPU-only on main 9b68e007.
- 主要言語
- Python
- スター
- 14.2k
- フォーク
- 1.4k
- 平均マージ
- 21時間 39分
- マージ済み PR(30日)
- 100
環境構築
このプロジェクトには開発コンテナ、Dockerfile、コントリビューションガイドがありません。まず README を読み、一般的な手順ははじめてのコントリビューションガイドを参照してください。
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
FlashML-org/FreeToken のほかの issue
-
Desktop feature
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
FlashML-org/FreeToken#800 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 84/100
FlashML-org/FreeToken#767 ·
メンテナーはふだん 1 日以内に返信
-
test_gemv_path_matches_the_transcription fails on sm_120 (bf16 rounding tie exceeds the tolerance)対応中かも @void-mckenzie が 17 日前に担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 77/100
FlashML-org/FreeToken#649 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
FlashML-org/FreeToken#648 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 76/100
FlashML-org/FreeToken#646 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
FlashML-org/FreeToken の issue をすべて見る
似ている issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 79/100
aws-samples/sample-per-model-bedrock#639 ·
メンテナーはふだん 1 日以内に返信
-
bug
難易度 1/5 1時間未満 初心者へのやさしさ 86/100
awslabs/visual-asset-management-system#937 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 62/100
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 79/100
project-codeflare/codeflare-sdk#1195 ·
メンテナーはふだん 1 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 65/100
pandas-dev/pandas-stubs#2051 ·
メンテナーはふだん 1 日以内に返信