멀티모달 입력

멀티모달 입력 (Multimodal Inputs)

이 페이지는 vLLM의 멀티모달 모델 에 멀티모달 입력을 전달하는 방법을 알려줘요. 이미지, 비디오, 오디오, 그리고 사전 계산된 임베딩 입력을 오프라인 추론과 OpenAI 호환 온라인 서빙 양쪽에서 다룹니다.

참고: vLLM은 멀티모달 지원을 적극적으로 개선하고 있습니다. 향후 변경 사항은 이 RFC 를, 피드백이나 기능 요청은 GitHub에 이슈를 열어 주세요.

: 멀티모달 모델을 서빙할 때는 --allowed-media-domains 를 설정해 vLLM이 접근할 수 있는 도메인을 제한하는 것을 고려하세요. 이렇게 하면 SSRF(Server-Side Request Forgery) 공격에 취약할 수 있는 임의 엔드포인트 접근을 막을 수 있어요. 예: --allowed-media-domains upload.wikimedia.org github.com www.bogotobogo.com. 또한 VLLM_MEDIA_URL_ALLOW_REDIRECTS=0 을 설정하면 HTTP 리다이렉트를 따라 도메인 제한을 우회하는 것을 방지할 수 있습니다.

이 제한은 vLLM을 컨테이너 환경에서 실행할 때 특히 중요합니다. vLLM pod가 내부 네트워크에 무제한 접근할 수 있을 수 있기 때문이에요.

출처: 문서

본문

오프라인 추론 (Offline Inference)

멀티모달 데이터를 입력하려면 vllm.inputs.PromptType 의 스키마를 따르세요.

  • prompt: HuggingFace에 문서화된 형식을 따라야 합니다.
  • multi_modal_data: vllm.inputs.MultiModalDataDict 에 정의된 스키마를 따르는 딕셔너리입니다.

이미지 입력 (Image Inputs)

멀티모달 딕셔너리의 'image' 필드에 단일 이미지를 전달할 수 있어요.

from vllm import LLM

llm = LLM(model="llava-hf/llava-1.5-7b-hf")

# Refer to the HuggingFace repo for the correct format to use
prompt = "USER: <image>\nWhat is the content of this image?\nASSISTANT:"

# Load the image using PIL.Image
image = PIL.Image.open(...)

# Single prompt inference
outputs = llm.generate({
    "prompt": prompt,
    "multi_modal_data": {"image": image},
})

for o in outputs:
    generated_text = o.outputs[0].text
    print(generated_text)

# Batch inference
image_1 = PIL.Image.open(...)
image_2 = PIL.Image.open(...)
outputs = llm.generate(
    [
        {
            "prompt": "USER: <image>\nWhat is the content of this image?\nASSISTANT:",
            "multi_modal_data": {"image": image_1},
        },
        {
            "prompt": "USER: <image>\nWhat's the color of this image?\nASSISTANT:",
            "multi_modal_data": {"image": image_2},
        }
    ]
)

for o in outputs:
    generated_text = o.outputs[0].text
    print(generated_text)

전체 예시: examples/generate/multimodal/vision_language_offline.py

같은 텍스트 프롬프트 안에서 여러 이미지를 대체하려면 이미지 목록을 전달하세요.

from vllm import LLM

llm = LLM(
    model="microsoft/Phi-3.5-vision-instruct",
    trust_remote_code=True,  # Required to load Phi-3.5-vision
    max_model_len=4096,  # Otherwise, it may not fit in smaller GPUs
    limit_mm_per_prompt={"image": 2},  # The maximum number to accept
)

# Refer to the HuggingFace repo for the correct format to use
prompt = "<|user|>\n<|image_1|>\n<|image_2|>\nWhat is the content of each image?<|end|>\n<|assistant|>\n"

# Load the images using PIL.Image
image1 = PIL.Image.open(...)
image2 = PIL.Image.open(...)

outputs = llm.generate({
    "prompt": prompt,
    "multi_modal_data": {"image": [image1, image2]},
})

for o in outputs:
    generated_text = o.outputs[0].text
    print(generated_text)

전체 예시: examples/generate/multimodal/vision_language_multi_image_offline.py

LLM.chat 메서드를 사용하면 이미지 URL, PIL Image 객체, 사전 계산된 임베딩 등 다양한 형식으로 메시지 콘텐츠에 이미지를 직접 전달할 수 있어요.

from vllm import LLM
from vllm.assets.image import ImageAsset

llm = LLM(model="llava-hf/llava-1.5-7b-hf")
image_url = "https://picsum.photos/id/32/512/512"
image_pil = ImageAsset('cherry_blossom').pil_image
image_embeds = torch.load(...)

conversation = [
    {"role": "system", "content": "You are a helpful assistant"},
    {"role": "user", "content": "Hello"},
    {"role": "assistant", "content": "Hello! How can I assist you today?"},
    {
        "role": "user",
        "content": [
            {
                "type": "image_url",
                "image_url": {"url": image_url},
            },
            {
                "type": "image_pil",
                "image_pil": image_pil,
            },
            {
                "type": "image_embeds",
                "image_embeds": image_embeds,
            },
            {
                "type": "text",
                "text": "What's in these images?",
            },
        ],
    },
]

# Perform inference and log output.
outputs = llm.chat(conversation)

for o in outputs:
    generated_text = o.outputs[0].text
    print(generated_text)

멀티이미지 입력은 비디오 캡셔닝으로 확장할 수 있습니다. 비디오를 지원하는 Qwen2-VL 로 보여드릴게요.

from vllm import LLM

# Specify the maximum number of frames per video to be 4. This can be changed.
llm = LLM("Qwen/Qwen2-VL-2B-Instruct", limit_mm_per_prompt={"image": 4})

