From 01bd0d3e8b8c70a59583b59af28d6d1e28685b0c Mon Sep 17 00:00:00 2001 From: Muqi Li Date: Sat, 27 Dec 2025 03:35:30 +0800 Subject: [PATCH] [Tool Call][DSV32] Streamline function call parameters (#14750) Signed-off-by: Muqi Li --- .../srt/function_call/deepseekv32_detector.py | 38 ++++++++------ .../test_function_call_parser.py | 51 ++++++++++++++----- 2 files changed, 60 insertions(+), 29 deletions(-) diff --git a/python/sglang/srt/function_call/deepseekv32_detector.py b/python/sglang/srt/function_call/deepseekv32_detector.py index 5f1408d1b..7d8742f39 100644 --- a/python/sglang/srt/function_call/deepseekv32_detector.py +++ b/python/sglang/srt/function_call/deepseekv32_detector.py @@ -2,6 +2,8 @@ import json import logging import re +from partial_json_parser.core.options import Allow + from sglang.srt.entrypoints.openai.protocol import Tool from sglang.srt.function_call.base_format_detector import BaseFormatDetector from sglang.srt.function_call.core_types import ( @@ -10,7 +12,7 @@ from sglang.srt.function_call.core_types import ( ToolCallItem, _GetInfoFunc, ) -from sglang.srt.function_call.utils import _find_common_prefix +from sglang.srt.function_call.utils import _find_common_prefix, _partial_json_loads logger = logging.getLogger(__name__) @@ -82,11 +84,12 @@ class DeepSeekV32Detector(BaseFormatDetector): self.invoke_regex = ( r'<|DSML|invoke\s+name="([^"]+)"\s*>(.*?)(|$)' ) + self.prefix_parameter_end_call = [" bool: """Check if the text contains a deepseek v32 format tool call.""" - return self.bot_token in text + return self.bot_token in text or "<|DSML|invoke" in text def _parse_parameters_from_xml( self, invoke_content: str, allow_partial: bool = False @@ -139,16 +142,25 @@ class DeepSeekV32Detector(BaseFormatDetector): # If allowed, try to parse a partial parameter at the end if allow_partial: remaining_content = invoke_content[last_match_end:] + + # Remove incomplete parameter_end_call prefix in case they are captured by param + for token in reversed(self.prefix_parameter_end_call): + remaining_content = remaining_content.rstrip(token) + # Match start of a parameter tag + value (potentially incomplete) # Regex: VALUE... (no end tag) partial_match = re.search( self.partial_parameter_regex, remaining_content, re.DOTALL ) - if partial_match: + if partial_match and (param_value := partial_match.group(3)): param_name = partial_match.group(1) - param_value = partial_match.group(3) - parameters[param_name] = param_value + if partial_match.group(2) == "true": + parameters[param_name] = param_value.strip() + else: + parameters[param_name] = _partial_json_loads( + param_value, Allow.ALL + )[0] return parameters @@ -206,13 +218,6 @@ class DeepSeekV32Detector(BaseFormatDetector): self._buffer += new_text current_text = self._buffer - # Check if we have a tool call or any DSML-related content - # Key insight: DSML tags contain distinctive markers like "|DSML|" - # If we see these markers anywhere, we should keep buffering - has_tool_call = ( - self.bot_token in current_text or "<|DSML|invoke" in current_text - ) - # Check if buffer contains any DSML markers or ends with potential tag prefix # This handles partial/streaming DSML content dsml_markers = ["|DSML|", "<|", "', end="", - trigger=f'<|DSML|invoke name="{name}">', + trigger=f"<|DSML|invoke", ) diff --git a/test/registered/function_call/test_function_call_parser.py b/test/registered/function_call/test_function_call_parser.py index 99a07639b..c6b1cb352 100644 --- a/test/registered/function_call/test_function_call_parser.py +++ b/test/registered/function_call/test_function_call_parser.py @@ -1159,6 +1159,10 @@ class TestDeepSeekV32Detector(unittest.TestCase): ), ] self.detector = DeepSeekV32Detector() + from transformers import AutoTokenizer + + self.tokenizer = AutoTokenizer.from_pretrained("deepseek-ai/DeepSeek-V3.2") + self.interval = 1 def test_detect_and_parse_xml_format(self): """Test parsing standard XML format (DSML)""" @@ -1236,12 +1240,19 @@ class TestDeepSeekV32Detector(unittest.TestCase): text = """<|DSML|function_calls> <|DSML|invoke name="get_favorite_tourist_spot"> <|DSML|parameter name="city" string="true">San Francisco + <|DSML|parameter name="another_city" string="true">London + <|DSML|parameter name="topn" string="false">10 + <|DSML|parameter name="obj" string="false">{"name": "John", "age": 30} """ - chunks = [text[i : i + 5] for i in range(0, len(text), 5)] + input_ids = self.tokenizer.encode(text, add_special_tokens=False) + chunk_ids = [ + input_ids[i : i + self.interval] + for i in range(0, len(input_ids), self.interval) + ] + chunks = [self.tokenizer.decode(chunk_id) for chunk_id in chunk_ids] - accumulated_calls = [] tool_calls_by_index = {} for chunk in chunks: @@ -1263,15 +1274,12 @@ class TestDeepSeekV32Detector(unittest.TestCase): self.assertEqual(len(tool_calls_by_index), 1) self.assertEqual(tool_calls_by_index[0]["name"], "get_favorite_tourist_spot") - # Note: The detector might accumulate partial JSON string which is valid, - # but for XML format it constructs JSON at the end. - # Let's check if the final parameters parse correctly. - try: - params = json.loads(tool_calls_by_index[0]["parameters"]) - self.assertEqual(params["city"], "San Francisco") - except json.JSONDecodeError: - # In streaming XML, parameters might be constructed differently or incrementally - pass + params = json.loads(tool_calls_by_index[0]["parameters"]) + self.assertEqual(params["city"], "San Francisco") + self.assertEqual(params["another_city"], "London") + self.assertEqual(params["topn"], 10) + self.assertEqual(params["obj"]["name"], "John") + self.assertEqual(params["obj"]["age"], 30) def test_streaming_json_format(self): """Test streaming parsing of JSON format""" @@ -1283,7 +1291,12 @@ class TestDeepSeekV32Detector(unittest.TestCase): """ - chunks = [text[i : i + 5] for i in range(0, len(text), 5)] + input_ids = self.tokenizer.encode(text, add_special_tokens=False) + chunk_ids = [ + input_ids[i : i + self.interval] + for i in range(0, len(input_ids), self.interval) + ] + chunks = [self.tokenizer.decode(chunk_id) for chunk_id in chunk_ids] tool_calls_by_index = {} @@ -1370,7 +1383,12 @@ class TestDeepSeekV32Detector(unittest.TestCase): self.detector = DeepSeekV32Detector() # Simulate streaming by splitting into small chunks - chunks = [text[i : i + 5] for i in range(0, len(text), 5)] + input_ids = self.tokenizer.encode(text, add_special_tokens=False) + chunk_ids = [ + input_ids[i : i + self.interval] + for i in range(0, len(input_ids), self.interval) + ] + chunks = [self.tokenizer.decode(chunk_id) for chunk_id in chunk_ids] tool_calls_by_index = {} @@ -1425,7 +1443,12 @@ class TestDeepSeekV32Detector(unittest.TestCase): # Reset detector state self.detector = DeepSeekV32Detector() - chunks = [text[i : i + 5] for i in range(0, len(text), 5)] + input_ids = self.tokenizer.encode(text, add_special_tokens=False) + chunk_ids = [ + input_ids[i : i + self.interval] + for i in range(0, len(input_ids), self.interval) + ] + chunks = [self.tokenizer.decode(chunk_id) for chunk_id in chunk_ids] tool_calls_by_index = {}