feat(constrained): two-phase reasoning grammar + --enable-strict-thinking (#23953)
This commit is contained in:
@@ -21,6 +21,7 @@ from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import torch
|
||||
|
||||
from sglang.srt.parser.reasoning_parser import ReasoningParser
|
||||
from sglang.srt.server_args import ServerArgs
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -128,6 +129,8 @@ class InvalidGrammarObject(BaseGrammarObject):
|
||||
|
||||
|
||||
class BaseGrammarBackend:
|
||||
_enable_strict_thinking: bool = False
|
||||
|
||||
def __init__(self):
|
||||
self.executor = ThreadPoolExecutor()
|
||||
self.cache: Dict[Tuple[str, str], BaseGrammarObject] = {}
|
||||
@@ -136,6 +139,24 @@ class BaseGrammarBackend:
|
||||
logger.warning(f"Skip unsupported {key_type=}, {key_string=}")
|
||||
return InvalidGrammarObject()
|
||||
|
||||
@property
|
||||
def enable_strict_thinking(self):
|
||||
return self._enable_strict_thinking
|
||||
|
||||
@property
|
||||
def is_support_token_filter(self):
|
||||
return False
|
||||
|
||||
def set_token_filter(
|
||||
self, vocab_mask, token_ids, batch_idx, is_allowed=True, reset_vocab_mask=True
|
||||
):
|
||||
"""Set or clear specific tokens in the vocab mask. No-op by default."""
|
||||
pass
|
||||
|
||||
def init_strict_reasoning_grammar(self, reasoning: bool):
|
||||
"""Create a grammar object for strict token filtering only. Returns None by default."""
|
||||
return None
|
||||
|
||||
def dispatch_fallback(self, key_type: str, key_string: str) -> BaseGrammarObject:
|
||||
"""
|
||||
This function should not be reached in any case.
|
||||
@@ -239,6 +260,13 @@ def create_grammar_backend(
|
||||
any_whitespace=not server_args.constrained_json_disable_any_whitespace,
|
||||
)
|
||||
except TokenizerNotSupportedError as e:
|
||||
if server_args.enable_strict_thinking:
|
||||
raise ValueError(
|
||||
f"--enable-strict-thinking requires a grammar backend with "
|
||||
f"token filtering support, but XGrammar failed to initialize: "
|
||||
f"{e}. Cannot fall back to grammar_backend='none' with strict "
|
||||
f"thinking enabled."
|
||||
) from e
|
||||
logger.warning(
|
||||
f"Grammar backend disabled because tokenizer is not supported by XGrammar: {e}. "
|
||||
"Falling back to grammar_backend='none'. "
|
||||
@@ -255,6 +283,13 @@ def create_grammar_backend(
|
||||
whitespace_pattern=server_args.constrained_json_whitespace_pattern,
|
||||
)
|
||||
elif name == "none":
|
||||
if server_args.enable_strict_thinking:
|
||||
raise ValueError(
|
||||
"--enable-strict-thinking requires a grammar backend that supports "
|
||||
"token filtering, but grammar_backend='none' was specified. Use "
|
||||
"--grammar-backend xgrammar or another backend that supports token "
|
||||
"filtering."
|
||||
)
|
||||
return None
|
||||
else:
|
||||
raise ValueError(f"Invalid grammar backend: {name}")
|
||||
@@ -264,6 +299,15 @@ def create_grammar_backend(
|
||||
ReasonerGrammarBackend,
|
||||
)
|
||||
|
||||
grammar_backend = ReasonerGrammarBackend(grammar_backend, think_end_id)
|
||||
reasoning_parser = ReasoningParser(
|
||||
model_type=server_args.reasoning_parser, stream_reasoning=False
|
||||
)
|
||||
|
||||
grammar_backend = ReasonerGrammarBackend(
|
||||
grammar_backend,
|
||||
reasoning_parser,
|
||||
tokenizer,
|
||||
enable_strict_thinking=server_args.enable_strict_thinking,
|
||||
)
|
||||
|
||||
return grammar_backend
|
||||
|
||||
@@ -11,6 +11,7 @@ from sglang.srt.constrained.base_grammar_backend import (
|
||||
InvalidGrammarObject,
|
||||
create_grammar_backend,
|
||||
)
|
||||
from sglang.srt.constrained.reasoner_grammar_backend import ReasonerGrammarObject
|
||||
from sglang.srt.environ import envs
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -37,6 +38,12 @@ class GrammarManager:
|
||||
else:
|
||||
self.grammar_backend = None
|
||||
|
||||
self._enable_strict_thinking = (
|
||||
self.grammar_backend.enable_strict_thinking
|
||||
if self.grammar_backend is not None
|
||||
else False
|
||||
)
|
||||
|
||||
self.grammar_sync_group = scheduler.dp_tp_cpu_group
|
||||
self.grammar_sync_size = scheduler.dp_tp_group.world_size
|
||||
self.grammar_sync_entry = scheduler.dp_tp_group.first_rank
|
||||
@@ -65,6 +72,20 @@ class GrammarManager:
|
||||
req.grammar.cancel()
|
||||
req.set_finish_with_abort("Aborted by AbortReq.")
|
||||
|
||||
def _get_request_thinking_budget(self, req: Req) -> int | None:
|
||||
custom_params = req.sampling_params.custom_params
|
||||
if not isinstance(custom_params, dict):
|
||||
return None
|
||||
thinking_budget = custom_params.get("thinking_budget")
|
||||
return thinking_budget if isinstance(thinking_budget, int) else None
|
||||
|
||||
def _apply_request_reasoning_budget(self, req: Req) -> None:
|
||||
thinking_budget = self._get_request_thinking_budget(req)
|
||||
if thinking_budget is None:
|
||||
return
|
||||
if isinstance(req.grammar, ReasonerGrammarObject):
|
||||
req.grammar.max_think_tokens = thinking_budget
|
||||
|
||||
def process_req_with_grammar(self, req: Req) -> bool:
|
||||
# Init grammar cache for this request
|
||||
add_to_grammar_queue = False
|
||||
@@ -103,6 +124,15 @@ class GrammarManager:
|
||||
f"Failed to compile {key[0]} grammar: {value.error_message}"
|
||||
)
|
||||
req.set_finish_with_abort(error_msg)
|
||||
else:
|
||||
self._apply_request_reasoning_budget(req)
|
||||
elif self._enable_strict_thinking:
|
||||
grammar_obj = self.grammar_backend.init_strict_reasoning_grammar(
|
||||
req.require_reasoning
|
||||
)
|
||||
if grammar_obj is not None:
|
||||
req.grammar = grammar_obj
|
||||
self._apply_request_reasoning_budget(req)
|
||||
|
||||
if add_to_grammar_queue:
|
||||
self.grammar_queue.append(req)
|
||||
@@ -177,8 +207,16 @@ class GrammarManager:
|
||||
continue
|
||||
|
||||
assert isinstance(req.grammar, futures.Future) and req.grammar_key
|
||||
req.grammar = req.grammar.result()
|
||||
try:
|
||||
req.grammar = req.grammar.result()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Grammar compilation raised an exception: {e}, "
|
||||
f"grammar_key={req.grammar_key}"
|
||||
)
|
||||
req.grammar = InvalidGrammarObject(f"Grammar compilation failed: {e}")
|
||||
self.grammar_backend.set_cache(req.grammar_key, req.grammar.copy())
|
||||
self._apply_request_reasoning_budget(req)
|
||||
if isinstance(req.grammar, InvalidGrammarObject):
|
||||
error_msg = f"Failed to compile {req.grammar_key[0]} grammar: {req.grammar.error_message}"
|
||||
req.set_finish_with_abort(error_msg)
|
||||
|
||||
@@ -13,9 +13,14 @@
|
||||
# ==============================================================================
|
||||
"""The baseclass of a backend for reasoner grammar-guided constrained decoding."""
|
||||
|
||||
from typing import List, Optional, Tuple
|
||||
import logging
|
||||
from typing import List, Optional, Tuple, Union
|
||||
|
||||
import torch
|
||||
from transformers import PreTrainedTokenizer, PreTrainedTokenizerFast
|
||||
|
||||
from sglang.srt.environ import envs
|
||||
from sglang.srt.parser.reasoning_parser import ReasoningParser
|
||||
|
||||
from .base_grammar_backend import (
|
||||
BaseGrammarBackend,
|
||||
@@ -23,102 +28,291 @@ from .base_grammar_backend import (
|
||||
InvalidGrammarObject,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReasonerGrammarObject(BaseGrammarObject):
|
||||
def __init__(self, grammar: BaseGrammarObject, think_end_id: int):
|
||||
"""Wraps a grammar object to handle reasoning (think/generation) phases.
|
||||
|
||||
State machine (must call maybe_init_reasoning before use):
|
||||
THINKING (tokens_in_think >= 0, tokens_after_end == -1)
|
||||
-> grammar not consulted, optional token filtering
|
||||
GENERATION (tokens_after_end >= 0)
|
||||
-> grammar consulted for accept/fill/rollback
|
||||
|
||||
When enable_token_filter=True (strict mode), fill_vocab_mask filters
|
||||
excluded tokens during THINKING and enforces max_think_tokens budget.
|
||||
When the budget is exhausted, only think_end_id is allowed, forcing the
|
||||
model to exit the thinking phase.
|
||||
When enable_token_filter=False (non-strict mode), fill_vocab_mask is
|
||||
a no-op during THINKING.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
grammar: Optional[BaseGrammarObject],
|
||||
think_end_id: int,
|
||||
think_excluded_token_ids: Optional[List[int]] = None,
|
||||
max_think_tokens: int = -1,
|
||||
enable_token_filter: bool = False,
|
||||
token_filter_fn=None,
|
||||
allocate_vocab_mask_fn=None,
|
||||
move_vocab_mask_fn=None,
|
||||
apply_vocab_mask_fn=None,
|
||||
):
|
||||
super().__init__()
|
||||
self.grammar = grammar
|
||||
self.think_end_id = think_end_id
|
||||
# -1 means thinking has not ended yet
|
||||
# 0 means just ended thinking in the last token
|
||||
# + means number of tokens after thinking ended
|
||||
self.tokens_after_think_end = -1
|
||||
self.think_excluded_token_ids = think_excluded_token_ids
|
||||
self.max_think_tokens = max_think_tokens
|
||||
self.enable_token_filter = enable_token_filter
|
||||
self.token_filter_fn = token_filter_fn
|
||||
self.allocate_vocab_mask_fn = allocate_vocab_mask_fn
|
||||
self.move_vocab_mask_fn = move_vocab_mask_fn
|
||||
self.apply_vocab_mask_fn = apply_vocab_mask_fn
|
||||
self._think_end_id_list = [think_end_id]
|
||||
|
||||
self.tokens_in_think = -1
|
||||
self.tokens_after_end = -1
|
||||
|
||||
def maybe_init_reasoning(self, reasoning: bool):
|
||||
self.tokens_after_think_end = -1 if reasoning else 0
|
||||
if reasoning:
|
||||
self.tokens_in_think = 0
|
||||
else:
|
||||
self.tokens_in_think = -1
|
||||
self.tokens_after_end = 0
|
||||
|
||||
def transfer_state(self, token: int) -> int:
|
||||
if self.tokens_after_think_end == -1 and token == self.think_end_id:
|
||||
self.tokens_after_think_end = 0
|
||||
elif self.tokens_after_think_end >= 0:
|
||||
self.tokens_after_think_end += 1
|
||||
def _is_thinking(self):
|
||||
return self.tokens_in_think >= 0 and self.tokens_after_end == -1
|
||||
|
||||
def _is_generation(self):
|
||||
return self.tokens_after_end >= 0
|
||||
|
||||
def transfer_state(self, token: int) -> None:
|
||||
if self._is_thinking():
|
||||
if token == self.think_end_id:
|
||||
self.tokens_after_end = 0
|
||||
else:
|
||||
self.tokens_in_think += 1
|
||||
elif self._is_generation():
|
||||
self.tokens_after_end += 1
|
||||
|
||||
def rollback_state(self):
|
||||
if self.tokens_after_think_end == 0:
|
||||
self.tokens_after_think_end = -1
|
||||
elif self.tokens_after_think_end > 0:
|
||||
self.tokens_after_think_end -= 1
|
||||
if self._is_thinking():
|
||||
if self.tokens_in_think > 0:
|
||||
self.tokens_in_think -= 1
|
||||
elif self._is_generation():
|
||||
if self.tokens_after_end == 0:
|
||||
self.tokens_after_end = -1
|
||||
elif self.tokens_after_end > 0:
|
||||
self.tokens_after_end -= 1
|
||||
|
||||
def accept_token(self, token: int):
|
||||
if self.tokens_after_think_end >= 0:
|
||||
if self._is_generation() and self.grammar is not None:
|
||||
self.grammar.accept_token(token)
|
||||
self.transfer_state(token)
|
||||
|
||||
def is_terminated(self):
|
||||
return self.grammar.is_terminated()
|
||||
if self.grammar is not None:
|
||||
return self.grammar.is_terminated()
|
||||
return False
|
||||
|
||||
def rollback(self, k):
|
||||
steps_after_think = min(k, self.tokens_after_think_end)
|
||||
if steps_after_think > 0:
|
||||
self.grammar.rollback(steps_after_think)
|
||||
|
||||
if self.grammar is not None:
|
||||
steps_after = min(k, max(0, self.tokens_after_end))
|
||||
if steps_after > 0:
|
||||
self.grammar.rollback(steps_after)
|
||||
for _ in range(k):
|
||||
self.rollback_state()
|
||||
|
||||
def allocate_vocab_mask(
|
||||
self, vocab_size: int, batch_size: int, device
|
||||
) -> torch.Tensor:
|
||||
return self.grammar.allocate_vocab_mask(vocab_size, batch_size, device)
|
||||
def _can_think_more(self):
|
||||
return self.max_think_tokens < 0 or self.tokens_in_think < self.max_think_tokens
|
||||
|
||||
def _do_token_filter(self, vocab_mask, token_ids, idx, is_allowed=True):
|
||||
if self.token_filter_fn is not None:
|
||||
self.token_filter_fn(vocab_mask, token_ids, idx, is_allowed)
|
||||
|
||||
def fill_vocab_mask(self, vocab_mask: torch.Tensor, idx: int) -> None:
|
||||
if self.tokens_after_think_end >= 0:
|
||||
if self._is_thinking():
|
||||
if not self.enable_token_filter:
|
||||
return
|
||||
if self._can_think_more():
|
||||
self._do_token_filter(
|
||||
vocab_mask, self.think_excluded_token_ids, idx, is_allowed=False
|
||||
)
|
||||
else:
|
||||
self._do_token_filter(
|
||||
vocab_mask, self._think_end_id_list, idx, is_allowed=True
|
||||
)
|
||||
return
|
||||
if self._is_generation() and self.grammar is not None:
|
||||
self.grammar.fill_vocab_mask(vocab_mask, idx)
|
||||
|
||||
def move_vocab_mask(self, vocab_mask: torch.Tensor, device) -> torch.Tensor:
|
||||
return self.grammar.move_vocab_mask(vocab_mask, device)
|
||||
def allocate_vocab_mask(self, vocab_size, batch_size, device):
|
||||
if self.grammar is not None:
|
||||
return self.grammar.allocate_vocab_mask(vocab_size, batch_size, device)
|
||||
if self.allocate_vocab_mask_fn is not None:
|
||||
return self.allocate_vocab_mask_fn(vocab_size, batch_size, device)
|
||||
return None
|
||||
|
||||
def move_vocab_mask(self, vocab_mask, device):
|
||||
if self.grammar is not None:
|
||||
return self.grammar.move_vocab_mask(vocab_mask, device)
|
||||
if self.move_vocab_mask_fn is not None:
|
||||
return self.move_vocab_mask_fn(vocab_mask, device)
|
||||
return vocab_mask
|
||||
|
||||
@property
|
||||
def apply_vocab_mask(self):
|
||||
return self.grammar.apply_vocab_mask
|
||||
if self.grammar is not None:
|
||||
return self.grammar.apply_vocab_mask
|
||||
return self.apply_vocab_mask_fn
|
||||
|
||||
def copy(self) -> BaseGrammarObject:
|
||||
return ReasonerGrammarObject(self.grammar.copy(), self.think_end_id)
|
||||
def copy(self):
|
||||
new_obj = ReasonerGrammarObject(
|
||||
self.grammar.copy() if self.grammar is not None else None,
|
||||
self.think_end_id,
|
||||
self.think_excluded_token_ids,
|
||||
self.max_think_tokens,
|
||||
self.enable_token_filter,
|
||||
self.token_filter_fn,
|
||||
self.allocate_vocab_mask_fn,
|
||||
self.move_vocab_mask_fn,
|
||||
self.apply_vocab_mask_fn,
|
||||
)
|
||||
new_obj.tokens_in_think = self.tokens_in_think
|
||||
new_obj.tokens_after_end = self.tokens_after_end
|
||||
new_obj._finished = self._finished
|
||||
return new_obj
|
||||
|
||||
@property
|
||||
def finished(self):
|
||||
return self.grammar.finished
|
||||
if self.grammar is not None:
|
||||
return self.grammar.finished
|
||||
return self._finished
|
||||
|
||||
@finished.setter
|
||||
def finished(self, finished):
|
||||
self.grammar.finished = finished
|
||||
if self.grammar is not None:
|
||||
self.grammar.finished = finished
|
||||
else:
|
||||
self._finished = finished
|
||||
|
||||
def try_jump_forward(self, tokenizer):
|
||||
return self.grammar.try_jump_forward(tokenizer)
|
||||
if self.grammar is not None:
|
||||
return self.grammar.try_jump_forward(tokenizer)
|
||||
return None
|
||||
|
||||
def jump_forward_str_state(self, helper):
|
||||
return self.grammar.jump_forward_str_state(helper)
|
||||
if self.grammar is not None:
|
||||
return self.grammar.jump_forward_str_state(helper)
|
||||
return None
|
||||
|
||||
def jump_and_retokenize(
|
||||
self, old_output_ids: List[int], new_output_ids: List[int], next_state: int
|
||||
):
|
||||
return self.grammar.jump_and_retokenize(
|
||||
old_output_ids, new_output_ids, next_state
|
||||
)
|
||||
def jump_and_retokenize(self, old_output_ids, new_output_ids, next_state):
|
||||
if self.grammar is not None:
|
||||
return self.grammar.jump_and_retokenize(
|
||||
old_output_ids, new_output_ids, next_state
|
||||
)
|
||||
|
||||
|
||||
class ReasonerGrammarBackend(BaseGrammarBackend):
|
||||
def __init__(self, grammar_backend: BaseGrammarBackend, think_end_id):
|
||||
def __init__(
|
||||
self,
|
||||
grammar_backend: BaseGrammarBackend,
|
||||
reasoning_parser: ReasoningParser,
|
||||
tokenizer: Union[PreTrainedTokenizer, PreTrainedTokenizerFast],
|
||||
enable_strict_thinking: bool = False,
|
||||
):
|
||||
super().__init__()
|
||||
self.grammar_backend = grammar_backend
|
||||
self.think_end_id = think_end_id
|
||||
think_end_ids = tokenizer.encode(
|
||||
reasoning_parser.detector.think_end_token, add_special_tokens=False
|
||||
)
|
||||
if not think_end_ids:
|
||||
raise ValueError(
|
||||
f"think_end_token '{reasoning_parser.detector.think_end_token}' "
|
||||
f"could not be encoded by the tokenizer."
|
||||
)
|
||||
if len(think_end_ids) != 1:
|
||||
raise ValueError(
|
||||
f"think_end_token '{reasoning_parser.detector.think_end_token}' "
|
||||
"must encode to exactly one token for constrained reasoning."
|
||||
)
|
||||
self.think_end_id = think_end_ids[0]
|
||||
self._enable_strict_thinking = enable_strict_thinking
|
||||
self.think_excluded_token_ids = self._get_think_excluded_token_ids(
|
||||
reasoning_parser, tokenizer
|
||||
)
|
||||
self.max_think_tokens = envs.SGLANG_MAX_THINK_TOKENS.get()
|
||||
if (
|
||||
self.enable_strict_thinking
|
||||
and self.think_excluded_token_ids is not None
|
||||
and not self.grammar_backend.is_support_token_filter
|
||||
):
|
||||
raise ValueError(
|
||||
"Strict reasoning format requested but the grammar backend does not "
|
||||
"support token filtering. Use a grammar backend that supports token "
|
||||
"filtering (e.g., xgrammar) or disable strict reasoning mode."
|
||||
)
|
||||
self.enable_token_filter = (
|
||||
self.enable_strict_thinking
|
||||
and self.think_excluded_token_ids is not None
|
||||
and self.grammar_backend.is_support_token_filter
|
||||
)
|
||||
self._token_filter_fn = (
|
||||
self.grammar_backend.set_token_filter if self.enable_token_filter else None
|
||||
)
|
||||
|
||||
def _get_think_excluded_token_ids(
|
||||
self,
|
||||
reasoning_parser: ReasoningParser,
|
||||
tokenizer: Union[PreTrainedTokenizer, PreTrainedTokenizerFast],
|
||||
) -> Optional[List[int]]:
|
||||
excluded_ids = []
|
||||
if (not self.enable_strict_thinking) or (
|
||||
not reasoning_parser.detector.think_excluded_tokens
|
||||
):
|
||||
return None
|
||||
for token in reasoning_parser.detector.think_excluded_tokens:
|
||||
new_ids = tokenizer.encode(token, add_special_tokens=False)
|
||||
if not new_ids:
|
||||
raise ValueError(
|
||||
f"think_excluded_token '{token}' could not be encoded by the "
|
||||
f"tokenizer. All excluded tokens must be encodable for strict "
|
||||
f"reasoning mode to function correctly."
|
||||
)
|
||||
excluded_ids += new_ids
|
||||
return excluded_ids
|
||||
|
||||
def _make_grammar_object(
|
||||
self, grammar: Optional[BaseGrammarObject], reasoning: bool
|
||||
) -> ReasonerGrammarObject:
|
||||
obj = ReasonerGrammarObject(
|
||||
grammar=grammar,
|
||||
think_end_id=self.think_end_id,
|
||||
think_excluded_token_ids=self.think_excluded_token_ids,
|
||||
max_think_tokens=self.max_think_tokens,
|
||||
enable_token_filter=self.enable_token_filter,
|
||||
token_filter_fn=self._token_filter_fn,
|
||||
allocate_vocab_mask_fn=self.grammar_backend.allocate_vocab_mask,
|
||||
move_vocab_mask_fn=self.grammar_backend.move_vocab_mask,
|
||||
apply_vocab_mask_fn=self.grammar_backend.apply_vocab_mask,
|
||||
)
|
||||
obj.maybe_init_reasoning(reasoning)
|
||||
return obj
|
||||
|
||||
def init_strict_reasoning_grammar(
|
||||
self, reasoning: bool
|
||||
) -> Optional[BaseGrammarObject]:
|
||||
"""Create a grammar object for strict token filtering only (no inner grammar)."""
|
||||
if not self.enable_strict_thinking:
|
||||
return None
|
||||
return self._make_grammar_object(None, reasoning)
|
||||
|
||||
def _init_value_dispatch(
|
||||
self, key: Tuple[str, str], reasoning: bool
|
||||
) -> Optional[BaseGrammarObject]:
|
||||
ret = self.grammar_backend._init_value_dispatch(key, reasoning)
|
||||
# avoid wrapping invalid grammar, so that the scheduler can detect it
|
||||
if ret is None or isinstance(ret, InvalidGrammarObject):
|
||||
return ret
|
||||
obj = ReasonerGrammarObject(ret, self.think_end_id)
|
||||
obj.maybe_init_reasoning(reasoning)
|
||||
return obj
|
||||
return self._make_grammar_object(ret, reasoning)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Copyright 2026 SGLang Team
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ==============================================================================
|
||||
"""Torch fallback for token filter operations (non-CUDA devices and HIP).
|
||||
|
||||
Sets or clears specific bits in an int32 bitmask by token ID. The token list
|
||||
is typically tiny (< 10 entries); aggregation is done in Python with the actual
|
||||
bitmask operations using torch tensor indexing.
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
from typing import List
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
def set_token_filter_torch(
|
||||
vocab_mask: torch.Tensor,
|
||||
token_ids: List[int],
|
||||
batch_idx: int,
|
||||
is_allowed: bool = True,
|
||||
reset_vocab_mask: bool = True,
|
||||
):
|
||||
if reset_vocab_mask:
|
||||
vocab_mask[batch_idx].fill_(-1 if (not is_allowed) else 0)
|
||||
|
||||
if not token_ids:
|
||||
return
|
||||
|
||||
# Aggregate bit masks per int32 element to handle duplicate indices.
|
||||
aggregated: dict[int, int] = {}
|
||||
for token_id in token_ids:
|
||||
element_idx = token_id // 32
|
||||
bit_idx = token_id % 32
|
||||
aggregated[element_idx] = aggregated.get(element_idx, 0) | (1 << bit_idx)
|
||||
|
||||
row = vocab_mask[batch_idx]
|
||||
element_indices = torch.tensor(
|
||||
list(aggregated.keys()), dtype=torch.long, device=row.device
|
||||
)
|
||||
bitmasks = torch.tensor(
|
||||
[
|
||||
ctypes.c_int32(mask if is_allowed else ~mask).value
|
||||
for mask in aggregated.values()
|
||||
],
|
||||
dtype=row.dtype,
|
||||
device=row.device,
|
||||
)
|
||||
|
||||
if is_allowed:
|
||||
row[element_indices] = torch.bitwise_or(row[element_indices], bitmasks)
|
||||
else:
|
||||
row[element_indices] = torch.bitwise_and(row[element_indices], bitmasks)
|
||||
@@ -0,0 +1,175 @@
|
||||
# Copyright 2026 SGLang Team
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ==============================================================================
|
||||
"""Triton kernels for token filter operations."""
|
||||
|
||||
from collections import OrderedDict
|
||||
from typing import List
|
||||
|
||||
import torch
|
||||
import triton
|
||||
import triton.language as tl
|
||||
|
||||
from sglang.srt.utils import get_device_core_count
|
||||
|
||||
|
||||
@triton.jit
|
||||
def reset_vocab_mask_kernel(
|
||||
vocab_mask_ptr,
|
||||
batch_idx: int,
|
||||
num_elements: int,
|
||||
reset_value: tl.constexpr,
|
||||
):
|
||||
"""Reset the vocab mask for a specific batch index to a given value.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
vocab_mask_ptr : tl.tensor
|
||||
Pointer to the vocab mask tensor.
|
||||
|
||||
batch_idx : int
|
||||
The batch index to reset.
|
||||
|
||||
num_elements : int
|
||||
Number of int32 elements in the vocab mask for each batch.
|
||||
|
||||
reset_value : int
|
||||
The value to reset the vocab mask to (typically -1 or 0).
|
||||
"""
|
||||
pid = tl.program_id(0)
|
||||
num_threads = tl.num_programs(0)
|
||||
|
||||
for i in tl.range(pid, num_elements, num_threads):
|
||||
offset = batch_idx * num_elements + i
|
||||
tl.store(vocab_mask_ptr + offset, reset_value)
|
||||
|
||||
|
||||
@triton.jit
|
||||
def set_token_filter_batch_kernel(
|
||||
vocab_mask_ptr,
|
||||
token_ids_ptr,
|
||||
batch_idx: int,
|
||||
num_tokens: int,
|
||||
num_elements: int,
|
||||
is_allowed: tl.constexpr,
|
||||
):
|
||||
"""Set or clear specific tokens in the vocab mask for a batch.
|
||||
|
||||
Each token ID maps to a specific bit in the int32 bitmask array.
|
||||
The kernel sets or clears those bits using atomic operations.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
vocab_mask_ptr : tl.tensor
|
||||
Pointer to the vocab mask tensor.
|
||||
|
||||
token_ids_ptr : tl.tensor
|
||||
Pointer to the token IDs to set/clear.
|
||||
|
||||
batch_idx : int
|
||||
The batch index to modify.
|
||||
|
||||
num_tokens : int
|
||||
Number of tokens to process.
|
||||
|
||||
num_elements : int
|
||||
Number of int32 elements in the vocab mask for each batch.
|
||||
|
||||
is_allowed : bool
|
||||
If True, set the bit to 1 (allow token).
|
||||
If False, clear the bit to 0 (block token).
|
||||
"""
|
||||
pid = tl.program_id(0)
|
||||
num_threads = tl.num_programs(0)
|
||||
|
||||
for i in tl.range(pid, num_tokens, num_threads):
|
||||
token_id = tl.load(token_ids_ptr + i)
|
||||
element_idx = token_id // 32
|
||||
bit_idx = token_id % 32
|
||||
|
||||
offset = batch_idx * num_elements + element_idx
|
||||
|
||||
if is_allowed:
|
||||
tl.atomic_or(vocab_mask_ptr + offset, 1 << bit_idx)
|
||||
else:
|
||||
tl.atomic_and(vocab_mask_ptr + offset, ~(1 << bit_idx))
|
||||
|
||||
|
||||
_cached_num_sms = None
|
||||
_cached_token_id_tensors: OrderedDict[tuple[int, tuple[int, ...]], torch.Tensor] = (
|
||||
OrderedDict()
|
||||
)
|
||||
_MAX_TOKEN_ID_TENSOR_CACHE_SIZE = 32
|
||||
|
||||
|
||||
def _compute_grid(work_items: int):
|
||||
global _cached_num_sms
|
||||
if _cached_num_sms is None:
|
||||
_cached_num_sms = get_device_core_count()
|
||||
if _cached_num_sms > 0:
|
||||
return (min(_cached_num_sms, work_items),)
|
||||
return (work_items,)
|
||||
|
||||
|
||||
def _get_cached_token_ids_tensor(
|
||||
token_ids: List[int], device: torch.device
|
||||
) -> torch.Tensor:
|
||||
key = (device.index or 0, tuple(token_ids))
|
||||
cached = _cached_token_id_tensors.get(key)
|
||||
if cached is not None:
|
||||
_cached_token_id_tensors.move_to_end(key)
|
||||
return cached
|
||||
|
||||
token_ids_tensor = torch.tensor(token_ids, dtype=torch.int32, device=device)
|
||||
_cached_token_id_tensors[key] = token_ids_tensor
|
||||
if len(_cached_token_id_tensors) > _MAX_TOKEN_ID_TENSOR_CACHE_SIZE:
|
||||
_cached_token_id_tensors.popitem(last=False)
|
||||
return token_ids_tensor
|
||||
|
||||
|
||||
def set_token_filter_triton(
|
||||
vocab_mask: torch.Tensor,
|
||||
token_ids: List[int],
|
||||
batch_idx: int,
|
||||
is_allowed: bool = True,
|
||||
reset_vocab_mask: bool = True,
|
||||
):
|
||||
"""Set or clear specific tokens in the vocab mask using Triton."""
|
||||
assert vocab_mask.device.type == "cuda"
|
||||
|
||||
num_elements = vocab_mask.shape[1]
|
||||
|
||||
if reset_vocab_mask:
|
||||
reset_value = 0 if is_allowed else -1
|
||||
reset_vocab_mask_kernel[_compute_grid(num_elements)](
|
||||
vocab_mask,
|
||||
batch_idx,
|
||||
num_elements,
|
||||
reset_value,
|
||||
num_warps=4,
|
||||
)
|
||||
|
||||
if not token_ids:
|
||||
return
|
||||
|
||||
num_tokens = len(token_ids)
|
||||
token_ids_tensor = _get_cached_token_ids_tensor(token_ids, vocab_mask.device)
|
||||
set_token_filter_batch_kernel[_compute_grid(num_tokens)](
|
||||
vocab_mask,
|
||||
token_ids_tensor,
|
||||
batch_idx,
|
||||
num_tokens,
|
||||
num_elements,
|
||||
is_allowed,
|
||||
num_warps=4,
|
||||
)
|
||||
@@ -49,6 +49,10 @@ else:
|
||||
apply_token_bitmask_inplace_triton,
|
||||
)
|
||||
|
||||
from sglang.srt.constrained.torch_ops.token_filter_torch_ops import (
|
||||
set_token_filter_torch,
|
||||
)
|
||||
from sglang.srt.constrained.triton_ops.token_filter_ops import set_token_filter_triton
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
MAX_ROLLBACK_TOKENS = 200
|
||||
@@ -62,7 +66,7 @@ class XGrammarGrammar(BaseGrammarObject):
|
||||
vocab_size: int,
|
||||
ctx: CompiledGrammar,
|
||||
override_stop_tokens: Optional[Union[List[int], int]],
|
||||
key_string: Optional[str] = None, # TODO (sk): for debugging, remove later
|
||||
key_string: Optional[str] = None,
|
||||
grammar_stats: Optional[GrammarStats] = GrammarStats(),
|
||||
) -> None:
|
||||
super().__init__()
|
||||
@@ -162,7 +166,14 @@ class XGrammarGrammar(BaseGrammarObject):
|
||||
self.matcher.rollback(len(old_output_ids) - k)
|
||||
|
||||
for i in range(k, len(new_output_ids)):
|
||||
assert self.matcher.accept_token(new_output_ids[i])
|
||||
if not self.matcher.accept_token(new_output_ids[i]):
|
||||
raise ValueError(
|
||||
f"Token not accepted during retokenization: {new_output_ids[i]} "
|
||||
f"at position {i}\n"
|
||||
f"Old output IDs: {old_output_ids}\n"
|
||||
f"New output IDs: {new_output_ids}\n"
|
||||
f"Key string: {self.key_string}"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"XGrammarGrammar({self.key_string=}, {self.accepted_tokens=}, {self.current_token=})"
|
||||
@@ -211,6 +222,53 @@ class XGrammarGrammarBackend(BaseGrammarBackend):
|
||||
self.override_stop_tokens = override_stop_tokens
|
||||
self.any_whitespace = any_whitespace
|
||||
|
||||
@property
|
||||
def is_support_token_filter(self):
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def allocate_vocab_mask(vocab_size: int, batch_size: int, device) -> torch.Tensor:
|
||||
return allocate_token_bitmask(batch_size, vocab_size)
|
||||
|
||||
@staticmethod
|
||||
def move_vocab_mask(vocab_mask: torch.Tensor, device) -> torch.Tensor:
|
||||
return vocab_mask.to(device, non_blocking=True)
|
||||
|
||||
@staticmethod
|
||||
def apply_vocab_mask(logits: torch.Tensor, vocab_mask: torch.Tensor) -> None:
|
||||
if logits.device.type in {"cuda", "npu", "xpu", "musa"}:
|
||||
if _is_hip:
|
||||
apply_token_bitmask_inplace_cuda(logits, vocab_mask)
|
||||
else:
|
||||
apply_token_bitmask_inplace_triton(logits, vocab_mask)
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported device: {logits.device.type}")
|
||||
|
||||
@staticmethod
|
||||
def set_token_filter(
|
||||
vocab_mask: torch.Tensor,
|
||||
token_ids: List[int],
|
||||
batch_idx: int,
|
||||
is_allowed: bool = True,
|
||||
reset_vocab_mask: bool = True,
|
||||
):
|
||||
if _is_hip or (vocab_mask.device.type != "cuda"):
|
||||
set_token_filter_torch(
|
||||
vocab_mask,
|
||||
token_ids,
|
||||
batch_idx,
|
||||
is_allowed=is_allowed,
|
||||
reset_vocab_mask=reset_vocab_mask,
|
||||
)
|
||||
else:
|
||||
set_token_filter_triton(
|
||||
vocab_mask,
|
||||
token_ids,
|
||||
batch_idx,
|
||||
is_allowed=is_allowed,
|
||||
reset_vocab_mask=reset_vocab_mask,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_structural_format(structural_format):
|
||||
"""Recursively replace missing json_schema fields with an empty schema."""
|
||||
|
||||
@@ -507,6 +507,9 @@ class Envs:
|
||||
# Tool-Call behavior
|
||||
SGLANG_TOOL_STRICT_LEVEL = EnvInt(ToolStrictLevel.OFF)
|
||||
|
||||
# Think tokens budget: negative means unlimited, >= 0 caps thinking tokens
|
||||
SGLANG_MAX_THINK_TOKENS = EnvInt(-1)
|
||||
|
||||
# Ngram
|
||||
SGLANG_NGRAM_FORCE_GREEDY_VERIFY = EnvBool(False)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Dict, Optional, Tuple, Type
|
||||
from typing import Dict, List, Optional, Tuple, Type
|
||||
|
||||
from sglang.srt.entrypoints.openai.protocol import ChatCompletionRequest
|
||||
from sglang.srt.parser.harmony_parser import HarmonyParser
|
||||
@@ -23,6 +23,7 @@ class BaseReasoningFormatDetector:
|
||||
self,
|
||||
think_start_token: str,
|
||||
think_end_token: str,
|
||||
think_excluded_tokens: Optional[List[str]] = None,
|
||||
force_reasoning: bool = False,
|
||||
stream_reasoning: bool = True,
|
||||
tool_start_token: Optional[str] = None,
|
||||
@@ -33,6 +34,7 @@ class BaseReasoningFormatDetector:
|
||||
):
|
||||
self.think_start_token = think_start_token
|
||||
self.think_end_token = think_end_token
|
||||
self.think_excluded_tokens = think_excluded_tokens
|
||||
self.tool_start_token = tool_start_token
|
||||
self.force_reasoning = force_reasoning
|
||||
self._in_reasoning = force_reasoning
|
||||
@@ -242,9 +244,16 @@ class Qwen3Detector(BaseReasoningFormatDetector):
|
||||
continue_final_message: bool = False,
|
||||
previous_content: str = "",
|
||||
):
|
||||
think_excluded_tokens = [
|
||||
"<tool_call>",
|
||||
"</tool_call>",
|
||||
"<|im_end|>",
|
||||
"<|endoftext|>",
|
||||
]
|
||||
super().__init__(
|
||||
"<think>",
|
||||
"</think>",
|
||||
think_excluded_tokens=think_excluded_tokens,
|
||||
force_reasoning=force_reasoning,
|
||||
stream_reasoning=stream_reasoning,
|
||||
continue_final_message=continue_final_message,
|
||||
@@ -297,9 +306,22 @@ class KimiK2Detector(BaseReasoningFormatDetector):
|
||||
continue_final_message: bool = False,
|
||||
previous_content: str = "",
|
||||
):
|
||||
think_excluded_tokens = [
|
||||
"<think>",
|
||||
"<|tool_calls_section_begin|>",
|
||||
"<|tool_call_begin|>",
|
||||
"<|tool_call_argument_begin|>",
|
||||
"<|tool_call_section_end|>",
|
||||
"<|tool_call_end|>",
|
||||
"[EOS]",
|
||||
"<|im_end|>",
|
||||
"<|end_header_id|>",
|
||||
"[EOT]",
|
||||
]
|
||||
super().__init__(
|
||||
"<think>",
|
||||
"</think>",
|
||||
think_excluded_tokens=think_excluded_tokens,
|
||||
force_reasoning=force_reasoning,
|
||||
stream_reasoning=stream_reasoning,
|
||||
tool_start_token="<|tool_calls_section_begin|>",
|
||||
@@ -323,9 +345,17 @@ class Glm45Detector(BaseReasoningFormatDetector):
|
||||
"""
|
||||
|
||||
def __init__(self, stream_reasoning: bool = True, force_reasoning: bool = False):
|
||||
think_excluded_tokens = [
|
||||
"<tool_call>",
|
||||
"</tool_call>",
|
||||
"<eop>",
|
||||
"<|user|>",
|
||||
"<|endoftext|>",
|
||||
]
|
||||
super().__init__(
|
||||
"<think>",
|
||||
"</think>",
|
||||
think_excluded_tokens=think_excluded_tokens,
|
||||
force_reasoning=force_reasoning,
|
||||
stream_reasoning=stream_reasoning,
|
||||
tool_start_token="<tool_call>",
|
||||
|
||||
@@ -495,6 +495,7 @@ class ServerArgs:
|
||||
enable_cache_report: bool = False
|
||||
reasoning_parser: Optional[str] = None
|
||||
strip_thinking_cache: bool = False
|
||||
enable_strict_thinking: bool = False
|
||||
tool_call_parser: Optional[str] = None
|
||||
tool_server: Optional[str] = None
|
||||
sampling_defaults: str = "model"
|
||||
@@ -5127,6 +5128,14 @@ class ServerArgs:
|
||||
"radix tree on finish; keep only the prompt prefix. Opt-in: changes "
|
||||
"cache contents.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--enable-strict-thinking",
|
||||
action="store_true",
|
||||
default=ServerArgs.enable_strict_thinking,
|
||||
help="Enable strict token filtering during the thinking phase. "
|
||||
"Blocks model-specific excluded tokens (e.g., tool call markers) "
|
||||
"during reasoning. Requires a grammar backend that supports token filtering.",
|
||||
)
|
||||
tool_call_parser_choices = list(FunctionCallParser.ToolCallParserEnum.keys())
|
||||
parser.add_argument(
|
||||
"--tool-call-parser",
|
||||
|
||||
Reference in New Issue
Block a user