[Bugfix] Parse MiniMax M3 streaming reasoning by text markers (#45718)
Signed-off-by: test test <2260891073@qq.com>
Rui Yin committed
Jun 24, 2026 at 02:43 UTC
d8e422ccda9b39ca8a0756b5b195513045abcee8
2 files changed
+357
-67
tests/reasoning/test_minimax_m3_reasoning_parser.py
+142
-1
@@ -83,6 +83,20 @@ class MiniMaxM3Tokenizer:
83
return "".join(tokens)
84
85
86
+class SplitMiniMaxM3Tokenizer(MiniMaxM3Tokenizer):
87
+ """Tokenizer that exposes marker vocab entries but encodes them as text."""
88
+
89
+ def tokenize(self, text: str) -> list[str]:
90
+ return list(text)
91
+
92
+
93
+class RuntimeSplitMiniMaxM3Tokenizer(MiniMaxM3Tokenizer):
94
+ """Tokenizer whose runtime output splits markers despite atomic encodes."""
95
+
96
+ def encode_runtime(self, text: str) -> list[int]:
97
+ return [self._add_token(token) for token in list(text)]
98
+
99
+
100
def make_parser(
101
chat_template_kwargs: dict[str, str] | None = None,
102
) -> tuple[MiniMaxM3ReasoningParser, MiniMaxM3Tokenizer]:
@@ -105,7 +119,8 @@ def run_streaming(
119
reasoning_end_states: list[bool] = []
120
121
for chunk in chunks:
108
- delta_token_ids = tokenizer.encode(chunk, add_special_tokens=False)
122
+ encode_runtime = getattr(tokenizer, "encode_runtime", tokenizer.encode)
123
+ delta_token_ids = encode_runtime(chunk)
124
current_text = previous_text + chunk
125
current_token_ids = previous_token_ids + delta_token_ids
126
delta = parser.extract_reasoning_streaming(
@@ -288,6 +303,132 @@ def test_streaming_plain_content_ends_reasoning_phase():
303
assert end_states == [True, True]
304
305
306
+def test_streaming_split_marker_tokens_are_not_returned():
307
+ tokenizer = RuntimeSplitMiniMaxM3Tokenizer()
308
+ parser = MiniMaxM3ReasoningParser(tokenizer)
309
+
310
+ reasoning, content, end_states = run_streaming(
311
+ parser,
312
+ tokenizer,
313
+ ["<mm:think>", "Reasoning", " content", "</mm:think>", "content"],
314
+ )
315
+
316
+ assert reasoning == "Reasoning content"
317
+ assert content == "content"
318
+ assert end_states == [False, False, False, True, True]
319
+
320
+
321
+def test_streaming_split_marker_text_drives_end_state():
322
+ tokenizer = RuntimeSplitMiniMaxM3Tokenizer()
323
+ parser = MiniMaxM3ReasoningParser(tokenizer)
324
+ previous_text = ""
325
+ previous_token_ids: list[int] = []
326
+
327
+ for chunk in ["<mm:think>", "Reasoning", " content", "</mm:think>"]:
328
+ delta_token_ids = tokenizer.encode_runtime(chunk)
329
+ current_text = previous_text + chunk
330
+ current_token_ids = previous_token_ids + delta_token_ids
331
+ parser.extract_reasoning_streaming(
332
+ previous_text=previous_text,
333
+ current_text=current_text,
334
+ delta_text=chunk,
335
+ previous_token_ids=previous_token_ids,
336
+ current_token_ids=current_token_ids,
337
+ delta_token_ids=delta_token_ids,
338
+ )
339
+ previous_text = current_text
340
+ previous_token_ids = current_token_ids
341
+
342
+ assert parser.is_reasoning_end_streaming(previous_token_ids, []) is True
343
+
344
+
345
+def test_streaming_split_end_marker_content_ids_are_stripped():
346
+ tokenizer = RuntimeSplitMiniMaxM3Tokenizer()
347
+ parser = MiniMaxM3ReasoningParser(tokenizer)
348
+ previous_text = "<mm:think>Reasoning"
349
+ previous_token_ids = tokenizer.encode_runtime(previous_text)
350
+ delta_text = "</mm:think>content"
351
+ delta_token_ids = tokenizer.encode_runtime(delta_text)
352
+ current_token_ids = previous_token_ids + delta_token_ids
353
+
354
+ parser.extract_reasoning_streaming(
355
+ previous_text=previous_text,
356
+ current_text=previous_text + delta_text,
357
+ delta_text=delta_text,
358
+ previous_token_ids=previous_token_ids,
359
+ current_token_ids=current_token_ids,
360
+ delta_token_ids=delta_token_ids,
361
+ )
362
+
363
+ assert parser.is_reasoning_end_streaming(current_token_ids, delta_token_ids)
364
+ assert tokenizer.decode(parser.extract_content_ids(delta_token_ids)) == "content"
365
+
366
+
367
+def test_streaming_split_marker_tokens_enabled_mode():
368
+ tokenizer = RuntimeSplitMiniMaxM3Tokenizer()
369
+ parser = MiniMaxM3ReasoningParser(
370
+ tokenizer, chat_template_kwargs={"thinking_mode": "enabled"}
371
+ )
372
+
373
+ reasoning, content, end_states = run_streaming(
374
+ parser,
375
+ tokenizer,
376
+ ["Reasoning", " content", "</mm:think>", "content"],
377
+ )
378
+
379
+ assert reasoning == "Reasoning content"
380
+ assert content == "content"
381
+ assert end_states == [False, False, True, True]
382
+
383
+
384
+def test_streaming_split_marker_text_across_deltas():
385
+ tokenizer = RuntimeSplitMiniMaxM3Tokenizer()
386
+ parser = MiniMaxM3ReasoningParser(tokenizer)
387
+
388
+ reasoning, content, end_states = run_streaming(
389
+ parser,
390
+ tokenizer,
391
+ ["<mm:", "think>", "Reasoning", " content", "</mm:", "think>", "content"],
392
+ )
393
+
394
+ assert reasoning == "Reasoning content"
395
+ assert content == "content"
396
+ assert end_states == [False, False, False, False, False, True, True]
397
+
398
+
399
+def test_streaming_split_leading_end_marker_text_across_deltas():
400
+ tokenizer = RuntimeSplitMiniMaxM3Tokenizer()
401
+ parser = MiniMaxM3ReasoningParser(tokenizer)
402
+
403
+ reasoning, content, end_states = run_streaming(
404
+ parser,
405
+ tokenizer,
406
+ ["</mm:", "think>", "content"],
407
+ )
408
+
409
+ assert reasoning is None
410
+ assert content == "content"
411
+ assert end_states == [False, True, True]
412
+
413
+
414
+def test_token_id_helpers_with_split_marker_tokens():
415
+ tokenizer = SplitMiniMaxM3Tokenizer()
416
+ parser = MiniMaxM3ReasoningParser(tokenizer)
417
+ output_ids = tokenizer.encode(
418
+ "<mm:think>abc</mm:think>def", add_special_tokens=False
419
+ )
420
+ open_reasoning_ids = tokenizer.encode("<mm:think>abc", add_special_tokens=False)
421
+ content_ids = tokenizer.encode("plain", add_special_tokens=False)
422
+
423
+ assert parser.is_reasoning_end(output_ids)
424
+ assert not parser.is_reasoning_end(open_reasoning_ids)
425
+ assert not parser.is_reasoning_end(content_ids)
426
+ assert tokenizer.decode(parser.extract_content_ids(output_ids)) == "def"
427
+ assert parser.extract_content_ids(open_reasoning_ids) == []
428
+ assert parser.extract_content_ids(content_ids) == content_ids
429
+ assert parser.count_reasoning_tokens(output_ids) == len(tokenizer.encode("abc"))
430
+
431
+
432
def test_token_id_helpers():
433
parser, tokenizer = make_parser()
434
output_ids = tokenizer.encode(
vllm/reasoning/minimax_m3_reasoning_parser.py
+215
-66
@@ -19,10 +19,12 @@ class MiniMaxM3ReasoningParser(BaseThinkingReasoningParser):
19
20
<mm:think>reasoning text</mm:think>assistant content
21
22
- The M3 tokenizer exposes both markers as complete vocabulary tokens. The
23
- chat template may also prefill the start marker when
24
- ``thinking_mode="enabled"``, so generated text can begin directly inside a
25
- reasoning block without emitting ``<mm:think>`` again.
22
+ The M3 tokenizer exposes both markers as complete vocabulary entries, but
23
+ generated marker text may be tokenized into smaller pieces. The streaming
24
+ parser therefore uses text markers for extraction instead of relying on the
25
+ single vocabulary IDs. The chat template may also prefill the start marker
26
+ when ``thinking_mode="enabled"``, so generated text can begin directly
27
+ inside a reasoning block without emitting ``<mm:think>`` again.
28
"""
29
30
@property
@@ -35,9 +37,135 @@ class MiniMaxM3ReasoningParser(BaseThinkingReasoningParser):
37
38
def __init__(self, tokenizer, *args, **kwargs):
39
super().__init__(tokenizer, *args, **kwargs)
40
+ self._start_token_ids = self._encode_marker(self.start_token)
41
+ self._end_token_ids = self._encode_marker(self.end_token)
42
chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {}
43
self._initial_in_reasoning = chat_kwargs.get("thinking_mode") == "enabled"
40
- self._at_response_start = True
44
+ self._reasoning_ended_streaming = False
45
+ self._reasoning_active_streaming = self._initial_in_reasoning
46
+ self._pending_marker_streaming = False
47
+ self._last_streaming_delta_token_ids: tuple[int, ...] | None = None
48
+ self._last_streaming_content_token_ids: list[int] | None = None
49
+
50
+ def _encode_text(self, text: str) -> list[int]:
51
+ try:
52
+ return list(self.model_tokenizer.encode(text, add_special_tokens=False))
53
+ except TypeError:
54
+ return list(self.model_tokenizer.encode(text))
55
+
56
+ def _encode_marker(self, marker: str) -> tuple[int, ...]:
57
+ return tuple(self._encode_text(marker))
58
+
59
+ def _decode_text(self, token_ids: Sequence[int]) -> str:
60
+ try:
61
+ return self.model_tokenizer.decode(
62
+ list(token_ids), skip_special_tokens=False
63
+ )
64
+ except TypeError:
65
+ return self.model_tokenizer.decode(list(token_ids))
66
+
67
+ def _content_suffix_token_ids(
68
+ self,
69
+ delta_text: str,
70
+ delta_token_ids: Sequence[int],
71
+ content: str | None,
72
+ ) -> list[int]:
73
+ if content is None:
74
+ return []
75
+ if content == delta_text:
76
+ return list(delta_token_ids)
77
+ if delta_text.endswith(content):
78
+ prefix_text = delta_text[: len(delta_text) - len(content)]
79
+ for index in range(len(delta_token_ids) + 1):
80
+ if self._decode_text(delta_token_ids[:index]) == prefix_text:
81
+ return list(delta_token_ids[index:])
82
+ return self._encode_text(content)
83
+
84
+ @staticmethod
85
+ def _contains_token_sequence(
86
+ token_ids: Sequence[int], marker_ids: Sequence[int]
87
+ ) -> bool:
88
+ if not marker_ids or len(marker_ids) > len(token_ids):
89
+ return False
90
+ marker_len = len(marker_ids)
91
+ return any(
92
+ tuple(token_ids[i : i + marker_len]) == tuple(marker_ids)
93
+ for i in range(len(token_ids) - marker_len + 1)
94
+ )
95
+
96
+ @staticmethod
97
+ def _rfind_token_sequence(
98
+ token_ids: Sequence[int], marker_ids: Sequence[int]
99
+ ) -> int:
100
+ if not marker_ids or len(marker_ids) > len(token_ids):
101
+ return -1
102
+ marker_len = len(marker_ids)
103
+ for i in range(len(token_ids) - marker_len, -1, -1):
104
+ if tuple(token_ids[i : i + marker_len]) == tuple(marker_ids):
105
+ return i
106
+ return -1
107
+
108
+ @staticmethod
109
+ def _ends_with_token_sequence_prefix(
110
+ token_ids: Sequence[int], marker_ids: Sequence[int]
111
+ ) -> bool:
112
+ if not marker_ids:
113
+ return False
114
+ max_len = min(len(token_ids), len(marker_ids) - 1)
115
+ for prefix_len in range(max_len, 0, -1):
116
+ if tuple(token_ids[-prefix_len:]) == tuple(marker_ids[:prefix_len]):
117
+ return True
118
+ return False
119
+
120
+ @staticmethod
121
+ def _strip_partial_marker_suffix(text: str, marker: str) -> str:
122
+ max_len = min(len(text), len(marker) - 1)
123
+ for suffix_len in range(max_len, 0, -1):
124
+ if marker.startswith(text[-suffix_len:]):
125
+ return text[:-suffix_len]
126
+ return text
127
+
128
+ @staticmethod
129
+ def _visible_delta(previous: str | None, current: str | None) -> str | None:
130
+ if not current:
131
+ return None
132
+ if not previous:
133
+ return current
134
+ if current.startswith(previous):
135
+ delta = current[len(previous) :]
136
+ return delta or None
137
+ return current
138
+
139
+ def _visible_segments(self, text: str) -> tuple[str | None, str | None]:
140
+ if not text:
141
+ return None, None
142
+
143
+ if not self._initial_in_reasoning:
144
+ if self.end_token.startswith(text) and len(text) < len(self.end_token):
145
+ return None, None
146
+ if text.startswith(self.end_token):
147
+ text = text[len(self.end_token) :]
148
+ if not text:
149
+ return None, None
150
+
151
+ if self._initial_in_reasoning and self.start_token not in text:
152
+ reasoning, end, content = text.partition(self.end_token)
153
+ if end:
154
+ return reasoning or None, content or None
155
+ reasoning = self._strip_partial_marker_suffix(reasoning, self.end_token)
156
+ return reasoning or None, None
157
+
158
+ if self.start_token not in text:
159
+ content = self._strip_partial_marker_suffix(text, self.start_token)
160
+ return None, content or None
161
+
162
+ content_before, _, after_start = text.partition(self.start_token)
163
+ reasoning, end, content_after = after_start.partition(self.end_token)
164
+ if end:
165
+ return reasoning or None, (content_before + content_after) or None
166
+
167
+ reasoning = self._strip_partial_marker_suffix(reasoning, self.end_token)
168
+ return reasoning or None, content_before or None
169
170
def extract_reasoning(
171
self,
@@ -69,26 +197,46 @@ class MiniMaxM3ReasoningParser(BaseThinkingReasoningParser):
197
def is_reasoning_end_streaming(
198
self, input_ids: Sequence[int], delta_ids: Iterable[int]
199
) -> bool:
200
+ if self._reasoning_ended_streaming:
201
+ return True
202
+
203
+ if self._reasoning_active_streaming or self._pending_marker_streaming:
204
+ return False
205
+
206
delta_ids = tuple(delta_ids)
73
- if self.end_token_id in delta_ids:
207
+ if self._contains_token_sequence(delta_ids, self._end_token_ids):
208
return True
75
- if self.end_token_id in input_ids:
209
+ if self._contains_token_sequence(input_ids, self._end_token_ids):
210
return True
211
if self._initial_in_reasoning:
212
return False
79
- if self.start_token_id not in input_ids:
213
+ if self._ends_with_token_sequence_prefix(input_ids, self._start_token_ids):
214
+ return False
215
+ if self._ends_with_token_sequence_prefix(input_ids, self._end_token_ids):
216
+ return False
217
+ if not self._contains_token_sequence(input_ids, self._start_token_ids):
218
return bool(input_ids)
219
return False
220
221
def extract_content_ids(self, input_ids: list[int]) -> list[int]:
84
- if self.end_token_id in input_ids:
85
- end_index = len(input_ids) - 1 - input_ids[::-1].index(self.end_token_id)
86
- return input_ids[end_index + 1 :]
222
+ if (
223
+ self._last_streaming_delta_token_ids == tuple(input_ids)
224
+ and self._last_streaming_content_token_ids is not None
225
+ ):
226
+ content_ids = self._last_streaming_content_token_ids
227
+ self._last_streaming_delta_token_ids = None
228
+ self._last_streaming_content_token_ids = None
229
+ return list(content_ids)
230
88
- if self._initial_in_reasoning and self.start_token_id not in input_ids:
231
+ end_index = self._rfind_token_sequence(input_ids, self._end_token_ids)
232
+ if end_index >= 0:
233
+ return input_ids[end_index + len(self._end_token_ids) :]
234
+
235
+ has_start = self._contains_token_sequence(input_ids, self._start_token_ids)
236
+ if self._initial_in_reasoning and not has_start:
237
return []
238
91
- if self.start_token_id not in input_ids:
239
+ if not has_start:
240
return input_ids
241
return []
242
@@ -104,68 +252,69 @@ class MiniMaxM3ReasoningParser(BaseThinkingReasoningParser):
252
if not delta_text:
253
return None
254
107
- if self._at_response_start and not self._initial_in_reasoning:
108
- # Apply the leading-closer tolerance once. Later unmatched closers
109
- # stay visible as content.
110
- self._at_response_start = False
111
- if delta_text.startswith(self.end_token):
112
- delta_text = delta_text[len(self.end_token) :]
113
- if not delta_text:
114
- return None
115
- if delta_token_ids and delta_token_ids[0] == self.end_token_id:
116
- delta_token_ids = delta_token_ids[1:]
117
-
118
- if self.end_token_id in previous_token_ids:
119
- return DeltaMessage(content=delta_text)
120
-
121
- if (
122
- self._initial_in_reasoning
123
- and self.start_token_id not in previous_token_ids
124
- and self.start_token_id not in delta_token_ids
125
- ):
126
- if self.end_token_id in delta_token_ids:
127
- reasoning, _, content = delta_text.partition(self.end_token)
128
- return DeltaMessage(
129
- reasoning=reasoning or None,
130
- content=content or None,
131
- )
132
- return DeltaMessage(reasoning=delta_text)
133
-
134
- if (
135
- self.start_token_id not in previous_token_ids
136
- and self.start_token_id not in delta_token_ids
137
- ):
138
- return DeltaMessage(content=delta_text)
139
-
140
- if self.end_token_id in delta_token_ids:
141
- reasoning_text, _, content = delta_text.partition(self.end_token)
142
- if self.start_token_id in delta_token_ids:
143
- _, _, reasoning_text = reasoning_text.partition(self.start_token)
144
- return DeltaMessage(
145
- reasoning=reasoning_text or None,
146
- content=content or None,
255
+ if not previous_text:
256
+ self._reasoning_ended_streaming = False
257
+ self._reasoning_active_streaming = self._initial_in_reasoning
258
+ self._pending_marker_streaming = False
259
+ self._last_streaming_delta_token_ids = None
260
+ self._last_streaming_content_token_ids = None
261
+ previous_reasoning, previous_content = self._visible_segments(previous_text)
262
+ current_reasoning, current_content = self._visible_segments(current_text)
263
+ if self.end_token in current_text or current_content is not None:
264
+ self._reasoning_ended_streaming = True
265
+ self._reasoning_active_streaming = False
266
+ self._pending_marker_streaming = False
267
+ else:
268
+ self._last_streaming_delta_token_ids = None
269
+ self._last_streaming_content_token_ids = None
270
+ self._reasoning_active_streaming = (
271
+ self._initial_in_reasoning
272
+ or self.start_token in current_text
273
+ or current_reasoning is not None
274
)
148
-
149
- if self.start_token_id in delta_token_ids:
150
- _, _, reasoning = delta_text.partition(self.start_token)
151
- return DeltaMessage(reasoning=reasoning) if reasoning else None
152
-
153
- return DeltaMessage(reasoning=delta_text)
275
+ self._pending_marker_streaming = not self._reasoning_active_streaming and (
276
+ self.start_token.startswith(current_text)
277
+ or self.end_token.startswith(current_text)
278
+ )
279
+ reasoning = self._visible_delta(previous_reasoning, current_reasoning)
280
+ content = self._visible_delta(previous_content, current_content)
281
+ if self._reasoning_ended_streaming:
282
+ self._last_streaming_delta_token_ids = tuple(delta_token_ids)
283
+ self._last_streaming_content_token_ids = self._content_suffix_token_ids(
284
+ delta_text, delta_token_ids, content
285
+ )
286
+ if reasoning is None and content is None:
287
+ return None
288
+ return DeltaMessage(reasoning=reasoning, content=content)
289
290
def count_reasoning_tokens(self, token_ids: Sequence[int]) -> int:
156
- if not self._initial_in_reasoning:
157
- return super().count_reasoning_tokens(token_ids)
158
-
291
count = 0
160
- depth = 1
161
- for token_id in token_ids:
162
- if token_id == self.start_token_id:
292
+ depth = 1 if self._initial_in_reasoning else 0
293
+ i = 0
294
+ while i < len(token_ids):
295
+ if tuple(token_ids[i : i + len(self._start_token_ids)]) == (
296
+ self._start_token_ids
297
+ ):
298
depth += 1
299
+ i += len(self._start_token_ids)
300
continue
165
- if token_id == self.end_token_id:
301
+ if tuple(token_ids[i : i + len(self._end_token_ids)]) == (
302
+ self._end_token_ids
303
+ ):
304
if depth > 0:
305
depth -= 1
306
+ i += len(self._end_token_ids)
307
continue
308
if depth > 0:
309
count += 1
310
+ i += 1
311
return count
312
+
313
+ def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
314
+ start_index = self._rfind_token_sequence(input_ids, self._start_token_ids)
315
+ end_index = self._rfind_token_sequence(input_ids, self._end_token_ids)
316
+ if end_index < 0:
317
+ return False
318
+ if start_index < 0:
319
+ return True
320
+ return end_index > start_index