[Bugfix][Multimodal] Preserve DeepSeek V4 image block spacing (#56882)
Signed-off-by: Xucheng Zhou <aden1350@outlook.com> Signed-off-by: Isotr0py <Isotr0py@outlook.com> Co-authored-by: OpenAI Codex <noreply@openai.com> Co-authored-by: Isotr0py <Isotr0py@outlook.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
adenzhou1350 committed
Sep 18, 2026 at 18:44 UTC
cc09352e5c50ca6b7d2c6d79b29d6d3b18df3046
5 files changed
+60
-12
tests/tokenizers_/fixtures/deepseek_v4/test_input_5.json
new
+39
@@ -0,0 +1,39 @@
1
+{
2
+ "thinking_mode": "thinking",
3
+ "reasoning_effort": "max",
4
+ "messages": [
5
+ {
6
+ "role": "system",
7
+ "content": "You are a helpful vision assistant."
8
+ },
9
+ {
10
+ "role": "user",
11
+ "content": [
12
+ {
13
+ "type": "text",
14
+ "text": "请按“第一张、第二张”的顺序回答:第一张图"
15
+ },
16
+ {
17
+ "type": "image_url",
18
+ "image_url": {
19
+ "url": "examples/images/carrots.jpeg"
20
+ }
21
+ },
22
+ {
23
+ "type": "text",
24
+ "text": "和第二张图"
25
+ },
26
+ {
27
+ "type": "image_url",
28
+ "image_url": {
29
+ "url": "examples/images/corn.jpeg"
30
+ }
31
+ },
32
+ {
33
+ "type": "text",
34
+ "text": "中分别是什么食材?它们通常食用的部位分别是什么?"
35
+ }
36
+ ]
37
+ }
38
+ ]
39
+}
tests/tokenizers_/fixtures/deepseek_v4/test_output_5.txt
new
+9
@@ -0,0 +1,9 @@
1
+<|begin▁of▁sentence|>You are a helpful vision assistant.<|User|>请按“第一张、第二张”的顺序回答:第一张图
2
+
3
+<|deepseek_image|>
4
+
5
+和第二张图
6
+
7
+<|deepseek_image|>
8
+
9
+中分别是什么食材?它们通常食用的部位分别是什么?<|Assistant|></think>
\ No newline at end of file
tests/tokenizers_/test_deepseek_v4.py
+7
-7
@@ -54,13 +54,9 @@ def _load_reference_case(case_id: int):
54
55
def _render_reference_case(case_id: int, **kwargs):
56
messages, tools = _load_reference_case(case_id)
57
- conversation, _, _ = parse_chat_messages(
58
- messages,
59
- _model_config(),
60
- content_format="string",
61
- )
57
+ # Preserve content blocks for encoding without fetching fixture media.
58
return _tokenizer().apply_chat_template(
63
- conversation=conversation,
59
+ conversation=messages,
60
messages=messages,
61
tools=tools,
62
tokenize=False,
@@ -397,6 +393,7 @@ def test_deepseek_v4_maps_xhigh_to_high_reasoning_effort():
393
(2, {"thinking": True, "reasoning_effort": "low"}),
394
(3, {"thinking": True, "reasoning_effort": "low"}),
395
(4, {"thinking": False}),
396
+ (5, {"thinking": False}),
397
],
398
)
399
def test_deepseek_v4_matches_reference_golden_fixtures(case_id, kwargs):
@@ -447,7 +444,10 @@ def test_deepseek_v4_image_blocks_become_placeholders():
444
thinking=False,
445
)
446
450
- assert "<|User|>first:<|deepseek_image|>second:<|deepseek_image|>" in prompt
447
+ assert (
448
+ "<|User|>first:\n\n<|deepseek_image|>\n\n"
449
+ "second:\n\n<|deepseek_image|>" in prompt
450
+ )
451
452
453
def test_deepseek_v4_image_sentinel_ids_match_tokenizer():
vllm/renderers/deepseek_v4.py
+2
-2
@@ -40,7 +40,7 @@ class DeepseekV4Renderer(BaseRenderer[DeepseekV4Tokenizer]):
40
conversation, mm_data, mm_uuids = parse_chat_messages(
41
messages,
42
self.model_config,
43
- content_format="string",
43
+ content_format="openai",
44
media_io_kwargs=params.media_io_kwargs,
45
mm_processor_kwargs=params.mm_processor_kwargs,
46
)
@@ -67,7 +67,7 @@ class DeepseekV4Renderer(BaseRenderer[DeepseekV4Tokenizer]):
67
conversation, mm_data, mm_uuids = await parse_chat_messages_async(
68
messages,
69
self.model_config,
70
- content_format="string",
70
+ content_format="openai",
71
media_io_kwargs=params.media_io_kwargs,
72
mm_processor_kwargs=params.mm_processor_kwargs,
73
)
vllm/tokenizers/deepseek_v4_encoding.py
+3
-3
@@ -224,7 +224,7 @@ def flatten_content_blocks(content: Any) -> Any:
224
raise ValueError(
225
f"Unsupported content block type: {block.get('type')!r}"
226
)
227
- return "".join(parts)
227
+ return "\n\n".join(parts)
228
229
230
def render_message(
@@ -267,7 +267,7 @@ def render_message(
267
)
268
269
role = msg.get("role")
270
- content = msg.get("content")
270
+ content = flatten_content_blocks(msg.get("content"))
271
tools = msg.get("tools")
272
response_format = msg.get("response_format")
273
tool_calls = msg.get("tool_calls")
@@ -337,7 +337,7 @@ def render_message(
337
parts.append(f"[Unsupported {block_type}]")
338
prompt += "\n\n".join(parts)
339
else:
340
- prompt += flatten_content_blocks(content) or ""
340
+ prompt += content or ""
341
342
elif role == "latest_reminder":
343
prompt += LATEST_REMINDER_SP_TOKEN + latest_reminder_msg_template.format(content=content)