[SGLang] Vision-Language 모델: CLIP, InternVL, LLaVA 프로세서
들어가며
Vision-Language 모델(VLM)은 이미지를 토큰 시퀀스로 변환하여 LLM에 주입한다. 모델마다 이미지 전처리, 타일링, 토큰 매핑 방식이 다르다. SGLang은 CLIP, InternVL, LLaVA 등 주요 VLM 각각에 대한 전용 프로세서를 제공한다.
이 글에서는 python/sglang/srt/multimodal/processors/ 디렉토리의 CLIP, InternVL, LLaVA 프로세서를 분석한다.
세 프로세서 비교 구조도
┌─────────────────────────────────────────────────────────┐
│ CLIP Processor (단순) │
│ image → HF processor → pixel_values → MultimodalDataItem│
│ 플레이스홀더: <image> │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ InternVL Processor (타일링) │
│ image → GPU resize → dynamic tiles → normalize │
│ <image> → <img>IMG_CONTEXT*N</img> │
│ + video → frame tiles │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ LLaVA Processor (anyres) │
│ image → pad/anyres → patch split → HF processor │
│ + multi-images / video 지원 │
└─────────────────────────────────────────────────────────┘
CLIP Processor: 가장 단순한 구현
CLIP 프로세서는 BaseMultimodalProcessor의 공통 메서드를 최대한 활용하는 최소 구현이다.
class ClipImageProcessor(BaseMultimodalProcessor):
models = [CLIPModel]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
super().__init__(hf_config, server_args, _processor, *args, **kwargs)
self.mm_tokens = MultimodalSpecialTokens(
image_token="<image>"
).build(_processor)
async def process_mm_data_async(self, image_data, input_text, *args, **kwargs):
base_output = self.load_mm_data(
prompt=input_text,
multimodal_tokens=self.mm_tokens,
image_data=image_data,
)
mm_items, input_ids, _ = self.process_and_combine_mm_data(
base_output, self.mm_tokens
)
return MultimodalProcessorOutput(
mm_items=mm_items, input_ids=input_ids.tolist(),
)
load_mm_data()가 이미지를 로드하고 플레이스홀더를 매칭하며, process_and_combine_mm_data()가 HuggingFace 프로세서를 호출하여 pixel_values를 생성한다.
InternVL Processor: GPU 기반 동적 타일링
InternVL은 가장 복잡한 프로세서로, GPU에서 직접 이미지를 리사이즈하고 타일링한다.
동적 타일 분할
@staticmethod
def dynamic_preprocess(tensor, image_size=448, max_num=12, use_thumbnail=False):
C, H, W = tensor.shape
aspect_ratio = W / H
target_ratios = set(
(i, j)
for n in range(1, max_num + 1)
for i in range(1, n + 1)
for j in range(1, n + 1)
if i * j <= max_num
)
# 최적 비율 선택
for x, y in target_ratios:
target_ar = x / y
diff = abs(aspect_ratio - target_ar)
# ...
# GPU 리사이즈
resized = torch.nn.functional.interpolate(
tensor.unsqueeze(0),
size=(target_h, target_w),
mode="bicubic",
align_corners=False,
).squeeze(0)
# 타일 분할
for i in range(blocks):
x = (i % best_ratio[0]) * image_size
y = (i // best_ratio[0]) * image_size
tile = resized[:, y : y + image_size, x : x + image_size]
tiles.append(tile)
max_num=12로 최대 12개 타일까지 분할한다. 원본 비율에 가장 가까운 그리드 비율을 선택하여 왜곡을 최소화한다.
프롬프트 토큰 확장
이미지 타일 수에 따라 플레이스홀더를 동적으로 확장한다.
for num_patches in num_patches_list:
image_tokens = (
self.IMG_START
+ (self.IMG_CONTEXT * (self.num_image_token * int(num_patches)))
+ self.IMG_END
)
input_text_updated = input_text_updated.replace(img_ph, image_tokens, 1)
<image> 하나가 <img> + <IMG_CONTEXT> * N + </img>로 확장된다. N은 타일 수 x 타일당 토큰 수다.
비디오 처리
InternVL은 비디오도 프레임 단위 타일링으로 처리한다.
num_frames = self._resolve_video_num_frames(
requested=requested_frames,
num_videos=len(base_output.videos),
text_len=self._token_len(base_output.input_text or prompt),
image_tile_cnt=int(sum(num_patches_list)),
)
컨텍스트 길이에서 텍스트와 이미지가 사용한 토큰을 제외한 나머지 budget으로 비디오 프레임 수를 결정한다.
LLaVA Processor: anyres와 멀티 이미지
LLaVA는 이미지 비율에 따라 세 가지 모드를 사용한다.
async def process_mm_data_async(self, image_data, input_text, request_obj, ...):
aspect_ratio = getattr(self.hf_config, "image_aspect_ratio", None)
if "multi-images" in modalities or "video" in modalities:
aspect_ratio = "pad" # 멀티 이미지 → pad 모드
| 모드 | 처리 방식 |
|---|---|
| pad | 정사각형으로 패딩 후 리사이즈 |
| anyres | 최적 해상도 선택 + 패치 분할 |
| 기본 | HF 프로세서에 직접 전달 |
anyres 모드에서는 mm_utils.process_anyres_image()를 호출한다.
def process_anyres_image(image, processor, grid_pinpoints):
best_resolution = select_best_resolution(image.size, possible_resolutions)
image_padded = resize_and_pad_image(image, best_resolution)
patches = divide_to_patches(image_padded, crop_size)
image_patches = [image_original_resize] + patches
원본 리사이즈 이미지를 첫 번째에 놓고, 고해상도 패치들을 뒤에 추가한다.
LlavaMultimodalProcessor: 래퍼 패턴
LlavaForConditionalGeneration 아키텍처는 내부 vision model 타입에 따라 프로세서를 동적으로 선택한다.
class LlavaMultimodalProcessor(BaseMultimodalProcessor):
models = [LlavaForConditionalGeneration, Mistral3ForConditionalGeneration]
def __init__(self, hf_config, server_args, _processor, *args, **kwargs):
if vision_type := getattr(self.vision_config, "model_type"):
self.inner = self._get_sgl_processor_cls(vision_type)(
hf_config, server_args, _processor, *args, **kwargs
)
async def process_mm_data_async(self, *args, **kwargs):
return await self.inner.process_mm_data_async(*args, **kwargs)
vision_config의 model_type이 clip_vision_model이면 LlavaImageProcessor를, 다른 타입이면 HF 매핑에서 찾는다.
설계 비교
| 특성 | CLIP | InternVL | LLaVA |
|---|---|---|---|
| 타일링 | 없음 | GPU 동적 타일링 | anyres 패치 분할 |
| 비디오 | 미지원 | 프레임 타일링 | modalities 기반 |
| 리사이즈 | HF 프로세서 | GPU interpolate | CPU PIL |
| 토큰 확장 | 기본 | <img> + <IMG_CONTEXT>*N + </img> |
모델 내부 |
| 복잡도 | 낮음 | 높음 | 중간 |
관련 포스트
- Multimodal 처리 파이프라인 개요 - 전체 멀티모달 파이프라인 구조
- ViT CUDA Graph: Vision Encoder 가속 - 비전 인코더 최적화
- Efficient Vision Sampling: 이미지 토큰 압축 - 비디오 토큰 효율화
참고
관련 포스트
SGLang 의 다른글
- 이전글 [SGLang] Multimodal 처리 파이프라인 개요: Vision/Audio/Video 통합
- 현재글 : [SGLang] Vision-Language 모델: CLIP, InternVL, LLaVA 프로세서
- 다음글 [SGLang] Audio 모델: Whisper, Qwen3-ASR, GLM-ASR 프로세서
댓글