# Create the request payload.
video_frames = ... # load your video making sure it only has the number of frames specified earlier.
message = {
    "role": "user",
    "content": [
        {
            "type": "text",
            "text": "Describe this set of frames. Consider the frames to be a part of the same video.",
        },
    ],
}
for i in range(len(video_frames)):
    base64_image = encode_image(video_frames[i]) # base64 encoding.
    new_image = {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{base64_image}"}}
    message["content"].append(new_image)

# Perform inference and log output.
outputs = llm.chat([message])

for o in outputs:
    generated_text = o.outputs[0].text
    print(generated_text)
커스텀 RGBA 배경색 (Custom RGBA Background Color)

RGBA 이미지(투명도가 있는 이미지)를 로드할 때 vLLM은 이를 RGB 형식으로 변환합니다. 기본적으로 투명 픽셀은 흰색 배경으로 대체됩니다. media_io_kwargsrgba_background_color 파라미터로 이 배경색을 커스터마이즈할 수 있어요.

from vllm import LLM

# Default white background (no configuration needed)
llm = LLM(model="llava-hf/llava-1.5-7b-hf")

# Custom black background for dark theme
llm = LLM(
    model="llava-hf/llava-1.5-7b-hf",
    media_io_kwargs={"image": {"rgba_background_color": [0, 0, 0]}},
)

# Custom brand color background (e.g., blue)
llm = LLM(
    model="llava-hf/llava-1.5-7b-hf",
    media_io_kwargs={"image": {"rgba_background_color": [0, 0, 255]}},
)

참고:

  • rgba_background_color[R, G, B] 목록 또는 (R, G, B) 튜플로 RGB 값을 받으며, 각 값은 0-255입니다.
  • 이 설정은 투명도가 있는 RGBA 이미지에만 영향을 미칩니다. RGB 이미지는 그대로입니다.
  • 지정하지 않으면 하위 호환성을 위해 기본 흰색 배경 (255, 255, 255) 이 사용됩니다.
Moondream3 프롬프트 레시피 (Moondream3 Prompt Recipes)

Moondream3ForCausalLM 은 두 가지 작업별 프롬프트 형식을 지원합니다.

  • query: 이미지에 대해 질문합니다.
  • caption: 이미지에 대한 캡션을 생성합니다.
from vllm import LLM, SamplingParams
from vllm.assets.image import ImageAsset

llm = LLM(
    model="moondream/moondream3-preview",
    tokenizer="moondream/starmie-v1",
    trust_remote_code=True,
    max_model_len=2048,
    limit_mm_per_prompt={"image": 1},
)

image = ImageAsset("stop_sign").pil_image

def make_query_prompt(question: str) -> str:
    return (
        "<|endoftext|><image><|md_reserved_0|>query<|md_reserved_1|>"
        f"{question}<|md_reserved_2|>"
    )

def make_caption_prompt(length: str = "normal") -> str:
    return (
        "<|endoftext|><image><|md_reserved_0|>"
        f"describe<|md_reserved_1|>{length}<|md_reserved_2|>"
    )

query_out = llm.generate(
    {
        "prompt": make_query_prompt("What is shown in this image?"),
        "multi_modal_data": {"image": image},
    },
    SamplingParams(max_tokens=64, temperature=0),
)[0].outputs[0].text

caption_out = llm.generate(
    {
        "prompt": make_caption_prompt(),
        "multi_modal_data": {"image": image},
    },
    SamplingParams(max_tokens=100, temperature=0),
)[0].outputs[0].text

print("query:", query_out)
print("caption:", caption_out)

참고: 네이티브 Moondream3 모델에는 detectpoint 스킬도 있습니다. 이들은 커스텀 좌표 디코딩이 필요하며 이 vLLM 구현에서는 노출되지 않습니다.

비디오 입력 (Video Inputs)

멀티이미지 입력 대신 NumPy 배열 목록을 멀티모달 딕셔너리의 'video' 필드에 직접 전달할 수 있어요.

NumPy 배열 대신 'torch.Tensor' 인스턴스도 전달할 수 있습니다. Qwen2.5-VL을 사용한 예시입니다.

from transformers import AutoProcessor
from vllm import LLM, SamplingParams
from qwen_vl_utils import process_vision_info

model_path = "Qwen/Qwen2.5-VL-3B-Instruct"
video_path = "https://content.pexels.com/videos/free-videos.mp4"

llm = LLM(
    model=model_path,
    gpu_memory_utilization=0.8,
    enforce_eager=True,
    limit_mm_per_prompt={"video": 1},
)

sampling_params = SamplingParams(max_tokens=1024)

video_messages = [
    {
        "role": "system",
        "content": "You are a helpful assistant.",
    },
    {
        "role": "user",
        "content": [
            {"type": "text", "text": "describe this video."},
            {
                "type": "video",
                "video": video_path,
                "total_pixels": 20480 * 28 * 28,
                "min_pixels": 16 * 28 * 28,
            },
        ]
    },
]

messages = video_messages
processor = AutoProcessor.from_pretrained(model_path)
prompt = processor.apply_chat_template(
    messages,
    tokenize=False,
    add_generation_prompt=True,
)

image_inputs, video_inputs = process_vision_info(messages)
mm_data = {}
if video_inputs is not None:
    mm_data["video"] = video_inputs

llm_inputs = {
    "prompt": prompt,
    "multi_modal_data": mm_data,
}

outputs = llm.generate([llm_inputs], sampling_params=sampling_params)
for o in outputs:
    generated_text = o.outputs[0].text
    print(generated_text)

참고: 'process_vision_info' 는 Qwen2.5-VL 및 유사 모델에만 적용됩니다.

전체 예시: examples/generate/multimodal/vision_language_offline.py

비디오 토큰 프루닝 (Video Token Pruning)

지원 모델에서 vLLM은 비전 인코더 이후 비디오 토큰을 프루닝해 prefill 시간과 KV 캐시 사용량을 줄일 수 있습니다(약간의 정확도 손실 비용). --video-pruning-rate <q> 를 설정해 각 비디오에서 비디오 토큰의 비율 q 를 프루닝하고, --video-pruning-method 로 학습이 필요 없는 알고리즘을 선택하세요.

  • evs (Efficient Video Sampling, 기본값): 이전 프레임과 시공간 유사성이 가장 낮은 토큰을 제거합니다. 첫 번째 프레임은 항상 완전히 유지됩니다.
  • vidcom2 (Video Compression Commander): 비디오 레벨과 프레임 레벨 피처 센터와의 유사성으로 토큰에 점수를 매기고, 구별되는 프레임에 예산의 더 큰 몫을 줍니다. 프레임당 최소 하나의 토큰은 유지됩니다.
vllm serve Qwen/Qwen3-VL-8B-Instruct \
    --video-pruning-rate 0.75 --video-pruning-method vidcom2

참고: 멀티모달 프루닝을 구현한 모든 모델이 evs 를 지원하고, vidcom2 는 현재 Qwen3-VL만 지원합니다. 지원되지 않는 조합은 시작 시 거부됩니다. 비디오 프루닝을 활성화하면 유지되는 토큰 수가 데이터에 의존하게 되므로 인코더 CUDA 그래프도 비활성화됩니다.

오디오 입력 (Audio Inputs)

멀티모달 딕셔너리의 'audio' 필드에 튜플 (array, sampling_rate) 을 전달할 수 있어요.

전체 예시: examples/generate/multimodal/audio_language_offline.py

긴 오디오 청킹 (Chunking Long Audio for Transcription)

Whisper 같은 음성-텍스트 모델은 처리할 수 있는 최대 오디오 길이(보통 30초)가 있습니다. 더 긴 오디오 파일에는 vLLM이 조용한 지점에서 오디오를 지능적으로 분할해 말소리를 자르는 것을 최소화하는 유틸리티를 제공합니다.

from vllm import LLM, SamplingParams
from vllm.multimodal.audio import split_audio
from vllm.multimodal.media.audio import load_audio

# Load long audio file
audio, sr = load_audio("long_audio.wav", sr=16000)

# Split into chunks at low-energy (quiet) regions
chunks = split_audio(
    audio_data=audio,
    sample_rate=sr,
    max_clip_duration_s=30.0,      # Maximum chunk length in seconds
    overlap_duration_s=1.0,         # Search window for finding quiet split points
    min_energy_window_size=1600,    # Window size for energy calculation (~100ms at 16kHz)
)

# Initialize Whisper model
llm = LLM(model="openai/whisper-large-v3-turbo")
sampling_params = SamplingParams(temperature=0, max_tokens=256)

# Transcribe each chunk
transcriptions = []
for chunk in chunks:
    outputs = llm.generate({
        "prompt": "<|startoftranscript|><|en|><|transcribe|><|notimestamps|>",
        "multi_modal_data": {"audio": (chunk, sr)},
    }, sampling_params)
    transcriptions.append(outputs[0].outputs[0].text)

# Combine results
full_transcription = " ".join(transcriptions)

split_audio 함수는:

  • 1D 모노 오디오를 기대합니다(load_audio 는 기본적으로 다운믹스).
  • 말소리를 자르지 않기 위해 조용한 지점에서 오디오를 분할합니다.
  • 오버랩 윈도우 내에서 저진폭 영역을 찾기 위해 RMS 에너지를 사용합니다.
  • 모든 오디오 샘플을 보존합니다(데이터 손실 없음).
  • 어떤 샘플 레이트도 지원합니다.
자동 오디오 채널 정규화 (Automatic Audio Channel Normalization)

vLLM은 특정 오디오 형식을 요구하는 모델에 대해 오디오 채널을 자동으로 정규화합니다. torchaudio 같은 라이브러리로 오디오를 로드하면 스테레오 파일은 [channels, time] 형태를 반환하지만, 많은 오디오 모델(특히 Whisper 기반)은 [time] 형태의 모노 오디오를 기대합니다.

자동 모노 변환을 지원하는 모델:

  • Whisper 및 모든 Whisper 기반 모델
  • Qwen2-Audio
  • Qwen2.5-Omni / Qwen3-Omni(Qwen2.5-Omni에서 상속)
  • Ultravox

이 모델들에 대해 vLLM은 자동으로:

  • 피처 추출기를 통해 모델이 모노 오디오를 요구하는지 감지합니다.
  • 채널 평균화로 멀티채널 오디오를 모노로 변환합니다.
  • (channels, time) 형식(torchaudio)과 (time, channels) 형식(soundfile) 모두 처리합니다.

스테레오 오디오 예시:

import torchaudio
from vllm import LLM

# Load stereo audio file - returns (channels, time) shape
audio, sr = torchaudio.load("stereo_audio.wav")
print(f"Original shape: {audio.shape}")  # e.g., torch.Size([2, 16000])

# vLLM automatically converts to mono for Whisper-based models
llm = LLM(model="openai/whisper-large-v3")

outputs = llm.generate({
    "prompt": "",
    "multi_modal_data": {"audio": (audio.numpy(), sr)},
})

수동 변환이 필요 없습니다. vLLM이 모델 요구 사항에 따라 채널 정규화를 자동으로 처리합니다.

임베딩 입력 (Embedding Inputs)

데이터 타입(이미지, 비디오, 오디오)에 속하는 사전 계산된 임베딩을 언어 모델에 직접 입력하려면 형태 (..., hidden_size of LM) 의 텐서를 멀티모달 딕셔너리의 해당 필드에 전달하세요. 정확한 형태는 사용하는 모델에 따라 달라집니다.

enable_mm_embeds=True 로 이 기능을 활성화해야 합니다.

경고: 잘못된 형태의 임베딩을 전달하면 vLLM 엔진이 충돌할 수 있습니다. 이 플래그는 신뢰할 수 있는 사용자에게만 활성화하세요!

이미지 임베딩:

from vllm import LLM

# Inference with image embeddings as input
llm = LLM(model="llava-hf/llava-1.5-7b-hf", enable_mm_embeds=True)

# Refer to the HuggingFace repo for the correct format to use
prompt = "USER: <image>\nWhat is the content of this image?\nASSISTANT:"

# For most models, `image_embeds` has shape: (num_images, image_feature_size, hidden_size)
image_embeds = torch.load(...)

outputs = llm.generate({
    "prompt": prompt,
    "multi_modal_data": {"image": image_embeds},
})

for o in outputs:
    generated_text = o.outputs[0].text
    print(generated_text)

# Additional examples for models that require extra fields
llm = LLM(
    "Qwen/Qwen2-VL-2B-Instruct",
    limit_mm_per_prompt={"image": 4},
    enable_mm_embeds=True,
)
mm_data = {
    "image": {
        # Shape: (total_feature_size, hidden_size)
        # total_feature_size = sum(image_feature_size for image in images)
        "image_embeds": torch.load(...),
        # Shape: (num_images, 3)
        # image_grid_thw is needed to calculate positional encoding.
        "image_grid_thw": torch.load(...),
    }
}

llm = LLM(
    "openbmb/MiniCPM-V-2_6",
    trust_remote_code=True,
    limit_mm_per_prompt={"image": 4},
    enable_mm_embeds=True,
)
mm_data = {
    "image": {
        # Shape: (num_images, num_slices, hidden_size)
        # num_slices can differ for each image
        "image_embeds": [torch.load(...) for image in images],  
        # Shape: (num_images, 2)
        # image_sizes is needed to calculate details of the sliced image.
        "image_sizes": [image.size for image in images],
    }
}

Qwen3-VL의 경우 image_embeds 는 기본 이미지 임베딩과 deepstack 피처를 모두 포함해야 합니다.

오디오 임베딩 입력:

from vllm import LLM
import torch

# Enable audio embeddings support
llm = LLM(model="fixie-ai/ultravox-v0_5-llama-3_2-1b", enable_mm_embeds=True)

# Refer to the HuggingFace repo for the correct format to use
prompt = "USER: <audio>\nWhat is in this audio?\nASSISTANT:"

# Load pre-computed audio embeddings, usually with shape:
# (num_audios, audio_feature_size, hidden_size of LM)
audio_embeds = torch.load(...)

outputs = llm.generate({
    "prompt": prompt,
    "multi_modal_data": {"audio": audio_embeds},
})

for o in outputs:
    generated_text = o.outputs[0].text
    print(generated_text)

캐시된 입력 (Cached Inputs)

멀티모달 입력을 사용할 때 vLLM은 일반적으로 각 미디어 항목을 콘텐츠로 해시해 요청 간 캐싱을 가능하게 합니다. 선택적으로 multi_modal_uuids 를 전달해 각 항목에 안정적인 고유 ID를 제공하면 원시 콘텐츠를 재해시하지 않고 요청 간 캐싱 작업을 재사용할 수 있어요.

from vllm import LLM
from PIL import Image

# Qwen2.5-VL example with two images
llm = LLM(model="Qwen/Qwen2.5-VL-3B-Instruct")

prompt = "USER: <image><image>\nDescribe the differences.\nASSISTANT:"
img_a = Image.open("/path/to/a.jpg")
img_b = Image.open("/path/to/b.jpg")

outputs = llm.generate({
    "prompt": prompt,
    "multi_modal_data": {"image": [img_a, img_b]},
    # Provide stable IDs for caching.
    # Requirements (matched by this example):
    #  - Include every modality present in multi_modal_data.
    #  - For lists, provide the same number of entries.
    #  - Use None to fall back to content hashing for that item.
    "multi_modal_uuids": {"image": ["sku-1234-a", None]},
})

for o in outputs:
    print(o.outputs[0].text)

UUID를 사용하면 해당 항목에 대해 캐시 적중을 기대할 때 미디어 데이터 전송을 완전히 건너뛸 수도 있어요. 단, 건너뛴 미디어에 해당 UUID가 없거나 UUID가 캐시에 적중하지 않으면 요청이 실패한다는 점에 유의하세요.

from vllm import LLM
from PIL import Image

# Qwen2.5-VL example with two images
llm = LLM(model="Qwen/Qwen2.5-VL-3B-Instruct")

prompt = "USER: <image><image>\nDescribe the differences.\nASSISTANT:"
img_b = Image.open("/path/to/b.jpg")

outputs = llm.generate({
    "prompt": prompt,
    "multi_modal_data": {"image": [None, img_b]},
    # Since img_a is expected to be cached, we can skip sending the actual
    # image entirely.
    "multi_modal_uuids": {"image": ["sku-1234-a", None]},
})

for o in outputs:
    print(o.outputs[0].text)

경고: 멀티모달 프로세서 캐싱과 프리픽스 캐싱이 모두 비활성화되면 사용자가 제공한 multi_modal_uuids 는 무시됩니다.

온라인 서빙 (Online Serving)

OpenAI 호환 서버는 Chat Completions API 를 통해 멀티모달 데이터를 받습니다. 미디어 입력은 각 미디어를 고유하게 식별하기 위해 사용자가 제공하는 선택적 UUID도 지원하며, 이를 사용해 요청 간 미디어 결과를 캐시합니다.

중요: Chat Completions API를 사용하려면 채팅 템플릿이 필수입니다. HF 형식 모델의 기본 채팅 템플릿은 chat_template.json 또는 tokenizer_config.json 안에 정의됩니다. 기본 채팅 템플릿이 없으면 먼저 vllm/transformers_utils/chat_templates/registry.py 에서 내장 폴백을 찾습니다. 폴백도 없으면 오류가 발생하며 --chat-template 인자로 채팅 템플릿을 직접 제공해야 합니다. 특정 모델에는 examples 안에 대안 채팅 템플릿이 있습니다. 예를 들어 VLM2Vec은 Phi-3-Vision용 기본 템플릿과 다른 examples/pooling/embed/template/vlm2vec_phi3v.jinja 를 사용합니다.

이미지 입력 (Image Inputs)

이미지 입력은 OpenAI Vision API 에 따라 지원됩니다. Phi-3.5-Vision을 사용한 간단한 예시입니다.

먼저 OpenAI 호환 서버를 시작합니다.

vllm serve microsoft/Phi-3.5-vision-instruct --runner generate \
  --trust-remote-code --max-model-len 4096 --limit-mm-per-prompt.image 2

그런 다음 OpenAI 클라이언트를 다음과 같이 사용할 수 있습니다.

import os
from openai import OpenAI

openai_api_key = "EMPTY"
openai_api_base = "http://localhost:8000/v1"

client = OpenAI(
    api_key=openai_api_key,
    base_url=openai_api_base,
)

# Single-image input inference

# Public image URL for testing remote image processing
image_url = "https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg"

# Create chat completion with remote image
chat_response = client.chat.completions.create(
    model="microsoft/Phi-3.5-vision-instruct",
    messages=[
        {
            "role": "user",
            "content": [
                # NOTE: The prompt formatting with the image token `<image>` is not needed
                # since the prompt will be processed automatically by the API server.
                {
                    "type": "text",
                    "text": "What’s in this image?",
                },
                {
                    "type": "image_url",
                    "image_url": {"url": image_url},
                    "uuid": image_url,  # Optional
                },
            ],
        }
    ],
)
print("Chat completion output:", chat_response.choices[0].message.content)

# Local image file path (update this to point to your actual image file)
image_file = "/path/to/image.jpg"

# Create chat completion with local image file
# Launch the API server/engine with the --allowed-local-media-path argument.
if os.path.exists(image_file):
    chat_completion_from_local_image_url = client.chat.completions.create(
        model="microsoft/Phi-3.5-vision-instruct",
        messages=[
            {
                "role": "user",
                "content": [
                    {
                        "type": "text",
                        "text": "What’s in this image?",
                    },
                    {
                        "type": "image_url",
                        "image_url": {"url": f"file://{image_file}"},
                    },
                ],
            }
        ],
    )
    result = chat_completion_from_local_image_url.choices[0].message.content
    print("Chat completion output from local image file:\n", result)
else:
    print(f"Local image file not found at {image_file}, skipping local file test.")

# Multi-image input inference
image_url_duck = "https://vllm-public-assets.s3.us-west-2.amazonaws.com/multimodal_asset/duck.jpg"
image_url_lion = "https://vllm-public-assets.s3.us-west-2.amazonaws.com/multimodal_asset/lion.jpg"

chat_response = client.chat.completions.create(
    model="microsoft/Phi-3.5-vision-instruct",
    messages=[
        {
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": "What are the animals in these images?",
                },
                {
                    "type": "image_url",
                    "image_url": {"url": image_url_duck},
                    "uuid": image_url_duck,  # Optional
                },
                {
                    "type": "image_url",
                    "image_url": {"url": image_url_lion},
                    "uuid": image_url_lion,  # Optional
                },
            ],
        }
    ],
)
print("Chat completion output:", chat_response.choices[0].message.content)

