[Bugfix][Parser] Scope reasoning-end detection to the current turn via turn-boundary tokens (#54089)

Signed-off-by: Mikhail Podvitskii <podvitskiymichael@gmail.com>
This commit is contained in:
Mikhail Podvitskii
2026-08-27 16:57:32 -04:00
committed by GitHub
parent 7d5769b2d9
commit 3a9bfc209f
5 changed files with 147 additions and 4 deletions
+120
View File
@@ -27,6 +27,8 @@ _THINK_START_ID = 50
_THINK_END_ID = 51
_TOOL_CALL_ID = 60
_TOOL_CALL_END_ID = 61
_IM_START_ID = 70
_IM_END_ID = 71
_TEXT_ID = 100
_QWEN3_VOCAB = {
@@ -34,6 +36,8 @@ _QWEN3_VOCAB = {
"</think>": _THINK_END_ID,
"<tool_call>": _TOOL_CALL_ID,
"</tool_call>": _TOOL_CALL_END_ID,
"<|im_start|>": _IM_START_ID,
"<|im_end|>": _IM_END_ID,
}
@@ -192,6 +196,72 @@ class TestIsReasoningEnd:
assert not parser.is_reasoning_end([])
class TestIsReasoningEndTurnBoundaries:
"""Reasoning markers from earlier turns must not mark the new turn's
reasoning as finished (think-marker injection / interleaved-thinking
replay).
"""
def test_injected_think_end_in_history_not_end(self, parser):
assert not parser.is_reasoning_end(
[_IM_START_ID, _TEXT_ID, _THINK_END_ID, _IM_END_ID, _IM_START_ID]
)
def test_replayed_reasoning_block_in_history_not_end(self, parser):
assert not parser.is_reasoning_end(
[
_IM_START_ID,
_THINK_START_ID,
_TEXT_ID,
_THINK_END_ID,
_TEXT_ID,
_IM_END_ID,
_IM_START_ID,
]
)
def test_think_end_in_current_turn_is_end(self, parser):
assert parser.is_reasoning_end(
[_IM_START_ID, _TEXT_ID, _IM_END_ID, _IM_START_ID, _THINK_END_ID]
)
def test_unpaired_tool_call_in_history_not_end(self, parser):
assert not parser.is_reasoning_end(
[_TOOL_CALL_ID, _TEXT_ID, _IM_END_ID, _IM_START_ID]
)
def test_continued_turn_with_closed_thinking_is_end(self, parser):
assert parser.is_reasoning_end(
[_IM_START_ID, _THINK_START_ID, _TEXT_ID, _THINK_END_ID, _TEXT_ID]
)
def test_continued_turn_with_open_thinking_not_end(self, parser):
assert not parser.is_reasoning_end([_IM_START_ID, _THINK_START_ID, _TEXT_ID])
def test_empty_boundary_config_keeps_global_walk(self):
"""Configs without turn_boundary_tokens keep the pre-existing
behavior: any </think> in the sequence ends reasoning."""
cfg = qwen3_config(turn_boundary_tokens=frozenset())
parser = Qwen3Parser(
make_mock_tokenizer(_QWEN3_VOCAB), parser_engine_config=cfg
)
assert parser.is_reasoning_end(
[_IM_START_ID, _TEXT_ID, _THINK_END_ID, _IM_END_ID, _IM_START_ID]
)
def test_boundary_tokens_missing_from_vocab_keep_global_walk(self):
"""Boundary tokens absent from the vocabulary resolve to nothing,
so subclasses reusing the default on a non-ChatML vocab keep the
pre-existing behavior."""
vocab = {
token: token_id
for token, token_id in _QWEN3_VOCAB.items()
if not token.startswith("<|im_")
}
parser = Qwen3Parser(make_mock_tokenizer(vocab))
assert parser.is_reasoning_end([_TEXT_ID, _THINK_END_ID, _TEXT_ID])
class TestDelegatingPromptDetection:
def test_prompt_tool_example_does_not_skip_streaming_reasoning(
self, mock_tokenizer, mock_request
@@ -211,6 +281,56 @@ class TestDelegatingPromptDetection:
assert delta.reasoning == "thinking"
assert delta.content is None
def test_injected_think_end_in_history_keeps_streaming_reasoning(
self, mock_tokenizer, mock_request
):
parser = _Qwen3DelegatingParser(mock_tokenizer)
prompt_ids = [
_IM_START_ID,
_TEXT_ID,
_THINK_END_ID,
_TEXT_ID,
_IM_END_ID,
_IM_START_ID,
]
delta = parser.parse_delta(
"thinking",
[_TEXT_ID],
mock_request,
prompt_token_ids=prompt_ids,
finished=False,
)
assert delta is not None
assert delta.reasoning == "thinking"
assert delta.content is None
def test_think_end_in_generation_prompt_still_seeds_reasoning_ended(
self, mock_tokenizer, mock_request
):
parser = _Qwen3DelegatingParser(mock_tokenizer)
prompt_ids = [
_IM_START_ID,
_TEXT_ID,
_IM_END_ID,
_IM_START_ID,
_THINK_START_ID,
_THINK_END_ID,
]
delta = parser.parse_delta(
"answer",
[_TEXT_ID],
mock_request,
prompt_token_ids=prompt_ids,
finished=False,
)
assert delta is not None
assert delta.reasoning is None
assert delta.content == "answer"
class TestStreaming:
def test_basic_streaming(self, parser):
+13 -2
View File
@@ -147,6 +147,11 @@ class ParserEngine(Parser):
self._reasoning_start_token_id = vocab.get(start_text)
if end_text:
self._reasoning_end_token_id = vocab.get(end_text)
self._turn_boundary_token_ids: frozenset[int] = frozenset(
token_id
for token in parser_engine_config.turn_boundary_tokens
if (token_id := vocab.get(token)) is not None
)
@property
def reasoning_start_str(self) -> str | None:
@@ -601,11 +606,17 @@ class ParserEngine(Parser):
if end_id is not None:
if not input_ids:
return self.parser_engine_config.initial_state != ParserState.REASONING
boundary_ids = self._turn_boundary_token_ids
for i in range(len(input_ids) - 1, -1, -1):
if input_ids[i] == end_id:
token_id = input_ids[i]
if token_id == end_id:
return True
if start_id is not None and input_ids[i] == start_id:
if start_id is not None and token_id == start_id:
return False
if token_id in boundary_ids:
return (
self.parser_engine_config.initial_state != ParserState.REASONING
)
return False
return self._reasoning_ended
@@ -84,6 +84,10 @@ class ParserEngineConfig:
# Special tokens exempt from auto-drop but not state-machine terminals.
preserve_tokens: frozenset[str] = field(default_factory=frozenset)
# Special tokens delimiting conversation turns in the prompt only
# considers reasoning markers after the last boundary token.
turn_boundary_tokens: frozenset[str] = field(default_factory=frozenset)
# Prevents trailing-whitespace accumulation across multi-turn conversations.
strip_trailing_reasoning_whitespace: bool = True
+2 -2
View File
@@ -16,7 +16,7 @@ import dataclasses
import functools
from typing import TYPE_CHECKING
from vllm.parser.qwen3 import Qwen3Parser, qwen3_config
from vllm.parser.qwen3 import CHATML_TURN_BOUNDARIES, Qwen3Parser, qwen3_config
if TYPE_CHECKING:
from vllm.entrypoints.openai.chat_completion.protocol import (
@@ -33,7 +33,7 @@ if TYPE_CHECKING:
@functools.cache
def nemotron_v3_config(thinking: bool = True) -> ParserEngineConfig:
return dataclasses.replace(
qwen3_config(thinking=thinking),
qwen3_config(thinking=thinking, turn_boundary_tokens=CHATML_TURN_BOUNDARIES),
name="nemotron_v3",
strip_trailing_reasoning_whitespace=True,
)
+8
View File
@@ -42,6 +42,7 @@ THINK_START = "<think>"
THINK_END = "</think>"
TOOL_CALL_START = "<tool_call>"
TOOL_CALL_END = "</tool_call>"
CHATML_TURN_BOUNDARIES = frozenset(("<|im_start|>", "<|im_end|>"))
FUNC_PREFIX = "<function="
FUNC_END = "</function>"
PARAM_START = "<parameter="
@@ -94,10 +95,12 @@ def qwen3_config(
think_end: str = THINK_END,
tool_start: str = TOOL_CALL_START,
tool_end: str = TOOL_CALL_END,
turn_boundary_tokens: frozenset[str] = frozenset(),
) -> ParserEngineConfig:
return ParserEngineConfig(
name=name,
initial_state=ParserState.REASONING if thinking else ParserState.CONTENT,
turn_boundary_tokens=turn_boundary_tokens,
terminals={
# Reasoning terminals
"THINK_START": think_start,
@@ -215,6 +218,7 @@ class Qwen3Parser(ParserEngine):
THINK_END = THINK_END
TOOL_START = TOOL_CALL_START
TOOL_END = TOOL_CALL_END
TURN_BOUNDARIES: frozenset[str] = CHATML_TURN_BOUNDARIES
def __init__(
self,
@@ -233,6 +237,7 @@ class Qwen3Parser(ParserEngine):
think_end=self.THINK_END,
tool_start=self.TOOL_START,
tool_end=self.TOOL_END,
turn_boundary_tokens=self.TURN_BOUNDARIES,
),
)
super().__init__(
@@ -259,6 +264,7 @@ class Qwen3Parser(ParserEngine):
tool_call_id = self._tool_call_token_id
tool_call_end_id = self._tool_call_end_token_id
reasoning_start_id = self._reasoning_start_token_id
boundary_ids = self._turn_boundary_token_ids
if tool_call_id is not None:
for i in range(len(input_ids) - 1, -1, -1):
if (
@@ -266,6 +272,8 @@ class Qwen3Parser(ParserEngine):
and input_ids[i] == reasoning_start_id
):
return False
if input_ids[i] in boundary_ids:
return False
if input_ids[i] == tool_call_id:
if tool_call_end_id is not None and any(
input_ids[j] == tool_call_end_id