feat: use XGrammar V4.1 DSML parameter constraints (#39026)
Co-authored-by: yuchuan <yuchuan.7streams@gmail.com> Co-authored-by: Xinyuan Tong <115166877+JustinTong0323@users.noreply.github.com> Co-authored-by: Xinyuan Tong <xinyuantong.cs@gmail.com>
This commit is contained in:
co-authored by
yuchuan
Xinyuan Tong
Xinyuan Tong
parent
f0940fe3a6
commit
ae7a516ba7
@@ -96,7 +96,7 @@ dependencies = [
|
||||
"uvicorn",
|
||||
"uvloop",
|
||||
"watchfiles",
|
||||
"xgrammar==0.2.1",
|
||||
"xgrammar==0.2.7",
|
||||
"xxhash",
|
||||
"zstandard",
|
||||
]
|
||||
|
||||
@@ -70,7 +70,7 @@ dependencies = [
|
||||
"uvicorn",
|
||||
"uvloop",
|
||||
"xxhash",
|
||||
"xgrammar==0.2.1",
|
||||
"xgrammar==0.2.7",
|
||||
"zstandard",
|
||||
]
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ dependencies = [
|
||||
"uvicorn",
|
||||
"uvloop",
|
||||
"xxhash",
|
||||
"xgrammar==0.2.1",
|
||||
"xgrammar==0.2.7",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -73,7 +73,7 @@ runtime_common = [
|
||||
"compressed-tensors",
|
||||
"outlines==0.1.11",
|
||||
"timm==1.0.16",
|
||||
"xgrammar==0.2.1",
|
||||
"xgrammar==0.2.7",
|
||||
]
|
||||
|
||||
# srt_empty: device-agnostic install — pure Python packages only, no torch dependency chain.
|
||||
|
||||
@@ -69,7 +69,7 @@ dependencies = [
|
||||
"uvicorn",
|
||||
"xxhash",
|
||||
"uvloop",
|
||||
# "xgrammar==0.2.1", xgrammar depends on CUDA PyTorch and Triton only
|
||||
# "xgrammar==0.2.7", xgrammar depends on CUDA PyTorch and Triton only
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -205,6 +205,7 @@ POSITIONAL_FIELD_ORDER = (
|
||||
"stat_loggers",
|
||||
"constrained_json_whitespace_pattern",
|
||||
"constrained_json_disable_any_whitespace",
|
||||
"constrained_json_max_whitespace_cnt",
|
||||
"attention_backend",
|
||||
"decode_attention_backend",
|
||||
"enable_lean_attention",
|
||||
|
||||
@@ -281,6 +281,10 @@ class Serving(msgspec.Struct):
|
||||
bool,
|
||||
"(xgrammar and llguidance backends only) Enforce compact representation in JSON constrained output.",
|
||||
] = False
|
||||
constrained_json_max_whitespace_cnt: A[
|
||||
Optional[int],
|
||||
"(xgrammar backend only) Max consecutive whitespace chars allowed in JSON constrained output. None means unbounded.",
|
||||
] = None
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Dynamic batch tokenizer
|
||||
|
||||
@@ -385,6 +385,7 @@ def create_grammar_backend(
|
||||
vocab_size=vocab_size,
|
||||
model_eos_token_ids=eos_list,
|
||||
any_whitespace=not get_serving().constrained_json_disable_any_whitespace,
|
||||
max_whitespace_cnt=get_serving().constrained_json_max_whitespace_cnt,
|
||||
)
|
||||
except TokenizerNotSupportedError as e:
|
||||
if get_serving().enable_strict_thinking:
|
||||
|
||||
@@ -215,6 +215,7 @@ class XGrammarGrammarBackend(BaseGrammarBackend):
|
||||
vocab_size: int,
|
||||
model_eos_token_ids: Optional[List[int]] = None,
|
||||
any_whitespace: bool = True,
|
||||
max_whitespace_cnt: Optional[int] = None,
|
||||
):
|
||||
super().__init__()
|
||||
|
||||
@@ -244,6 +245,7 @@ class XGrammarGrammarBackend(BaseGrammarBackend):
|
||||
self.vocab_size = vocab_size
|
||||
self.override_stop_tokens = override_stop_tokens
|
||||
self.any_whitespace = any_whitespace
|
||||
self.max_whitespace_cnt = max_whitespace_cnt
|
||||
|
||||
@property
|
||||
def is_support_token_filter(self):
|
||||
@@ -348,7 +350,9 @@ class XGrammarGrammarBackend(BaseGrammarBackend):
|
||||
schema = json.loads(key_string)
|
||||
validate_xgrammar_json_schema(schema)
|
||||
ctx = self.grammar_compiler.compile_json_schema(
|
||||
schema=key_string, any_whitespace=self.any_whitespace
|
||||
schema=key_string,
|
||||
any_whitespace=self.any_whitespace,
|
||||
max_whitespace_cnt=self.max_whitespace_cnt,
|
||||
)
|
||||
|
||||
except (
|
||||
|
||||
@@ -404,10 +404,8 @@ class BaseFormatDetector(ABC):
|
||||
(the typical case when --reasoning-parser is configured) so
|
||||
only one layer constrains the reasoning section.
|
||||
parallel_tool_calls: Whether multiple tool calls may appear in one
|
||||
assistant response. xgrammar's get_model_structural_tag does
|
||||
not expose this knob, so this base implementation ignores it;
|
||||
only detectors that build their own tags (e.g. Kimi K3)
|
||||
honor it.
|
||||
assistant response. Forwarded to XGrammar to constrain the
|
||||
number of tool calls in the generated structural tag.
|
||||
|
||||
Returns:
|
||||
StructuralTag if this detector supports model-native tags, otherwise None
|
||||
@@ -427,6 +425,7 @@ class BaseFormatDetector(ABC):
|
||||
tools=converted_tools,
|
||||
tool_choice=converted_tool_choice,
|
||||
reasoning=thinking_mode,
|
||||
parallel_tool_calls=parallel_tool_calls,
|
||||
)
|
||||
|
||||
def get_auto_tool_call_structural_tag(
|
||||
|
||||
@@ -75,6 +75,7 @@ class DeepSeekV32Detector(BaseFormatDetector):
|
||||
tool_calls_block_name = "function_calls"
|
||||
invoke_tag_name = "invoke"
|
||||
parameter_tag_name = "parameter"
|
||||
strip_string_param_value: bool = True
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -170,7 +171,11 @@ class DeepSeekV32Detector(BaseFormatDetector):
|
||||
|
||||
# Convert value based on type
|
||||
if param_type == "true": # string type
|
||||
parameters[param_name] = param_value.strip()
|
||||
parameters[param_name] = (
|
||||
param_value.strip()
|
||||
if self.strip_string_param_value
|
||||
else param_value
|
||||
)
|
||||
else:
|
||||
# Try to parse as JSON for other types
|
||||
try:
|
||||
@@ -195,7 +200,11 @@ class DeepSeekV32Detector(BaseFormatDetector):
|
||||
if partial_match and (param_value := partial_match.group(3)):
|
||||
param_name = partial_match.group(1)
|
||||
if partial_match.group(2) == "true":
|
||||
parameters[param_name] = param_value.strip()
|
||||
parameters[param_name] = (
|
||||
param_value.strip()
|
||||
if self.strip_string_param_value
|
||||
else param_value
|
||||
)
|
||||
else:
|
||||
try:
|
||||
parameters[param_name] = _partial_json_loads(
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
from typing import List, Literal, Optional, Union
|
||||
from typing import Optional
|
||||
|
||||
from xgrammar.structural_tag import (
|
||||
AnyTextFormat,
|
||||
ConstStringFormat,
|
||||
JSONSchemaFormat,
|
||||
OrFormat,
|
||||
SequenceFormat,
|
||||
TagFormat,
|
||||
TagsWithSeparatorFormat,
|
||||
TriggeredTagsFormat,
|
||||
)
|
||||
|
||||
from sglang.srt.entrypoints.openai.protocol import Tool, ToolChoice
|
||||
from sglang.srt.function_call.base_format_detector import StructuralTag
|
||||
from sglang.srt.function_call.deepseekv32_detector import DeepSeekV32Detector
|
||||
|
||||
|
||||
@@ -25,6 +12,7 @@ class DeepSeekV41Detector(DeepSeekV32Detector):
|
||||
tool_calls_block_name = " calls"
|
||||
invoke_tag_name = " invoke"
|
||||
parameter_tag_name = " parameter"
|
||||
strip_string_param_value: bool = False
|
||||
|
||||
# The encoder joins an assistant turn's content and its calls block with a
|
||||
# blank line, and renders it even when there is no content.
|
||||
@@ -32,72 +20,4 @@ class DeepSeekV41Detector(DeepSeekV32Detector):
|
||||
think_end_token = "</think>"
|
||||
|
||||
def get_structural_tag_name(self) -> Optional[str]:
|
||||
# xgrammar's builtin "deepseek_v4" tag hardcodes the unspaced names,
|
||||
# so the V4.1 tag is assembled in get_structural_tag instead.
|
||||
return None
|
||||
|
||||
def get_structural_tag(
|
||||
self,
|
||||
tools: Union[List[Tool], None] = None,
|
||||
tool_choice: Union[ToolChoice, Literal["auto", "required"]] = "auto",
|
||||
thinking_mode: bool = False,
|
||||
parallel_tool_calls: bool = True,
|
||||
) -> Optional[StructuralTag]:
|
||||
"""The builtin "deepseek_v4" shape with the spaced tag names.
|
||||
|
||||
Bodies are JSON: xgrammar's "deepseek_xml" body style also hardcodes
|
||||
the unspaced "parameter" name, and the V3.2-lineage parser accepts a
|
||||
JSON body inside an invoke.
|
||||
"""
|
||||
tools = list(tools or [])
|
||||
if isinstance(tool_choice, ToolChoice):
|
||||
tools = [
|
||||
tool
|
||||
for tool in tools
|
||||
if tool.function.name == tool_choice.function.name
|
||||
]
|
||||
if len(tools) != 1:
|
||||
return None
|
||||
if not tools:
|
||||
return None
|
||||
|
||||
def invoke_tag(tool: Tool) -> TagFormat:
|
||||
function = tool.function
|
||||
schema = function.parameters if function.strict else True
|
||||
if schema is None:
|
||||
schema = True
|
||||
return TagFormat(
|
||||
begin=f'{self.invoke_start_token} name="{function.name}">',
|
||||
content=JSONSchemaFormat(json_schema=schema),
|
||||
end=f"{self.invoke_end_token}\n",
|
||||
)
|
||||
|
||||
tags = [invoke_tag(tool) for tool in tools]
|
||||
if isinstance(tool_choice, ToolChoice):
|
||||
calls = tags[0]
|
||||
elif parallel_tool_calls:
|
||||
calls = TagsWithSeparatorFormat(tags=tags, separator="", at_least_one=True)
|
||||
else:
|
||||
calls = OrFormat(elements=tags)
|
||||
block_begin = f"{self.bot_token}\n"
|
||||
|
||||
if tool_choice == "auto":
|
||||
body = TriggeredTagsFormat(
|
||||
triggers=[self.bot_token],
|
||||
tags=[TagFormat(begin=block_begin, content=calls, end=self.eot_token)],
|
||||
excludes=["<think>", self.think_end_token],
|
||||
)
|
||||
else:
|
||||
body = SequenceFormat(
|
||||
elements=[
|
||||
ConstStringFormat(value=self.tool_calls_prefix + block_begin),
|
||||
calls,
|
||||
ConstStringFormat(value=self.eot_token),
|
||||
]
|
||||
)
|
||||
if not thinking_mode:
|
||||
return StructuralTag(format=body)
|
||||
reasoning = TagFormat(
|
||||
begin="", content=AnyTextFormat(), end=self.think_end_token
|
||||
)
|
||||
return StructuralTag(format=SequenceFormat(elements=[reasoning, body]))
|
||||
return "deepseek_v4_1"
|
||||
|
||||
@@ -468,6 +468,7 @@ class KimiK2Detector(BaseFormatDetector):
|
||||
tools=converted_tools,
|
||||
tool_choice=converted_tool_choice,
|
||||
reasoning=thinking_mode,
|
||||
parallel_tool_calls=parallel_tool_calls,
|
||||
)
|
||||
|
||||
def get_structural_tag_name(self) -> str:
|
||||
|
||||
Reference in New Issue
Block a user