전체 예시: examples/generate/multimodal/openai_chat_completion_client_for_multimodal.py

:

  • 로컬 파일 경로에서의 로드도 vLLM에서 지원됩니다. API 서버/엔진을 시작할 때 --allowed-local-media-path 로 허용된 로컬 미디어 경로를 지정하고, API 요청의 url 에 파일 경로를 전달하면 됩니다.
  • API 요청의 텍스트 콘텐츠에 이미지 플레이스홀더를 넣을 필요가 없습니다. 이미 이미지 콘텐츠로 표현되기 때문이에요. 실제로 텍스트와 이미지 콘텐츠를 번갈아 넣어 텍스트 중간에 이미지 플레이스홀더를 배치할 수 있습니다.
  • 참고: 기본적으로 HTTP URL을 통한 이미지 fetch 타임아웃은 5 초입니다. 환경 변수로 오버라이드할 수 있어요: export VLLM_IMAGE_FETCH_TIMEOUT=<timeout>

비디오 입력 (Video Inputs)

image_url 대신 video_url 로 비디오 파일을 전달할 수 있어요. LLaVA-OneVision 을 사용한 간단한 예시입니다.

먼저 OpenAI 호환 서버를 시작합니다.

vllm serve llava-hf/llava-onevision-qwen2-0.5b-ov-hf --runner generate --max-model-len 8192

