[Bugfix][MiniCPM-o] Align structured chat content with native omni rendering (#7344)
Signed-off-by: Sy03 <1370724210@qq.com>
This commit is contained in:
7 files changed
+204
-11
No files matched your search
@@ -155,6 +155,7 @@ steps:
|
||||
source_file_dependencies:
|
||||
- tests/e2e/offline_inference/test_minicpmo_4_5.py
|
||||
- tests/e2e/online_serving/test_minicpmo_4_5.py
|
||||
- vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja
|
||||
- vllm_omni/model_executor/models/minicpmo_4_5/
|
||||
- vllm_omni/model_executor/models/step_audio2/
|
||||
- vllm_omni/model_executor/models/cosyvoice3/code2wav_core/
|
||||
|
||||
@@ -188,6 +188,7 @@ steps:
|
||||
- label: "Omni · MiniCPM-o 4.5 Online Test"
|
||||
source_file_dependencies:
|
||||
- tests/e2e/online_serving/test_minicpmo_4_5.py
|
||||
- vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja
|
||||
- vllm_omni/model_executor/models/minicpmo_4_5/
|
||||
- vllm_omni/model_executor/models/step_audio2/
|
||||
- vllm_omni/model_executor/models/cosyvoice3/code2wav_core/
|
||||
|
||||
+1
-1
@@ -161,7 +161,7 @@ where = ["."]
|
||||
include = ["vllm_omni*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
"vllm_omni" = ["_version.py", "py.typed"]
|
||||
"vllm_omni" = ["_version.py", "py.typed", "transformers_utils/chat_templates/*.jinja"]
|
||||
|
||||
[tool.setuptools_scm]
|
||||
# no extra settings needed, presence enables setuptools-scm
|
||||
|
||||
@@ -28,13 +28,13 @@ also provided.
|
||||
|
||||
- Default deploy configs (auto-loaded by HF `model_type=minicpmo` +
|
||||
`hf_config.version="4.5"`):
|
||||
- Default single-GPU compatibility layout (auto-loaded):
|
||||
- Default single-GPU compatibility layout (auto-loaded):
|
||||
[`vllm_omni/deploy/minicpmo_4_5.yaml`](../../vllm_omni/deploy/minicpmo_4_5.yaml)
|
||||
- Recommended 2-GPU continuous-batching layout:
|
||||
- Recommended 2-GPU continuous-batching layout:
|
||||
[`vllm_omni/deploy/minicpmo_4_5_2gpu.yaml`](../../vllm_omni/deploy/minicpmo_4_5_2gpu.yaml),
|
||||
- 3-GPU layout:
|
||||
- 3-GPU layout:
|
||||
[`vllm_omni/deploy/minicpmo_4_5_3gpu.yaml`](../../vllm_omni/deploy/minicpmo_4_5_3gpu.yaml)
|
||||
- 8x RTX 4090 layout:
|
||||
- 8x RTX 4090 layout:
|
||||
[`vllm_omni/deploy/minicpmo_4_5_8x4090.yaml`](../../vllm_omni/deploy/minicpmo_4_5_8x4090.yaml)
|
||||
- Online example + Gradio demo:
|
||||
[`examples/online_serving/minicpmo/`](../../examples/online_serving/minicpmo/)
|
||||
@@ -126,9 +126,20 @@ video. Use the two-GPU profile for production throughput.
|
||||
```bash
|
||||
vllm serve openbmb/MiniCPM-o-4_5 --omni \
|
||||
--trust-remote-code \
|
||||
--chat-template vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja \
|
||||
--chat-template-content-format openai \
|
||||
--host 0.0.0.0 --port 8099
|
||||
```
|
||||
|
||||
Run these commands from the repository root. The bundled chat template
|
||||
matches the Hugging Face `chat(omni_mode=True)` content layout: media and text
|
||||
parts are concatenated without adding separators, while whitespace explicitly
|
||||
included in a text part is preserved. This matters for speech generation because
|
||||
an inserted newline changes the Thinker condition passed to Talker. For image
|
||||
questions, place the image before the question, as in the native image-chat
|
||||
layout. Continue to request `use_tts_template: true` and
|
||||
`enable_thinking: false` for spoken answers.
|
||||
|
||||
The deploy config is auto-loaded by the model registry — no
|
||||
`--deploy-config` flag needed for this default single-GPU layout.
|
||||
|
||||
@@ -271,6 +282,8 @@ uses GPU 5. GPUs 6–7 are left free.
|
||||
vllm serve openbmb/MiniCPM-o-4_5 --omni \
|
||||
--deploy-config vllm_omni/deploy/minicpmo_4_5_8x4090.yaml \
|
||||
--trust-remote-code \
|
||||
--chat-template vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja \
|
||||
--chat-template-content-format openai \
|
||||
--host 0.0.0.0 --port 8099
|
||||
```
|
||||
|
||||
|
||||
@@ -24,6 +24,9 @@ from tests.helpers.stage_config import get_deploy_config_path, modify_stage_conf
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
|
||||
_MODEL = "openbmb/MiniCPM-o-4_5"
|
||||
_NATIVE_CHAT_TEMPLATE = (
|
||||
Path(__file__).resolve().parents[3] / "vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja"
|
||||
)
|
||||
_CI_DEPLOY = modify_stage_config(
|
||||
get_deploy_config_path("minicpmo_4_5.yaml"),
|
||||
updates={
|
||||
@@ -63,7 +66,14 @@ test_params = [
|
||||
model=_MODEL,
|
||||
stage_config_path=_CI_DEPLOY,
|
||||
use_stage_cli=False,
|
||||
server_args=["--trust-remote-code", "--async-chunk"],
|
||||
server_args=[
|
||||
"--trust-remote-code",
|
||||
"--async-chunk",
|
||||
"--chat-template",
|
||||
str(_NATIVE_CHAT_TEMPLATE),
|
||||
"--chat-template-content-format",
|
||||
"openai",
|
||||
],
|
||||
env_dict={
|
||||
"VLLM_CONFIGURE_LOGGING": "1",
|
||||
"VLLM_LOGGING_CONFIG_PATH": str(_PROMPT_LOGGING_CONFIG_PATH),
|
||||
@@ -265,11 +275,18 @@ def test_image_to_text_audio_001(omni_server, openai_client) -> None:
|
||||
Input Setting: stream=True
|
||||
"""
|
||||
image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(24, 24)['base64']}"
|
||||
messages = dummy_messages_from_mix_data(
|
||||
system_prompt=get_system_prompt(),
|
||||
image_data_url=image_data_url,
|
||||
content_text=get_prompt("text_image"),
|
||||
)
|
||||
# Match the native image-chat layout explicitly; do not rely on the
|
||||
# renderer moving media placeholders ahead of text.
|
||||
messages = [
|
||||
get_system_prompt(),
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": image_data_url}},
|
||||
{"type": "text", "text": get_prompt("text_image")},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
request_config = {
|
||||
"model": omni_server.model,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""MiniCPM structured-content rendering must preserve native part boundaries."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from jinja2 import TemplateError
|
||||
from transformers.utils.chat_template_utils import _compile_jinja_template
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
TEMPLATE = Path(__file__).resolve().parents[4] / "vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"parts,expected",
|
||||
[
|
||||
([{"type": "image"}, {"type": "text", "text": "Question?"}], "(<image>./</image>)Question?"),
|
||||
([{"type": "text", "text": "Question?"}, {"type": "image"}], "Question?(<image>./</image>)"),
|
||||
([{"type": "image"}, {"type": "text", "text": "\n\nQuestion?"}], "(<image>./</image>)\n\nQuestion?"),
|
||||
([{"type": "text", "text": " A "}, {"type": "text", "text": " B "}], " A B "),
|
||||
([{"type": "audio"}, {"type": "text", "text": "Transcribe."}], "(<audio>./</audio>)Transcribe."),
|
||||
([{"type": "video"}, {"type": "image"}], "(<video>./</video>)(<image>./</image>)"),
|
||||
("\nKeep this whitespace.\n", "\nKeep this whitespace.\n"),
|
||||
],
|
||||
)
|
||||
def test_native_content_concatenation(parts, expected):
|
||||
rendered = _compile_jinja_template(TEMPLATE.read_text()).render(
|
||||
messages=[{"role": "user", "content": parts}],
|
||||
add_generation_prompt=True,
|
||||
enable_thinking=False,
|
||||
use_tts_template=True,
|
||||
)
|
||||
assert rendered == (
|
||||
f"<|im_start|>user\n{expected}<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n<|tts_bos|>"
|
||||
)
|
||||
|
||||
|
||||
def test_unsupported_part_is_not_silently_discarded():
|
||||
with pytest.raises(TemplateError, match="Unsupported MiniCPM-o content part"):
|
||||
_compile_jinja_template(TEMPLATE.read_text()).render(
|
||||
messages=[{"role": "user", "content": [{"type": "unknown"}]}],
|
||||
add_generation_prompt=True,
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
{# SPDX-License-Identifier: Apache-2.0
|
||||
Adapted from OpenBMB/MiniCPM-o-4_5 tokenizer_config.json.
|
||||
Match chat(omni_mode=True): concatenate structured content parts without
|
||||
injecting separators, preserving whitespace explicitly supplied as text.
|
||||
#}
|
||||
{%- set normalized = namespace(messages=[]) -%}
|
||||
{%- for message in messages -%}
|
||||
{%- if message.content is string or message.content is none -%}
|
||||
{%- set normalized.messages = normalized.messages + [message] -%}
|
||||
{%- else -%}
|
||||
{%- set content = namespace(text='') -%}
|
||||
{%- for part in message.content -%}
|
||||
{%- if part.type == 'text' -%}
|
||||
{%- set content.text = content.text + part.text -%}
|
||||
{%- elif part.type == 'image' -%}
|
||||
{%- set content.text = content.text + '(<image>./</image>)' -%}
|
||||
{%- elif part.type == 'audio' -%}
|
||||
{%- set content.text = content.text + '(<audio>./</audio>)' -%}
|
||||
{%- elif part.type == 'video' -%}
|
||||
{%- set content.text = content.text + '(<video>./</video>)' -%}
|
||||
{%- else -%}
|
||||
{{- raise_exception('Unsupported MiniCPM-o content part: ' + part.type) -}}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- set normalized.messages = normalized.messages + [dict(message, content=content.text)] -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{%- set messages = normalized.messages -%}
|
||||
{%- if tools %}
|
||||
{{- '<|im_start|>system\n' }}
|
||||
{%- if messages[0].role == 'system' %}
|
||||
{{- messages[0].content + '\n\n' }}
|
||||
{%- endif %}
|
||||
{{- "# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within <tools></tools> XML tags:\n<tools>" }}
|
||||
{%- for tool in tools %}
|
||||
{{- "\n" }}
|
||||
{{- tool | tojson }}
|
||||
{%- endfor %}
|
||||
{{- "\n</tools>\n\nFor each function call, return a json object with function name and arguments within <tool_call></tool_call> XML tags:\n<tool_call>\n{\"name\": <function-name>, \"arguments\": <args-json-object>}\n</tool_call><|im_end|>\n" }}
|
||||
{%- else %}
|
||||
{%- if messages[0].role == 'system' %}
|
||||
{{- '<|im_start|>system\n' + messages[0].content + '<|im_end|>\n' }}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
|
||||
{%- for message in messages[::-1] %}
|
||||
{%- set index = (messages|length - 1) - loop.index0 %}
|
||||
{%- if ns.multi_step_tool and message.role == "user" and not(message.content.startswith('<tool_response>') and message.content.endswith('</tool_response>')) %}
|
||||
{%- set ns.multi_step_tool = false %}
|
||||
{%- set ns.last_query_index = index %}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- for message in messages %}
|
||||
{%- if (message.role == "user") or (message.role == "system" and not loop.first) %}
|
||||
{{- '<|im_start|>' + message.role + '\n' + message.content + '<|im_end|>' + '\n' }}
|
||||
{%- elif message.role == "assistant" %}
|
||||
{%- set content = message.content %}
|
||||
{%- set reasoning_content = '' %}
|
||||
{%- if message.reasoning_content is defined and message.reasoning_content is not none %}
|
||||
{%- set reasoning_content = message.reasoning_content %}
|
||||
{%- else %}
|
||||
{%- if '</think>' in message.content %}
|
||||
{%- set content = message.content.split('</think>')[-1].lstrip('\n') %}
|
||||
{%- set reasoning_content = message.content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- if loop.index0 > ns.last_query_index %}
|
||||
{%- if loop.last or (not loop.last and reasoning_content) %}
|
||||
{{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content.strip('\n') + '\n</think>\n\n' + content.lstrip('\n') }}
|
||||
{%- else %}
|
||||
{{- '<|im_start|>' + message.role + '\n' + content }}
|
||||
{%- endif %}
|
||||
{%- else %}
|
||||
{{- '<|im_start|>' + message.role + '\n' + content }}
|
||||
{%- endif %}
|
||||
{%- if message.tool_calls %}
|
||||
{%- for tool_call in message.tool_calls %}
|
||||
{%- if (loop.first and content) or (not loop.first) %}
|
||||
{{- '\n' }}
|
||||
{%- endif %}
|
||||
{%- if tool_call.function %}
|
||||
{%- set tool_call = tool_call.function %}
|
||||
{%- endif %}
|
||||
{{- '<tool_call>\n{"name": "' }}
|
||||
{{- tool_call.name }}
|
||||
{{- '", "arguments": ' }}
|
||||
{%- if tool_call.arguments is string %}
|
||||
{{- tool_call.arguments }}
|
||||
{%- else %}
|
||||
{{- tool_call.arguments | tojson }}
|
||||
{%- endif %}
|
||||
{{- '}\n</tool_call>' }}
|
||||
{%- endfor %}
|
||||
{%- endif %}
|
||||
{{- '<|im_end|>\n' }}
|
||||
{%- elif message.role == "tool" %}
|
||||
{%- if loop.first or (messages[loop.index0 - 1].role != "tool") %}
|
||||
{{- '<|im_start|>user' }}
|
||||
{%- endif %}
|
||||
{{- '\n<tool_response>\n' }}
|
||||
{{- message.content }}
|
||||
{{- '\n</tool_response>' }}
|
||||
{%- if loop.last or (messages[loop.index0 + 1].role != "tool") %}
|
||||
{{- '<|im_end|>\n' }}
|
||||
{%- endif %}
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- if add_generation_prompt %}
|
||||
{{- '<|im_start|>assistant\n' }}
|
||||
{%- if enable_thinking is defined and enable_thinking is false %}
|
||||
{{- '<think>\n\n</think>\n\n' }}
|
||||
{%- endif %}
|
||||
{%- if use_tts_template is defined and use_tts_template is true %}
|
||||
{{- '<|tts_bos|>' }}
|
||||
{%- endif %}
|
||||
{%- endif -%}
|
||||
Reference in new issue
Block a user