멀티모달 입력

멀티모달 입력 (Multimodal Inputs)

텍스트만 다루는 LLM은 이제 옛말이에요. 이미지, 비디오, 오디오까지 함께 처리할 수 있는 멀티모달 모델이 점점 흔해지고 있죠. vLLM은 이런 멀티모달 모델에 이미지·비디오·오디오·사전 계산된 임베딩을 입력으로 전달하는 방법을 지원합니다. 이 페이지에서는 각 입력 타입을 오프라인 추론과 온라인 서빙에서 어떻게 넘기는지 살펴볼게요.

출처: vLLM 공식 문서 — multimodal_inputs

📌 참고: vLLM은 멀티모달 지원을 계속 개선하고 있어요. 예정된 변경은 이 RFC에서, 피드백이나 기능 요청은 GitHub 이슈로 남길 수 있습니다.

보안 팁 (Security tip)

멀티모달 모델을 서빙할 때는 --allowed-media-domains로 vLLM이 접근할 수 있는 도메인을 제한하는 것을 고려하세요. 이렇게 하면 잠재적으로 서버 사이드 요청 위조(SSRF) 공격에 취약할 수 있는 임의의 엔드포인트 접근을 막을 수 있어요.

--allowed-media-domains upload.wikimedia.org github.com www.bogotobogo.com

또한 VLLM_MEDIA_URL_ALLOW_REDIRECTS=0를 설정하면 HTTP 리다이렉트를 따라가 도메인 제한을 우회하는 것도 방지할 수 있어요. 특히 컨테이너 환경에서 vLLM 파드가 내부 네트워크에 무제한 접근할 수 있다면 이 제한이 더욱 중요해요.

오프라인 추론 (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")

# HuggingFace 저장소에서 올바른 형식을 확인하세요
prompt = "USER: <image>\nWhat is the content of this image?\nASSISTANT:"

# PIL.Image로 이미지 로드
image = PIL.Image.open(...)

# 단일 프롬프트 추론
outputs = llm.generate({
    "prompt": prompt,
    "multi_modal_data": {"image": image},
})

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

# 배치 추론
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

같은 텍스트 프롬프트 안에 여러 이미지를 넣으려면 이미지 리스트를 전달하면 돼요. 이때 limit_mm_per_prompt로 개수도 제한할 수 있습니다.

from vllm import LLM

llm = LLM(
    model="microsoft/Phi-3.5-vision-instruct",
    trust_remote_code=True,  # Phi-3.5-vision 로드에 필요
    max_model_len=4096,  # 그렇지 않으면 작은 GPU에 안 들어갈 수 있음
    limit_mm_per_prompt={"image": 2},  # 허용할 최대 개수
)

prompt = "<|user|>\n<|image_1|>\n<|image_2|>\nWhat is the content of each image?<|end|>\n<|assistant|>\n"

image1 = PIL.Image.open(...)
image2 = PIL.Image.open(...)

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

RGBA 배경색 커스터마이징

RGBA(투명도 포함) 이미지를 로드할 때 vLLM은 이를 RGB로 변환해요. 기본적으로 투명 픽셀은 흰색 배경으로 대체됩니다. 이 배경색은 media_io_kwargsrgba_background_color 파라미터로 바꿀 수 있어요.

from vllm import LLM

# 기본 흰색 배경 (설정 불필요)
llm = LLM(model="llava-hf/llava-1.5-7b-hf")

# 다크 테마용 커스텀 검은 배경
llm = LLM(
    model="llava-hf/llava-1.5-7b-hf",
    media_io_kwargs={"image": {"rgba_background_color": [0, 0, 0]}},
)

# 커스텀 브랜드 색 배경 (예: 파란색)
llm = LLM(
    model="llava-hf/llava-1.5-7b-hf",
    media_io_kwargs={"image": {"rgba_background_color": [0, 0, 255]}},
)

📌 참고: rgba_background_color는 각 값이 0~255인 리스트 [R, G, B]나 튜플 (R, G, B)의 RGB 값을 받아요. 이 설정은 RGBA 이미지에만 영향을 주며, 지정하지 않으면 하위 호환성 위해 기본 흰색 (255, 255, 255)이 사용됩니다.

비디오 입력 (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)

📌 참고: process_vision_info는 Qwen2.5-VL 및 유사 모델에만 적용 가능해요.

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

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

  • evs (Efficient Video Sampling, 기본값): 이전 프레임과의 시간적 유사성(temporal dissimilarity)이 가장 낮은 토큰을 버려요. 첫 프레임은 항상 완전히 유지됩니다.
  • vidcom2 (Video Compression Commander): 비디오 수준·프레임 수준 특징 중심과의 유사성으로 토큰을 점수화하고, 뚜렷한 프레임에 더 큰 예산을 줘요. 프레임당 최소 1개 토큰은 유지됩니다.
vllm serve Qwen/Qwen3-VL-8B-Instruct \
    --video-pruning-rate 0.75 --video-pruning-method vidcom2

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

오디오 입력 (Audio Inputs)

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

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

긴 오디오 전사(transcription) 위한 청킹

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

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

# 긴 오디오 파일 로드
audio, sr = load_audio("long_audio.wav", sr=16000)

# 낮은 에너지(조용한) 영역에서 청크로 분할
chunks = split_audio(
    audio_data=audio,
    sample_rate=sr,
    max_clip_duration_s=30.0,      # 최대 청크 길이(초)
    overlap_duration_s=1.0,         # 조용한 분할 지점을 찾는 탐색 창
    min_energy_window_size=1600,    # 에너지 계산용 창 크기 (~16kHz에서 100ms)
)

# Whisper 모델 초기화
llm = LLM(model="openai/whisper-large-v3-turbo")
sampling_params = SamplingParams(temperature=0, max_tokens=256)

# 각 청크 전사
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)