그런 다음 OpenAI 클라이언트를 다음과 같이 사용할 수 있습니다.

from openai import OpenAI

openai_api_key = "EMPTY"
openai_api_base = "http://localhost:8000/v1"

client = OpenAI(
    api_key=openai_api_key,
    base_url=openai_api_base,
)

video_url = "https://huggingface.co/datasets/raushan-testing-hf/videos-test/resolve/main/sample_demo_1.mp4"

## Use video url in the payload
chat_completion_from_url = client.chat.completions.create(
    messages=[
        {
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": "What's in this video?",
                },
                {
                    "type": "video_url",
                    "video_url": {"url": video_url},
                    "uuid": video_url,  # Optional
                },
            ],
        }
    ],
    model=model,
    max_completion_tokens=64,
)

result = chat_completion_from_url.choices[0].message.content
print("Chat completion output from image url:", result)

전체 예시: examples/generate/multimodal/openai_chat_completion_client_for_multimodal.py

참고: 기본적으로 HTTP URL을 통한 비디오 fetch 타임아웃은 30 초입니다. 환경 변수로 오버라이드할 수 있어요: export VLLM_VIDEO_FETCH_TIMEOUT=<timeout>

비디오 디코딩 백엔드 (Video Decoding Backend)

vLLM은 선택 가능한 디코딩 백엔드를 사용해 비디오 바이트를 프레임으로 디코딩합니다. 4개의 백엔드를 지원합니다.

