diff --git a/backend/python/sglang/backend.py b/backend/python/sglang/backend.py index 76d99a726aaf..84c122cc0574 100644 --- a/backend/python/sglang/backend.py +++ b/backend/python/sglang/backend.py @@ -374,10 +374,67 @@ def _build_prompt(self, request) -> str: messages_dicts, tokenize=False, add_generation_prompt=True, ) - def _make_parsers(self, request): + def _new_reasoning_parser(self, stream_reasoning: bool, prompt: str = "", + grammar_constrained: bool = False): + """Build a ReasoningParser for one request, or None. + + Reasoning templates come in two flavours. Some let the model emit the + opening tag, others put it into the *prompt* — Qwen3's template appends + ```` when thinking is on, so the completion starts straight in + the reasoning block and only the closing ```` ever shows up. + sglang's detector keys off the opening tag, so in that second case it + classifies the whole completion as normal content and + ``reasoning_content`` stays empty. + + sglang's own OpenAI server covers this with + ``template_manager.force_reasoning``; this backend has no template + manager, so it derives the same signal from the rendered prompt. + ``force_reasoning`` is only passed when we mean True, leaving detector + defaults (e.g. DeepSeek-R1's built-in True) untouched. + + ``grammar_constrained`` suppresses the prefill heuristic. A structured + decoding constraint applies from the first token, so the model cannot + emit the closing tag even though the template opened the block: the + whole completion is schema output and belongs in ``content``. Forcing + there files the answer as reasoning and leaves content empty. sglang's + own server keeps the two apart for the same reason — its grammar + backend owns the reasoning prefix when a reasoning parser is set. + """ + if grammar_constrained: + prompt = "" + + if not (HAS_REASONING_PARSERS and self.reasoning_parser_name): + return None + + kwargs = { + "model_type": self.reasoning_parser_name, + "stream_reasoning": stream_reasoning, + } + try: + parser = ReasoningParser(**kwargs) + except Exception as e: + print(f"ReasoningParser init failed: {e!r}", file=sys.stderr) + return None + + start = getattr(getattr(parser, "detector", None), "think_start_token", None) + if start and prompt and prompt.rstrip().endswith(start): + try: + parser = ReasoningParser(force_reasoning=True, **kwargs) + except TypeError: + # sglang without the force_reasoning kwarg: keep the default + # parser rather than failing the request. + pass + except Exception as e: + print( + f"ReasoningParser(force_reasoning=True) failed: {e!r}", + file=sys.stderr, + ) + + return parser + + def _make_parsers(self, request, prompt: str = ""): """Construct fresh per-request parser instances (stateful).""" tool_parser = None - reasoning_parser = None if HAS_TOOL_PARSERS and self.tool_parser_name and request.Tools: try: @@ -389,14 +446,9 @@ def _make_parsers(self, request): except Exception as e: print(f"FunctionCallParser init failed: {e!r}", file=sys.stderr) - if HAS_REASONING_PARSERS and self.reasoning_parser_name: - try: - reasoning_parser = ReasoningParser( - model_type=self.reasoning_parser_name, - stream_reasoning=True, - ) - except Exception as e: - print(f"ReasoningParser init failed: {e!r}", file=sys.stderr) + reasoning_parser = self._new_reasoning_parser( + True, prompt, bool(getattr(request, "Grammar", "")), + ) return tool_parser, reasoning_parser @@ -404,7 +456,7 @@ async def _predict(self, request, context, streaming: bool = False): sampling_params = self._build_sampling_params(request) prompt = self._build_prompt(request) - tool_parser, reasoning_parser = self._make_parsers(request) + tool_parser, reasoning_parser = self._make_parsers(request, prompt) image_data = list(request.Images) if request.Images else None video_data = list(request.Videos) if request.Videos else None @@ -500,15 +552,9 @@ async def _predict(self, request, context, streaming: bool = False): final_tool_calls: List[backend_pb2.ToolCallDelta] = [] if not streaming: - final_reasoning_parser = None - if HAS_REASONING_PARSERS and self.reasoning_parser_name: - try: - final_reasoning_parser = ReasoningParser( - model_type=self.reasoning_parser_name, - stream_reasoning=False, - ) - except Exception: - final_reasoning_parser = None + final_reasoning_parser = self._new_reasoning_parser( + False, prompt, bool(getattr(request, "Grammar", "")), + ) if final_reasoning_parser is not None: try: diff --git a/backend/python/sglang/test.py b/backend/python/sglang/test.py index c50ed577f941..b64ee503200c 100644 --- a/backend/python/sglang/test.py +++ b/backend/python/sglang/test.py @@ -128,6 +128,58 @@ def kwargs_for(metadata): self.assertNotIn("enable_thinking", kwargs_for({})) self.assertIs(kwargs_for({"enable_thinking": "FALSE"})["enable_thinking"], False) + def test_reasoning_parser_forced_when_template_prefills_think_tag(self): + """Qwen3's template puts ```` in the prompt, so the completion + never contains it. Without force_reasoning the detector treats the whole + completion as normal text and reasoning_content stays empty.""" + servicer = self._servicer() + servicer.reasoning_parser_name = "qwen3" + + # What the model actually emits when the prompt ends in "". + completion = "adding two and two4" + + forced = servicer._new_reasoning_parser(False, prompt="user: hi\n\n") + reasoning, content = forced.parse_non_stream(completion) + self.assertEqual(reasoning, "adding two and two") + self.assertEqual(content, "4") + + # No prefilled tag in the prompt: detector default, unchanged behaviour. + unforced = servicer._new_reasoning_parser(False, prompt="user: hi\n") + reasoning, content = unforced.parse_non_stream(completion) + self.assertFalse(reasoning) + self.assertEqual(content, completion) + + def test_reasoning_parser_not_forced_when_thinking_is_off(self): + """Thinking off means no ```` in the prompt either, so the answer + must not be swallowed into reasoning_content.""" + servicer = self._servicer() + servicer.reasoning_parser_name = "qwen3" + + parser = servicer._new_reasoning_parser(False, prompt="user: primes?\n") + reasoning, content = parser.parse_non_stream("2,3,5,7,11") + self.assertFalse(reasoning) + self.assertEqual(content, "2,3,5,7,11") + + def test_grammar_constrained_output_is_not_forced_into_reasoning(self): + """Structured decoding applies from the first token, so the model cannot + emit the closing tag even though the template opened the block. The whole + completion is schema output and must stay in content.""" + servicer = self._servicer() + servicer.reasoning_parser_name = "qwen3" + + schema_out = '{"findings": [{"line": 42, "issue": "off-by-one"}]}' + parser = servicer._new_reasoning_parser( + False, prompt="audit this\n\n", grammar_constrained=True, + ) + reasoning, content = parser.parse_non_stream(schema_out) + self.assertFalse(reasoning) + self.assertEqual(content, schema_out) + + def test_reasoning_parser_absent_without_configured_parser(self): + servicer = self._servicer() + servicer.reasoning_parser_name = None + self.assertIsNone(servicer._new_reasoning_parser(False, prompt="")) + def test_explicit_zero_temperature_is_preserved(self): """Temperature=0 is valid greedy decoding, not an unset value.""" from types import SimpleNamespace