# 결과 결합
full_transcription = " ".join(transcriptions)

split_audio 함수는 다음 특징이 있어요.

  • 1D 모노 오디오를 기대함 (load_audio가 기본적으로 다운믹스)
  • 말을 잘리지 않도록 조용한 지점에서 분할
  • RMS 에너지로 overlap 창 안의 저진폭 영역을 찾음
  • 모든 오디오 샘플 보존 (데이터 손실 없음)
  • 모든 샘플 레이트 지원

자동 오디오 채널 정규화

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

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

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

이 모델들에 대해 vLLM은 자동으로: (1) 피처 추출기로 모노 오디오 필요 여부를 감지하고, (2) 채널 평균화로 멀티채널 오디오를 모노로 변환하며, (3) (channels, time) 형식(torchaudio)과 (time, channels) 형식(soundfile)을 모두 처리합니다.

import torchaudio
from vllm import LLM

# 스테레오 오디오 파일 로드 - (channels, time) 형태 반환
audio, sr = torchaudio.load("stereo_audio.wav")
print(f"Original shape: {audio.shape}")  # e.g., torch.Size([2, 16000])

# vLLM이 Whisper 기반 모델용으로 자동으로 모노 변환
llm = LLM(model="openai/whisper-large-v3")

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

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

임베딩 입력 (Embedding Inputs)

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

이 기능은 enable_mm_embeds=True로 켜야 합니다.

⚠️ 경고: 잘못된 형태의 임베딩을 전달하면 vLLM 엔진이 크래시할 수 있어요. 이 플래그는 신뢰할 수 있는 사용자에게만 켜야 합니다!

이미지 임베딩 (Image Embeddings)

from vllm import LLM

# 이미지 임베딩을 입력으로 사용한 추론
llm = LLM(model="llava-hf/llava-1.5-7b-hf", enable_mm_embeds=True)

prompt = "USER: <image>\nWhat is the content of this image?\nASSISTANT:"

# 대부분의 모델에서 `image_embeds` 형태: (num_images, image_feature_size, hidden_size)
image_embeds = torch.load(...)

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

추가 필드가 필요한 모델들도 있는데, 예를 들어 Qwen2-VL은 위치 인코딩 계산에 image_grid_thw가 필요하고, MiniCPM-V는 슬라이스 이미지 상세 계산에 image_sizes가 필요해요. Qwen3-VL의 image_embeds는 베이스 이미지 임베딩과 deepstack features를 모두 포함해야 합니다.

오디오 임베딩 (Audio Embedding Inputs)

이미지 임베딩과 유사하게 사전 계산된 오디오 임베딩도 전달할 수 있어요.

from vllm import LLM
import torch

# 오디오 임베딩 지원 활성화
llm = LLM(model="fixie-ai/ultravox-v0_5-llama-3_2-1b", enable_mm_embeds=True)

prompt = "USER: <audio>\nWhat is in this audio?\nASSISTANT:"

# 사전 계산된 오디오 임베딩, 보통 형태:
# (num_audios, audio_feature_size, LM의 hidden size)
audio_embeds = torch.load(...)

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

캐시된 입력 (Cached Inputs)

멀티모달 입력을 사용할 때 vLLM은 보통 각 미디어 항목을 내용 기준으로 해시해서 요청 간 캐싱을 가능하게 해요. 선택적으로 multi_modal_uuids를 제공해 각 항목에 자신만의 안정적인 ID를 부여하면, 원시 내용을 다시 해시하지 않고도 캐싱이 작업을 재사용할 수 있습니다.

from vllm import LLM
from PIL import Image

# 이미지 2개를 쓰는 Qwen2.5-VL 예시
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]},
    # 캐싱용 안정적인 ID 제공
    # 요구사항:
    #  - multi_modal_data에 있는 모든 모달리티를 포함
    #  - 리스트라면 같은 수의 항목 제공
    #  - 해당 항목은 내용 해시로 대체하려면 None 사용
    "multi_modal_uuids": {"image": ["sku-1234-a", None]},
})

UUID를 사용하면 캐시 적중을 기대하는 항목에 대해 미디어 데이터를 아예 보내지 않을 수도 있어요. 단, 건너뛴 미디어에 해당하는 UUID가 없거나 UUID가 캐시에 적중하지 않으면 요청은 실패합니다.

온라인 서빙 (Online Serving)

OpenAI 호환 서버에서도 멀티모달 입력을 전달할 수 있어요. Chat Completions API에서 이미지/오디오는 메시지 content 안의 content part로, image_url, image_embeds, input_audio 등 다양한 형식으로 주고받을 수 있고, 텍스트와 인터리브해서 여러 이미지를 함께 넣을 수도 있어요. 오프라인과 마찬가지로 캐시 적중이 기대되는 항목은 uuid를 지정하고 미디어 내용을 None으로 보내 건너뛸 수 있습니다.

# 온라인 서빙에서 캐시 적중을 기대하며 미디어 생략:
{
    "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,
}

더 알아보기 (Learn more)