Skip to content

[Fix] Route text before the opening think token by position, not by index 0 - #35085

Open
tancheng33 wants to merge 1 commit into
sgl-project:mainfrom
tancheng33:fix/reasoning-start-token-position
Open

tancheng33 wants to merge 1 commit into
sgl-project:mainfrom
tancheng33:fix/reasoning-start-token-position

Conversation

@tancheng33

@tancheng33 tancheng33 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Motivation

Fixes #35083.

BaseReasoningFormatDetector enters a reasoning block on think_start_token in text (anywhere) but strips the marker with startswith (index 0 only).
Anything in front of the opening marker — a single space or newline is enough —
falls into the gap, and the streaming path has the mirror-image gap: it only
recognises a partial opening marker when the whole buffer is a prefix of it.

Same string, four answers on main (--reasoning-parser qwen3,
"Sure.<think>checking</think>Done."):

path reasoning_content content
non-streaming 'Sure.<think>checking' 'Done.'
streaming, marker whole in one chunk 'checking' 'Sure.Done.'
streaming, chunk ends inside <think> '' 'Sure.<think>checking</think>Done.'
streaming, whole turn in one chunk 'Sure.checking' 'Done.'

Row 3 streams the whole reasoning block, markers included, to the client as
assistant content. It needs a chunk carrying content plus a strict prefix of the
marker, which a multi-token decode step produces: --stream-interval > 1,
sampling_params.stream_interval, speculative decoding, or the detokenizer
releasing several tokens after an incomplete UTF-8 sequence. Rows 1, 2 and 4 need
no such condition.

#34458 already named this case and pinned it:

def test_text_before_think_token_is_chunk_dependent(self):
    """Accepted divergence, inherited from main: text before `<think>` lands
    in reasoning or content depending on where the chunk boundary falls."""

This PR closes that exception instead of pinning it.

Modifications

python/sglang/srt/parser/reasoning_parser.py

  • _split_at_think_start() (new): removes the opening marker wherever it first
    appears and returns the text left of it separately. Under force_reasoning the
    block was already opened by the chat template, so the lead stays reasoning and
    only the echoed marker is dropped; otherwise the lead is content. Both match
    what the streaming path already did when the marker landed whole at a chunk
    start.
  • _detect_and_parse_impl(): uses it, and prepends the lead to normal_text on
    every return path (truncated reasoning, tool_start_token interruption, the
    </think> split, and the continue_final_message branch).
  • _parse_streaming_increment_impl(): the strip splits the lead out instead of
    replace(..., 1) swallowing it; the not-in-reasoning branch now holds back a
    trailing partial opening marker with _ends_with_partial_token, the same way
    the in-reasoning branch holds back the closing one. The holdback is skipped
    once stripped_think_start is set, so content after the block is not delayed,
    and finish() already flushes a held-back slice that never became a marker.

Detectors that override both methods (gpt-oss, kimi_k3, muse, inkling,
apertus2509, cohere_command4) are untouched.

Note: #34600 also edits _parse_streaming_increment_impl (the same two regions).
Whichever lands first, I am happy to rebase this one on top of it.

Accuracy Tests

A differential harness over all 26 ReasoningParser.DetectorMap entries
(12 samples x {chunk sizes 1,2,3,7,13, six random splits, whole} x
stream_reasoning on/off, comparing streamed + finish() against
detect_and_parse): 21 model types diverged on this shape before, 0 after.
The two that still diverge (gpt-oss, kimi_k3) are separate defects in their
own overrides, reported separately and out of scope here.

Tests, in test/registered/unit/parser/test_reasoning_parser.py:

  • test_text_before_think_token_is_content — replaces
    test_text_before_think_token_is_chunk_dependent; the four splits collapse to
    ("r", "leadtail") across all chunk sizes and one-shot.
  • test_single_leading_newline_before_think_token — the common shape; guards the
    one-shot marker leak.
  • test_forced_reasoning_keeps_lead_text_as_reasoning — the opposite routing
    under force_reasoning, so a future "simplification" cannot collapse the two.
  • test_content_ending_in_start_token_prefix_survives — the new holdback must not
    swallow content that merely ends in a marker prefix and never completes.

Verified fail-before / pass-after: on the pre-fix source the first three fail
(16 failed / 1 passed with the four selected); with the fix
test/registered/unit/parser/ is 344 passed, 165 subtests, and
test/registered/unit/function_call/ + parser/ is 808 passed.
test/registered/unit/entrypoints/openai has 8 pre-existing failures in
test_serving_transcription.py — identical with and without this change.

Benchmarking and Profiling

Not applicable — string handling on the parse path only. The new holdback defers
at most len(think_start_token) - 1 characters of content, and only before the
first reasoning block.

Checklist


CI States

Latest PR Test (Base): ❌ Run #31992374032
Latest PR Test (Extra): ❌ Run #31992373703

…ndex 0

BaseReasoningFormatDetector enters a reasoning block on `think_start_token in
text` (anywhere) but strips the marker with `startswith` (index 0 only), so a
single leading space or newline leaves the literal marker inside
reasoning_content. The streaming path has the mirror-image gap: it recognises a
partial opening marker only when the whole buffer is a prefix of it, so a chunk
carrying content plus "<" clears the buffer and the rest of the marker arrives
with nothing to attach to -- the whole reasoning block then reaches the client
as raw content.

Strip the marker wherever it first appears and split off what preceded it:
content, or reasoning under force_reasoning where the chat template already
opened the block. Hold back a trailing partial opening marker in the
not-in-reasoning branch, the same way the reasoning branch holds back the
closing one.

Closes the exception pinned by sgl-project#34458 as test_text_before_think_token_is_
chunk_dependent; that test becomes an invariance test.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] Reasoning parsers: text before the opening think token is routed four different ways (chunk-dependent, and one-shot keeps the literal marker)

1 participant