Transformers 多模态聊天模板:用 chat template 把图像、视频接入对话式 VLM
本文以 Hugging Face Transformers 的多模态聊天模板(Multimodal chat templates)指南为主体,系统讲解如何为视觉语言模型构建多模态对话:既可以用高层的 ImageTextToTextPipeline 以“聊天模式”直接对话,也可以用低层的 ProcessorMixin.apply_chat_template 与 model.generate 精细控制分词、图像/视频预处理与帧采样参数(num_frames、fps)。读完后,你将能完整复现“图像+文本”问答与“视频+文本”理解两种典型工作流,并理解其底层 Jinja 模板渲染、媒体抽取与帧采样机制。
多模态聊天的核心:content 是一个异构列表
多模态聊天模型在文本之外还能接受图像、音频或视频输入。与纯文本聊天模型的关键区别在于消息结构:纯文本模型的 content 键是单个字符串,而多模态聊天历史中的 content 键是一个列表,包含多个不同类型(text / image / video 等)的条目。
正如纯文本模型的 Tokenizer 类负责聊天模板与分词,多模态模型由 Processor 类负责预处理、分词和聊天模板。两者的 apply_chat_template 方法几乎相同。标准的消息格式如下:
messages = [
{
"role": "system",
"content": [{"type": "text", "text": "You are a friendly chatbot who always responds in the style of a pirate"}],
},
{
"role": "user",
"content": [
{"type": "image", "url": "http://images.cocodataset.org/val2017/000000039769.jpg"},
{"type": "text", "text": "What are these?"},
],
},
]
其中 {"type": "image", "url": "..."} 是“图像块”,{"type": "text", "text": "..."} 是“文本块”,两者按顺序排列在 content 列表中。从源码看,ProcessorMixin.apply_chat_template 的 docstring 明确了这一约定:每条 content 可以是 text 与 image/video 的列表,也可以直接提供图像/视频的 URL 或本地路径,在 return_dict=True 时会据此构造 pixel_values。
高层方案:用 ImageTextToTextPipeline 开启“聊天模式”
ImageTextToTextPipeline 是一个高层的图像-文本生成管线,支持“聊天模式”(chat mode)。聊天模式在检测到对话模型且聊天提示格式正确时自动启用(格式规范见 LLM 教程)。
构建消息并调用管线
创建 ImageTextToTextPipeline 并把上面的 chat 消息传入即可。对大模型而言:
- 设置
device_map="auto"可加快加载速度,并自动把模型放到最快的可用设备上; - 将数据类型设为
torch_dtype="auto"有助于节省显存、提升速度。
import torch
from transformers import pipeline
pipe = pipeline("image-text-to-text", model="Qwen/Qwen2.5-VL-3B-Instruct", device_map="auto", dtype="auto")
out = pipe(text=messages, max_new_tokens=128)
print(out[0]['generated_text'][-1]['content'])
输出示例(原文档中的真实运行结果):
Ahoy, me hearty! These be two feline friends, likely some tabby cats, taking a siesta on a cozy pink blanket. They're resting near remote controls, perhaps after watching some TV or just enjoying some quiet time together. Cats sure know how to find comfort and relaxation, don't they?
除了海盗腔会渐渐“退化”回标准英语(毕竟只是个 3B 模型)之外,回答是正确的。
源码视角:聊天模式是如何被检测和执行的
从 image_text_to_text.py 的实现看,聊天模式的机制有三个关键点:
- 聊天检测:
__call__通过_is_chat判断text参数是否为“字典列表”(list-of-dicts)。是则进入聊天模式;如果用户同时把图像单独放在images参数里,会抛出ValueError,明确提示“图像必须放在聊天消息的content里”。 - 预处理:聊天分支的
preprocess会把消息包装为Chat对象,然后调用self.processor.apply_chat_template(...),参数固定为tokenize=True, return_dict=True, return_tensors="pt",且add_generation_prompt与continue_final_message互补(见 preprocess 实现)。默认生成配置为max_new_tokens=256(_default_generation_config,可在generation_config.json中覆盖)。 - 输出裁剪:
postprocess会把生成结果与输入分别解码,若生成文本以输入文本开头(允许前后 ≤2 个残余字符,如空格换行),则把输入部分剔除,只保留新增内容;在聊天模式下还会把新内容包装成一条assistant消息追加到对话末尾。这解释了为何out[0]['generated_text'][-1]['content']恰好是最后一轮助手回复的文本块。
__call__ 还支持 return_full_text / return_type(FULL_TEXT / NEW_TEXT / TENSORS 三种枚举)、stop_sequence(会被转换为 stop_strings 并注入 tokenizer)、continue_final_message(用于“预填充”助手消息)等参数。
低层方案:apply_chat_template + generate
与纯文本模型的做法一致,使用 ProcessorMixin.apply_chat_template 为多模态模型准备聊天消息。该方法负责聊天消息的分词与格式化(包括图像等媒体类型),产物直接交给模型做生成。
加载模型与处理器
from transformers import AutoProcessor, AutoModelForImageTextToText
model = AutoModelForImageTextToText.from_pretrained("Qwen/Qwen2.5-VL-3B-Instruct", device_map="auto", torch_dtype="auto")
processor = AutoProcessor.from_pretrained("Qwen/Qwen2.5-VL-3B-Instruct")
messages = [
{
"role": "system",
"content": [{"type": "text", "text": "You are a friendly chatbot who always responds in the style of a pirate"}],
},
{
"role": "user",
"content": [
{"type": "image", "url": "http://images.cocodataset.org/val2017/000000039769.jpg"},
{"type": "text", "text": "What are these?"},
],
},
]
一步完成模板渲染 + 分词 + 图像预处理
把 messages 传给 apply_chat_template 进行输入内容分词。与文本模型不同,其输出除了分词文本外还包含携带预处理图像数据的 pixel_values 键:
processed_chat = processor.apply_chat_template(messages, add_generation_prompt=True, tokenize=True, return_dict=True, return_tensors="pt")
print(list(processed_chat.keys()))
['input_ids', 'attention_mask', 'pixel_values', 'image_grid_thw']
键的具体名称因处理器而异:上例中
image_grid_thw是 Qwen2.5-VL 特有的图像网格尺寸信息,其他 VLM 可能输出image_sizes、pixel_values_videos等不同键。
送入 generate 并解码
把这些输入直接传给 ~GenerationMixin.generate:
out = model.generate(**processed_chat.to(model.device), max_new_tokens=128)
print(processor.decode(out[0]))
解码输出包含截至目前的全部对话——用户消息、承载图像信息的占位 token 以及模型回复。展示给用户前,你可能需要先裁剪掉之前的对话内容(这与上面 pipeline 的 postprocess 自动裁剪逻辑相对应,低层使用时需要手动做,或使用 tokenizer 的 parse_response)。
源码视角:apply_chat_template 内部发生了什么
从 processing_utils.py 的完整签名与实现看,调用链如下:
- 模板选择:优先使用显式传入的
chat_template;否则回退到处理器自带的self.chat_template(若是多模板字典则要求default键或显式指定模板名)。 - OpenAI 格式归一化:自动把 OpenAI 风格的
{"type": "image_url", "image_url": {"url": ...}}内容块转换为 Hugging Face 风格的{"type": "image", "url": ...},两种生态的消息可以直接互换(见 L2106-L2121)。 - 媒体抽取:
tokenize=True时,逐条消息扫描content列表,分别收集type == "image"(支持image/url/path/base64键)、type == "video"(支持video/url/path键)与type == "audio"的条目,音频会立即用load_audio解码。 - Jinja 渲染:调用
render_jinja_template,把多轮对话渲染成单个可分词字符串(占位符如<image>由模板写入文本)。 - 调用处理器:把渲染后的
text与收集到的images/videos/audio一起交给self(...),得到input_ids、attention_mask、pixel_values等键;return_dict=True时返回BatchFeature,否则只返回input_ids。
完整参数表(摘自源码签名,L1980-L1995):
| 参数 | 默认值 | 说明 |
|---|---|---|
conversation |
必填 | 对话列表;支持单条或多条(批处理) |
chat_template |
None |
显式指定 Jinja 模板字符串或模板名 |
tools |
None |
工具定义列表(函数调用场景) |
add_generation_prompt |
False |
是否追加助手回复头,让模型开始新一轮回答 |
continue_final_message |
False |
让模型续写最后一条消息(不能与 add_generation_prompt 同用) |
return_assistant_tokens_mask |
False |
返回助手 token 掩码(要求 fast tokenizer) |
tokenize |
False |
是否分词并执行媒体预处理 |
return_tensors |
None |
张量框架(如 "pt") |
return_dict |
False |
tokenize=True 时返回 BatchFeature 而非裸 input_ids |
load_audio_from_video |
False |
从视频文件中抽取音频作为独立模态 |
processor_kwargs |
None |
透传给处理器 __call__ 的参数字典(如 fps、num_frames) |
一个值得注意的细节:当 tokenize=True 且你在 processor_kwargs 中传入 num_frames 或 fps 时,源码会自动把 do_sample_frames 置为 True(L2203-L2208)——即“只要指定了采样数量,就一定采样”。
视频输入:type: "video" 的三种来源与帧采样
一些视觉模型还支持视频输入。消息格式与图像输入非常相似:
- 内容块的
"type"应为"video",表明内容是视频; - 可以是视频链接(
"url")或文件路径("path")。视频用 torchcodec 解码;若 torchcodec 不可用且你使用的是较老版本的 torchvision,则回退到 torchvision 解码; - 除了 URL 和文件路径,还可以直接传入已解码的视频数据。如果你已在内存中预处理或解码过视频帧,这非常有用——无需落盘或存放 URL。
提示:PyAV 与 Decord 也可用,但前提是你自己解码视频,并通过
load_video(backend=...)显式指定后端。
完整视频问答示例
from transformers import AutoProcessor, LlavaOnevisionForConditionalGeneration
model_id = "llava-hf/llava-onevision-qwen2-0.5b-ov-hf"
model = LlavaOnevisionForConditionalGeneration.from_pretrained(model_id)
processor = AutoProcessor.from_pretrained(model_id)
messages = [
{
"role": "system",
"content": [{"type": "text", "text": "You are a friendly chatbot who always responds in the style of a pirate"}],
},
{
"role": "user",
"content": [
{"type": "video", "url": "https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/720/Big_Buck_Bunny_720_10s_10MB.mp4"},
{"type": "text", "text": "What do you see in this video?"},
],
},
]
直接传入已解码的视频对象
import numpy as np
video_object1 = np.random.randint(0, 255, size=(16, 224, 224, 3), dtype=np.uint8),
messages = [
{
"role": "system",
"content": [{"type": "text", "text": "You are a friendly chatbot who always responds in the style of a pirate"}],
},
{
"role": "user",
"content": [
{"type": "video", "video": video_object1},
{"type": "text", "text": "What do you see in this video?"}
],
},
]
注意这里用的是 "video" 键直接携带 numpy 帧数组(形状 [帧数, H, W, 3])。你也可以用现成的 load_video() 函数把视频加载进内存、按需编辑后再传入:
# Make sure a video backend library (torchcodec, pyav, or decord) is available.
from transformers.video_utils import load_video
# load a video file in memory for testing
video_object2, _ = load_video(
"https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/720/Big_Buck_Bunny_720_10s_10MB.mp4"
)
messages = [
{
"role": "system",
"content": [{"type": "text", "text": "You are a friendly chatbot who always responds in the style of a pirate"}],
},
{
"role": "user",
"content": [
{"type": "video", "video": video_object2},
{"type": "text", "text": "What do you see in this video?"}
],
},
]
从源码看,load_video 接受 URL(内部用 httpx 拉取到内存)或本地路径,返回 (np.ndarray, metadata) 元组;帧数组为 RGB、形状 [num_frames, height, width, 3]。它支持的后端有 decord、pyav、opencv、torchvision、torchcodec(其中 opencv 不能用于 URL 输入)。若传入了非字符串(即已解码的帧数组),函数会直接原样返回——这正是“直接传内存对象”能工作的前提。此外还支持通过 yt_dlp 加载 YouTube 链接(需额外安装 yt_dlp)。
帧采样三选一:num_frames / fps / 帧列表
把 messages 传给 apply_chat_template 时,还有几个控制采样过程的额外参数。
方式一:num_frames —— 固定帧数
num_frames 参数控制从视频中均匀采样多少帧。每个 checkpoint 都有一个预训练时见过的最大帧数,超过该值会显著降低生成质量。因此要选择既符合模型能力又符合硬件资源的帧数。如果不指定 num_frames,整个视频会被完整加载而不做任何帧采样。
processed_chat = processor.apply_chat_template(
messages,
add_generation_prompt=True,
tokenize=True,
return_dict=True,
return_tensors="pt",
num_frames=32,
)
print(processed_chat.keys())
这些输入即可直接用于 generate。
方式二:fps —— 按帧率采样
对更长的视频,可能希望用 fps 参数多采样一些帧以获得更好的表征。它决定每秒抽取多少帧。例如,一个 10 秒的视频在 fps=2 时会采样 20 帧。
processed_chat = processor.apply_chat_template(
messages,
add_generation_prompt=True,
tokenize=True,
return_dict=True,
fps=16,
)
print(processed_chat.keys())
方式三:图像帧路径列表
视频有时并非完整文件,而是以采样帧图像集合的形式存在。此时传入一组图像文件路径列表,处理器会自动把它们拼接成视频。请确保所有图像尺寸一致,因为默认它们来自同一个视频。
frames_paths = ["/path/to/frame0.png", "/path/to/frame5.png", "/path/to/frame10.png"]
messages = [
{
"role": "system",
"content": [{"type": "text", "text": "You are a friendly chatbot who always responds in the style of a pirate"}],
},
{
"role": "user",
"content": [
{"type": "video", "path": frames_paths},
{"type": "text", "text": "What do you see in this video?"},
],
},
]
processed_chat = processor.apply_chat_template(
messages,
add_generation_prompt=True,
tokenize=True,
return_dict=True,
)
print(processed_chat.keys())
补充说明:从 load_video 的校验逻辑 看,num_frames 与 fps(以及自定义的 sample_indices_fn)是互斥的,只能使用其中一个;帧采样本身由 default_sample_indices_fn 按均匀采样策略完成,也可传入自定义 sample_indices_fn 实现任意索引策略。
小结与使用建议
- 消息结构是核心契约:多模态聊天历史中
content必须是列表,媒体块与文本块按序混排;图像块支持url/path/base64,视频块支持url/path/video(内存对象)或图像帧路径列表。 - 两条路径:快速原型直接用
pipeline("image-text-to-text")的聊天模式(它内部正是调用processor.apply_chat_template,见 pipeline preprocess);需要精细控制帧采样、特殊 token 掩码或输出裁剪时,走apply_chat_template+generate低层路径。 - 帧数宁少勿多:
num_frames/fps要尊重 checkpoint 的预训练上限;不指定采样参数时整个视频会被完整加载,长视频务必显式采样。 - 输出注意:低层路径解码出的结果包含完整对话历史与图像占位 token,展示前需裁剪;高层 pipeline 则已自动完成裁剪与助手消息包装。
- OpenAI 格式兼容:
apply_chat_template会自动把image_url风格的块归一化为 Hugging Face 风格,迁移 OpenAI 多模态对话代码时消息体基本可以原样复用。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00