A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 12, 02:15 UTC
Add DeepSeek-V3.2 tool parser. (#29848)
Signed-off-by: 许文卿 <xwq391974@alibaba-inc.com>
Xu Wenqing committed
Dec 4, 2025 at 16:46 UTC
ffdd18111b767d271786b982378d51b51ce151fe
2 files changed
+595
vllm/entrypoints/openai/tool_parsers/__init__.py
+4
@@ -30,6 +30,10 @@ _TOOL_PARSERS_TO_REGISTER = {
30
"deepseekv31_tool_parser",
31
"DeepSeekV31ToolParser",
32
),
33
+ "deepseek_v32": (
34
+ "deepseekv32_tool_parser",
35
+ "DeepSeekV32ToolParser",
36
+ ),
37
"ernie45": (
38
"ernie45_tool_parser",
39
"Ernie45ToolParser",
vllm/entrypoints/openai/tool_parsers/deepseekv32_tool_parser.py
new
+591
@@ -0,0 +1,591 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+import json
5
+import uuid
6
+from collections.abc import Sequence
7
+from typing import Any
8
+
9
+import regex as re
10
+
11
+from vllm.entrypoints.openai.protocol import (
12
+ ChatCompletionRequest,
13
+ DeltaFunctionCall,
14
+ DeltaMessage,
15
+ DeltaToolCall,
16
+ ExtractedToolCallInformation,
17
+ FunctionCall,
18
+ ToolCall,
19
+)
20
+from vllm.entrypoints.openai.tool_parsers.abstract_tool_parser import (
21
+ ToolParser,
22
+)
23
+from vllm.logger import init_logger
24
+from vllm.tokenizers import TokenizerLike
25
+
26
+logger = init_logger(__name__)
27
+
28
+
29
+class DeepSeekV32ToolParser(ToolParser):
30
+ """
31
+ example tool call content:
32
+ <|DSML|function_calls>
33
+ <|DSML|invoke name="get_weather">
34
+ <|DSML|parameter name="location" string="true">杭州</|DSML|parameter>
35
+ <|DSML|parameter name="date" string="true">2024-01-16</|DSML|parameter>
36
+ </|DSML|invoke>
37
+ <|DSML|invoke name="get_weather">
38
+ <|DSML|parameter name="location" string="true">北京</|DSML|parameter>
39
+ <|DSML|parameter name="date" string="true">2024-01-16</|DSML|parameter>
40
+ </|DSML|invoke>
41
+ </|DSML|function_calls>
42
+ """
43
+
44
+ def __init__(self, tokenizer: TokenizerLike):
45
+ super().__init__(tokenizer)
46
+
47
+ self.prev_tool_call_arr: list[dict] = []
48
+
49
+ # Sentinel tokens
50
+ self.dsml_token: str = "|DSML|"
51
+ self.dsml_start_check: str = "<" + self.dsml_token
52
+ self.tool_call_start_token: str = "<|DSML|function_calls>"
53
+ self.tool_call_end_token: str = "</|DSML|function_calls>"
54
+ self.invoke_start_prefix: str = "<|DSML|invoke name="
55
+ self.invoke_end_token: str = "</|DSML|invoke>"
56
+ self.parameter_prefix: str = "<|DSML|parameter name="
57
+ self.parameter_end_token: str = "</|DSML|parameter>"
58
+
59
+ # Streaming state variables
60
+ self.current_tool_name_sent: bool = False
61
+ # Override base class type - we use string IDs for tool calls
62
+ self.current_tool_id: str | None = None # type: ignore
63
+ self.streamed_args_for_tool: list[str] = []
64
+ self.is_tool_call_started: bool = False
65
+ self.failed_count: int = 0
66
+
67
+ # Initialize streaming state variables
68
+ self.current_tool_index: int = 0
69
+ self.invoke_index: int = 0
70
+ self.header_sent: bool = False
71
+ self.current_function_name: str | None = None
72
+ self.current_param_name: str | None = None
73
+ self.current_param_value: str = ""
74
+ self.param_count: int = 0
75
+ self.in_param: bool = False
76
+ self.in_function: bool = False
77
+ self.json_started: bool = False
78
+ self.json_closed: bool = False
79
+ self.accumulated_params: dict = {}
80
+ self.streaming_request: ChatCompletionRequest | None = None
81
+
82
+ # Enhanced streaming state - reset for each new message
83
+ self._reset_streaming_state()
84
+
85
+ # Regex patterns for complete parsing
86
+ self.tool_call_complete_regex = re.compile(
87
+ r"<|DSML|function_calls>(.*?)</|DSML|function_calls>", re.DOTALL
88
+ )
89
+ self.invoke_complete_regex = re.compile(
90
+ r'<|DSML|invoke\s+name="([^"]+)"\s*>(.*?)</|DSML|invoke>', re.DOTALL
91
+ )
92
+ self.parameter_complete_regex = re.compile(
93
+ r'<|DSML|parameter\s+name="([^"]+)"\s+string="(?:true|false)"\s*>(.*?)</|DSML|parameter>',
94
+ re.DOTALL,
95
+ )
96
+
97
+ if not self.model_tokenizer:
98
+ raise ValueError(
99
+ "The model tokenizer must be passed to the ToolParser "
100
+ "constructor during construction."
101
+ )
102
+
103
+ logger.debug(
104
+ "vLLM Successfully import tool parser %s !", self.__class__.__name__
105
+ )
106
+
107
+ def _generate_tool_call_id(self) -> str:
108
+ """Generate a unique tool call ID."""
109
+ return f"call_{uuid.uuid4().hex[:24]}"
110
+
111
+ def _reset_streaming_state(self):
112
+ """Reset all streaming state."""
113
+ self.current_tool_index = 0
114
+ self.invoke_index = 0
115
+ self.is_tool_call_started = False
116
+ self.header_sent = False
117
+ self.current_tool_id = None
118
+ self.current_function_name = None
119
+ self.current_param_name = None
120
+ self.current_param_value = ""
121
+ self.param_count = 0
122
+ self.in_param = False
123
+ self.in_function = False
124
+ self.json_started = False
125
+ self.json_closed = False
126
+ # Store accumulated parameters for type conversion
127
+ self.accumulated_params = {}
128
+ self.streaming_request = None
129
+ # Clear previous tool call history to avoid state pollution
130
+ self.prev_tool_call_arr.clear()
131
+
132
+ def _parse_invoke_params(self, invoke_str: str) -> dict | None:
133
+ param_dict = dict()
134
+ for param_name, param_val in self.parameter_complete_regex.findall(invoke_str):
135
+ param_dict[param_name] = param_val
136
+ return param_dict
137
+
138
+ def extract_tool_calls(
139
+ self,
140
+ model_output: str,
141
+ request: ChatCompletionRequest,
142
+ ) -> ExtractedToolCallInformation:
143
+ """Extract tool calls from complete model output (non-streaming)."""
144
+ # Quick check
145
+ if self.tool_call_start_token not in model_output:
146
+ return ExtractedToolCallInformation(
147
+ tools_called=False, tool_calls=[], content=model_output
148
+ )
149
+
150
+ try:
151
+ tool_calls = []
152
+
153
+ # Find all complete tool_call blocks
154
+ for tool_call_match in self.tool_call_complete_regex.findall(model_output):
155
+ # Find all invokes within this tool_call
156
+ for invoke_name, invoke_content in self.invoke_complete_regex.findall(
157
+ tool_call_match
158
+ ):
159
+ param_dict = self._parse_invoke_params(invoke_content)
160
+ tool_calls.append(
161
+ ToolCall(
162
+ type="function",
163
+ function=FunctionCall(
164
+ name=invoke_name,
165
+ arguments=json.dumps(param_dict, ensure_ascii=False),
166
+ ),
167
+ )
168
+ )
169
+
170
+ if not tool_calls:
171
+ return ExtractedToolCallInformation(
172
+ tools_called=False, tool_calls=[], content=model_output
173
+ )
174
+
175
+ # Extract content before first tool call
176
+ first_tool_idx = model_output.find(self.tool_call_start_token)
177
+ content = model_output[:first_tool_idx] if first_tool_idx > 0 else None
178
+
179
+ return ExtractedToolCallInformation(
180
+ tools_called=True, tool_calls=tool_calls, content=content
181
+ )
182
+
183
+ except Exception:
184
+ logger.exception("Error extracting tool calls")
185
+ return ExtractedToolCallInformation(
186
+ tools_called=False, tool_calls=[], content=model_output
187
+ )
188
+
189
+ def _extract_name(self, name_str: str) -> str:
190
+ """Extract name from quoted string."""
191
+ name_str = name_str.strip()
192
+ if (
193
+ name_str.startswith('"')
194
+ and name_str.endswith('"')
195
+ or name_str.startswith("'")
196
+ and name_str.endswith("'")
197
+ ):
198
+ return name_str[1:-1]
199
+ return name_str
200
+
201
+ def _extract_param_name(self, input_str: str) -> str:
202
+ """Extract param name"""
203
+ start = input_str.find('"') + 1
204
+ end = input_str.find('"', start)
205
+ return input_str[start:end] if start > 0 and end > start else input_str
206
+
207
+ def _convert_param_value(self, value: str, param_type: str) -> Any:
208
+ """Convert parameter value to the correct type."""
209
+ if value.lower() == "null":
210
+ return None
211
+
212
+ param_type = param_type.lower()
213
+ if param_type in ["string", "str", "text"]:
214
+ return value
215
+ elif param_type in ["integer", "int"]:
216
+ try:
217
+ return int(value)
218
+ except (ValueError, TypeError):
219
+ return value
220
+ elif param_type in ["number", "float"]:
221
+ try:
222
+ val = float(value)
223
+ return val if val != int(val) else int(val)
224
+ except (ValueError, TypeError):
225
+ return value
226
+ elif param_type in ["boolean", "bool"]:
227
+ return value.lower() in ["true", "1"]
228
+ elif param_type in ["object", "array"]:
229
+ try:
230
+ return json.loads(value)
231
+ except json.JSONDecodeError:
232
+ return value
233
+ else:
234
+ # Try JSON parse first, fallback to string
235
+ try:
236
+ return json.loads(value)
237
+ except json.JSONDecodeError:
238
+ return value
239
+
240
+ def extract_tool_calls_streaming(
241
+ self,
242
+ previous_text: str,
243
+ current_text: str,
244
+ delta_text: str,
245
+ previous_token_ids: Sequence[int], # pylint: disable=unused-argument
246
+ current_token_ids: Sequence[int], # pylint: disable=unused-argument
247
+ delta_token_ids: Sequence[int],
248
+ request: ChatCompletionRequest,
249
+ ) -> DeltaMessage | None:
250
+ """Extract tool calls from streaming model output."""
251
+
252
+ # Store request for type conversion
253
+ if not previous_text:
254
+ self._reset_streaming_state()
255
+ self.streaming_request = request
256
+
257
+ # If no delta text, return None unless it's an EOS token after tools
258
+ if not delta_text:
259
+ # Check if this is an EOS token after all tool calls are complete
260
+ if delta_token_ids:
261
+ # Count complete tool calls
262
+ complete_calls = len(
263
+ self.tool_call_complete_regex.findall(current_text)
264
+ )
265
+
266
+ # If we have completed tool calls and populated prev_tool_call_arr
267
+ if complete_calls > 0 and len(self.prev_tool_call_arr) > 0:
268
+ # Check if all tool calls are closed
269
+ open_calls = current_text.count(
270
+ self.tool_call_start_token
271
+ ) - current_text.count(self.tool_call_end_token)
272
+ if open_calls == 0:
273
+ # Return empty delta for finish_reason processing
274
+ return DeltaMessage(content="")
275
+ elif not self.is_tool_call_started and current_text:
276
+ # This is a regular content response that's now complete
277
+ return DeltaMessage(content="")
278
+ return None
279
+
280
+ # Check if we need to advance to next tool
281
+ if self.json_closed and not self.in_function:
282
+ # Check if this tool call has ended
283
+ invoke_ends = current_text.count(self.invoke_end_token)
284
+ if invoke_ends > self.current_tool_index:
285
+ # This tool has ended, advance to next
286
+ self.current_tool_index += 1
287
+ self.header_sent = False
288
+ self.param_count = 0
289
+ self.json_started = False
290
+ self.json_closed = False
291
+ self.in_function = False # Now we can safely set this to False
292
+ self.accumulated_params = {}
293
+ # Continue processing next tool
294
+ return None
295
+
296
+ # Handle normal content before tool calls
297
+ if not self.is_tool_call_started:
298
+ # Check if tool call is starting
299
+ if self.dsml_token in current_text:
300
+ self.is_tool_call_started = True
301
+ # Return any content before the tool call
302
+ if self.dsml_start_check in delta_text:
303
+ content_before = delta_text[
304
+ : delta_text.index(self.dsml_start_check)
305
+ ]
306
+ if content_before:
307
+ return DeltaMessage(content=content_before)
308
+ return None
309
+ else:
310
+ # Check if we're between tool calls - skip whitespace
311
+ if (
312
+ current_text.rstrip().endswith(self.tool_call_end_token)
313
+ and delta_text.strip() == ""
314
+ ):
315
+ # We just ended a tool call, skip whitespace
316
+ return None
317
+ # Normal content, no tool call
318
+ if delta_text.endswith("<"):
319
+ return DeltaMessage(content=delta_text[:-1])
320
+ if previous_text and previous_text.endswith("<"):
321
+ return DeltaMessage(content="<" + delta_text)
322
+ return DeltaMessage(content=delta_text)
323
+
324
+ # Check if we're between tool calls (waiting for next one)
325
+ invoke_starts_count = current_text.count(self.invoke_start_prefix)
326
+ if self.current_tool_index >= invoke_starts_count:
327
+ # We're past all tool calls, shouldn't be here
328
+ return None
329
+
330
+ # Find the current tool call portion
331
+ invoke_start_positions: list[int] = []
332
+ idx = 0
333
+ while True:
334
+ idx = current_text.find(self.invoke_start_prefix, idx)
335
+ if idx == -1:
336
+ break
337
+ invoke_start_positions.append(idx)
338
+ idx += len(self.invoke_start_prefix)
339
+
340
+ if self.current_tool_index >= len(invoke_start_positions):
341
+ # No more tool calls to process yet
342
+ return None
343
+
344
+ invoke_start_idx = invoke_start_positions[self.current_tool_index]
345
+ # Find where this tool call ends (or current position if not ended yet)
346
+ invoke_end_idx = current_text.find(self.invoke_end_token, invoke_start_idx)
347
+ if invoke_end_idx == -1:
348
+ tool_text = current_text[invoke_start_idx:]
349
+ else:
350
+ tool_text = current_text[
351
+ invoke_start_idx : invoke_end_idx + len(self.invoke_end_token)
352
+ ]
353
+
354
+ # Looking for function header
355
+ if not self.header_sent:
356
+ if self.invoke_start_prefix in tool_text:
357
+ func_start = tool_text.find(self.invoke_start_prefix) + len(
358
+ self.invoke_start_prefix
359
+ )
360
+ # Find the end quote for the function name
361
+ func_end = tool_text.find(">", func_start)
362
+
363
+ if func_end != -1:
364
+ # Found complete function name
365
+ function_name_raw = tool_text[func_start:func_end]
366
+ self.current_function_name = self._extract_name(function_name_raw)
367
+ self.current_tool_id = self._generate_tool_call_id()
368
+ self.header_sent = True
369
+ self.in_function = True
370
+
371
+ # Add to prev_tool_call_arr immediately when we detect a tool call
372
+ # Each tool call should be recorded regardless of function name
373
+ # Ensure we don't add the same tool call index multiple times
374
+ if len(self.prev_tool_call_arr) <= self.current_tool_index:
375
+ self.prev_tool_call_arr.append(
376
+ {
377
+ "name": self.current_function_name,
378
+ "arguments": "{}", # Placeholder, will be updated later
379
+ }
380
+ )
381
+
382
+ # Send header with function info
383
+ return DeltaMessage(
384
+ tool_calls=[
385
+ DeltaToolCall(
386
+ index=self.current_tool_index,
387
+ id=self.current_tool_id,
388
+ function=DeltaFunctionCall(
389
+ name=self.current_function_name, arguments=""
390
+ ),
391
+ type="function",
392
+ )
393
+ ]
394
+ )
395
+ return None
396
+
397
+ # We've sent header, now handle function body
398
+ if self.in_function:
399
+ # Send opening brace if not sent yet
400
+ if self.in_function and not self.json_started:
401
+ self.json_started = True
402
+ return DeltaMessage(
403
+ tool_calls=[
404
+ DeltaToolCall(
405
+ index=self.current_tool_index,
406
+ function=DeltaFunctionCall(arguments="{"),
407
+ )
408
+ ]
409
+ )
410
+
411
+ # Make sure json_started is set if we're processing parameters
412
+ if not self.json_started:
413
+ self.json_started = True
414
+
415
+ # Check for function end in accumulated text
416
+ if not self.json_closed and self.invoke_end_token in tool_text:
417
+ # Count total parameters in the tool text
418
+ total_param_count = tool_text.count(self.parameter_prefix)
419
+
420
+ # Only close JSON if all parameters have been processed
421
+ if self.param_count >= total_param_count:
422
+ # Close JSON
423
+ self.json_closed = True
424
+
425
+ # Extract complete tool call
426
+ # Find the invoke content
427
+ invoke_start = tool_text.find(self.invoke_start_prefix) + len(
428
+ self.invoke_start_prefix
429
+ )
430
+ invoke_content_end = tool_text.find(
431
+ self.invoke_end_token, invoke_start
432
+ )
433
+ if invoke_content_end != -1:
434
+ invoke_content = tool_text[invoke_start:invoke_content_end]
435
+ # Parse to get the complete arguments
436
+ try:
437
+ invoke_params = self._parse_invoke_params(invoke_content)
438
+ if invoke_params and self.current_tool_index < len(
439
+ self.prev_tool_call_arr
440
+ ):
441
+ # Update existing entry in prev_tool_call_arr
442
+ self.prev_tool_call_arr[self.current_tool_index][
443
+ "arguments"
444
+ ] = json.dumps(invoke_params, ensure_ascii=False)
445
+ except Exception:
446
+ pass # Ignore parsing errors during streaming
447
+
448
+ result = DeltaMessage(
449
+ tool_calls=[
450
+ DeltaToolCall(
451
+ index=self.current_tool_index,
452
+ function=DeltaFunctionCall(arguments="}"),
453
+ )
454
+ ]
455
+ )
456
+
457
+ # Reset state for next tool
458
+ self.json_closed = True
459
+ self.in_function = False
460
+ self.accumulated_params = {}
461
+
462
+ logger.debug("[M2_STREAMING] Tool call completed")
463
+
464
+ return result
465
+ else:
466
+ # Don't close JSON yet, continue processing parameters
467
+ return None
468
+
469
+ # Look for parameters
470
+ # Find all parameter starts
471
+ param_starts = []
472
+ idx = 0
473
+ while True:
474
+ idx = tool_text.find(self.parameter_prefix, idx)
475
+ if idx == -1:
476
+ break
477
+ param_starts.append(idx)
478
+ idx += len(self.parameter_prefix)
479
+
480
+ # Check if we should start a new parameter
481
+ if (
482
+ not self.in_param
483
+ and self.param_count < len(param_starts)
484
+ and len(param_starts) > self.param_count
485
+ ):
486
+ # Process the next parameter
487
+ param_idx = param_starts[self.param_count]
488
+ param_start = param_idx + len(self.parameter_prefix)
489
+ remaining = tool_text[param_start:]
490
+
491
+ if ">" in remaining:
492
+ # We have the complete parameter name
493
+ name_end = remaining.find(">")
494
+ param_name_raw = remaining[:name_end]
495
+ self.current_param_name = self._extract_param_name(param_name_raw)
496
+
497
+ # Find the parameter value
498
+ value_start = param_start + name_end + 1
499
+ value_text = tool_text[value_start:]
500
+ if value_text.startswith("\n"):
501
+ value_text = value_text[1:]
502
+
503
+ # Find where this parameter ends
504
+ param_end_idx = value_text.find(self.parameter_end_token)
505
+ if param_end_idx == -1:
506
+ # No closing tag, look for next parameter or function end
507
+ next_param_idx = value_text.find(self.parameter_prefix)
508
+ func_end_idx = value_text.find(self.invoke_end_token)
509
+
510
+ if next_param_idx != -1 and (
511
+ func_end_idx == -1 or next_param_idx < func_end_idx
512
+ ):
513
+ param_end_idx = next_param_idx
514
+ elif func_end_idx != -1:
515
+ param_end_idx = func_end_idx
516
+ else:
517
+ # Neither found, check if tool call is complete
518
+ if self.invoke_end_token in tool_text:
519
+ # Tool call and parameter is complete
520
+ param_end_idx = len(value_text)
521
+ else:
522
+ # Still streaming, wait for more content
523
+ return None
524
+
525
+ if param_end_idx != -1:
526
+ # Complete parameter found
527
+ param_value = value_text[:param_end_idx]
528
+ if param_value.endswith("\n"):
529
+ param_value = param_value[:-1]
530
+
531
+ # Store raw value for later processing
532
+ self.accumulated_params[self.current_param_name] = param_value
533
+
534
+ # Get parameter configuration for type conversion
535
+ param_config = {}
536
+ if self.streaming_request and self.streaming_request.tools:
537
+ for tool in self.streaming_request.tools:
538
+ if (
539
+ hasattr(tool, "function")
540
+ and tool.function.name == self.current_function_name
541
+ and hasattr(tool.function, "parameters")
542
+ ):
543
+ params = tool.function.parameters
544
+ if (
545
+ isinstance(params, dict)
546
+ and "properties" in params
547
+ ):
548
+ param_config = params["properties"]
549
+ break
550
+
551
+ # Get parameter type
552
+ param_type = "string"
553
+ if (
554
+ self.current_param_name in param_config
555
+ and isinstance(param_config[self.current_param_name], dict)
556
+ and "type" in param_config[self.current_param_name]
557
+ ):
558
+ param_type = param_config[self.current_param_name]["type"]
559
+
560
+ # Convert param value to appropriate type
561
+ converted_value = self._convert_param_value(
562
+ param_value, param_type
563
+ )
564
+
565
+ # Build JSON fragment based on the converted type
566
+ # Use json.dumps to properly serialize the value
567
+ serialized_value = json.dumps(
568
+ converted_value, ensure_ascii=False
569
+ )
570
+
571
+ if self.param_count == 0:
572
+ json_fragment = (
573
+ f'"{self.current_param_name}": {serialized_value}'
574
+ )
575
+ else:
576
+ json_fragment = (
577
+ f', "{self.current_param_name}": {serialized_value}'
578
+ )
579
+
580
+ self.param_count += 1
581
+
582
+ return DeltaMessage(
583
+ tool_calls=[
584
+ DeltaToolCall(
585
+ index=self.current_tool_index,
586
+ function=DeltaFunctionCall(arguments=json_fragment),
587
+ )
588
+ ]
589
+ )
590
+
591
+ return None