Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions tests/renderers/test_empty_conversation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""An empty conversation must be rejected whatever the renderer.

The HF tokenizer refuses one inside ``apply_chat_template``, but renderers with
their own chat encoding (e.g. DeepSeek V3.2/V4/V4.1) render it to a bare BOS
prompt, from which the model generates unrelated text.
"""

import pytest

from vllm.exceptions import VLLMValidationError
from vllm.renderers.base import BaseRenderer
from vllm.renderers.params import ChatParams, TokenizeParams


class _Rendered(Exception):
"""Raised by the stub once a conversation reaches ``render_messages``."""


def _renderer() -> BaseRenderer:
class _StubRenderer(BaseRenderer):
def __init__(self):
self.tokenizer = None

def render_messages(self, messages, params):
raise _Rendered

return _StubRenderer()


def _render(renderer, conversations):
return renderer.render_chat(
conversations, ChatParams(), TokenizeParams(max_total_tokens=4096)
)


@pytest.mark.parametrize(
"conversations",
[[[]], [[{"role": "user", "content": "hi"}], []]],
ids=["only", "one_of_two"],
)
def test_render_chat_rejects_an_empty_conversation(conversations):
with pytest.raises(VLLMValidationError, match="empty conversation"):
_render(_renderer(), conversations)


@pytest.mark.asyncio
async def test_render_chat_async_rejects_an_empty_conversation():
with pytest.raises(VLLMValidationError, match="empty conversation"):
await _renderer().render_chat_async(
[[]], ChatParams(), TokenizeParams(max_total_tokens=4096)
)


@pytest.mark.parametrize(
"conversation",
[[{"role": "user", "content": "hi"}], [{"role": "system", "content": "Be brief."}]],
ids=["user", "system_only"],
)
def test_render_chat_renders_a_non_empty_conversation(conversation):
with pytest.raises(_Rendered):
_render(_renderer(), [conversation])
15 changes: 15 additions & 0 deletions vllm/renderers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1339,6 +1339,19 @@ async def render_cmpl_async(
)
)

@staticmethod
def _check_conversations_not_empty(
conversations: Sequence[list["ChatCompletionMessageParam"]],
) -> None:
# the HF tokenizer refuses an empty conversation in apply_chat_template,
# but renderers with their own chat encoding render it to a bare BOS
# prompt. Refuse it here so every renderer answers the same way.
if any(not conversation for conversation in conversations):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is the right fix. As conversations grows larger, we should avoid introducing an additional loop.

@albertoperdomo2 albertoperdomo2 Oct 21, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for looking @chaunceyjiang! conversations here is the batch of conversations, not the messages in one, so the check is one O(1) emptiness test per conversation and it short-circuits

online serving always renders a single conversation (online_renderer.py:805), so there it's one check; only offline LLM.chat passes a batch

measured: 0.14µs for one conversation of any length, 16µs for 1000 conversations, which can be considered negligible next to rendering and tokenizing each of them IMHO

raise VLLMValidationError(
"Cannot apply chat template to an empty conversation. "
"Provide at least one message."
)

def render_chat(
self,
conversations: Sequence[list["ChatCompletionMessageParam"]],
Expand All @@ -1348,6 +1361,7 @@ def render_chat(
prompt_extras: dict[str, Any] | None = None,
skip_mm_cache: bool = False,
):
self._check_conversations_not_empty(conversations)
arrival_time = time.time()

if tok_params is None:
Expand Down Expand Up @@ -1388,6 +1402,7 @@ async def render_chat_async(
prompt_extras: dict[str, Any] | None = None,
skip_mm_cache: bool = False,
):
self._check_conversations_not_empty(conversations)
arrival_time = time.time()

if tok_params is None:
Expand Down