백엔드 디바이스 설명
opencv (기본값) CPU OpenCV 기반 디코더
torchcodec CPU TorchCodec (PyTorch-네이티브) 디코더
pynvvideocodec GPU NVIDIA PyNvVideoCodec 디코더
deepstream GPU NVIDIA DeepStream 디코더

두 CPU 백엔드는 궁극적으로 FFmpeg에 기반합니다. torchcodec 는 사용할 FFmpeg 버전을 선택할 수 있게 하며, opencv 는 링크된 FFmpeg 빌드에 의존합니다.

--media-io-kwargsbackend 파라미터를 전달해 백엔드를 선택하세요.

vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \
  --media-io-kwargs '{"video": {"backend": "torchcodec"}}'

TorchCodec 전용 파라미터:

  • num_ffmpeg_threads: FFmpeg 디코딩 스레드 수입니다. 0(기본값)은 FFmpeg 기본값(min(cpu_count + 1, 16))에 의존합니다. 스레드 과구독을 제어할 수 있습니다.
  • seek_mode: 디코더의 seek 모드입니다. "exact"(기본값)는 디코더 생성 시 파일을 스캔해 프레임 정확한 샘플링을 보장합니다. "approximate" 는 그 스캔을 생략해 더 빠른 디코더 생성을 얻는 대신 파일 메타데이터에 의존합니다(덜 정확한 seek 가능).
