Renderers and chat templates: from messages to token ids
Code version: vLLM @ 3e44c0d · 2026-10-22
In one sentence
A renderer turns the messages of a request into token ids. It fills the chat template, tokenizes in a thread pool and checks the length.
The idea#
The model reads one list of token ids. A chat request has a list of messages, maybe images, maybe tools. Something in between must write the messages into one text with the model's special markers and turn that text into ids. The chat template (a Jinja program from the model's files) does the writing; the tokenizer does the cutting (Tokens and tokenizers). In vLLM, the object that drives both is the renderer.
The renderer has two layers. OnlineRenderer knows the OpenAI API: it checks tool_choice, refuses untrusted templates, merges the template variables and lets the parsers adjust the request (vllm/renderers/online_renderer.py:225-318). Under it, a BaseRenderer subclass knows the model family: HfRenderer for Hugging Face tokenizers, MistralRenderer for mistral-common, and a few others. Both layers run in the API server process (front end); the engine core gets token ids only.
The renderer also starts the clock. arrival_time is taken at the start of rendering, before the template runs. The TTFT metric of vLLM starts there (Serving metrics: TTFT, TPOT, ITL, throughput, goodput).
An everyday comparison. Think of a clerk who copies a customer's letter onto a fixed form, then types the form into a machine code. The form layout comes from the model's owner, not from the clerk. The comparison stops being true in one place: the "form" is a small program (Jinja) that can branch on variables such as add_generation_prompt or tools. So the same messages can give different prompts.
The picture#
Read each panel from left to right. Violet boxes run in the renderer's thread pool. The amber box is the length check; it can stop the request with HTTP 400 before the engine sees it.
In the code#
Step 1: which renderer#
AsyncLLM makes one renderer at start-up (vllm/v1/engine/async_llm.py:167). The choice uses the tokenizer mode as the renderer mode:
_VLLM_RENDERERS = {
"alpamayo2_super": ("hf", "HfRenderer"),
"cohere": ("cohere", "CohereRenderer"),
"deepseek_v32": ("deepseek_v32", "DeepseekV32Renderer"),
"deepseek_v4": ("deepseek_v4", "DeepseekV4Renderer"),
"deepseek_v41": ("deepseek_v4", "DeepseekV41Renderer"),
"hf": ("hf", "HfRenderer"),
"kimi_audio": ("hf", "HfRenderer"),
"kimi_k3": ("kimi_k3", "KimiK3Renderer"),
"mistral": ("mistral", "MistralRenderer"),
"terratorch": ("terratorch", "TerratorchRenderer"),
"inkling": ("inkling", "InklingRenderer"),
}def renderer_from_config(config: "VllmConfig", **kwargs):
model_config = config.model_config
tokenizer = cached_tokenizer_from_config(model_config, **kwargs)
renderer_mode, *_ = tokenizer_args_from_config(model_config, **kwargs)
return RENDERER_REGISTRY.load_renderer(renderer_mode, config, tokenizer)The default --tokenizer-mode auto is resolved in two places:
ModelConfigsets a family mode for some architectures: for exampleDeepseekV32ForCausalLM→deepseek_v32,DeepseekV4ForCausalLM→deepseek_v4,KimiK3ForConditionalGeneration→kimi_k3(vllm/config/model.py:746-766).- If the mode is still
auto, a Mistral repository withtekken.jsonortokenizer.model.v*files getsmistral. Everything else getshf(vllm/tokenizers/registry.py:145-164).
So most models use HfRenderer; a few families get their own renderer without a flag, and cohere needs --tokenizer-mode cohere. RendererRegistry.register can add or overwrite a mode, with a warning (vllm/renderers/registry.py:42-54).
Step 2: OnlineRenderer prepares the chat#
OpenAIServingChat calls OnlineRenderer.render_chat after the model and engine checks (vllm/entrypoints/openai/chat_completion/serving.py:278-285). Before any template work, it checks the tools and the template:
# Validate tool_choice when tool parsing is required but unavailable
if tool_parsing_unavailable and request.tool_choice not in (
None,
"none",
):
if request.tool_choice == "auto" and not self.enable_auto_tools:
# for hf tokenizers, "auto" tools requires
# --enable-auto-tool-choice and --tool-call-parser
return self.create_error_response(
'"auto" tool choice requires '
"--enable-auto-tool-choice and --tool-call-parser to be set"
)
elif request.tool_choice != "auto":
# "required" or named tool requires tool parser
if isinstance(request.tool_choice, ChatCompletionNamedToolChoiceParam):
tool_choice_desc = f'function "{request.tool_choice.function.name}"'
else:
tool_choice_desc = f'"{request.tool_choice}"'
return self.create_error_response(
f"tool_choice={tool_choice_desc} requires "
"--tool-call-parser to be set"
)
if request.tools is None or (
request.tool_choice == "none" and self.exclude_tools_when_tool_choice_none
):
tool_dicts = None
else:
tool_dicts = [tool.model_dump() for tool in request.tools]
if not self.use_harmony:
# Common case.
error_check_ret = self.validate_chat_template(
request_chat_template=request.chat_template,
chat_template_kwargs=request.chat_template_kwargs,
trust_request_chat_template=self.trust_request_chat_template,
)
if error_check_ret is not None:
return error_check_ret- Lines 260-281: without a tool parser,
tool_choice"auto","required"or a named function gives an error response. Mistral tokenizers and gpt-oss (Harmony) models skip this check (vllm/renderers/online_renderer.py:253-258). The page Tool and reasoning parsers: from model text to tool_calls and reasoning explains when the parser exists. - Lines 283-288: the tool definitions become plain dicts for the template. With
tool_choice: "none"and--exclude-tools-when-tool-choice-none, the template gets no tools. - Lines 292-296: a template in the request (field
chat_template, or the keychat_templateinchat_template_kwargs) is refused unless the server runs with--trust-request-chat-template(vllm/renderers/online_renderer.py:679-698).
preprocess_chat then merges the variables and calls the model-level renderer (vllm/renderers/online_renderer.py:774-815). After rendering, it builds a parser object and calls its adjust_request, if a tool or reasoning parser is set (vllm/renderers/online_renderer.py:824-859). A tool parser can add a structured-output constraint there (Structured outputs: JSON schemas, grammars and token bitmasks in vLLM).
Step 3: render_chat_async, step by step#
async def render_chat_async(
self,
conversations: Sequence[list["ChatCompletionMessageParam"]],
chat_params: ChatParams,
tok_params: TokenizeParams | None = None,
*,
prompt_extras: dict[str, Any] | None = None,
skip_mm_cache: bool = False,
):
arrival_time = time.time()
if tok_params is None:
tok_params = self.default_chat_tok_params
rendered = [
self.render_messages_async(conversation, chat_params)
for conversation in conversations
]
out_conversations = list[list["ConversationMessage"]]()
dict_prompts = list[DictPrompt]()
for conv, prompt in await asyncio.gather(*rendered):
out_conversations.append(conv)
dict_prompts.append(prompt)
tok_prompts = await self.tokenize_prompts_async(dict_prompts, tok_params)
prompt_extras = dict(prompt_extras or {})
prompt_extras["media_io_kwargs"] = chat_params.media_io_kwargs or {}
self._apply_prompt_extras(tok_prompts, prompt_extras)
eng_prompts = await asyncio.gather(
*(
self.process_for_engine_async(
p, arrival_time, skip_mm_cache=skip_mm_cache, tok_params=tok_params
)
for p in tok_prompts
)
)
return out_conversations, eng_prompts- Line 1380:
arrival_time = time.time(), before anything else. - Lines 1385-1394:
render_messages_asyncfor each conversation (the chat route sends one). It parses the content parts and applies the template; the result is a dict prompt with text or ids. - Line 1396:
tokenize_prompts_async. A prompt that already hasprompt_token_idsskips the tokenizer, but still gets the length check (vllm/renderers/base.py:756-769). - Lines 1398-1400: the extras
cache_salt,mm_processor_kwargsandmedia_io_kwargsgo into the prompt. - Lines 1402-1409:
process_for_engine_asyncruns the multimodal processor if needed and writesarrival_timeinto the engine input (vllm/renderers/base.py:1255-1276).
Step 4: which template#
HfRenderer resolves the template for every request, in this order:
def resolve_chat_template(
tokenizer: HfTokenizer,
chat_template: str | None,
tools: list[dict[str, Any]] | None,
*,
model_config: ModelConfig,
) -> str | None:
# 1st priority: The given chat template
if chat_template is not None:
# Resolve template names (e.g. "tool_use") to actual Jinja content
# so that downstream kwargs detection can parse template variables.
return tokenizer.get_chat_template(chat_template, tools=tools)
# 2nd priority: AutoProcessor chat template, unless tool calling is enabled
if tools is None:
chat_template = _try_get_processor_chat_template(
tokenizer,
revision=model_config.tokenizer_revision,
code_revision=(
model_config.code_revision
if model_config.tokenizer == model_config.model
else None
),
trust_remote_code=model_config.trust_remote_code,
)
if chat_template is not None:
return chat_template
# 3rd priority: AutoTokenizer chat template
try:
return tokenizer.get_chat_template(chat_template, tools=tools)
except Exception:
logger.debug(
"Failed to load AutoTokenizer chat template for %s",
tokenizer.name_or_path,
exc_info=True,
)
# 4th priority: Predefined fallbacks
path = get_chat_template_fallback_path(
model_type=model_config.hf_config.model_type,
tokenizer_name_or_path=tokenizer.name_or_path,
)
if path is not None:
logger.info_once(
"Loading chat template fallback for %s as there isn't one "
"defined on HF Hub.",
tokenizer.name_or_path,
)
chat_template = load_chat_template(path)
else:
logger.debug_once(
"There is no chat template fallback for %s", tokenizer.name_or_path
)
return chat_template- Given. The request template (only when trusted) or
--chat-template(vllm/entrypoints/openai/chat_completion/protocol.py:656). A name such astool_useselects a named template of the tokenizer (line 275). - Processor. The template of the model's
AutoProcessor, but only when the request has no tools (lines 278-290). Multimodal models often keep their template there. - Tokenizer. The template in the tokenizer files (line 294).
- Fallback. A template that vLLM ships, chosen by model type, for example
template_basic.jinjafor CLIP ortemplate_chatml.jinjafor older MiniCPM-V (vllm/transformers_utils/chat_templates/registry.py:25-40).
No template at all raises ChatTemplateResolutionError, a client error, so the request gets HTTP 400 (vllm/renderers/hf.py:842-847, vllm/exceptions.py:65-68). --chat-template accepts a file path, a built-in file name, or the template text itself (vllm/renderers/chat_utils/chat_template.py:56-101). A path-like value that does not exist stops the server at start-up (vllm/renderers/chat_utils/chat_template.py:21-53, vllm/entrypoints/common/cli_args.py:510-511).
Step 5: the template variables#
The template sees messages, tools and a set of keyword variables. The request builds them first:
def build_chat_params(
self,
default_template: str | None,
default_template_content_format: ChatTemplateContentFormatOption,
) -> ChatParams:
extra_kwargs: dict[str, Any] = dict(
add_generation_prompt=self.add_generation_prompt,
continue_final_message=self.continue_final_message,
documents=self.documents,
reasoning_effort=self.reasoning_effort,
)
# When reasoning is requested, activate thinking for models whose
# chat templates require explicit opt-in (e.g., Gemma4 defaults
# enable_thinking to false). For templates that don't declare the
# variable, resolve_chat_template_kwargs filters it out harmlessly.
user_kwargs = self.chat_template_kwargs or {}
if self.reasoning_effort is not None and "enable_thinking" not in user_kwargs:
extra_kwargs["enable_thinking"] = self.reasoning_effort != "none"
return ChatParams(
chat_template=self.chat_template or default_template,
chat_template_content_format=default_template_content_format,
chat_template_kwargs=merge_kwargs(
self.chat_template_kwargs,
extra_kwargs,
),
media_io_kwargs=self.media_io_kwargs,
# No-tools requests default to tool_choice="none" at the API
# layer. Collapse that default before rendering, so K3 emits a
# model-visible tool-choice instruction only for requests with a
# tools block.
tool_choice=self.tool_choice if self.tools else None,
response_format=self.response_format,
)add_generation_prompt(default true) andcontinue_final_message(default false) always go in. A request that sets both to true gets HTTP 400 (vllm/entrypoints/openai/chat_completion/protocol.py:1045-1055).reasoning_effortalso setsenable_thinking: false for"none", true for any other value. This does not happen when the request setsenable_thinkingitself (lines 652-653).chat_template_kwargsfrom the request win over--default-chat-template-kwargs. The merge drops request values that areNoneor"auto"(vllm/renderers/params.py:36-48,vllm/renderers/params.py:104-134).
Before the call, resolve_chat_template_kwargs keeps only the names that the template reads, that apply_chat_template accepts, or that the Hugging Face base class knows (vllm/renderers/hf.py:778-789). So a misspelled kwarg is dropped with no error. Two more fixes happen in safe_apply_chat_template. A developer message becomes a system message when the template does not know that role. An exception inside the template becomes VLLMValidationError, HTTP 400 (vllm/renderers/hf.py:848-861, vllm/renderers/hf.py:871-886).
Content format. Some templates read message.content as a string; others loop over a list of parts in OpenAI form. vLLM parses the template's syntax tree to find out, unless --chat-template-content-format sets it (vllm/renderers/hf.py:537-554, vllm/renderers/hf.py:678-710). With the string format, all text parts of a message are joined with newlines (vllm/renderers/chat_utils/chat_message.py:396-411).
Step 6: the thread pool and the timeout#
Template rendering and tokenization are blocking Python work. The renderer runs them in a thread pool, so the event loop stays free for other requests:
# Thread pool executor for blocking tokenizer operations. The
# multimodal processor receives a deep-copied tokenizer (see #36557)
# so it is safe to run tokenization and MM preprocessing concurrently.
pool_workers = config.model_config.renderer_num_workers
self._executor = _SwappableExecutor(max_workers=pool_workers)
self._resources.callback(self._executor.shutdown, wait=False)
# Separate single-worker executor so tokenization never queues behind
# MM preprocessing; must stay single-worker per #38418 (P0/P1 order).
self._mm_executor = ThreadPoolExecutor(max_workers=1)
self._resources.callback(self._mm_executor.shutdown, wait=False)
# Offload tokenization to the thread pool. The sync
# ``_tokenize_prompt`` already encapsulates the unified ``__call__``
# path and char-offset extraction, so the async variant is just it
# offloaded (mirrors ``_process_multimodal_async`` below).
self._tokenize_prompt_async = make_async(
self._tokenize_prompt, executor=self._executor
) async def _render_with_timeout(self, coro):
"""Run a template-rendering coroutine with a timeout.
On timeout, replaces the executor so subsequent renders are not
permanently blocked by the stuck thread.
"""
timeout = envs.VLLM_CHAT_TEMPLATE_RENDER_TIMEOUT
if timeout <= 0:
return await coro
try:
return await asyncio.wait_for(coro, timeout=timeout)
except TimeoutError:
self._replace_executor()
raise TimeoutError(
f"Chat template rendering timed out after "
f"{timeout}s. This may indicate a malicious or "
f"excessively complex template. Adjust "
f"VLLM_CHAT_TEMPLATE_RENDER_TIMEOUT to override."
) from None- The pool has
--renderer-num-workersthreads, 1 by default (vllm/config/model.py:412-421). With one thread, renders of different requests queue behind each other. Multimodal work has its own one-thread pool. - The HF tokenizer gets a pool of
renderer_num_workers + 1deep copies, so that calls from several threads do not share one Rust tokenizer object (vllm/renderers/hf.py:1015-1018,vllm/tokenizers/hf.py:27-47). - The template call has a timeout:
VLLM_CHAT_TEMPLATE_RENDER_TIMEOUT, 30 s by default, 0 or less for none (vllm/envs.py:817-819). On a timeout, the pool's threads are replaced, because Python cannot stop the stuck thread. The client gets aTimeoutError, which the generic handler turns into HTTP 500 (vllm/entrypoints/serve/exception_handling/error_response.py:93-96). - Only
HfRendererhas this timeout wrapper.MistralRenderercalls its template through the same pool with no timeout (vllm/renderers/mistral.py:58-60,vllm/renderers/mistral.py:104-108).
At start-up, init_app_state renders one warm-up message to compile the template (vllm/entrypoints/launchers/api_server/app_state.py:104, vllm/renderers/base.py:356-383). A model with no template only logs this at DEBUG level, so the first chat request is where the error shows.
Step 7: tokenization and the length check#
For chat, the tokenizer adds no special tokens of its own, because the template already writes them (vllm/entrypoints/openai/chat_completion/protocol.py:342-351). The request field add_special_tokens can turn them on. The limit is the context length:
def build_tok_params(self, model_config: ModelConfig) -> TokenizeParams:
if self.max_completion_tokens is not None:
max_output_tokens: int | None = self.max_completion_tokens
max_output_tokens_param = "max_completion_tokens"
else:
max_output_tokens = self.max_tokens
max_output_tokens_param = "max_tokens"
return TokenizeParams(
max_total_tokens=model_config.max_model_len,
max_output_tokens=max_output_tokens,
truncate_prompt_tokens=self.truncate_prompt_tokens,
truncation_side=self.truncation_side,
add_special_tokens=self.add_special_tokens,
needs_detokenization=bool(self.echo and not self.return_token_ids),
max_total_tokens_param="max_model_len",
max_output_tokens_param=max_output_tokens_param,
return_token_offsets=bool(self.return_token_offsets),
) def _token_len_check(self, tokenizer: TokenizerLike | None, tokens: _S) -> _S:
"""Apply length checks to prompt tokens if necessary."""
max_input_tokens = self.max_input_tokens
if max_input_tokens is None:
return tokens
if len(tokens) > max_input_tokens:
token_count = len(tokens)
# The tokenizer may have truncated the prompt to
# max_input_tokens + 1 (see get_encode_kwargs), so the
# actual prompt length could be larger.
qualifier = "at least " if token_count == max_input_tokens + 1 else ""
raise VLLMValidationError(
f"This model's maximum context length is "
f"{self.max_total_tokens} tokens. However, your prompt "
f"contains {qualifier}{token_count} input tokens, which "
f"exceeds this limit even without any output tokens. "
f"Please reduce the length of the input prompt.",
parameter="input_tokens",
value=token_count,
)A prompt of exactly max_model_len tokens passes this check. Then get_max_tokens computes 0 tokens of room (vllm/entrypoints/serve/utils/api_utils.py:248-253), and the sampling parameters reject max_tokens 0 with HTTP 400 (vllm/sampling_params.py:691-696). With truncate_prompt_tokens, the prompt is cut first, from the side that truncation_side names (left by default for generate models, vllm/tokenizers/registry.py:137-143). Generative requests keep one token free (vllm/renderers/params.py:225-244).
Step 8: multimodal content parts#
An image_url, input_audio or video_url part is fetched and stored in a tracker. The tracker asks the model class for its placeholder text, for example <image> (vllm/renderers/chat_utils/item_tracker.py:294). With the string content format and --interleave-mm-strings off (the default, vllm/config/multimodal.py:420), the placeholders go in front of the message text (vllm/renderers/chat_utils/chat_message.py:188-195). With the OpenAI format, the parts stay a list, and the template places them. The data goes into the prompt as multi_modal_data (vllm/renderers/hf.py:1263-1266). Later, the multimodal processor expands each placeholder into the real number of tokens. The page Multimodal serving: images, audio and video in vLLM covers that part.
Step 9: the derenderer and token-in token-out#
The renderer has a mirror image, OnlineDerenderer (vllm/renderers/online_derenderer.py:69-86). It turns generated token ids back into an OpenAI response: detokenization plus reasoning and tool parsing, with no GPU. It serves /v1/chat/completions/derender and /v1/completions/derender (vllm/entrypoints/scale_out/derender/api_router.py:31-33, vllm/entrypoints/scale_out/derender/api_router.py:81-83). Together with /v1/chat/completions/render and the token-in token-out route /inference/v1/generate, it splits serving into render, generate and derender. A front end on another machine can then do the CPU work.
These scale-out routes are off on a normal vllm serve. Without --enable-scale-out (or --tokens-only), they answer HTTP 403 (vllm/entrypoints/scale_out/factories.py:94-127). The docs describe the request formats: docs/serving/online_serving/renderer.md, derenderer.md and token_in_token_out.md (docs/serving/online_serving/token_in_token_out.md:1-9).
Two other ways skip the template on the normal routes. /v1/completions accepts token ids as the prompt (vllm/entrypoints/openai/completion/protocol.py:53-59). A chat request in disaggregated serving can carry the prefill's ids in kv_transfer_params["prompt_token_ids"]; the renderer then skips templating and tokenization, but not the checks (vllm/renderers/online_renderer.py:106-151, vllm/renderers/online_renderer.py:794-803).
Deep dive#
The sections below give the full detail. Open them one at a time, or use Open all deep dives at the top of the page.
Deep diveWorked example: three messages, one ChatML template, 31 tokens
The example uses the ChatML template of the repository (start the server with --chat-template examples/template_chatml.jinja) and the Qwen2.5 tokenizer. This is the whole template:
{% for message in messages %}{{'<|im_start|>' + message['role'] + '\n' + message['content']}}{% if (loop.last and add_generation_prompt) or not loop.last %}{{ '<|im_end|>' + '\n'}}{% endif %}{% endfor %}
{% if add_generation_prompt and messages[-1]['role'] != 'assistant' %}{{ '<|im_start|>assistant\n' }}{% endif %}The request has three messages and asks the model to continue the last one (a "prefill" of the answer):
{"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "What is the capital of France?"},
{"role": "assistant", "content": "The capital of France is"}],
"continue_final_message": true, "add_generation_prompt": false}
build_chat_paramssetsadd_generation_prompt=Falseandcontinue_final_message=True. Both pass the kwargs filter, because the template readsadd_generation_promptand the Hugging Face base method knows both names (vllm/renderers/hf.py:778-789).- Line 1 of the template writes each message as
<|im_start|>role\ncontent. It adds<|im_end|>\nafter every message except the last one, becauseloop.last and add_generation_promptis false andnot loop.lastis false. - Line 2 adds no new assistant turn, because
add_generation_promptis false.
The prompt string is (newlines shown as \n):
<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n<|im_start|>user\nWhat is the capital of France?<|im_end|>\n<|im_start|>assistant\nThe capital of France is
The tokenizer gives 31 ids (measured with the Qwen2.5 tokenizer.json, outside the repo):
[151644, 8948, 198, 2610, 525, 264, 10950, 17847, 13, 151645, 198,
151644, 872, 198, 3838, 374, 279, 6722, 315, 9625, 30, 151645, 198,
151644, 77091, 198, 785, 6722, 315, 9625, 374]
<|im_start|> = 151644 <|im_end|> = 151645 "\n" = 198
Now take the default flags instead (add_generation_prompt: true) and drop the assistant message. The last user turn then ends with <|im_end|>\n, and line 2 adds <|im_start|>assistant\n. That prompt has 26 tokens: the same first 26 ids as above. The model starts a new answer instead of continuing "The capital of France is".
Deep divePrecedence of template variables, with numbers
Server started with --default-chat-template-kwargs '{"enable_thinking": false, "documents_style": "short"}'. A request sends "chat_template_kwargs": {"enable_thinking": true, "foo": 1} and "reasoning_effort": null.
build_chat_paramsmerges the request kwargs withadd_generation_prompt=True,continue_final_message=False,documents=None,reasoning_effort=None. Values that areNoneare dropped bymerge_kwargs(vllm/renderers/params.py:48). Result:{enable_thinking: true, foo: 1, add_generation_prompt: true, continue_final_message: false}.with_defaultsputs the server defaults under it:enable_thinkingstaystrue(request wins);documents_style: "short"is added (vllm/renderers/params.py:117-123).preprocess_chataddstoolsandtokenize=Falseas defaults under that (vllm/renderers/online_renderer.py:774-792).resolve_chat_template_kwargskeepsenable_thinkingonly if the template reads it, and dropsfooanddocuments_styleunless the template reads them (vllm/renderers/hf.py:778-789).
The same merged dict also goes to the reasoning parser of the request (vllm/entrypoints/openai/chat_completion/serving.py:310-313). So enable_thinking: false changes both the prompt and how the parser reads the output.
Deep diveErrors of the render step
| Situation | Exception or response | HTTP | Where |
|---|---|---|---|
| template in the request, server not trusting | error response "Refused request with untrusted chat template" | 400 | vllm/renderers/online_renderer.py:686-697 |
tool_choice: "auto" without auto tool choice | error response | 400 | vllm/renderers/online_renderer.py:265-271 |
| no template found | ChatTemplateResolutionError | 400 | vllm/renderers/hf.py:842-847 |
the template raises (for example an unknown reasoning_effort) | VLLMValidationError with the template's message | 400 | vllm/renderers/hf.py:879-886 |
| template render takes longer than the timeout | TimeoutError; the pool is replaced | 500 | vllm/renderers/hf.py:1047-1056 |
prompt longer than max_model_len | VLLMValidationError, parameter input_tokens | 400 | vllm/renderers/params.py:524-538 |
| more placeholders in the text than media items | VLLMValidationError | 400 | vllm/renderers/chat_utils/chat_message.py:171-184 |
All of these happen before the serving class returns, so even a streaming request gets a real HTTP status, not an error chunk.
Deep diveFlags and environment variables
| Flag or variable | Default | Effect |
|---|---|---|
--chat-template | none (vllm/entrypoints/common/cli_args.py:85-87) | Template file, built-in name or text; first in the resolution order. |
--chat-template-content-format | auto (vllm/entrypoints/common/cli_args.py:88-93) | string or openai; auto detects it from the template. |
--trust-request-chat-template | false (vllm/entrypoints/common/cli_args.py:94-97) | Allow a template in the request. |
--default-chat-template-kwargs | none (vllm/entrypoints/common/cli_args.py:103-108) | Server defaults under the request's kwargs. |
--exclude-tools-when-tool-choice-none | false (vllm/entrypoints/common/cli_args.py:118-120) | Leave tools out of the prompt for tool_choice: "none". |
--renderer-num-workers | 1 (vllm/config/model.py:412) | Threads for template and tokenizer work. |
--tokenizer-mode | auto | Also selects the renderer (Step 1). |
VLLM_CHAT_TEMPLATE_RENDER_TIMEOUT | 30 s (vllm/envs.py:817-819) | Time limit of one HF template render; 0 or less turns it off (vllm/renderers/hf.py:1044-1046). |
What can go wrong#
- The model answers in the wrong format or rambles. The template may not be the one the model was trained with, for example a processor template chosen over the tokenizer template. Pass the right one with
--chat-template(Step 4). - A template variable has no effect. Misspelled or unused names in
chat_template_kwargsare dropped with no error (vllm/renderers/hf.py:778-789). - "Cannot set both continue_final_message and add_generation_prompt". To continue an assistant message, send
"add_generation_prompt": falsetoo (vllm/entrypoints/openai/chat_completion/protocol.py:1050-1054). - Slow first token under many concurrent requests. With the default single render thread, long templates or long prompts queue. Raise
--renderer-num-workersor add API servers (The OpenAI-compatible server: launch modes, app, serving classes and streaming). - HTTP 500 after 30 seconds. The template render timed out; a complex request template is the usual cause (Step 6).
- A double BOS token.
"add_special_tokens": trueon a chat request adds the tokenizer's BOS in front of the template's BOS (vllm/entrypoints/openai/chat_completion/protocol.py:342-351).
Why it matters. The prompt that the model sees is made here, and so is the start of the latency clock. Wrong templates give wrong answers with HTTP 200, and slow rendering shows up as TTFT that the engine cannot explain.
Check yourself#
- 1Which renderer class does a normal Hugging Face model use, and how is it chosen?easy
Show answer
HfRenderer. The renderer mode is the tokenizer mode (vllm/renderers/registry.py:84-90). For an architecture without a family mode,autobecomeshf, unless the repository is a Mistral repository with Mistral tokenizer files (vllm/config/model.py:746-766,vllm/tokenizers/registry.py:145-164). - 2A request has tools, and the model has both a processor template and a tokenizer template. Nomedium
--chat-templateis set. Which template renders it, and why?Show answer
The tokenizer template. The processor template is tried only when
toolsisNone(vllm/renderers/hf.py:277-290); with tools, resolution goes on to the tokenizer template (vllm/renderers/hf.py:292-294). - 3Why does the chat route tokenize withmedium
add_special_tokens=False, while/v1/completionsusesTrue?Show answer
The chat template already writes the special tokens, such as BOS and the turn markers. Adding them again would double them (
vllm/entrypoints/openai/chat_completion/protocol.py:342-351). A raw completion prompt has no template, so the completion request's fieldadd_special_tokensdefaults to true (vllm/entrypoints/openai/completion/protocol.py:118-124,vllm/entrypoints/openai/completion/protocol.py:299-310). - 4With the ChatML template, a request sends two messages (system and user) with the default flags. How many tokens does the prompt of the worked example have, and what is the last token?hard
Show answer
26 tokens with the Qwen2.5 tokenizer. The last user turn ends with
<|im_end|>\nbecauseadd_generation_promptis true, and line 2 of the template adds<|im_start|>assistant\n(examples/template_chatml.jinja:1-2). So the last token is\n(198), after theassistanttoken 77091 (deep dive "Worked example"). - 5The server runs with the defaulthard
--renderer-num-workers. 50 chat requests arrive at the same moment, and each needs 20 ms of pool work (template plus tokenizer). About how long does the last request wait before its own render starts, and is that wait inside vLLM's TTFT metric?Show answer
About 49 × 20 ms ≈ 1 s. The pool has one thread by default, and the template and the tokenizer of every request run in it (
vllm/config/model.py:412,vllm/renderers/base.py:143-158,vllm/renderers/hf.py:1011-1013). The wait is inside the metric, becausearrival_timeis taken at the start ofrender_chat_async, before the request waits for the pool (vllm/renderers/base.py:1380). (Derived; real render times depend on the template and the prompt.)