[Bugfix] Fix Step-3.5 reasoning parser for structured outputs (#53174)

Signed-off-by: Yifan Zong <yzong@redhat.com>
This commit is contained in:
yzong-rh
2026-09-09 17:10:23 -04:00
committed by GitHub
parent 8c87c333b8
commit 26fec6d183
2 changed files with 26 additions and 91 deletions
@@ -25,12 +25,11 @@ SIMPLE_REASONING = {
"content": "This is the rest",
"is_reasoning_end": True,
}
# need to get into parser again to remove newline after </think>
COMPLETE_REASONING = {
"output": "This is a reasoning section</think>",
"reasoning": "This is a reasoning section",
"content": None,
"is_reasoning_end": False,
"is_reasoning_end": True,
}
NO_CONTENT = {
"output": "This is content",
@@ -72,7 +71,7 @@ COMPLETE_REASONING_WITH_THINK = {
"output": "<think>This is a reasoning section</think>",
"reasoning": "This is a reasoning section",
"content": None,
"is_reasoning_end": False,
"is_reasoning_end": True,
}
MULTIPLE_LINES_WITH_THINK = {
"output": "<think>This\nThat</think>This is the rest\nThat",
@@ -113,21 +112,21 @@ EMPTY_STREAMING = {
NEW_LINE = {
"output": "\n<think>This is a reasoning section</think>\nThis is the rest",
"reasoning": "This is a reasoning section",
"content": "This is the rest",
"content": "\nThis is the rest",
"is_reasoning_end": True,
}
NEW_LINE_STREAMING = {
"output": "\n<think>This is a reasoning section\n</think>\nThis is the rest",
"reasoning": "\nThis is a reasoning section",
"content": "This is the rest",
"content": "\nThis is the rest",
"is_reasoning_end": True,
}
NEW_LINE_STREAMING_COMPLEX_CONTENT = {
"output": "\n This is a \n reasoning section\n\n\n</think>\n\nThis is the rest",
"reasoning": "\n This is a \n reasoning section\n\n",
"content": "\nThis is the rest",
"content": "\n\nThis is the rest",
"is_reasoning_end": True,
}
@@ -301,9 +300,20 @@ def test_reasoning(
# Test is_reasoning_end
output_ids = step3p5_tokenizer.convert_tokens_to_ids(output)
if streaming:
is_reasoning_end = parser.is_reasoning_end(output_ids)
assert is_reasoning_end == param_dict["is_reasoning_end"]
is_reasoning_end = parser.is_reasoning_end(output_ids)
assert is_reasoning_end == param_dict["is_reasoning_end"]
# Test is_reasoning_end_streaming
current_ids: list[int] = []
for token_id in output_ids:
current_ids.append(token_id)
if token_id == parser.end_token_id:
assert parser.is_reasoning_end_streaming(current_ids, [token_id])
assert parser.is_reasoning_end_streaming(output_ids, output_ids)
break
assert not parser.is_reasoning_end_streaming(current_ids, [token_id])
else:
assert not parser.is_reasoning_end_streaming(output_ids, output_ids)
# Test extract_content
if param_dict["content"] is not None:
@@ -324,18 +334,5 @@ def test_reasoning(
)
assert content == expected_content_ids
else:
content = parser.extract_content_ids(output)
content = parser.extract_content_ids(output_ids)
assert content == []
def test_step3p5_streaming_drops_leading_newline(step3p5_tokenizer):
parser_cls = ReasoningParserManager.get_reasoning_parser("step3p5")
parser = parser_cls(step3p5_tokenizer)
output = "<think>calc</think>\nAnswer"
tokens = step3p5_tokenizer.tokenize(output)
output_tokens = [
step3p5_tokenizer.convert_tokens_to_string([token]) for token in tokens
]
_, content = run_reasoning_extraction(parser, output_tokens, streaming=True)
assert content == "Answer"
+6 -68
View File
@@ -1,7 +1,7 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from collections.abc import Iterable, Sequence
from collections.abc import Sequence
from typing import TYPE_CHECKING
from vllm.entrypoints.generate.base.protocol import DeltaMessage
@@ -18,9 +18,11 @@ class Step3p5ReasoningParser(BaseThinkingReasoningParser):
Reasoning parser for Step3p5 model.
Step3p5 uses the <think>...</think> format, but it tends to emit an extra
newline immediately before and/or after the </think> token. This parser trims:
- the newline right before </think>
- the newline right after </think>
newline immediately before and/or after the </think> token. This parser
trims the newline before </think> in extract_reasoning*.
Reasoning end detection doesn't wait for an extra token after </think> so
structured output can constrain the first post-think token.
"""
@property
@@ -38,60 +40,6 @@ class Step3p5ReasoningParser(BaseThinkingReasoningParser):
# whether it is immediately before </think>.
self._pending_reasoning_newline = False
# Tracks whether we've seen </think> but are still waiting for one more
# token to confirm the end.
self._end_token_pending = False
def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
return self._is_reasoning_end_from_ids(input_ids)
def is_reasoning_end_streaming(
self, input_ids: Sequence[int], delta_ids: Iterable[int]
) -> bool:
# Only examine newly generated tokens; they may contain multiple ids.
return self._is_reasoning_end_from_ids(tuple(delta_ids))
def _is_reasoning_end_from_ids(self, input_ids: Sequence[int]) -> bool:
# Scan backwards to find the last special token, <think> or </think>.
last_special = None
last_idx = -1
for i in range(len(input_ids) - 1, -1, -1):
token_id = input_ids[i]
if token_id == self.start_token_id:
last_special = "start"
last_idx = i
break
if token_id == self.end_token_id:
last_special = "end"
last_idx = i
break
if last_special == "start":
# If we're already waiting for one token after </think>, do not
# clear the pending state just because the prompt contains <think>.
# Streaming deltas should not include <think> for this model.
if self._end_token_pending:
return False
# A start token after any end token means reasoning is ongoing.
self._end_token_pending = False
return False
if last_special == "end":
# Require at least one token after </think> before ending.
if last_idx < len(input_ids) - 1:
self._end_token_pending = False
return True
self._end_token_pending = True
return False
# No special tokens in this input. If we were waiting for one token
# after </think>, any new token completes the end.
if self._end_token_pending and input_ids:
self._end_token_pending = False
return True
return False
def extract_reasoning(
self,
model_output: str,
@@ -100,8 +48,6 @@ class Step3p5ReasoningParser(BaseThinkingReasoningParser):
reasoning, content = super().extract_reasoning(model_output, request)
if reasoning is not None:
reasoning = reasoning.removesuffix("\n")
if content is not None:
content = content.removeprefix("\n")
return reasoning or None, content or None
def extract_reasoning_streaming(
@@ -113,14 +59,6 @@ class Step3p5ReasoningParser(BaseThinkingReasoningParser):
current_token_ids: Sequence[int],
delta_token_ids: Sequence[int],
) -> DeltaMessage | None:
# Drop the immediate newline that models often emit after </think>.
if previous_text.endswith(self.end_token) and delta_text:
if delta_text == "\n":
return None
elif delta_text.startswith("\n"):
remaining = delta_text.removeprefix("\n")
return DeltaMessage(content=remaining) if remaining else None
ret = super().extract_reasoning_streaming(
previous_text,
current_text,