# Example: TorchCodec with approximate seek mode and 4 FFmpeg threads
vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \
  --media-io-kwargs '{"video": {"backend": "torchcodec", "seek_mode": "approximate", "num_ffmpeg_threads": 4}}'

PyNvVideoCodec 전용 파라미터:

  • hw_decoders: 각 API 서버 프로세스가 보유하는 동시 하드웨어 디코더 슬롯의 최대 수입니다. 양의 정수여야 하며 기본값은 2 로, 동시 비디오 워크로드의 권장 시작점입니다. vLLM이 시작 시 이 슬롯들을 위해 GPU 메모리를 예약하므로 이 값은 요청별로 오버라이드할 수 없습니다. 각 추가 슬롯이 GPU 메모리 예약을 늘리므로 벤치마크 후 올리세요.
# Example: explicitly use the recommended 2 hardware decoders
vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \
  --media-io-kwargs '{"video": {"backend": "pynvvideocodec", "hw_decoders": 2}}'
비디오 프레임 복구 (Video Frame Recovery)

손상되거나 잘린 비디오 파일을 처리할 때 견고성을 높이기 위해 vLLM은 동적 윈도우 전방 스캔(forward-scan) 방식으로 선택적 프레임 복구를 지원합니다. 활성화하면 순차 읽기 중 대상 프레임 로드에 실패할 경우, 다음 대상 프레임 이전의 성공적으로 잡힌 다음 프레임이 그 자리를 대체합니다.

--media-io-kwargsframe_recovery 파라미터를 전달해 활성화하세요.

# Example: Enable frame recovery
vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \
  --media-io-kwargs '{"video": {"frame_recovery": true}}'

파라미터:

  • frame_recovery: 전방 스캔 복구를 활성화하는 불리언 플래그입니다. true 이면 동적 윈도우(다음 대상 프레임까지) 내에서 사용 가능한 다음 프레임으로 실패한 프레임을 복구합니다. 기본값은 false 입니다.

동작 방식:

  • 시스템이 프레임을 순차적으로 읽습니다.
  • 대상 프레임을 잡는 데 실패하면 "실패"로 표시합니다.
  • (다음 대상에 도달하기 전) 성공적으로 잡힌 다음 프레임으로 실패한 프레임을 복구합니다.
  • 이 방식은 비디오 중간 손상과 비디오 끝 잘림(truncation)을 모두 처리합니다.
  • OpenCV 백엔드를 사용할 때 MP4 같은 일반 비디오 형식에서 동작합니다.
PyNvVideoCodec(NVDEC)로 GPU 비디오 디코딩

pynvvideocodec 백엔드는 NVIDIA NVDEC를 사용해 멀티모달 전처리를 위해 호스트 메모리로 복사하기 전에 GPU 상에서 샘플링된 비디오 프레임을 디코딩합니다. 비디오 태깅처럼 큰 비디오를 비교적 가벼운 추론으로 처리하는 워크로드에서 CPU 기반 비디오 디코더의 병목을 완화할 수 있습니다.

경고: 이 백엔드를 사용할 때는 CUDA Multi-Process Service (MPS) 가 필요합니다. 비디오 디코딩은 API 서버 프로세스에서 실행되고 모델 서빙은 엔진 프로세스에서 실행되므로 여러 CUDA 프로세스가 같은 GPU를 공유합니다. vLLM을 시작하기 전에 MPS를 구성하고 시작하세요.

또한 비디오 디코딩용 VRAM을 예약하려면 양수 --mm-ipc-gpu-memory-gb 값을 설정해야 합니다. vLLM은 KV 캐시에 사용 가능한 메모리에서 이 예산을 잘라내고 이를 사용해 동시 프론트엔드 디코드 할당을 제한합니다. 예산이 소진되면 디코드 작업은 엔진의 VRAM 여유를 소모하거나 서빙 중 out-of-memory 오류를 일으키는 대신 대기합니다.

환경 변수로 백엔드를 선택하고 워크로드에 적합한 VRAM 예산을 지정하세요. 예를 들어 1 GiB를 예약하려면:

export VLLM_VIDEO_LOADER_BACKEND=pynvvideocodec
vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \
  --mm-ipc-gpu-memory-gb 1

또는 --media-io-kwargs 로 선택합니다.

vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \
  --media-io-kwargs '{"video": {"backend": "pynvvideocodec"}}' \
  --mm-ipc-gpu-memory-gb 1

단일 API 서버 프로세스가 디코딩해야 하는 가장 큰 샘플링된 비디오에 충분한 예산을 선택하세요. 여러 API 서버 프로세스를 사용할 때 vLLM은 구성된 예산을 그들에게 균등하게 나눕니다.

스트리밍 비디오 소스에는 DeepStream 백엔드를 사용하세요.

DeepStream(NVDEC)으로 GPU 비디오 디코딩

기본적으로 vLLM은 CPU에서 비디오를 디코딩합니다. NVIDIA GPU에서는 DeepStream 백엔드로 하드웨어 비디오 엔진(NVDEC)에서 직접 디코딩할 수 있어 디코딩을 CPU에서 분리하고 비디오 처리량을 크게 높일 수 있습니다. 스트리밍 비디오 소스에 권장되는 GPU 백엔드입니다.

백엔드를 설치합니다(Linux x86-64 전용):

pip install vllm[deepstream]

pip wheel은 DeepStream 라이브러리를 번들하지만 pip이 설치할 수 없는 몇 가지 시스템 패키지에 여전히 의존합니다. Ubuntu에서:

apt-get install -y \
  gstreamer1.0-tools gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
  gstreamer1.0-plugins-bad gstreamer1.0-libav \
  python3-gi python3-gst-1.0 libv4l-0 cuda-libraries-13-0

환경 변수로 백엔드를 선택합니다.

export VLLM_VIDEO_LOADER_BACKEND=deepstream
vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct

