@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 10eb24cc91
Fork 0

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 15, 01:00 UTC

GLM-4 Update (#20736)

Signed-off-by: zRzRzRzRzRzRzR <2448370773@qq.com> Signed-off-by: Isotr0py <mozf@mail2.sysu.edu.cn> Signed-off-by: Lu Fang <fanglu@fb.com> Co-authored-by: Isotr0py <mozf@mail2.sysu.edu.cn> Co-authored-by: Lu Fang <fanglu@fb.com>

Yuxuan Zhang committed Jul 20, 2025 at 06:40 UTC 10eb24cc91315481414fba0e0134209e6a9d7c94
14 files changed +2006 -11
benchmarks/kernels/benchmark_moe.py
+5 -1
@@ -576,7 +576,11 @@ def main(args: argparse.Namespace):
576 topk = config.num_experts_per_tok
577 intermediate_size = config.intermediate_size
578 shard_intermediate_size = 2 * intermediate_size // args.tp_size
579 - elif config.architectures[0] in ("DeepseekV3ForCausalLM", "DeepseekV2ForCausalLM"):
579 + elif config.architectures[0] in (
580 + "DeepseekV3ForCausalLM",
581 + "DeepseekV2ForCausalLM",
582 + "Glm4MoeForCausalLM",
583 + ):
584 E = config.n_routed_experts
585 topk = config.num_experts_per_tok
586 intermediate_size = config.moe_intermediate_size
benchmarks/kernels/benchmark_moe_permute_unpermute.py
+1
@@ -318,6 +318,7 @@ def main(args: argparse.Namespace):
318 elif (
319 config.architectures[0] == "DeepseekV3ForCausalLM"
320 or config.architectures[0] == "DeepseekV2ForCausalLM"
321 + or config.architectures[0] == "Glm4MoeForCausalLM"
322 ):
323 E = config.n_routed_experts
324 topk = config.num_experts_per_tok
docs/models/supported_models.md
+1
@@ -576,6 +576,7 @@ Specified using `--task generate`.
576 | `Gemma3ForConditionalGeneration` | Gemma 3 | T + I<sup>+</sup> | `google/gemma-3-4b-it`, `google/gemma-3-27b-it`, etc. | ✅︎ | ✅︎ | ⚠️ |
577 | `GLM4VForCausalLM`<sup>^</sup> | GLM-4V | T + I | `THUDM/glm-4v-9b`, `THUDM/cogagent-9b-20241220`, etc. | ✅︎ | ✅︎ | ✅︎ |
578 | `Glm4vForConditionalGeneration` | GLM-4.1V-Thinking | T + I<sup>E+</sup> + V<sup>E+</sup> | `THUDM/GLM-4.1V-9B-Thinking`, etc. | ✅︎ | ✅︎ | ✅︎ |
579 +| `Glm4MoeForCausalLM` | GLM-4.5 | T + I<sup>E+</sup> + V<sup>E+</sup> | `THUDM/GLM-4.5`, etc. | ✅︎ | ✅︎ | ✅︎ |
580 | `GraniteSpeechForConditionalGeneration` | Granite Speech | T + A | `ibm-granite/granite-speech-3.3-8b` | ✅︎ | ✅︎ | ✅︎ |
581 | `H2OVLChatModel` | H2OVL | T + I<sup>E+</sup> | `h2oai/h2ovl-mississippi-800m`, `h2oai/h2ovl-mississippi-2b`, etc. | | ✅︎ | ✅︎ |
582 | `Idefics3ForConditionalGeneration` | Idefics3 | T + I | `HuggingFaceM4/Idefics3-8B-Llama3`, etc. | ✅︎ | | ✅︎ |
tests/models/registry.py
+7
@@ -360,6 +360,9 @@ _MULTIMODAL_EXAMPLE_MODELS = {
360 trust_remote_code=True,
361 hf_overrides={"architectures": ["GLM4VForCausalLM"]}), # noqa: E501
362 "Glm4vForConditionalGeneration": _HfExamplesInfo("THUDM/GLM-4.1V-9B-Thinking", min_transformers_version="4.53"), # noqa: E501
363 + "Glm4MoeForCausalLM": _HfExamplesInfo("THUDM/GLM-4.5",
364 + min_transformers_version="4.54",
365 + is_available_online=False), # noqa: E501
366 "H2OVLChatModel": _HfExamplesInfo("h2oai/h2ovl-mississippi-800m",
367 extras={"2b": "h2oai/h2ovl-mississippi-2b"}, # noqa: E501
368 max_transformers_version="4.48", # noqa: E501
@@ -485,6 +488,10 @@ _SPECULATIVE_DECODING_EXAMPLE_MODELS = {
488 is_available_online=False,
489 speculative_model="openbmb/MiniCPM-2B-sft-bf16",
490 tokenizer="openbmb/MiniCPM-2B-sft-bf16"),
491 + "Glm4MoeMTPModel": _HfExamplesInfo("THUDM/GLM-4.5",
492 + speculative_model="THUDM/GLM-4.5",
493 + min_transformers_version="4.54",
494 + is_available_online=False),
495 "MiMoMTPModel": _HfExamplesInfo("XiaomiMiMo/MiMo-7B-RL",
496 trust_remote_code=True,
497 speculative_model="XiaomiMiMo/MiMo-7B-RL")
tests/tool_use/test_glm4_moe_tool_parser.py new
+410
@@ -0,0 +1,410 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +# ruff: noqa: E501
4 +
5 +import json
6 +
7 +import pytest
8 +
9 +from vllm.entrypoints.openai.protocol import FunctionCall, ToolCall
10 +from vllm.entrypoints.openai.tool_parsers import Glm4MoeModelToolParser
11 +from vllm.transformers_utils.tokenizer import get_tokenizer
12 +
13 +pytest.skip("skip glm4_moe parser test", allow_module_level=True)
14 +# Use a common model that is likely to be available
15 +MODEL = "THUDM/GLM-4.5"
16 +
17 +
18 +@pytest.fixture(scope="module")
19 +def glm4_moe_tokenizer():
20 + return get_tokenizer(tokenizer_name=MODEL)
21 +
22 +
23 +@pytest.fixture
24 +def glm4_moe_tool_parser(glm4_moe_tokenizer):
25 + return Glm4MoeModelToolParser(glm4_moe_tokenizer)
26 +
27 +
28 +def assert_tool_calls(actual_tool_calls: list[ToolCall],
29 + expected_tool_calls: list[ToolCall]):
30 + assert len(actual_tool_calls) == len(expected_tool_calls)
31 +
32 + for actual_tool_call, expected_tool_call in zip(actual_tool_calls,
33 + expected_tool_calls):
34 + assert isinstance(actual_tool_call.id, str)
35 + assert len(actual_tool_call.id) > 0
36 +
37 + assert actual_tool_call.type == "function"
38 + assert actual_tool_call.function.name == expected_tool_call.function.name
39 + # Compare arguments as JSON objects to handle formatting differences
40 + actual_args = json.loads(actual_tool_call.function.arguments)
41 + expected_args = json.loads(expected_tool_call.function.arguments)
42 + assert actual_args == expected_args
43 +
44 +
45 +def test_extract_tool_calls_no_tools(glm4_moe_tool_parser):
46 + model_output = "This is a test"
47 + extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls(
48 + model_output, request=None) # type: ignore[arg-type]
49 + assert not extracted_tool_calls.tools_called
50 + assert extracted_tool_calls.tool_calls == []
51 + assert extracted_tool_calls.content == model_output
52 +
53 +
54 +@pytest.mark.parametrize(
55 + ids=[
56 + "single_tool_call",
57 + "multiple_tool_calls",
58 + "tool_call_with_content_before",
59 + "tool_call_with_mixed_args",
60 + "tool_call_with_chinese_content",
61 + ],
62 + argnames=["model_output", "expected_tool_calls", "expected_content"],
63 + argvalues=[
64 + (
65 + """<tool_call>get_current_weather
66 + <arg_key>city</arg_key>
67 + <arg_value>Dallas</arg_value>
68 + <arg_key>state</arg_key>
69 + <arg_value>TX</arg_value>
70 + <arg_key>unit</arg_key>
71 + <arg_value>fahrenheit</arg_value>
72 + </tool_call>""",
73 + [
74 + ToolCall(function=FunctionCall(
75 + name="get_current_weather",
76 + arguments=json.dumps({
77 + "city": "Dallas",
78 + "state": "TX",
79 + "unit": "fahrenheit",
80 + }),
81 + ))
82 + ],
83 + None,
84 + ),
85 + (
86 + """<tool_call>get_current_weather
87 + <arg_key>city</arg_key>
88 + <arg_value>Dallas</arg_value>
89 + <arg_key>state</arg_key>
90 + <arg_value>TX</arg_value>
91 + <arg_key>unit</arg_key>
92 + <arg_value>fahrenheit</arg_value>
93 + </tool_call>
94 + <tool_call>get_current_weather
95 + <arg_key>city</arg_key>
96 + <arg_value>Orlando</arg_value>
97 + <arg_key>state</arg_key>
98 + <arg_value>FL</arg_value>
99 + <arg_key>unit</arg_key>
100 + <arg_value>fahrenheit</arg_value>
101 + </tool_call>""",
102 + [
103 + ToolCall(function=FunctionCall(
104 + name="get_current_weather",
105 + arguments=json.dumps({
106 + "city": "Dallas",
107 + "state": "TX",
108 + "unit": "fahrenheit",
109 + }),
110 + )),
111 + ToolCall(function=FunctionCall(
112 + name="get_current_weather",
113 + arguments=json.dumps({
114 + "city": "Orlando",
115 + "state": "FL",
116 + "unit": "fahrenheit",
117 + }),
118 + )),
119 + ],
120 + None,
121 + ),
122 + (
123 + """I'll help you check the weather. <tool_call>get_current_weather
124 + <arg_key>city</arg_key>
125 + <arg_value>Seattle</arg_value>
126 + <arg_key>state</arg_key>
127 + <arg_value>WA</arg_value>
128 + <arg_key>unit</arg_key>
129 + <arg_value>celsius</arg_value>
130 + </tool_call>""",
131 + [
132 + ToolCall(function=FunctionCall(
133 + name="get_current_weather",
134 + arguments=json.dumps({
135 + "city": "Seattle",
136 + "state": "WA",
137 + "unit": "celsius",
138 + }),
139 + ))
140 + ],
141 + "I'll help you check the weather.",
142 + ),
143 + (
144 + """<tool_call>get_current_weather
145 + <arg_key>city</arg_key>
146 + <arg_value>New York</arg_value>
147 + <arg_key>state</arg_key>
148 + <arg_value>NY</arg_value>
149 + <arg_key>unit</arg_key>
150 + <arg_value>celsius</arg_value>
151 + </tool_call>""",
152 + [
153 + ToolCall(function=FunctionCall(
154 + name="get_current_weather",
155 + arguments=json.dumps({
156 + "city": "New York",
157 + "state": "NY",
158 + "unit": "celsius",
159 + }),
160 + ))
161 + ],
162 + None,
163 + ),
164 + ("""I will help you get the weather.<tool_call>get_weather
165 + <arg_key>city</arg_key>
166 + <arg_value>Beijing</arg_value>
167 + <arg_key>date</arg_key>
168 + <arg_value>2025-08-01</arg_value>
169 + </tool_call>""", [
170 + ToolCall(function=FunctionCall(
171 + name="get_weather",
172 + arguments=json.dumps({
173 + "city": "Beijing",
174 + "date": "2025-08-01",
175 + }),
176 + ))
177 + ], "I will help you get the weather."),
178 + ],
179 +)
180 +def test_extract_tool_calls(glm4_moe_tool_parser, model_output,
181 + expected_tool_calls, expected_content):
182 + extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls(
183 + model_output, request=None) # type: ignore[arg-type]
184 + assert extracted_tool_calls.tools_called
185 + assert_tool_calls(extracted_tool_calls.tool_calls, expected_tool_calls)
186 +
187 + assert extracted_tool_calls.content == expected_content
188 +
189 +
190 +def test_extract_tool_calls_with_thinking_tags(glm4_moe_tool_parser):
191 + """Test tool extraction when thinking tags are present."""
192 + model_output = """<think>I want to get the weather.</think>
193 +
194 +I will help you get the weather.
195 +<tool_call>get_weather
196 +<arg_key>city</arg_key>
197 +<arg_value>Beijing</arg_value>
198 +<arg_key>date</arg_key>
199 +<arg_value>2025-08-01</arg_value>
200 +</tool_call>"""
201 +
202 + extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls(
203 + model_output, request=None) # type: ignore[arg-type]
204 +
205 + assert extracted_tool_calls.tools_called
206 + assert len(extracted_tool_calls.tool_calls) == 1
207 + assert extracted_tool_calls.tool_calls[0].function.name == "get_weather"
208 +
209 + expected_content = """<think>I want to get the weather.</think>
210 +
211 +I will help you get the weather."""
212 + assert extracted_tool_calls.content == expected_content
213 +
214 +
215 +def test_extract_tool_calls_malformed_xml(glm4_moe_tool_parser):
216 + """Test that malformed XML is handled gracefully."""
217 + model_output = """<tool_call>get_weather
218 +<arg_key>city</arg_key>
219 +<arg_value>Seattle</arg_value>
220 +<arg_key>incomplete_arg
221 +<arg_value>value</arg_value>
222 +</tool_call>"""
223 +
224 + extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls(
225 + model_output, request=None) # type: ignore[arg-type]
226 +
227 + # Should handle malformed XML gracefully
228 + # The parser should either extract what it can or return no tool calls
229 + # depending on how robust we want the parsing to be
230 + assert isinstance(extracted_tool_calls.tools_called, bool)
231 + assert isinstance(extracted_tool_calls.tool_calls, list)
232 +
233 +
234 +def test_extract_tool_calls_empty_arguments(glm4_moe_tool_parser):
235 + """Test tool calls with no arguments."""
236 + model_output = """<tool_call>get_current_time
237 +</tool_call>"""
238 +
239 + extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls(
240 + model_output, request=None) # type: ignore[arg-type]
241 +
242 + assert extracted_tool_calls.tools_called
243 + assert len(extracted_tool_calls.tool_calls) == 1
244 + assert extracted_tool_calls.tool_calls[
245 + 0].function.name == "get_current_time"
246 + # Empty arguments should result in empty JSON object
247 + assert extracted_tool_calls.tool_calls[0].function.arguments == "{}"
248 +
249 +
250 +def test_extract_tool_calls_mixed_content(glm4_moe_tool_parser):
251 + """Test extraction with mixed content and multiple tool calls."""
252 + model_output = """I will help you get the weather info.
253 +
254 +<tool_call>get_weather
255 +<arg_key>city</arg_key>
256 +<arg_value>Beijing</arg_value>
257 +<arg_key>date</arg_key>
258 +<arg_value>2025-08-01</arg_value>
259 +</tool_call>
260 +
261 +meaningwhile, I will also check the weather in Shanghai.
262 +
263 +<tool_call>get_weather
264 +<arg_key>city</arg_key>
265 +<arg_value>Shanghai</arg_value>
266 +<arg_key>date</arg_key>
267 +<arg_value>2025-08-01</arg_value>
268 +</tool_call>"""
269 +
270 + extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls(
271 + model_output, request=None) # type: ignore[arg-type]
272 +
273 + assert extracted_tool_calls.tools_called
274 + assert len(extracted_tool_calls.tool_calls) == 2
275 +
276 + # Check first tool call
277 + assert extracted_tool_calls.tool_calls[0].function.name == "get_weather"
278 + args1 = json.loads(extracted_tool_calls.tool_calls[0].function.arguments)
279 + assert args1["city"] == "Beijing"
280 + assert args1["date"] == "2025-08-01"
281 +
282 + # Check second tool call
283 + assert extracted_tool_calls.tool_calls[1].function.name == "get_weather"
284 + args2 = json.loads(extracted_tool_calls.tool_calls[1].function.arguments)
285 + assert args2["city"] == "Shanghai"
286 + assert args2["date"] == "2025-08-01"
287 +
288 + # Content should be everything before the first tool call
289 + assert extracted_tool_calls.content == "I will help you get the weather info."
290 +
291 +
292 +def test_streaming_basic_functionality(glm4_moe_tool_parser):
293 + """Test basic streaming functionality."""
294 + # Reset streaming state
295 + glm4_moe_tool_parser.current_tool_name_sent = False
296 + glm4_moe_tool_parser.prev_tool_call_arr = []
297 + glm4_moe_tool_parser.current_tool_id = -1
298 + glm4_moe_tool_parser.streamed_args_for_tool = []
299 +
300 + # Test with a simple tool call
301 + current_text = """<tool_call>get_weather
302 +<arg_key>city</arg_key>
303 +<arg_value>Beijing</arg_value>
304 +</tool_call>"""
305 +
306 + # Mock token IDs for testing
307 + tool_call_start_id = glm4_moe_tool_parser.tool_call_start_token_id or 12345
308 + tool_call_end_id = glm4_moe_tool_parser.tool_call_end_token_id or 12346
309 +
310 + result = glm4_moe_tool_parser.extract_tool_calls_streaming(
311 + previous_text="",
312 + current_text=current_text,
313 + delta_text="</tool_call>",
314 + previous_token_ids=[],
315 + current_token_ids=[tool_call_start_id, tool_call_end_id],
316 + delta_token_ids=[tool_call_end_id],
317 + request=None,
318 + )
319 +
320 + # The result behavior depends on the streaming state
321 + # This test mainly ensures no exceptions are thrown
322 + assert result is None or hasattr(result, 'tool_calls') or hasattr(
323 + result, 'content')
324 +
325 +
326 +def test_streaming_no_tool_calls(glm4_moe_tool_parser):
327 + """Test streaming when there are no tool calls."""
328 + current_text = "This is just regular text without any tool calls."
329 +
330 + result = glm4_moe_tool_parser.extract_tool_calls_streaming(
331 + previous_text="This is just regular text",
332 + current_text=current_text,
333 + delta_text=" without any tool calls.",
334 + previous_token_ids=[],
335 + current_token_ids=[],
336 + delta_token_ids=[],
337 + request=None,
338 + )
339 +
340 + # Should return the delta text as content
341 + assert result is not None
342 + assert hasattr(result, 'content')
343 + assert result.content == " without any tool calls."
344 +
345 +
346 +def test_streaming_with_content_before_tool_calls(glm4_moe_tool_parser):
347 + """Test streaming when there's content before tool calls."""
348 + # Reset streaming state
349 + glm4_moe_tool_parser.current_tool_name_sent = False
350 + glm4_moe_tool_parser.prev_tool_call_arr = []
351 + glm4_moe_tool_parser.current_tool_id = -1
352 + glm4_moe_tool_parser.streamed_args_for_tool = []
353 +
354 + current_text = "I will help you get the weather<tool_call>"
355 +
356 + result = glm4_moe_tool_parser.extract_tool_calls_streaming(
357 + previous_text="I will help you",
358 + current_text=current_text,
359 + delta_text="get the weather.<tool_call>",
360 + previous_token_ids=[],
361 + current_token_ids=[],
362 + delta_token_ids=[],
363 + request=None,
364 + )
365 +
366 + # Should return content when no tool call tokens are detected
367 + assert result is not None
368 + assert hasattr(result, 'content')
369 + assert result.content == "get the weather.<tool_call>"
370 +
371 +
372 +def test_extract_tool_calls_special_characters(glm4_moe_tool_parser):
373 + """Test tool calls with special characters and unicode."""
374 + model_output = """<tool_call>send_message
375 +<arg_key>recipient</arg_key>
376 +<arg_value>Amy</arg_value>
377 +<arg_key>message</arg_key>
378 +<arg_value>It is a nice day</arg_value>
379 +<arg_key>priority</arg_key>
380 +<arg_value>high</arg_value>
381 +</tool_call>"""
382 +
383 + extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls(
384 + model_output, request=None) # type: ignore[arg-type]
385 +
386 + assert extracted_tool_calls.tools_called
387 + assert len(extracted_tool_calls.tool_calls) == 1
388 + assert extracted_tool_calls.tool_calls[0].function.name == "send_message"
389 +
390 + args = json.loads(extracted_tool_calls.tool_calls[0].function.arguments)
391 + assert args["recipient"] == "Amy"
392 + assert args["message"] == "It is a nice day"
393 + assert args["priority"] == "high"
394 +
395 +
396 +def test_extract_tool_calls_incomplete_tool_call(glm4_moe_tool_parser):
397 + """Test incomplete tool calls (missing closing tag)."""
398 + model_output = """<tool_call>get_weather
399 +<arg_key>city</arg_key>
400 +<arg_value>Beijing</arg_value>
401 +<arg_key>date</arg_key>
402 +<arg_value>2025-08-01</arg_value>"""
403 +
404 + extracted_tool_calls = glm4_moe_tool_parser.extract_tool_calls(
405 + model_output, request=None) # type: ignore[arg-type]
406 +
407 + # Incomplete tool calls should not be extracted
408 + assert not extracted_tool_calls.tools_called
409 + assert extracted_tool_calls.tool_calls == []
410 + assert extracted_tool_calls.content == model_output
vllm/config.py
+12 -3
@@ -1333,7 +1333,8 @@ class ModelConfig:
1333 self, parallel_config: "ParallelConfig") -> tuple[int, int]:
1334 from vllm.distributed.utils import get_pp_indices
1335 if (self.hf_text_config.model_type == "deepseek_mtp"
1336 - or self.hf_config.model_type == "mimo_mtp"):
1336 + or self.hf_config.model_type == "mimo_mtp"
1337 + or self.hf_config.model_type == "glm4_moe_mtp"):
1338 total_num_hidden_layers = getattr(self.hf_text_config,
1339 "num_nextn_predict_layers", 0)
1340 else:
@@ -2663,7 +2664,15 @@ class SpeculativeConfig:
2664 "n_predict": n_predict,
2665 "architectures": ["MiMoMTPModel"]
2666 })
2666 - return hf_config
2667 +
2668 + if hf_config.architectures[0] == "Glm4MoeForCausalLM":
2669 + hf_config.model_type = "glm4_moe_mtp"
2670 + n_predict = getattr(hf_config, "num_nextn_predict_layers", None)
2671 + hf_config.update({
2672 + "num_hidden_layers": 0,
2673 + "n_predict": n_predict,
2674 + "architectures": ["Glm4MoeMTPModel"]
2675 + })
2676
2677 return hf_config
2678
@@ -2774,7 +2783,7 @@ class SpeculativeConfig:
2783 "mlp_speculator"):
2784 self.method = "mlp_speculator"
2785 elif (self.draft_model_config.hf_config.model_type
2777 - in ("deepseek_mtp", "mimo_mtp")):
2786 + in ("deepseek_mtp", "mimo_mtp", "glm4_moe_mtp")):
2787 self.method = "deepseek_mtp"
2788 if self.num_speculative_tokens > 1:
2789 logger.warning(
vllm/entrypoints/openai/tool_parsers/__init__.py
+19 -6
@@ -3,6 +3,7 @@
3
4 from .abstract_tool_parser import ToolParser, ToolParserManager
5 from .deepseekv3_tool_parser import DeepSeekV3ToolParser
6 +from .glm4_moe_tool_parser import Glm4MoeModelToolParser
7 from .granite_20b_fc_tool_parser import Granite20bFCToolParser
8 from .granite_tool_parser import GraniteToolParser
9 from .hermes_tool_parser import Hermes2ProToolParser
@@ -19,10 +20,22 @@ from .pythonic_tool_parser import PythonicToolParser
20 from .xlam_tool_parser import xLAMToolParser
21
22 __all__ = [
22 - "ToolParser", "ToolParserManager", "Granite20bFCToolParser",
23 - "GraniteToolParser", "Hermes2ProToolParser", "MistralToolParser",
24 - "Internlm2ToolParser", "Llama3JsonToolParser", "JambaToolParser",
25 - "Llama4PythonicToolParser", "PythonicToolParser", "Phi4MiniJsonToolParser",
26 - "DeepSeekV3ToolParser", "xLAMToolParser", "MinimaxToolParser",
27 - "KimiK2ToolParser", "HunyuanA13BToolParser"
23 + "ToolParser",
24 + "ToolParserManager",
25 + "Granite20bFCToolParser",
26 + "GraniteToolParser",
27 + "Hermes2ProToolParser",
28 + "MistralToolParser",
29 + "Internlm2ToolParser",
30 + "Llama3JsonToolParser",
31 + "JambaToolParser",
32 + "Llama4PythonicToolParser",
33 + "PythonicToolParser",
34 + "Phi4MiniJsonToolParser",
35 + "DeepSeekV3ToolParser",
36 + "xLAMToolParser",
37 + "MinimaxToolParser",
38 + "KimiK2ToolParser",
39 + "HunyuanA13BToolParser",
40 + "Glm4MoeModelToolParser",
41 ]
vllm/entrypoints/openai/tool_parsers/glm4_moe_tool_parser.py new
+402
@@ -0,0 +1,402 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +# code modified from deepseekv3_tool_parser.py
4 +
5 +from collections.abc import Sequence
6 +from typing import Union
7 +
8 +import regex as re
9 +
10 +from vllm.entrypoints.openai.protocol import (ChatCompletionRequest,
11 + DeltaFunctionCall, DeltaMessage,
12 + DeltaToolCall,
13 + ExtractedToolCallInformation,
14 + FunctionCall, ToolCall)
15 +from vllm.entrypoints.openai.tool_parsers.abstract_tool_parser import (
16 + ToolParser, ToolParserManager)
17 +from vllm.logger import init_logger
18 +from vllm.transformers_utils.tokenizer import AnyTokenizer
19 +
20 +logger = init_logger(__name__)
21 +
22 +
23 +@ToolParserManager.register_module("glm4_moe")
24 +class Glm4MoeModelToolParser(ToolParser):
25 +
26 + def __init__(self, tokenizer: AnyTokenizer):
27 + super().__init__(tokenizer)
28 + self.current_tool_name_sent = False
29 + self.prev_tool_call_arr: list[dict] = []
30 + self.current_tool_id = -1
31 + self.streamed_args_for_tool: list[str] = []
32 + self.tool_call_start_token = "<tool_call>"
33 + self.tool_call_end_token = "</tool_call>"
34 +
35 + self.tool_calls_start_token = self.tool_call_start_token
36 +
37 + # Updated regex for the XML-based format
38 + self.tool_call_regex = re.compile(
39 + r"<tool_call>\s*"
40 + r"(?P<function_name>[^\n<]+)\s*" # 函数名(到换行或 <)
41 + r"(?P<arguments>(?:\s*<arg_key>[^<]+</arg_key>\s*"
42 + r"<arg_value>[^<]*</arg_value>\s*)*)\s*"
43 + r"</tool_call>",
44 + re.DOTALL,
45 + )
46 +
47 + # Regex for parsing individual arguments
48 + self.arg_regex = re.compile(
49 + r"<arg_key>(?P<key>[^<]+)</arg_key>\s*<arg_value>(?P<value>[^<]*)</arg_value>",
50 + re.DOTALL,
51 + )
52 +
53 + # Streaming regex
54 + self.stream_tool_call_portion_regex = re.compile(
55 + r"(?P<function_name>[^\n<]+)\s*"
56 + r"(?P<arguments>(?:\s*<arg_key>[^<]+</arg_key>\s*"
57 + r"<arg_value>[^<]*</arg_value>\s*)*)",
58 + re.DOTALL,
59 + )
60 +
61 + # For streaming, we also need a regex to match just the function name
62 + self.stream_tool_call_name_regex = re.compile(
63 + r"(?P<function_name>[^\n<]+)",
64 + re.DOTALL,
65 + )
66 +
67 + if not self.model_tokenizer:
68 + raise ValueError(
69 + "The model tokenizer must be passed to the ToolParser "
70 + "constructor during construction.")
71 +
72 + self.tool_call_start_token_id = self.vocab.get(
73 + self.tool_call_start_token)
74 + self.tool_call_end_token_id = self.vocab.get(self.tool_call_end_token)
75 +
76 + def _parse_arguments(self, args_text: str) -> str:
77 + """Parse XML-based arguments into JSON format."""
78 + if not args_text or not args_text.strip():
79 + return "{}"
80 +
81 + args_dict = {}
82 + matches = self.arg_regex.findall(args_text)
83 +
84 + for key, value in matches:
85 + args_dict[key.strip()] = value.strip()
86 +
87 + import json
88 + return json.dumps(args_dict, ensure_ascii=False)
89 +
90 + def extract_tool_calls(
91 + self,
92 + model_output: str,
93 + request: ChatCompletionRequest,
94 + ) -> ExtractedToolCallInformation:
95 +
96 + # sanity check; avoid unnecessary processing
97 + if self.tool_calls_start_token not in model_output:
98 + return ExtractedToolCallInformation(tools_called=False,
99 + tool_calls=[],
100 + content=model_output)
101 +
102 + try:
103 + # Find all tool calls in the output
104 + function_call_matches = self.tool_call_regex.findall(model_output)
105 +
106 + logger.debug("function_call_matches: %s", function_call_matches)
107 +
108 + if not function_call_matches:
109 + return ExtractedToolCallInformation(
110 + tools_called=False,
111 + tool_calls=[],
112 + content=model_output,
113 + )
114 +
115 + tool_calls = []
116 + for i, match in enumerate(function_call_matches):
117 + function_name, function_args_xml = match
118 + function_name = function_name.strip()
119 +
120 + # Parse XML arguments to JSON
121 + function_args_json = self._parse_arguments(function_args_xml)
122 +
123 + tool_calls.append(
124 + ToolCall(
125 + id=f"call_{i}",
126 + type='function',
127 + function=FunctionCall(name=function_name,
128 + arguments=function_args_json),
129 + ))
130 +
131 + # Extract content before the first tool call
132 + content = model_output[:model_output.find(self.
133 + tool_calls_start_token)]
134 + return ExtractedToolCallInformation(
135 + tools_called=bool(tool_calls),
136 + tool_calls=tool_calls,
137 + content=content.strip() if content.strip() else None,
138 + )
139 +
140 + except Exception:
141 + logger.exception("Error in extracting tool call from response.")
142 + return ExtractedToolCallInformation(tools_called=False,
143 + tool_calls=[],
144 + content=model_output)
145 +
146 + def extract_tool_calls_streaming(
147 + self,
148 + previous_text: str,
149 + current_text: str,
150 + delta_text: str,
151 + previous_token_ids: Sequence[int],
152 + current_token_ids: Sequence[int],
153 + delta_token_ids: Sequence[int],
154 + request: ChatCompletionRequest,
155 + ) -> Union[DeltaMessage, None]:
156 +
157 + logger.debug("delta_text: %s", delta_text)
158 + logger.debug("delta_token_ids: %s", delta_token_ids)
159 + # check to see if we should be streaming a tool call - is there a
160 + if self.tool_call_start_token_id not in current_token_ids:
161 + logger.debug("No tool call tokens found!")
162 + return DeltaMessage(content=delta_text)
163 + delta_text = delta_text.replace(self.tool_calls_start_token,
164 + "").replace(self.tool_call_end_token,
165 + "")
166 + try:
167 +
168 + # figure out where we are in the parsing by counting tool call
169 + # start & end tags
170 + prev_tool_start_count = previous_token_ids.count(
171 + self.tool_call_start_token_id)
172 + prev_tool_end_count = previous_token_ids.count(
173 + self.tool_call_end_token_id)
174 + cur_tool_start_count = current_token_ids.count(
175 + self.tool_call_start_token_id)
176 + cur_tool_end_count = current_token_ids.count(
177 + self.tool_call_end_token_id)
178 + tool_call_portion = None
179 + text_portion = None
180 +
181 + # case: if we're generating text, OR rounding out a tool call
182 + if (cur_tool_start_count == cur_tool_end_count
183 + and prev_tool_end_count == cur_tool_end_count
184 + and self.tool_call_end_token not in delta_text):
185 + logger.debug("Generating text content! skipping tool parsing.")
186 + return DeltaMessage(content=delta_text)
187 +
188 + if self.tool_call_end_token in delta_text:
189 + logger.debug("tool_call_end_token in delta_text")
190 + full_text = current_text + delta_text
191 + tool_call_portion = full_text.split(
192 + self.tool_call_start_token)[-1].split(
193 + self.tool_call_end_token)[0].rstrip()
194 + delta_text = delta_text.split(
195 + self.tool_call_end_token)[0].rstrip()
196 + text_portion = delta_text.split(
197 + self.tool_call_end_token)[-1].lstrip()
198 +
199 + # case -- we're starting a new tool call
200 + if (cur_tool_start_count > cur_tool_end_count
201 + and cur_tool_start_count > prev_tool_start_count):
202 + if len(delta_token_ids) > 1:
203 + tool_call_portion = current_text.split(
204 + self.tool_call_start_token)[-1]
205 + else:
206 + tool_call_portion = None
207 + delta = None
208 +
209 + text_portion = None
210 +
211 + # set cursors and state appropriately
212 + self.current_tool_id += 1
213 + self.current_tool_name_sent = False
214 + self.streamed_args_for_tool.append("")
215 + logger.debug("Starting on a new tool %s", self.current_tool_id)
216 +
217 + # case -- we're updating an existing tool call
218 + elif (cur_tool_start_count > cur_tool_end_count
219 + and cur_tool_start_count == prev_tool_start_count):
220 +
221 + # get the portion of the text that's the tool call
222 + tool_call_portion = current_text.split(
223 + self.tool_call_start_token)[-1]
224 + text_portion = None
225 +
226 + # case -- the current tool call is being closed.
227 + elif (cur_tool_start_count == cur_tool_end_count
228 + and cur_tool_end_count >= prev_tool_end_count):
229 + if self.prev_tool_call_arr is None or len(
230 + self.prev_tool_call_arr) == 0:
231 + logger.debug(
232 + "attempting to close tool call, but no tool call")
233 + return None
234 + diff = self.prev_tool_call_arr[self.current_tool_id].get(
235 + "arguments")
236 + if diff:
237 + diff = (diff.encode("utf-8").decode("unicode_escape")
238 + if diff is str else diff)
239 + if '"}' not in delta_text:
240 + return None
241 + end_loc = delta_text.rindex('"}')
242 + diff = delta_text[:end_loc] + '"}'
243 + logger.debug(
244 + "Finishing tool and found diff that had not "
245 + "been streamed yet: %s",
246 + diff,
247 + )
248 + self.streamed_args_for_tool[self.current_tool_id] += diff
249 + return DeltaMessage(tool_calls=[
250 + DeltaToolCall(
251 + index=self.current_tool_id,
252 + function=DeltaFunctionCall(
253 + arguments=diff).model_dump(exclude_none=True),
254 + )
255 + ])
256 +
257 + # case -- otherwise we're just generating text
258 + else:
259 + text = delta_text.replace(self.tool_call_start_token, "")
260 + text = text.replace(self.tool_call_end_token, "")
261 + delta = DeltaMessage(tool_calls=[], content=text)
262 + return delta
263 +
264 + current_tool_call = dict()
265 + if tool_call_portion:
266 + current_tool_call_matches = (
267 + self.stream_tool_call_portion_regex.match(
268 + tool_call_portion))
269 + if current_tool_call_matches:
270 + tool_id, tool_args = (current_tool_call_matches.groups())
271 + tool_name = tool_id.split('.')[1].split(':')[0]
272 + current_tool_call['id'] = tool_id
273 + current_tool_call["name"] = tool_name
274 + current_tool_call["arguments"] = tool_args
275 + else:
276 + current_tool_call_name_matches = (
277 + self.stream_tool_call_name_regex.match(
278 + tool_call_portion))
279 + if current_tool_call_name_matches:
280 + tool_id_str, = current_tool_call_name_matches.groups()
281 + tool_name = tool_id_str.split('.')[1].split(':')[0]
282 + current_tool_call['id'] = tool_id_str
283 + current_tool_call["name"] = tool_name
284 + current_tool_call["arguments"] = ""
285 + else:
286 + logger.debug("Not enough token")
287 + return None
288 +
289 + # case - we haven't sent the tool name yet. If it's available, send
290 + # it. otherwise, wait until it's available.
291 + if not self.current_tool_name_sent:
292 + if current_tool_call is None:
293 + return None
294 + function_name: Union[str, None] = current_tool_call.get("name")
295 + tool_id = current_tool_call.get("id")
296 + if function_name:
297 + self.current_tool_name_sent = True
298 + return DeltaMessage(tool_calls=[
299 + DeltaToolCall(
300 + index=self.current_tool_id,
301 + type="function",
302 + id=tool_id,
303 + function=DeltaFunctionCall(
304 + name=function_name).model_dump(
305 + exclude_none=True),
306 + )
307 + ])
308 + else:
309 + return None
310 +
311 + # case -- otherwise, send the tool call delta
312 +
313 + # if the tool call portion is None, send the delta as text
314 + if tool_call_portion is None:
315 + # if there's text but not tool calls, send that -
316 + # otherwise None to skip chunk
317 + delta = (DeltaMessage(
318 + content=delta_text) if text_portion is not None else None)
319 + return delta
320 +
321 + # now, the nitty-gritty of tool calls
322 + # now we have the portion to parse as tool call.
323 +
324 + logger.debug("Trying to parse current tool call with ID %s",
325 + self.current_tool_id)
326 +
327 + # if we're starting a new tool call, push an empty object in as
328 + # a placeholder for the arguments
329 + if len(self.prev_tool_call_arr) <= self.current_tool_id:
330 + self.prev_tool_call_arr.append({})
331 +
332 + # main logic for tool parsing here - compare prev. partially-parsed
333 + # JSON to the current partially-parsed JSON
334 + prev_arguments = self.prev_tool_call_arr[self.current_tool_id].get(
335 + "arguments")
336 + cur_arguments = current_tool_call.get("arguments")
337 +
338 + logger.debug("diffing old arguments: %s", prev_arguments)
339 + logger.debug("against new ones: %s", cur_arguments)
340 +
341 + # case -- no arguments have been created yet. skip sending a delta.
342 + if not cur_arguments and not prev_arguments:
343 + logger.debug("Skipping text %s - no arguments", delta_text)
344 + delta = None
345 +
346 + # case -- prev arguments are defined, but non are now.
347 + # probably impossible, but not a fatal error - just keep going
348 + elif not cur_arguments and prev_arguments:
349 + logger.error("should be impossible to have arguments reset "
350 + "mid-call. skipping streaming anything.")
351 + delta = None
352 +
353 + # case -- we now have the first info about arguments available from
354 + # autocompleting the JSON
355 + elif cur_arguments and not prev_arguments:
356 +
357 + delta = DeltaMessage(tool_calls=[
358 + DeltaToolCall(
359 + index=self.current_tool_id,
360 + function=DeltaFunctionCall(
361 + arguments=cur_arguments).model_dump(
362 + exclude_none=True),
363 + )
364 + ])
365 + self.streamed_args_for_tool[
366 + self.current_tool_id] = cur_arguments
367 +
368 + # last case -- we have an update to existing arguments.
369 + elif cur_arguments and prev_arguments:
370 + if (isinstance(delta_text, str)
371 + and cur_arguments != prev_arguments
372 + and len(cur_arguments) > len(prev_arguments)
373 + and cur_arguments.startswith(prev_arguments)):
374 + delta_arguments = cur_arguments[len(prev_arguments):]
375 + logger.debug("got diff %s", delta_text)
376 +
377 + delta = DeltaMessage(tool_calls=[
378 + DeltaToolCall(
379 + index=self.current_tool_id,
380 + function=DeltaFunctionCall(
381 + arguments=delta_arguments).model_dump(
382 + exclude_none=True),
383 + )
384 + ])
385 + self.streamed_args_for_tool[
386 + self.current_tool_id] = cur_arguments
387 + else:
388 + delta = None
389 +
390 + # handle saving the state for the current tool into
391 + # the "prev" list for use in diffing for the next iteration
392 + if self.current_tool_id == len(self.prev_tool_call_arr) - 1:
393 + self.prev_tool_call_arr[
394 + self.current_tool_id] = current_tool_call
395 + else:
396 + self.prev_tool_call_arr.append(current_tool_call)
397 +
398 + return delta
399 +
400 + except Exception:
401 + logger.exception("Error trying to handle streaming tool call.")
402 + return None # do not stream a delta. skip this token ID.
vllm/model_executor/models/glm4_moe.py new
+685
@@ -0,0 +1,685 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +# Copyright 2025 The ZhipuAI Team.
5 +# Copyright 2023 The vLLM team.
6 +# Copyright 2022 EleutherAI and the HuggingFace Inc. team. All rights reserved.
7 +#
8 +# This code is based on EleutherAI's GPT-NeoX library and the GPT-NeoX
9 +# and OPT implementations in this library. It has been modified from its
10 +# original forms to accommodate minor architectural differences compared
11 +# to GPT-NeoX and OPT used by the Meta AI team that trained the model.
12 +#
13 +# Licensed under the Apache License, Version 2.0 (the "License");
14 +# you may not use this file except in compliance with the License.
15 +# You may obtain a copy of the License at
16 +#
17 +# http://www.apache.org/licenses/LICENSE-2.0
18 +#
19 +# Unless required by applicable law or agreed to in writing, software
20 +# distributed under the License is distributed on an "AS IS" BASIS,
21 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
22 +# See the License for the specific language governing permissions and
23 +# limitations under the License.
24 +"""Inference-only GLM-4.5 model compatible with HuggingFace weights."""
25 +import typing
26 +from collections.abc import Callable, Iterable
27 +from typing import Any, Optional, Union
28 +
29 +import torch
30 +from torch import nn
31 +from transformers import PretrainedConfig
32 +
33 +from vllm.attention import Attention
34 +from vllm.compilation.decorators import support_torch_compile
35 +from vllm.config import CacheConfig, VllmConfig, get_current_vllm_config
36 +from vllm.distributed import (get_ep_group, get_pp_group,
37 + get_tensor_model_parallel_world_size)
38 +from vllm.logger import init_logger
39 +from vllm.model_executor.layers.activation import SiluAndMul
40 +from vllm.model_executor.layers.fused_moe import FusedMoE
41 +from vllm.model_executor.layers.layernorm import RMSNorm
42 +from vllm.model_executor.layers.linear import (MergedColumnParallelLinear,
43 + QKVParallelLinear,
44 + ReplicatedLinear,
45 + RowParallelLinear)
46 +from vllm.model_executor.layers.logits_processor import LogitsProcessor
47 +from vllm.model_executor.layers.quantization import QuantizationConfig
48 +from vllm.model_executor.layers.rotary_embedding import get_rope
49 +from vllm.model_executor.layers.vocab_parallel_embedding import (
50 + ParallelLMHead, VocabParallelEmbedding)
51 +from vllm.model_executor.model_loader.weight_utils import (
52 + default_weight_loader, maybe_remap_kv_scale_name)
53 +from vllm.model_executor.sampling_metadata import SamplingMetadata
54 +from vllm.sequence import IntermediateTensors
55 +
56 +from .interfaces import SupportsPP
57 +from .utils import (AutoWeightsLoader, PPMissingLayer, is_pp_missing_parameter,
58 + make_empty_intermediate_tensors_factory, make_layers,
59 + maybe_prefix)
60 +
61 +logger = init_logger(__name__)
62 +
63 +
64 +class Glm4MoeMLP(nn.Module):
65 +
66 + def __init__(
67 + self,
68 + hidden_size: int,
69 + intermediate_size: int,
70 + hidden_act: str,
71 + quant_config: Optional[QuantizationConfig] = None,
72 + reduce_results: bool = True,
73 + prefix: str = "",
74 + ) -> None:
75 + super().__init__()
76 + self.gate_up_proj = MergedColumnParallelLinear(
77 + hidden_size, [intermediate_size] * 2,
78 + bias=False,
79 + quant_config=quant_config,
80 + prefix=f"{prefix}.gate_up_proj")
81 + self.down_proj = RowParallelLinear(intermediate_size,
82 + hidden_size,
83 + bias=False,
84 + quant_config=quant_config,
85 + reduce_results=reduce_results,
86 + prefix=f"{prefix}.down_proj")
87 + if hidden_act != "silu":
88 + raise ValueError(f"Unsupported activation: {hidden_act}. "
89 + "Only silu is supported for now.")
90 + self.act_fn = SiluAndMul()
91 +
92 + def forward(self, x):
93 + gate_up, _ = self.gate_up_proj(x)
94 + x = self.act_fn(gate_up)
95 + x, _ = self.down_proj(x)
96 + return x
97 +
98 +
99 +class Glm4MoE(nn.Module):
100 +
101 + def __init__(
102 + self,
103 + config: PretrainedConfig,
104 + quant_config: Optional[QuantizationConfig] = None,
105 + prefix: str = "",
106 + enable_eplb: bool = False,
107 + ):
108 + super().__init__()
109 + self.tp_size = get_tensor_model_parallel_world_size()
110 + self.routed_scaling_factor = config.routed_scaling_factor
111 +
112 + self.ep_group = get_ep_group().device_group
113 + self.ep_rank = self.ep_group.rank()
114 + self.ep_size = self.ep_group.size()
115 + self.n_routed_experts: int = config.n_routed_experts
116 + self.n_shared_experts: int = config.n_shared_experts
117 +
118 + if config.hidden_act != "silu":
119 + raise ValueError(f"Unsupported activation: {config.hidden_act}. "
120 + "Only silu is supported for now.")
121 +
122 + self.gate = ReplicatedLinear(config.hidden_size,
123 + config.n_routed_experts,
124 + bias=False,
125 + quant_config=None,
126 + prefix=f"{prefix}.gate")
127 +
128 + # noaux_tc is not set in transformers new config now
129 + self.gate.e_score_correction_bias = (nn.Parameter(
130 + torch.empty(config.n_routed_experts)))
131 +
132 + # Load balancing settings.
133 + vllm_config = get_current_vllm_config()
134 + parallel_config = vllm_config.parallel_config
135 + self.enable_eplb = enable_eplb
136 +
137 + self.n_redundant_experts = parallel_config.num_redundant_experts
138 + self.n_logical_experts = self.n_routed_experts
139 + self.n_physical_experts = (self.n_logical_experts +
140 + self.n_redundant_experts)
141 + self.n_local_physical_experts = self.n_physical_experts // self.ep_size
142 +
143 + self.physical_expert_start = (self.ep_rank *
144 + self.n_local_physical_experts)
145 + self.physical_expert_end = (self.physical_expert_start +
146 + self.n_local_physical_experts)
147 +
148 + self.experts = FusedMoE(
149 + num_experts=config.n_routed_experts,
150 + top_k=config.num_experts_per_tok,
151 + hidden_size=config.hidden_size,
152 + intermediate_size=config.moe_intermediate_size,
153 + reduce_results=False,
154 + renormalize=config.norm_topk_prob,
155 + quant_config=quant_config,
156 + use_grouped_topk=True,
157 + num_expert_group=config.n_group,
158 + topk_group=config.topk_group,
159 + prefix=f"{prefix}.experts",
160 + scoring_func="sigmoid",
161 + e_score_correction_bias=self.gate.e_score_correction_bias,
162 + enable_eplb=self.enable_eplb,
163 + num_redundant_experts=self.n_redundant_experts)
164 +
165 + if config.n_shared_experts is not None:
166 + intermediate_size = (config.moe_intermediate_size *
167 + config.n_shared_experts)
168 + self.shared_experts = Glm4MoeMLP(
169 + hidden_size=config.hidden_size,
170 + intermediate_size=intermediate_size,
171 + hidden_act=config.hidden_act,
172 + quant_config=quant_config,
173 + reduce_results=self.experts.must_reduce_shared_expert_outputs(
174 + ),
175 + prefix=f"{prefix}.shared_experts",
176 + )
177 +
178 + def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
179 + num_tokens, hidden_dim = hidden_states.shape
180 + hidden_states = hidden_states.view(-1, hidden_dim)
181 +
182 + if self.n_shared_experts is not None:
183 + shared_output = self.shared_experts(hidden_states)
184 + router_logits, _ = self.gate(hidden_states)
185 + final_hidden_states = self.experts(
186 + hidden_states=hidden_states,
187 + router_logits=router_logits) * self.routed_scaling_factor
188 + if shared_output is not None:
189 + final_hidden_states = final_hidden_states + shared_output
190 + if self.tp_size > 1:
191 + final_hidden_states = (
192 + self.experts.maybe_all_reduce_tensor_model_parallel(
193 + final_hidden_states))
194 + return final_hidden_states.view(num_tokens, hidden_dim)
195 +
196 +
197 +class Glm4MoeAttention(nn.Module):
198 +
199 + def __init__(
200 + self,
201 + config: PretrainedConfig,
202 + hidden_size: int,
203 + num_heads: int,
204 + num_kv_heads: int,
205 + rope_theta: float = 10000,
206 + rope_scaling: Optional[dict[str, Any]] = None,
207 + max_position_embeddings: int = 131072,
208 + head_dim: Optional[int] = None,
209 + rms_norm_eps: float = 1e-05,
210 + qkv_bias: bool = False,
211 + use_qk_norm: bool = False,
212 + cache_config: Optional[CacheConfig] = None,
213 + quant_config: Optional[QuantizationConfig] = None,
214 + prefix: str = "",
215 + ) -> None:
216 + super().__init__()
217 + self.hidden_size = hidden_size
218 + tp_size = get_tensor_model_parallel_world_size()
219 + self.total_num_heads = num_heads
220 + assert self.total_num_heads % tp_size == 0
221 + self.num_heads = self.total_num_heads // tp_size
222 + self.total_num_kv_heads = num_kv_heads
223 + if self.total_num_kv_heads >= tp_size:
224 + # Number of KV heads is greater than TP size, so we partition
225 + # the KV heads across multiple tensor parallel GPUs.
226 + assert self.total_num_kv_heads % tp_size == 0
227 + else:
228 + # Number of KV heads is less than TP size, so we replicate
229 + # the KV heads across multiple tensor parallel GPUs.
230 + assert tp_size % self.total_num_kv_heads == 0
231 + self.num_kv_heads = max(1, self.total_num_kv_heads // tp_size)
232 + self.head_dim = head_dim or (hidden_size // self.total_num_heads)
233 + self.q_size = self.num_heads * self.head_dim
234 + self.kv_size = self.num_kv_heads * self.head_dim
235 + self.scaling = self.head_dim**-0.5
236 + self.rope_theta = rope_theta
237 + self.max_position_embeddings = max_position_embeddings
238 + self.use_qk_norm = use_qk_norm
239 +
240 + self.qkv_proj = QKVParallelLinear(hidden_size,
241 + self.head_dim,
242 + self.total_num_heads,
243 + self.total_num_kv_heads,
244 + bias=qkv_bias,
245 + quant_config=quant_config,
246 + prefix=f"{prefix}.qkv_proj")
247 +
248 + self.o_proj = RowParallelLinear(self.total_num_heads * self.head_dim,
249 + hidden_size,
250 + bias=False,
251 + quant_config=quant_config,
252 + prefix=f"{prefix}.o_proj")
253 +
254 + partial_rotary_factor = getattr(config, "partial_rotary_factor", 0.5)
255 + self.rotary_emb = get_rope(
256 + self.head_dim,
257 + rotary_dim=self.head_dim,
258 + max_position=max_position_embeddings,
259 + base=rope_theta,
260 + rope_scaling=rope_scaling,
261 + partial_rotary_factor=partial_rotary_factor,
262 + )
263 + self.attn = Attention(
264 + self.num_heads,
265 + self.head_dim,
266 + self.scaling,
267 + num_kv_heads=self.num_kv_heads,
268 + cache_config=cache_config,
269 + quant_config=quant_config,
270 + prefix=f"{prefix}.attn",
271 + )
272 +
273 + if self.use_qk_norm:
274 + self.q_norm = RMSNorm(self.head_dim, eps=rms_norm_eps)
275 + self.k_norm = RMSNorm(self.head_dim, eps=rms_norm_eps)
276 +
277 + def forward(
278 + self,
279 + positions: torch.Tensor,
280 + hidden_states: torch.Tensor,
281 + ) -> torch.Tensor:
282 + qkv, _ = self.qkv_proj(hidden_states)
283 + q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1)
284 + if self.use_qk_norm:
285 + q = self.q_norm(q.reshape(-1, self.num_heads,
286 + self.head_dim)).reshape(q.shape)
287 + k = self.k_norm(k.reshape(-1, self.num_kv_heads,
288 + self.head_dim)).reshape(k.shape)
289 +
290 + q, k = self.rotary_emb(positions, q, k)
291 + attn_output = self.attn(q, k, v)
292 + output, _ = self.o_proj(attn_output)
293 + return output
294 +
295 +
296 +class Glm4MoeDecoderLayer(nn.Module):
297 +
298 + def __init__(
299 + self,
300 + config: PretrainedConfig,
301 + cache_config: Optional[CacheConfig] = None,
302 + quant_config: Optional[QuantizationConfig] = None,
303 + prefix: str = "",
304 + enable_eplb: bool = False,
305 + ) -> None:
306 + super().__init__()
307 + self.hidden_size = config.hidden_size
308 + rope_theta = getattr(config, "rope_theta", 10000)
309 + rope_scaling = getattr(config, "rope_scaling", None)
310 + max_position_embeddings = getattr(config, "max_position_embeddings",
311 + 131072)
312 + # DecoderLayers are created with `make_layers` which passes the prefix
313 + # with the layer's index.
314 + layer_idx = int(prefix.split(sep='.')[-1])
315 + self.layer_idx = layer_idx
316 +
317 + self.self_attn = Glm4MoeAttention(
318 + config=config,
319 + hidden_size=self.hidden_size,
320 + num_heads=config.num_attention_heads,
321 + num_kv_heads=config.num_key_value_heads,
322 + rope_theta=rope_theta,
323 + rope_scaling=rope_scaling,
324 + max_position_embeddings=max_position_embeddings,
325 + head_dim=config.head_dim,
326 + rms_norm_eps=config.rms_norm_eps,
327 + qkv_bias=config.attention_bias,
328 + cache_config=cache_config,
329 + quant_config=quant_config,
330 + prefix=f"{prefix}.self_attn",
331 + use_qk_norm=config.use_qk_norm,
332 + )
333 +
334 + if (config.n_routed_experts is not None
335 + and layer_idx >= config.first_k_dense_replace):
336 + self.mlp = Glm4MoE(
337 + config=config,
338 + quant_config=quant_config,
339 + prefix=f"{prefix}.mlp",
340 + enable_eplb=enable_eplb,
341 + )
342 + else:
343 + self.mlp = Glm4MoeMLP(hidden_size=config.hidden_size,
344 + intermediate_size=config.intermediate_size,
345 + hidden_act=config.hidden_act,
346 + quant_config=quant_config,
347 + prefix=f"{prefix}.mlp")
348 +
349 + self.input_layernorm = RMSNorm(config.hidden_size,
350 + eps=config.rms_norm_eps)
351 + self.post_attention_layernorm = RMSNorm(config.hidden_size,
352 + eps=config.rms_norm_eps)
353 + self.routed_scaling_factor = config.routed_scaling_factor
354 +
355 + def forward(
356 + self,
357 + positions: torch.Tensor,
358 + hidden_states: torch.Tensor,
359 + residual: Optional[torch.Tensor],
360 + ) -> tuple[torch.Tensor, torch.Tensor]:
361 + if residual is None:
362 + residual = hidden_states
363 + hidden_states = self.input_layernorm(hidden_states)
364 + else:
365 + hidden_states, residual = self.input_layernorm(
366 + hidden_states, residual)
367 + hidden_states = self.self_attn(positions=positions,
368 + hidden_states=hidden_states)
369 + hidden_states, residual = self.post_attention_layernorm(
370 + hidden_states, residual)
371 + hidden_states = self.mlp(hidden_states)
372 + return hidden_states, residual
373 +
374 +
375 +@support_torch_compile
376 +class Glm4MoeModel(nn.Module):
377 +
378 + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
379 + super().__init__()
380 +
381 + config = vllm_config.model_config.hf_config
382 + cache_config = vllm_config.cache_config
383 + quant_config = vllm_config.quant_config
384 + enable_eplb = vllm_config.parallel_config.enable_eplb
385 + self.config = config
386 +
387 + self.vocab_size = config.vocab_size
388 +
389 + if get_pp_group().is_first_rank:
390 + self.embed_tokens = VocabParallelEmbedding(
391 + config.vocab_size,
392 + config.hidden_size,
393 + quant_config=quant_config,
394 + prefix=f"{prefix}.embed_tokens")
395 + else:
396 + self.embed_tokens = PPMissingLayer()
397 +
398 + self.start_layer, self.end_layer, self.layers = make_layers(
399 + config.num_hidden_layers,
400 + lambda prefix: Glm4MoeDecoderLayer(
401 + config=config,
402 + cache_config=cache_config,
403 + quant_config=quant_config,
404 + prefix=prefix,
405 + enable_eplb=enable_eplb,
406 + ),
407 + prefix=f"{prefix}.layers")
408 +
409 + if get_pp_group().is_last_rank:
410 + self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
411 + else:
412 + self.norm = PPMissingLayer()
413 + self.make_empty_intermediate_tensors = (
414 + make_empty_intermediate_tensors_factory(
415 + ["hidden_states", "residual"], config.hidden_size))
416 +
417 + def get_input_embeddings(self, input_ids: torch.Tensor) -> torch.Tensor:
418 + return self.embed_tokens(input_ids)
419 +
420 + def forward(
421 + self,
422 + input_ids: torch.Tensor,
423 + positions: torch.Tensor,
424 + intermediate_tensors: Optional[IntermediateTensors] = None,
425 + inputs_embeds: Optional[torch.Tensor] = None,
426 + ) -> Union[torch.Tensor, IntermediateTensors]:
427 + if get_pp_group().is_first_rank:
428 + if inputs_embeds is not None:
429 + hidden_states = inputs_embeds
430 + else:
431 + hidden_states = self.get_input_embeddings(input_ids)
432 + residual = None
433 + else:
434 + assert intermediate_tensors is not None
435 + hidden_states = intermediate_tensors["hidden_states"]
436 + residual = intermediate_tensors["residual"]
437 +
438 + for i in range(self.start_layer, self.end_layer):
439 + layer = self.layers[i]
440 + hidden_states, residual = layer(positions, hidden_states, residual)
441 +
442 + if not get_pp_group().is_last_rank:
443 + return IntermediateTensors({
444 + "hidden_states": hidden_states,
445 + "residual": residual
446 + })
447 +
448 + hidden_states, _ = self.norm(hidden_states, residual)
449 + return hidden_states
450 +
451 + def make_empty_intermediate_tensors(
452 + self, batch_size: int, dtype: torch.dtype,
453 + device: torch.device) -> IntermediateTensors:
454 + return IntermediateTensors({
455 + "hidden_states":
456 + torch.zeros((batch_size, self.config.hidden_size),
457 + dtype=dtype,
458 + device=device),
459 + "residual":
460 + torch.zeros((batch_size, self.config.hidden_size),
461 + dtype=dtype,
462 + device=device),
463 + })
464 +
465 + def load_weights(self, weights: Iterable[tuple[str,
466 + torch.Tensor]]) -> set[str]:
467 + stacked_params_mapping = [
468 + # (param_name, shard_name, shard_id)
469 + ("qkv_proj", "q_proj", "q"),
470 + ("qkv_proj", "k_proj", "k"),
471 + ("qkv_proj", "v_proj", "v"),
472 + ("gate_up_proj", "gate_proj", 0),
473 + ("gate_up_proj", "up_proj", 1),
474 + ]
475 +
476 + # Params for weights, fp8 weight scales, fp8 activation scales
477 + # (param_name, weight_name, expert_id, shard_id)
478 + expert_params_mapping = FusedMoE.make_expert_params_mapping(
479 + ckpt_gate_proj_name="gate_proj",
480 + ckpt_down_proj_name="down_proj",
481 + ckpt_up_proj_name="up_proj",
482 + num_experts=self.config.n_routed_experts)
483 +
484 + params_dict = dict(self.named_parameters())
485 + loaded_params: set[str] = set()
486 + for name, loaded_weight in weights:
487 + spec_layer = get_spec_layer_idx_from_weight_name(self.config, name)
488 + if spec_layer is not None:
489 + continue
490 + for (param_name, weight_name, shard_id) in stacked_params_mapping:
491 + # Skip non-stacked layers and experts (experts handled below).
492 + if weight_name not in name:
493 + continue
494 + # We have mlp.experts[0].gate_proj in the checkpoint.
495 + # Since we handle the experts below in expert_params_mapping,
496 + # we need to skip here BEFORE we update the name, otherwise
497 + # name will be updated to mlp.experts[0].gate_up_proj, which
498 + # will then be updated below in expert_params_mapping
499 + # for mlp.experts[0].gate_gate_up_proj, which breaks load.
500 + if (("mlp.experts." in name) and name not in params_dict):
501 + continue
502 + name = name.replace(weight_name, param_name)
503 + # Skip loading extra bias for GPTQ models.
504 + if name.endswith(".bias") and name not in params_dict:
505 + continue
506 + if is_pp_missing_parameter(name, self):
507 + continue
508 +
509 + param = params_dict[name]
510 + weight_loader = param.weight_loader
511 + weight_loader(param, loaded_weight, shard_id)
512 + break
513 + else:
514 + is_expert_weight = False
515 + for mapping in expert_params_mapping:
516 + param_name, weight_name, expert_id, shard_id = mapping
517 + if weight_name not in name:
518 + continue
519 +
520 + # Anyway, this is an expert weight and should not be
521 + # attempted to load as other weights later
522 + is_expert_weight = True
523 +
524 + # Do not modify `name` since the loop may continue here
525 + # Instead, create a new variable
526 + name_mapped = name.replace(weight_name, param_name)
527 +
528 + if is_pp_missing_parameter(name_mapped, self):
529 + continue
530 +
531 + param = params_dict[name_mapped]
532 + # We should ask the weight loader to return success or not
533 + # here since otherwise we may skip experts with other
534 + # available replicas.
535 + weight_loader = typing.cast(Callable[..., bool],
536 + param.weight_loader)
537 + success = weight_loader(param,
538 + loaded_weight,
539 + name_mapped,
540 + shard_id=shard_id,
541 + expert_id=expert_id,
542 + return_success=True)
543 + if success:
544 + name = name_mapped
545 + break
546 + else:
547 + if is_expert_weight:
548 + # We've checked that this is an expert weight
549 + # However it's not mapped locally to this rank
550 + # So we simply skip it
551 + continue
552 +
553 + # Skip loading extra bias for GPTQ models.
554 + if name.endswith(".bias") and name not in params_dict:
555 + continue
556 +
557 + # Remapping the name of FP8 kv-scale.
558 + name = maybe_remap_kv_scale_name(name, params_dict)
559 + if name is None:
560 + continue
561 +
562 + if is_pp_missing_parameter(name, self):
563 + continue
564 +
565 + param = params_dict[name]
566 + weight_loader = getattr(param, "weight_loader",
567 + default_weight_loader)
568 + weight_loader(param, loaded_weight)
569 + loaded_params.add(name)
570 +
571 + return loaded_params
572 +
573 +
574 +class Glm4MoeForCausalLM(nn.Module, SupportsPP):
575 + packed_modules_mapping = {
576 + "qkv_proj": [
577 + "q_proj",
578 + "k_proj",
579 + "v_proj",
580 + ],
581 + "gate_up_proj": [
582 + "gate_proj",
583 + "up_proj",
584 + ],
585 + }
586 +
587 + fall_back_to_pt_during_load = False
588 +
589 + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
590 + super().__init__()
591 + config = vllm_config.model_config.hf_config
592 + quant_config = vllm_config.quant_config
593 + self.config = config
594 + self.quant_config = quant_config
595 + self.model = Glm4MoeModel(vllm_config=vllm_config,
596 + prefix=maybe_prefix(prefix, "model"))
597 + if get_pp_group().is_last_rank:
598 + self.lm_head = ParallelLMHead(config.vocab_size,
599 + config.hidden_size,
600 + quant_config=quant_config)
601 + else:
602 + self.lm_head = PPMissingLayer()
603 + if self.config.tie_word_embeddings:
604 + self.lm_head.weight = self.model.embed_tokens.weight
605 + self.logits_processor = LogitsProcessor(config.vocab_size)
606 + self.make_empty_intermediate_tensors = (
607 + self.model.make_empty_intermediate_tensors)
608 + self.expert_weights = []
609 +
610 + # Set MoE hyperparameters
611 + self.num_moe_layers = (config.num_hidden_layers -
612 + config.first_k_dense_replace)
613 + self.num_expert_groups = config.n_group
614 +
615 + self.moe_layers: list[FusedMoE] = []
616 + for layer in self.model.layers:
617 + assert isinstance(layer, Glm4MoeDecoderLayer)
618 + if isinstance(layer.mlp, Glm4MoE):
619 + self.moe_layers.append(layer.mlp.experts)
620 +
621 + # Pick last one layer since the first ones may be dense layers.
622 + example_moe = typing.cast(
623 + Glm4MoE, self.model.layers[config.num_hidden_layers - 1].mlp)
624 + self.num_logical_experts = example_moe.n_logical_experts
625 + self.num_physical_experts = example_moe.n_physical_experts
626 + self.num_local_physical_experts = example_moe.n_local_physical_experts
627 + self.num_routed_experts = example_moe.n_routed_experts
628 + self.num_shared_experts = example_moe.n_shared_experts
629 + self.num_redundant_experts = example_moe.n_redundant_experts
630 +
631 + def set_eplb_state(
632 + self,
633 + expert_load_view: torch.Tensor,
634 + logical_to_physical_map: torch.Tensor,
635 + logical_replica_count: torch.Tensor,
636 + ) -> None:
637 + for layer_idx, layer in enumerate(self.moe_layers):
638 + # Register the expert weights.
639 + self.expert_weights.append(layer.get_expert_weights())
640 + layer.set_eplb_state(
641 + moe_layer_idx=layer_idx,
642 + expert_load_view=expert_load_view,
643 + logical_to_physical_map=logical_to_physical_map,
644 + logical_replica_count=logical_replica_count,
645 + )
646 +
647 + def get_input_embeddings(self, input_ids: torch.Tensor) -> torch.Tensor:
648 + return self.model.get_input_embeddings(input_ids)
649 +
650 + def forward(
651 + self,
652 + input_ids: torch.Tensor,
653 + positions: torch.Tensor,
654 + intermediate_tensors: Optional[IntermediateTensors] = None,
655 + inputs_embeds: Optional[torch.Tensor] = None,
656 + ) -> Union[torch.Tensor, IntermediateTensors]:
657 + hidden_states = self.model(input_ids, positions, intermediate_tensors,
658 + inputs_embeds)
659 + return hidden_states
660 +
661 + def compute_logits(
662 + self,
663 + hidden_states: torch.Tensor,
664 + sampling_metadata: SamplingMetadata,
665 + ) -> Optional[torch.Tensor]:
666 + logits = self.logits_processor(self.lm_head, hidden_states,
667 + sampling_metadata)
668 + return logits
669 +
670 + def load_weights(self, weights: Iterable[tuple[str,
671 + torch.Tensor]]) -> set[str]:
672 + loader = AutoWeightsLoader(self)
673 + return loader.load_weights(weights)
674 +
675 +
676 +def get_spec_layer_idx_from_weight_name(config: PretrainedConfig,
677 + weight_name: str) -> Optional[int]:
678 + if hasattr(config,
679 + "num_nextn_predict_layers") and (config.num_nextn_predict_layers
680 + > 0):
681 + layer_idx = config.num_hidden_layers
682 + for i in range(config.num_nextn_predict_layers):
683 + if f"layers.{layer_idx+i}." in weight_name:
684 + return layer_idx + i
685 + return None
vllm/model_executor/models/glm4_moe_mtp.py new
+307
@@ -0,0 +1,307 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +# Copyright 2025 The ZhipuAI Team.
5 +# Copyright 2023 The vLLM team.
6 +# Copyright 2022 EleutherAI and the HuggingFace Inc. team. All rights reserved.
7 +#
8 +# This code is based on EleutherAI's GPT-NeoX library and the GPT-NeoX
9 +# and OPT implementations in this library. It has been modified from its
10 +# original forms to accommodate minor architectural differences compared
11 +# to GPT-NeoX and OPT used by the Meta AI team that trained the model.
12 +#
13 +# Licensed under the Apache License, Version 2.0 (the "License");
14 +# you may not use this file except in compliance with the License.
15 +# You may obtain a copy of the License at
16 +#
17 +# http://www.apache.org/licenses/LICENSE-2.0
18 +#
19 +# Unless required by applicable law or agreed to in writing, software
20 +# distributed under the License is distributed on an "AS IS" BASIS,
21 +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
22 +# See the License for the specific language governing permissions and
23 +# limitations under the License.
24 +"""Inference-only GLM-4.5 MTP model compatible with HuggingFace weights."""
25 +
26 +from collections.abc import Iterable
27 +from typing import Optional
28 +
29 +import torch
30 +import torch.nn as nn
31 +from transformers import PretrainedConfig
32 +
33 +from vllm.config import CacheConfig, VllmConfig
34 +from vllm.model_executor.layers.fused_moe import FusedMoE
35 +from vllm.model_executor.layers.layernorm import RMSNorm
36 +from vllm.model_executor.layers.logits_processor import LogitsProcessor
37 +from vllm.model_executor.layers.quantization import QuantizationConfig
38 +from vllm.model_executor.layers.vocab_parallel_embedding import (
39 + ParallelLMHead, VocabParallelEmbedding)
40 +from vllm.model_executor.model_loader.weight_utils import default_weight_loader
41 +from vllm.model_executor.sampling_metadata import SamplingMetadata
42 +from vllm.sequence import IntermediateTensors
43 +
44 +from .glm4_moe import Glm4MoeDecoderLayer, get_spec_layer_idx_from_weight_name
45 +from .interfaces import SupportsPP
46 +from .utils import maybe_prefix
47 +
48 +
49 +class SharedHead(nn.Module):
50 +
51 + def __init__(
52 + self,
53 + config: PretrainedConfig,
54 + quant_config: Optional[QuantizationConfig] = None,
55 + ) -> None:
56 + super().__init__()
57 + self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
58 + self.head = ParallelLMHead(config.vocab_size,
59 + config.hidden_size,
60 + quant_config=quant_config)
61 +
62 + def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
63 + return self.norm(hidden_states)
64 +
65 +
66 +class Glm4MoeMultiTokenPredictorLayer(nn.Module):
67 +
68 + def __init__(
69 + self,
70 + config: PretrainedConfig,
71 + prefix: str,
72 + cache_config: Optional[CacheConfig] = None,
73 + quant_config: Optional[QuantizationConfig] = None,
74 + ) -> None:
75 + super().__init__()
76 + self.enorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
77 + self.hnorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
78 + self.eh_proj = nn.Linear(config.hidden_size * 2,
79 + config.hidden_size,
80 + bias=False)
81 + self.shared_head = SharedHead(config=config, quant_config=quant_config)
82 + self.mtp_block = Glm4MoeDecoderLayer(config=config,
83 + cache_config=cache_config,
84 + quant_config=quant_config,
85 + prefix=prefix)
86 +
87 + def forward(
88 + self,
89 + input_ids: torch.Tensor,
90 + positions: torch.Tensor,
91 + previous_hidden_states: torch.Tensor,
92 + inputs_embeds: Optional[torch.Tensor] = None,
93 + spec_step_index: int = 0,
94 + ) -> torch.Tensor:
95 + assert inputs_embeds is not None
96 + # masking inputs at position 0, as not needed by MTP
97 + inputs_embeds[positions == 0] = 0
98 + inputs_embeds = self.enorm(inputs_embeds)
99 + previous_hidden_states = self.hnorm(previous_hidden_states)
100 +
101 + hidden_states = self.eh_proj(
102 + torch.cat([inputs_embeds, previous_hidden_states], dim=-1))
103 +
104 + hidden_states, residual = self.mtp_block(positions=positions,
105 + hidden_states=hidden_states,
106 + residual=None)
107 + hidden_states = residual + hidden_states
108 + return hidden_states
109 +
110 +
111 +class Glm4MoeMultiTokenPredictor(nn.Module):
112 +
113 + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
114 + super().__init__()
115 + config = vllm_config.model_config.hf_config
116 + self.mtp_start_layer_idx = config.num_hidden_layers
117 + self.num_mtp_layers = config.num_nextn_predict_layers
118 + # to map the exact layer index from weights
119 + self.layers = torch.nn.ModuleDict({
120 + str(idx):
121 + Glm4MoeMultiTokenPredictorLayer(
122 + config,
123 + f"{prefix}.layers.{idx}",
124 + cache_config=vllm_config.cache_config,
125 + quant_config=vllm_config.quant_config,
126 + )
127 + for idx in range(self.mtp_start_layer_idx,
128 + self.mtp_start_layer_idx + self.num_mtp_layers)
129 + })
130 + self.embed_tokens = VocabParallelEmbedding(
131 + config.vocab_size,
132 + config.hidden_size,
133 + )
134 + self.logits_processor = LogitsProcessor(config.vocab_size)
135 +
136 + def forward(
137 + self,
138 + input_ids: torch.Tensor,
139 + positions: torch.Tensor,
140 + previous_hidden_states: torch.Tensor,
141 + inputs_embeds: Optional[torch.Tensor] = None,
142 + spec_step_idx: int = 0,
143 + ) -> torch.Tensor:
144 + if inputs_embeds is None:
145 + inputs_embeds = self.embed_tokens(input_ids)
146 + current_step_idx = (spec_step_idx % self.num_mtp_layers)
147 + return self.layers[str(self.mtp_start_layer_idx + current_step_idx)](
148 + input_ids,
149 + positions,
150 + previous_hidden_states,
151 + inputs_embeds,
152 + current_step_idx,
153 + )
154 +
155 + def compute_logits(
156 + self,
157 + hidden_states: torch.Tensor,
158 + sampling_metadata: SamplingMetadata,
159 + spec_step_idx: int = 0,
160 + ) -> torch.Tensor:
161 + current_step_idx = (spec_step_idx % self.num_mtp_layers)
162 + mtp_layer = self.layers[str(self.mtp_start_layer_idx +
163 + current_step_idx)]
164 + logits = self.logits_processor(mtp_layer.shared_head.head,
165 + mtp_layer.shared_head(hidden_states),
166 + sampling_metadata)
167 + return logits
168 +
169 +
170 +class Glm4MoeMTP(nn.Module, SupportsPP):
171 +
172 + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
173 + super().__init__()
174 + self.config = vllm_config.model_config.hf_config
175 + self.model = Glm4MoeMultiTokenPredictor(vllm_config=vllm_config,
176 + prefix=maybe_prefix(
177 + prefix, "model"))
178 +
179 + def forward(
180 + self,
181 + input_ids: torch.Tensor,
182 + positions: torch.Tensor,
183 + previous_hidden_states: torch.Tensor,
184 + intermediate_tensors: Optional[IntermediateTensors] = None,
185 + inputs_embeds: Optional[torch.Tensor] = None,
186 + spec_step_idx: int = 0,
187 + ) -> torch.Tensor:
188 + hidden_states = self.model(input_ids, positions,
189 + previous_hidden_states, inputs_embeds,
190 + spec_step_idx)
191 + return hidden_states
192 +
193 + def compute_logits(
194 + self,
195 + hidden_states: torch.Tensor,
196 + sampling_metadata: SamplingMetadata,
197 + spec_step_idx: int = 0,
198 + ) -> Optional[torch.Tensor]:
199 + return self.model.compute_logits(hidden_states, sampling_metadata,
200 + spec_step_idx)
201 +
202 + def load_weights(self, weights: Iterable[tuple[str,
203 + torch.Tensor]]) -> set[str]:
204 + stacked_params_mapping = [
205 + # (param_name, shard_name, shard_id)
206 + ("qkv_proj", "q_proj", "q"),
207 + ("qkv_proj", "k_proj", "k"),
208 + ("qkv_proj", "v_proj", "v"),
209 + ("gate_up_proj", "gate_proj", 0),
210 + ("gate_up_proj", "up_proj", 1),
211 + ]
212 +
213 + # Params for weights, fp8 weight scales, fp8 activation scales
214 + # (param_name, weight_name, expert_id, shard_id)
215 + expert_params_mapping = FusedMoE.make_expert_params_mapping(
216 + ckpt_gate_proj_name="gate_proj",
217 + ckpt_down_proj_name="down_proj",
218 + ckpt_up_proj_name="up_proj",
219 + num_experts=self.config.n_routed_experts)
220 +
221 + params_dict = dict(self.named_parameters())
222 + loaded_params: set[str] = set()
223 + for name, loaded_weight in weights:
224 + spec_layer = get_spec_layer_idx_from_weight_name(self.config, name)
225 + if spec_layer is None:
226 + continue
227 + name = self._rewrite_spec_layer_name(spec_layer, name)
228 + for (param_name, weight_name, shard_id) in stacked_params_mapping:
229 + # Skip non-stacked layers and experts (experts handled below).
230 + if weight_name not in name:
231 + continue
232 + # We have mlp.experts[0].gate_proj in the checkpoint.
233 + # Since we handle the experts below in expert_params_mapping,
234 + # we need to skip here BEFORE we update the name, otherwise
235 + # name will be updated to mlp.experts[0].gate_up_proj, which
236 + # will then be updated below in expert_params_mapping
237 + # for mlp.experts[0].gate_gate_up_proj, which breaks load.
238 + if (("mlp.experts." in name) and name not in params_dict):
239 + continue
240 + name = name.replace(weight_name, param_name)
241 + # Skip loading extra bias for GPTQ models.
242 + if name.endswith(".bias") and name not in params_dict:
243 + continue
244 +
245 + param = params_dict[name]
246 + weight_loader = param.weight_loader
247 + weight_loader(param, loaded_weight, shard_id)
248 + break
249 + else:
250 + for mapping in expert_params_mapping:
251 + param_name, weight_name, expert_id, shard_id = mapping
252 + if weight_name not in name:
253 + continue
254 + name = name.replace(weight_name, param_name)
255 +
256 + param = params_dict[name]
257 + weight_loader = param.weight_loader
258 + weight_loader(param,
259 + loaded_weight,
260 + name,
261 + shard_id=shard_id,
262 + expert_id=expert_id)
263 + break
264 + else:
265 + # Skip loading extra bias for GPTQ models.
266 + if name.endswith(".bias") and name not in params_dict:
267 + continue
268 +
269 + # According to DeepSeek-V3 Technical Report, MTP modules
270 + # shares embedding layer. We only load the first weights.
271 + if (spec_layer != self.model.mtp_start_layer_idx
272 + and ".layers" not in name):
273 + continue
274 +
275 + param = params_dict[name]
276 + weight_loader = getattr(param, "weight_loader",
277 + default_weight_loader)
278 + weight_loader(param, loaded_weight)
279 + loaded_params.add(name)
280 + return loaded_params
281 +
282 + def _rewrite_spec_layer_name(self, spec_layer: int, name: str) -> str:
283 + """
284 + Rewrite the weight name to match the format of the original model.
285 + Add .mtp_block for modules in transformer layer block for spec layer
286 + and rename shared layer weights to be top level.
287 + """
288 + spec_layer_weight_names = [
289 + "embed_tokens", "enorm", "hnorm", "eh_proj", "shared_head"
290 + ]
291 + shared_weight_names = ["embed_tokens"]
292 + spec_layer_weight = False
293 + shared_weight = False
294 + for weight_name in spec_layer_weight_names:
295 + if weight_name in name:
296 + spec_layer_weight = True
297 + if weight_name in shared_weight_names:
298 + shared_weight = True
299 + break
300 + if not spec_layer_weight:
301 + # treat rest weights as weights for transformer layer block
302 + name = name.replace(f"model.layers.{spec_layer}.",
303 + f"model.layers.{spec_layer}.mtp_block.")
304 + elif shared_weight:
305 + # treat shared weights as top level weights
306 + name = name.replace(f"model.layers.{spec_layer}.", "model.")
307 + return name
vllm/model_executor/models/registry.py
+2
@@ -67,6 +67,7 @@ _TEXT_GENERATION_MODELS = {
67 "Gemma3nForConditionalGeneration": ("gemma3n", "Gemma3nForConditionalGeneration"), # noqa: E501
68 "GlmForCausalLM": ("glm", "GlmForCausalLM"),
69 "Glm4ForCausalLM": ("glm4", "Glm4ForCausalLM"),
70 + "Glm4MoeForCausalLM": ("glm4_moe", "Glm4MoeForCausalLM"),
71 "GPT2LMHeadModel": ("gpt2", "GPT2LMHeadModel"),
72 "GPTBigCodeForCausalLM": ("gpt_bigcode", "GPTBigCodeForCausalLM"),
73 "GPTJForCausalLM": ("gpt_j", "GPTJForCausalLM"),
@@ -244,6 +245,7 @@ _SPECULATIVE_DECODING_MODELS = {
245 "EagleMiniCPMForCausalLM": ("minicpm_eagle", "EagleMiniCPMForCausalLM"),
246 "Eagle3LlamaForCausalLM": ("llama_eagle3", "Eagle3LlamaForCausalLM"),
247 "DeepSeekMTPModel": ("deepseek_mtp", "DeepSeekMTP"),
248 + "Glm4MoeMTPModel": ("glm4_moe_mtp", "Glm4MoeMTP"),
249 "MedusaModel": ("medusa", "Medusa"),
250 # Temporarily disabled.
251 # # TODO(woosuk): Re-enable this once the MLP Speculator is supported in V1.
vllm/reasoning/__init__.py
+2
@@ -3,6 +3,7 @@
3
4 from .abs_reasoning_parsers import ReasoningParser, ReasoningParserManager
5 from .deepseek_r1_reasoning_parser import DeepSeekR1ReasoningParser
6 +from .glm4_moe_reasoning_parser import Glm4MoeModelReasoningParser
7 from .granite_reasoning_parser import GraniteReasoningParser
8 from .hunyuan_a13b_reasoning_parser import HunyuanA13BReasoningParser
9 from .qwen3_reasoning_parser import Qwen3ReasoningParser
@@ -14,4 +15,5 @@ __all__ = [
15 "GraniteReasoningParser",
16 "HunyuanA13BReasoningParser",
17 "Qwen3ReasoningParser",
18 + "Glm4MoeModelReasoningParser",
19 ]
vllm/reasoning/glm4_moe_reasoning_parser.py new
+151
@@ -0,0 +1,151 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +from collections.abc import Sequence
5 +from typing import Optional, Union
6 +
7 +from transformers import PreTrainedTokenizerBase
8 +
9 +from vllm.entrypoints.openai.protocol import (ChatCompletionRequest,
10 + DeltaMessage)
11 +from vllm.logger import init_logger
12 +from vllm.reasoning import ReasoningParser, ReasoningParserManager
13 +
14 +logger = init_logger(__name__)
15 +
16 +
17 +@ReasoningParserManager.register_module("glm4_moe")
18 +class Glm4MoeModelReasoningParser(ReasoningParser):
19 + """
20 + Reasoning parser for the Glm4MoeModel model.
21 +
22 + The Glm4MoeModel model uses <think>...</think> tokens to denote reasoning
23 + text within its output. The model provides a strict switch to disable
24 + reasoning output via the 'enable_thinking=False' parameter. This parser
25 + extracts the reasoning content enclosed by <think> and </think> tokens
26 + from the model's output.
27 + """
28 +
29 + def __init__(self, tokenizer: PreTrainedTokenizerBase):
30 + super().__init__(tokenizer)
31 + self.think_start_token = "<think>"
32 + self.think_end_token = "</think>"
33 +
34 + if not self.model_tokenizer:
35 + raise ValueError(
36 + "The model tokenizer must be passed to the ReasoningParser "
37 + "constructor during construction.")
38 +
39 + self.think_start_token_id = self.vocab.get(self.think_start_token)
40 + self.think_end_token_id = self.vocab.get(self.think_end_token)
41 + if (self.think_start_token_id is None
42 + or self.think_end_token_id is None):
43 + raise RuntimeError(
44 + "Glm4MoeModel reasoning parser could not locate "
45 + "think start/end tokens in the tokenizer!")
46 +
47 + def is_reasoning_end(self, input_ids: list[int]) -> bool:
48 + return self.think_end_token_id in input_ids
49 +
50 + def extract_content_ids(self, input_ids: list[int]) -> list[int]:
51 + """
52 + Extract the content after the end tokens
53 + """
54 + if self.think_end_token_id not in input_ids[:-1]:
55 + return []
56 + else:
57 + return input_ids[input_ids.index(self.think_end_token_id) + 1:]
58 +
59 + def extract_reasoning_content_streaming(
60 + self,
61 + previous_text: str,
62 + current_text: str,
63 + delta_text: str,
64 + previous_token_ids: Sequence[int],
65 + current_token_ids: Sequence[int],
66 + delta_token_ids: Sequence[int],
67 + ) -> Union[DeltaMessage, None]:
68 + """
69 + Extract reasoning content from a delta message.
70 + Handles streaming output where previous + delta = current.
71 + Uses token IDs for faster processing.
72 + For text <think>abc</think>xyz:
73 + - 'abc' goes to reasoning_content
74 + - 'xyz' goes to content
75 + """
76 + # Skip single special tokens
77 + if len(delta_token_ids) == 1 and (delta_token_ids[0] in [
78 + self.think_start_token_id, self.think_end_token_id
79 + ]):
80 + return None
81 +
82 + if self.think_start_token_id in previous_token_ids:
83 + if self.think_end_token_id in delta_token_ids:
84 + # <think> in previous, </think> in delta,
85 + # extract reasoning content
86 + end_index = delta_text.find(self.think_end_token)
87 + reasoning_content = delta_text[:end_index]
88 + content = delta_text[end_index + len(self.think_end_token):]
89 + return DeltaMessage(reasoning_content=reasoning_content,
90 + content=content if content else None)
91 + elif self.think_end_token_id in previous_token_ids:
92 + # <think> in previous, </think> in previous,
93 + # reasoning content continues
94 + return DeltaMessage(content=delta_text)
95 + else:
96 + # <think> in previous, no </think> in previous or delta,
97 + # reasoning content continues
98 + return DeltaMessage(reasoning_content=delta_text)
99 + elif self.think_start_token_id in delta_token_ids:
100 + if self.think_end_token_id in delta_token_ids:
101 + # <think> in delta, </think> in delta, extract reasoning content
102 + start_index = delta_text.find(self.think_start_token)
103 + end_index = delta_text.find(self.think_end_token)
104 + reasoning_content = delta_text[start_index +
105 + len(self.think_start_token
106 + ):end_index]
107 + content = delta_text[end_index + len(self.think_end_token):]
108 + return DeltaMessage(reasoning_content=reasoning_content,
109 + content=content if content else None)
110 + else:
111 + # <think> in delta, no </think> in delta,
112 + # reasoning content continues
113 + return DeltaMessage(reasoning_content=delta_text)
114 + else:
115 + # thinking is disabled, just content
116 + return DeltaMessage(content=delta_text)
117 +
118 + def extract_reasoning_content(
119 + self, model_output: str, request: ChatCompletionRequest
120 + ) -> tuple[Optional[str], Optional[str]]:
121 + """
122 + Extract reasoning content from the model output.
123 +
124 + For text <think>abc</think>xyz:
125 + - 'abc' goes to reasoning_content
126 + - 'xyz' goes to content
127 +
128 + Returns:
129 + tuple[Optional[str], Optional[str]]: reasoning content and content
130 + """
131 +
132 + # Check if the model output contains the <think> and </think> tokens.
133 + if (self.think_start_token not in model_output
134 + or self.think_end_token not in model_output):
135 + return None, model_output
136 + # Check if the <think> is present in the model output, remove it
137 + # if it is present.
138 + model_output_parts = model_output.partition(self.think_start_token)
139 + model_output = model_output_parts[2] if model_output_parts[
140 + 1] else model_output_parts[0]
141 + # Check if the model output contains the </think> tokens.
142 + # If the end token is not found, return the model output as is.
143 + if self.think_end_token not in model_output:
144 + return None, model_output
145 +
146 + # Extract reasoning content from the model output.
147 + reasoning_content, _, content = model_output.partition(
148 + self.think_end_token)
149 +
150 + final_content = content or None
151 + return reasoning_content, final_content
vllm/worker/worker.py
+2 -1
@@ -77,7 +77,8 @@ class Worker(LocalOrDistributedWorkerBase):
77 "mlp_speculator",
78 "eagle",
79 "deepseek_mtp",
80 - "mimo_mtp")) \
80 + "glm4_moe_mtp",
81 + "mimo_mtp")) \
82 else {"return_hidden_states": True}
83
84 ModelRunnerClass: Type[GPUModelRunnerBase] = ModelRunner