본문으로 건너뛰기

[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> 모델 내부
복잡도 낮음 높음 중간

관련 포스트

참고

댓글

관련 포스트

SGLang 의 다른글