또는 --media-io-kwargs 로 요청별로 선택합니다.

vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \
  --media-io-kwargs '{"video": {"backend": "deepstream"}}'

파라미터:

  • pool_size: 프로세스 전체 디코드 풀의 GPU 디코드 워커 수입니다([1, 16] 로 클램프). 설정하지 않으면 VLLM_MEDIA_LOADING_THREAD_COUNT(기본 8)로 기본 설정됩니다. 풀은 싱글턴이므로 첫 요청의 값이 승리합니다.
# Example: 12 decode workers
vllm serve Qwen/Qwen3-VL-30B-A3B-Instruct \
  --media-io-kwargs '{"video": {"backend": "deepstream", "pool_size": 12}}'
media_io_kwargs 로 사전 추출된 프레임 시퀀스 (Pre-extracted Frame Sequences)

클라이언트 측에서 비디오 프레임을 추출해 video/jpeg(base64 결합 JPEG 프레임)로 보낼 때, 요청에 media_io_kwargs 를 사용해 원본 비디오 메타데이터를 보존할 수 있어요. 이렇게 하면 클라이언트 측 프레임 추출 중 손실될 시간 정보를 보존해 더 정확한 비디오 이해가 가능합니다.

지원 파라미터:

파라미터 타입 설명
fps float 원본 비디오의 프레임 레이트
frames_indices list[int] 실제로 샘플링된 프레임의 인덱스
total_num_frames int 원본 비디오의 총 프레임 수
duration float 원본 비디오의 길이(초)
do_sample_frames bool 프레임 샘플링 수행 여부
from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")

# Client-side frame extraction
frames = extract_frames(video_path, num_frames=32)
frames_b64 = ",".join([encode_image(f) for f in frames])
video_url = f"data:video/jpeg;base64,{frames_b64}"

# Pass video metadata via media_io_kwargs
response = client.chat.completions.create(
    model="your-multimodal-model",
    messages=[{
        "role": "user",
        "content": [
            {"type": "video_url", "video_url": {"url": video_url}},
            {"type": "text", "text": "Describe what happens in this video."}
        ]
    }],
    extra_body={
        "media_io_kwargs": {
            "video": {
                "fps": 30.0,
                "frames_indices": [0, 10, 20, 30, 40, 50, 60, 70, 80, 90,
                                   100, 110, 120, 130, 140, 150, 160, 170,
                                   180, 190, 200, 210, 220, 230, 240, 250,
                                   260, 270, 280, 290, 300, 310],
                "total_num_frames": 900,
                "duration": 30.0,
            }
        }
    },
)

print(response.choices[0].message.content)

media_io_kwargs 를 사용하나요?

클라이언트 측에서 프레임을 추출하면 서버는 원본 비디오에 대한 중요한 맥락을 잃습니다.

  • 시간 정보(Temporal information): 어떤 프레임이 샘플링되었고 원본 타임라인에서 어느 위치에 있는지
  • 비디오 길이(Video duration): 원본 비디오가 얼마나 길었는지
  • 프레임 레이트(Frame rate): 원본 재생 속도

이 메타데이터를 전달하면 모델이 샘플링된 프레임의 시간 분포와 중요한 순간이 건너뛰어졌을 가능성을 더 잘 이해할 수 있습니다.

커스텀 RGBA 배경색 (Custom RGBA Background Color)

RGBA 이미지에 커스텀 배경색을 사용하려면 --media-io-kwargsrgba_background_color 파라미터를 전달하세요.

# Example: Black background for dark theme
vllm serve llava-hf/llava-1.5-7b-hf \
  --media-io-kwargs '{"image": {"rgba_background_color": [0, 0, 0]}}'

# Example: Custom gray background
vllm serve llava-hf/llava-1.5-7b-hf \
  --media-io-kwargs '{"image": {"rgba_background_color": [128, 128, 128]}}'

오디오 입력 (Audio Inputs)

오디오 입력은 OpenAI Audio API 에 따라 지원됩니다. Ultravox-v0.5-1B를 사용한 간단한 예시입니다.

먼저 OpenAI 호환 서버를 시작합니다.

vllm serve fixie-ai/ultravox-v0_5-llama-3_2-1b

그런 다음 OpenAI 클라이언트를 다음과 같이 사용할 수 있습니다.

import base64
import requests
from openai import OpenAI
from vllm.assets.audio import AudioAsset

def encode_base64_content_from_url(content_url: str) -> str:
    """Encode a content retrieved from a remote url to base64 format."""

    with requests.get(content_url) as response:
        response.raise_for_status()
        result = base64.b64encode(response.content).decode('utf-8')

    return result

openai_api_key = "EMPTY"
openai_api_base = "http://localhost:8000/v1"

client = OpenAI(
    api_key=openai_api_key,
    base_url=openai_api_base,
)

# Any format supported by soundfile/PyAV is supported
audio_url = AudioAsset("winning_call").url
audio_base64 = encode_base64_content_from_url(audio_url)

chat_completion_from_base64 = client.chat.completions.create(
    messages=[
        {
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": "What's in this audio?",
                },
                {
                    "type": "input_audio",
                    "input_audio": {
                        "data": audio_base64,
                        "format": "wav",
                    },
                    "uuid": audio_url,  # Optional
                },
            ],
        },
    ],
    model=model,
    max_completion_tokens=64,
)

result = chat_completion_from_base64.choices[0].message.content
print("Chat completion output from input audio:", result)

이미지 입력의 image_url 에 해당하는 오디오 버전인 audio_url 도 전달할 수 있습니다.

chat_completion_from_url = client.chat.completions.create(
    messages=[
        {
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": "What's in this audio?",
                },
                {
                    "type": "audio_url",
                    "audio_url": {"url": audio_url},
                    "uuid": audio_url,  # Optional
                },
            ],
        }
    ],
    model=model,
    max_completion_tokens=64,
)

result = chat_completion_from_url.choices[0].message.content
print("Chat completion output from audio url:", result)

전체 예시: examples/generate/multimodal/openai_chat_completion_client_for_multimodal.py

참고: 기본적으로 HTTP URL을 통한 오디오 fetch 타임아웃은 10 초입니다. 환경 변수로 오버라이드할 수 있어요: export VLLM_AUDIO_FETCH_TIMEOUT=<timeout>

오디오 디코딩 백엔드 (Audio Decoding Backend)

vLLM은 선택 가능한 디코딩 백엔드로 오디오 바이트를 파형으로 디코딩합니다.

백엔드 설명
auto (기본값) soundfile, 그다음 torchcodec, 그다음 PyAV 폴백
soundfile libsndfile만, 폴백 없음
pyav PyAV (FFmpeg)만, 폴백 없음
torchcodec TorchCodec (PyTorch-네이티브)만, 폴백 없음

--media-io-kwargs 로 서버별 백엔드를 선택합니다.

vllm serve mistralai/Voxtral-Mini-3B-2507 \
  --media-io-kwargs '{"audio": {"audio_backend": "soundfile"}}'

: pyav 는 프레임별 Python 제너레이터로 FFmpeg를 구동하므로, 동시성에서 Python/C 교차점이 GIL에서 경합합니다. torchcodec 는 전체 기간 동안 GIL을 해제하는 단일 호출로 각 스트림을 디코딩합니다. 동시 디코딩 워크로드에는 명시적으로 선택하세요. auto 는 인코더 패딩을 포함한 기존 디코딩 동작을 보존하기 위해 지원 형식에서는 soundfile을 선호합니다. soundfile이 읽을 수 없는 비디오 컨테이너 같은 형식에서 추출된 오디오는 폴백 체인을 통해 torchcodec을 사용할 수 있습니다.

참고: torchcodec 는 CUDA, CPU, XPU 빌드의 기본 요구 사항으로 제공됩니다. 다른 플랫폼(예: ROCm, TPU)에서는 해당 백엔드를 활성화하려면 수동으로 설치해야 합니다. torchcodec은 시스템 FFmpeg 설치에도 링크됩니다. 패키지나 FFmpeg를 사용할 수 없으면 auto 는 soundfile → PyAV 체인을 사용합니다.

임베딩 입력 (Embedding Inputs)

데이터 타입(이미지, 비디오, 오디오)에 속하는 사전 계산된 임베딩을 언어 모델에 직접 입력하려면 각 항목에 대해 형태 (..., hidden_size of LM) 의 텐서를 멀티모달 딕셔너리의 해당 필드에 전달하세요.

중요: 오프라인 추론과 달리, 채팅 템플릿이 플레이스홀더 토큰을 올바르게 적용하려면 각 항목의 임베딩을 별도로 전달해야 합니다.

vllm serve--enable-mm-embeds 플래그로 이 기능을 활성화해야 합니다.

경고: 잘못된 형태의 임베딩을 전달하면 vLLM 엔진이 충돌할 수 있습니다. 이 플래그는 신뢰할 수 있는 사용자에게만 활성화하세요!

이미지 임베딩 입력:

image_embeds 필드에 base64 인코딩된 텐서를 전달할 수 있습니다. 다음 예시는 OpenAI 서버에 이미지 임베딩을 전달하는 방법을 보여줍니다.

from vllm.utils.serial_utils import tensor2base64

client = OpenAI(
    # defaults to os.environ.get("OPENAI_API_KEY")
    api_key=openai_api_key,
    base_url=openai_api_base,
)

# Basic usage - this is equivalent to the LLaVA example for offline inference
model = "llava-hf/llava-1.5-7b-hf"
embeds = {
    "type": "image_embeds",
    "image_embeds": tensor2base64(torch.load(...)),  # Shape: (image_feature_size, hidden_size)
    "uuid": image_url,  # Optional
}

# Additional examples for models that require extra fields
model = "Qwen/Qwen2-VL-2B-Instruct"
embeds = {
    "type": "image_embeds",
    "image_embeds": {
        "image_embeds": tensor2base64(torch.load(...)),  # Shape: (image_feature_size, hidden_size)
        "image_grid_thw": tensor2base64(torch.load(...)),  # Shape: (3,)
    },
    "uuid": image_url,  # Optional
}

model = "openbmb/MiniCPM-V-2_6"
embeds = {
    "type": "image_embeds",
    "image_embeds": {
        "image_embeds": tensor2base64(torch.load(...)),  # Shape: (num_slices, hidden_size)
        "image_sizes": tensor2base64(torch.load(...)),  # Shape: (2,)
    },
    "uuid": image_url,  # Optional
}

# Single image input
chat_completion = client.chat.completions.create(
    messages=[
        {
            "role": "system",
            "content": "You are a helpful assistant.",
        },
        {
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": "What's in this image?",
                },
                embeds,
            ],
        },
    ],
    model=model,
)

# Multi image input
chat_completion = client.chat.completions.create(
    messages=[
        {
            "role": "system",
            "content": "You are a helpful assistant.",
        },
        {
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": "What's in this image?",
                },
                embeds,
            ],
        },
    ],
    model=model,
)

# Multi image input (interleaved)
chat_completion = client.chat.completions.create(
    messages=[
        {
            "role": "system",
            "content": "You are a helpful assistant.",
        },
        {
            "role": "user",
            "content": [
                embeds,
                {
                    "type": "text",
                    "text": "What's in this image?",
                },
                embeds,
            ],
        },
    ],
    model=model,
)

캐시된 입력 (Cached Inputs)

오프라인 추론처럼 제공된 UUID로 캐시 적중을 기대한다면 미디어 전송을 건너뛸 수 있어요. 미디어를 이렇게 보내면 됩니다.

    # Image/video/audio URL:
    {
        "type": "image_url",
        "image_url": None,
        "uuid": image_uuid,
    },

    # image_embeds
    {
        "type": "image_embeds",
        "image_embeds": None,
        "uuid": image_uuid,
    },

    # input_audio:
    {
        "type": "input_audio",
        "input_audio": None,
        "uuid": audio_uuid,
    },

    # PIL Image:
    {
        "type": "image_pil",
        "image_pil": None,
        "uuid": image_uuid,
    },

    # video_url:
    {
        "type": "video_url",
        "video_url": {},
        "uuid": video_uuid,
    },

더 알아보기 (Learn more)