본문으로 건너뛰기
AICosmus

Where tech meets the everyday — AI, fintech, swimming, and cars.

AICosmus

Where tech meets the everyday — AI, fintech, swimming, and cars.

  • 홈
  • IT기술
    • RAG
    • GRPC
    • Kotlin
    • LLM
    • 금융 IT
    • 에이전트
    • 제로Trust
    • 자동화
  • 일상
    • 자동차
    • 경제/재테크
    • 생활정보
  • About
    • Contact
    • Terms of Service
    • Disclaimer
    • Privacy – Policy
  • 홈
  • IT기술
    • RAG
    • GRPC
    • Kotlin
    • LLM
    • 금융 IT
    • 에이전트
    • 제로Trust
    • 자동화
  • 일상
    • 자동차
    • 경제/재테크
    • 생활정보
  • About
    • Contact
    • Terms of Service
    • Disclaimer
    • Privacy – Policy
Qwen3-VL 멀티모달 AI 서빙 서버
IT기술

[온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계] 7/14화: Qwen3-VL 서빙 실전 — 멀티모달 추론과 Visual Agent 통합 설계

By AICosmus
2026년 07월 06일 21 Min Read
1

시리즈 안내

이 글은 「온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계」 시리즈 7일차입니다. 텍스트 전용 Qwen3 서빙을 완성한 지난 3일(4~6화)의 여정을 마치고, 오늘부터 이미지와 문서를 함께 이해하는 멀티모달 추론 영역으로 진입하며, 최종적으로 Visual Agent 통합 설계까지 다룹니다.

어제(6화) 한 줄 회상: LiteLLM 기반 하이브리드 추론 게이트웨이를 구축해, Mac Studio(MLX)와 CUDA(vLLM) 노드를 단일 OpenAI 호환 엔드포인트 뒤로 통합하고 폴백·타임아웃 체인을 설계했습니다.

오늘의 핵심 3가지

  • Qwen3-VL 아키텍처 해부 — ViT 비전 인코더 + LLM 디코더 구조, 8B(Dense)와 30B-A3B(MoE) 두 변형의 메모리·속도 프로파일 비교
  • vLLM·MLX 멀티모달 서빙 — 이미지/문서를 포함하는 요청을 처리하는 서버 설정, OpenAI Vision API 호환 엔드포인트 구성, 6화 게이트웨이와의 결선
  • Visual Agent·금융 문서 파이프라인 — 스캔본·차트·UI 캡처를 실시간으로 이해하고 행동하는 통합 아키텍처, 금융IT 규제 환경에서의 적용 포인트

1. 왜 멀티모달인가 — 텍스트만으로는 부족한 현실

4~6화에서 구축한 텍스트 전용 Qwen3 스택은 대화·요약·코드 생성에 강력합니다. 그러나 실무에서 AI Assistant가 마주하는 입력의 상당 비율은 텍스트가 아닙니다. 스캔된 계약서 PDF, 실적 보고서의 차트, 에러가 발생한 화면 캡처, 화이트보드 사진 — 이 모두를 “이미지를 설명해 주세요”라고 따로 OCR 파이프라인을 거치는 것은 정보 손실과 지연을 동시에 일으킵니다.

Qwen3-VL은 Qwen3 패밀리 안에서 동일한 디코더 백본을 공유하면서 비전 인코더를 결합한 모델입니다. 텍스트 전용 Qwen3와 동일한 chat template, 동일한 tool-use 프로토콜, 동일한 Apache 2.0 라이선스를 유지하기 때문에, 이미 구축한 게이트웨이·프롬프트·에이전트 레이어를 최소한의 변경으로 멀티모달로 확장할 수 있습니다. 이것이 “같은 패밀리 안에서 텍스트와 비전을 통합한다”는 2화에서 언급한 Qwen3 선정 이유의 핵심이었습니다.

2. Qwen3-VL 아키텍처 해부

2-1. 전체 구조: ViT + Cross-Attention + LLM 디코더

Qwen3-VL의 아키텍처는 세 블록으로 나뉩니다.

  • 비전 인코더(Vision Encoder) — ViT(Vision Transformer) 기반. 입력 이미지를 고정 크기 패치(patch)로 분할한 뒤, 각 패치를 임베딩 벡터로 변환합니다. Qwen3-VL은 동적 해상도(dynamic resolution)를 지원해, 입력 이미지의 종횡비를 보존하면서 패치 수를 가변적으로 조절합니다. 예를 들어 1920×1080 스크린샷은 세로 672 문서보다 더 많은 패치를 할당받습니다.
  • 비전-언어 어댑터(Vision-Language Adapter) — 비전 인코더의 출력 시퀀스를 LLM 디코더의 히든 차원에 맞게 투영(projection)합니다. 여기서 패치 토큰이 텍스트 토큰과 동일한 공간으로 정렬되어, 디코더가 이미지 패치와 텍스트 토큰을 구분 없이 어텐션할 수 있게 됩니다.
  • LLM 디코더 — Qwen3의 텍스트 디코더와 동일한 Transformer 블록. 비전 토큰과 텍스트 토큰이 결합된 시퀀스를 받아 자기회귀(autoregressive) 생성을 수행합니다.
Qwen3-VL 비전 인코더와 LLM 디코더 구조 - Visual Agent

2-2. 두 변형 — 8B Dense vs 30B-A3B MoE

Qwen3-VL은 텍스트 전용 Qwen3와 동일한 Dense/MoE 분기를 따릅니다.

항목 Qwen3-VL-8B Qwen3-VL-30B-A3B
아키텍처 Dense MoE (활성 파라미터 ~3B)
총 파라미터 ~8B ~30B (추론 시 ~3B 활성)
비전 인코더 ViT-600M급 ViT-600M급 (동일)
FP16 VRAM ~18 GB ~62 GB (전체) / 추론 ~10 GB (활성)
FP8 VRAM ~10 GB ~32 GB
AWQ-INT4 VRAM ~6 GB ~18 GB
단일 이미지 추론 지연 (A100 80GB, FP8) ~1.2초 (512px 기준) ~1.8초 (512px 기준)
동시 처리 강점 배치 효율 높음, 지연 짧음 복잡한 문서/차트 정확도 우수
권장 용도 실시간 UI 에이전트, 빠른 캡처 분류 정밀 문서 분석, 차트 데이터 추출

핵심 인사이트: 30B-A3B MoE는 총 파라미터가 30B이지만 추론 시 활성화되는 파라미터가 약 3B에 불과합니다. 이는 8B Dense보다 실제 연산량이 적으면서도 전문가(expert) 네트워크의 다양성 덕분에 복잡한 시각 추론에서 더 높은 정확도를 보입니다. 다만 전체 가중치를 메모리에 올려야 하므로 VRAM 요구량은 8B보다 큽니다 — 이것이 2화에서 설명한 MoE의 메모리-연산 트레이드오프입니다.

2-3. 동적 해상도와 패치 토큰 수

멀티모달 서빙에서 가장 중요한 운영 변수는 이미지당 패치 토큰 수입니다. 이 값이 KV 캐시 크기와 추론 지연을 직접 결정하기 때문입니다.

  • 최소 해상도: 28×28 (단일 패치) — 아이콘·썸네일 분류용
  • 일반 문서: 672×896 → 약 1,344 패치 토큰
  • 고해상도 스크린샷: 1344×1344 → 약 5,376 패치 토큰
  • 최대 지원: 16,384 패치 토큰 (설정으로 제한 가능)

텍스트 토큰 1개와 패치 토큰 1개의 KV 캐시 비용은 동일합니다. 따라서 1344×1344 이미지 하나가 텍스트 약 5,000 토큰에 해당하는 메모리를 소비합니다. 한 요청에 이미지 3장이 포함되면 텍스트만으로 15,000 토큰 분량의 KV 캐시가 추가로 필요한 셈입니다. 이 비용을 통제하지 않으면 동시 요청 수가 급감합니다.

# Qwen3-VL 패치 토큰 수 추정 공식 (근사)
# 입력 이미지가 (W, H) 해상도일 때
patch_size = 14  # ViT 패치 크기
temporal_merge = 2  # 시공간 병합 팩터

# 동적 리사이즈 후 유효 해상도 (28의 배수로 정렬)
eff_w = round(W / 28) * 28
eff_h = round(H / 28) * 28

# 패치 토큰 수
n_patches = (eff_w // patch_size) * (eff_h // patch_size) // (temporal_merge ** 2)
# 예: 1344x1344 → (96 * 96) / 4 = 2,304 패치 (실제 구현체마다 약간 차이)

3. vLLM으로 Qwen3-VL 서빙하기

3-1. 사전 준비: 모델 다운로드

5화에서 텍스트 전용 Qwen3를 다운로드한 것과 동일한 방식입니다. Hugging Face에서 원하는 변형을 가져옵니다.

# FP8 양자화 버전 (A100 80GB 단일 GPU 권장)
huggingface-cli download Qwen/Qwen3-VL-30B-A3B-FP8 \
  --local-dir /models/Qwen3-VL-30B-A3B-FP8

# 8B Dense (RTX 4090 24GB에서도 가능)
huggingface-cli download Qwen/Qwen3-VL-8B \
  --local-dir /models/Qwen3-VL-8B

3-2. vLLM 멀티모달 서빙 — 핵심 설정

vLLM은 0.8+ 버전부터 멀티모달 모델을 네이티브로 지원합니다. 텍스트 전용 서빙과의 핵심 차이는 이미지 전처리 파이프라인과 멀티모달 입력 매핑이 추가된다는 점입니다.

# docker-compose.yml — Qwen3-VL vLLM 서빙
version: "3.8"

services:
  qwen3-vl-30b:
    image: vllm/vllm-openai:v0.9.1
    runtime: nvidia
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1          # A100 80GB 단일 GPU
              capabilities: [gpu]
    ports:
      - "127.0.0.1:8200:8000"   # 게이트웨이 뒤에서만 접근
    volumes:
      - /models:/models:ro
      - /tmp/vllm-vl-cache:/root/.cache/vllm
    environment:
      - VLLM_WORKER_MULTIPROC_METHOD=spawn
    command:
      - --model=/models/Qwen3-VL-30B-A3B-FP8
      - --served-model-name=qwen3-vl-30b
      - --dtype=auto
      - --max-model-len=32768
      - --gpu-memory-utilization=0.90
      - --max-num-seqs=8
      # ── 멀티모달 핵심 설정 ──
      - --limit-mm-per-prompt=image=4
      - --mm-processor-kwargs={"max_pixels":1344*1344}
      - --chat-template=/models/qwen3_vl_chat_template.jinja
      # ── KV 캐시 보호 ──
      - --enable-prefix-caching
      - --enable-chunked-prefill
      - --max-num-batched-tokens=16384
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 10s
      retries: 3

  qwen3-vl-8b:
    image: vllm/vllm-openai:v0.9.1
    runtime: nvidia
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              device_ids: ["1"]  # 두 번째 GPU (RTX 4090 등)
              capabilities: [gpu]
    ports:
      - "127.0.0.1:8201:8000"
    volumes:
      - /models:/models:ro
    command:
      - --model=/models/Qwen3-VL-8B
      - --served-model-name=qwen3-vl-8b
      - --dtype=bfloat16
      - --max-model-len=32768
      - --gpu-memory-utilization=0.85
      - --max-num-seqs=16
      - --limit-mm-per-prompt=image=6
      - --mm-processor-kwargs={"max_pixels":896*896}
      - --enable-prefix-caching
    restart: unless-stopped

3-3. 핵심 파라미터 해설

--limit-mm-per-prompt=image=4: 단일 요청당 허용하는 최대 이미지 수. 이 값을 제한하지 않으면 클라이언트가 수십 장의 이미지를 한 번에 보내 KV 캐시를 폭파시킬 수 있습니다. 30B-A3B에서는 4장, 8B에서는 지연이 짧으므로 6장까지 허용합니다.

--mm-processor-kwargs: 멀티모달 프로세서에 전달되는 키워드 인자. max_pixels는 이미지의 최대 해상도를 제한합니다. 1344×1344 = 1,806,336 픽셀이 상한 — 이를 초과하는 이미지는 비율을 유지하며 자동 축소됩니다.

--max-num-batched-tokens=16384: 한 배치에서 처리할 최대 토큰 수. 멀티모달에서는 이미지 패치 토큰이 이 예산을 빠르게 소진하므로, 텍스트 전용(32768)보다 보수적으로 설정합니다. 동시 요청이 많을 때 OOM(Out of Memory)을 방지하는 핵심 안전장치입니다.

--enable-chunked-prefill: 긴 프리필(prefill) 시퀀스를 청크로 나누어 처리. 고해상도 이미지의 패치 토큰이 수천 개일 때 프리필 지연 스파이크를 완화합니다. 이미 실행 중인 디코딩 요청의 지연도 보호됩니다.

3-4. API 호출 예시 — OpenAI Vision 호환

vLLM의 OpenAI 호환 서버는 /v1/chat/completions에서 멀티모달 입력을 그대로 지원합니다. 클라이언트 코드는 OpenAI의 GPT-4o Vision API와 동일한 형태입니다.

import base64
import httpx
from pathlib import Path


def encode_image(image_path: str) -> str:
    """이미지를 base64로 인코딩합니다."""
    img_bytes = Path(image_path).read_bytes()
    return base64.standard_b64encode(img_bytes).decode("utf-8")


async def analyze_document(image_path: str, question: str) -> str:
    """Qwen3-VL에 이미지와 질문을 전송합니다."""
    b64_image = encode_image(image_path)

    payload = {
        "model": "qwen3-vl-30b",
        "messages": [
            {
                "role": "system",
                "content": (
                    "당신은 금융 문서 분석 전문가입니다. "
                    "표, 차트, 스캔 문서를 정확히 읽고 "
                    "구조화된 데이터로 변환합니다. "
                    "추측하지 말고, 보이는 것만 답하세요."
                ),
            },
            {
                "role": "user",
                "content": [
                    {
                        "type": "image_url",
                        "image_url": {
                            "url": f"data:image/png;base64,{b64_image}",
                            "detail": "high",  # 고해상도 모드
                        },
                    },
                    {
                        "type": "text",
                        "text": question,
                    },
                ],
            },
        ],
        "max_tokens": 2048,
        "temperature": 0.1,   # 문서 분석은 낮은 temperature
        "stream": False,
    }

    async with httpx.AsyncClient(timeout=60.0) as client:
        resp = await client.post(
            "http://localhost:8200/v1/chat/completions",
            json=payload,
        )
        resp.raise_for_status()
        data = resp.json()
        return data["choices"][0]["message"]["content"]


# 사용 예시
# result = await analyze_document(
#     "financial_report_q2.png",
#     "이 표에서 2분기 영업이익과 전년 동기 대비 증감률을 추출해 주세요."
# )

URL 입력 방식 두 가지:

  • data:image/png;base64,{...} — base64 인라인. 네트워크 의존성 없음. 온프레미스 환경에서 권장.
  • http://internal-nas/images/doc.png — URL 참조. vLLM이 이미지를 직접 다운로드. 대용량 이미지 배치 처리에 유리하나, vLLM 서버에서 해당 URL에 접근 가능해야 합니다.

3-5. 스트리밍 응답

비전 요청도 텍스트와 동일하게 SSE(Server-Sent Events) 스트리밍을 지원합니다. 6화에서 구축한 게이트웨이의 스트리밍 프록시가 그대로 작동합니다.

import json
import httpx


async def stream_visual_analysis(image_path: str, question: str):
    """스트리밍으로 비전 분석 결과를 수신합니다."""
    b64_image = encode_image(image_path)

    payload = {
        "model": "qwen3-vl-30b",
        "messages": [
            {
                "role": "user",
                "content": [
                    {
                        "type": "image_url",
                        "image_url": {
                            "url": f"data:image/png;base64,{b64_image}",
                        },
                    },
                    {"type": "text", "text": question},
                ],
            },
        ],
        "max_tokens": 4096,
        "stream": True,
    }

    async with httpx.AsyncClient(timeout=120.0) as client:
        async with client.stream(
            "POST",
            "http://localhost:8200/v1/chat/completions",
            json=payload,
        ) as resp:
            resp.raise_for_status()
            async for line in resp.aiter_lines():
                if not line.startswith("data: "):
                    continue
                data_str = line[6:]
                if data_str.strip() == "[DONE]":
                    break
                chunk = json.loads(data_str)
                delta = chunk["choices"][0].get("delta", {})
                content = delta.get("content", "")
                if content:
                    print(content, end="", flush=True)
    print()  # 줄바꿈

비전 요청의 프리필(이미지 패치 처리)은 텍스트보다 오래 걸립니다. 1344×1344 이미지의 경우 A100 80GB에서 첫 토큰까지(TTFT, Time To First Token) 약 2~4초가 소요됩니다. 스트리밍을 켜면 이 대기 시간 동안 클라이언트가 타임아웃하지 않도록 서버가 keepalive를 유지합니다.

4. MLX로 Qwen3-VL 서빙하기 — Apple Silicon 트랙

4-1. 현재 상태와 제약

4화에서 다룬 mlx-lm은 텍스트 전용 Qwen3 서빙에 최적화되어 있습니다. 멀티모달 모델의 MLX 서빙은 mlx-vlm 패키지를 통해 지원됩니다.

# mlx-vlm 설치
pip install mlx-vlm

# Qwen3-VL-8B MLX 변환 모델 다운로드
# (커뮤니티 변환본 또는 직접 변환)
huggingface-cli download mlx-community/Qwen3-VL-8B-4bit \
  --local-dir /models/mlx/Qwen3-VL-8B-4bit

4-2. mlx-vlm 서빙 설정

# mlx-vlm으로 OpenAI 호환 서버 시작
python -m mlx_vlm.server \
  --model /models/mlx/Qwen3-VL-8B-4bit \
  --host 127.0.0.1 \
  --port 8210

# 또는 Python 스크립트에서 직접 추론
# Python에서 mlx-vlm 직접 사용
from mlx_vlm import load, generate
from mlx_vlm.utils import load_image

model, processor = load("/models/mlx/Qwen3-VL-8B-4bit")

image = load_image("financial_chart.png")

messages = [
    {
        "role": "user",
        "content": [
            {"type": "image"},
            {"type": "text", "text": "이 차트의 추세를 분석해 주세요."},
        ],
    }
]

prompt = processor.apply_chat_template(
    messages, tokenize=False, add_generation_prompt=True
)

output = generate(
    model,
    processor,
    prompt,
    image,
    max_tokens=1024,
    temperature=0.1,
)
print(output)

4-3. vLLM vs mlx-vlm 비교

항목 vLLM (CUDA) mlx-vlm (Apple Silicon)
지원 모델 8B, 30B-A3B 모두 8B (4bit/8bit 변환)
동시 요청 continuous batching, 8~16 동시 단일 요청 순차 처리
OpenAI 호환 네이티브 지원 mlx-vlm.server로 지원
이미지 해상도 최대 1344×1344+ 메모리 허용 범위 내
TTFT (8B, 672×896) ~0.8초 (A100 80GB) ~2.5초 (M2 Ultra 192GB)
처리량 (tok/s, 8B) ~45 tok/s per req ~25 tok/s
적합 용도 프로덕션 멀티모달 서빙 개발·테스트, 소량 배치

실용적 결론: 멀티모달 프로덕션 서빙은 vLLM + CUDA가 1차 선택입니다. Mac Studio(MLX)는 텍스트 전용 Qwen3 서빙에 집중하고, 비전 요청은 CUDA 노드로 라우팅하는 것이 6화 게이트웨이 설계의 자연스러운 확장입니다.

5. 게이트웨이 통합 — 비전 라우팅 추가

5-1. 6화 게이트웨이에 VL 모델 등록

6화에서 구축한 LiteLLM 게이트웨이에 Qwen3-VL 엔드포인트를 추가합니다. 클라이언트는 model 필드만 바꾸면 텍스트↔비전을 전환할 수 있습니다.

# litellm_config.yaml — 비전 모델 추가 (6화 설정에 병합)
model_list:
  # ── 기존 텍스트 모델 (6화) ──
  - model_name: qwen3-30b
    litellm_params:
      model: openai/qwen3-30b
      api_base: http://cuda-node:8100/v1
      api_key: dummy

  - model_name: qwen3-14b-mlx
    litellm_params:
      model: openai/qwen3-14b-mlx
      api_base: http://mac-studio:8000/v1
      api_key: dummy

  # ── 비전 모델 (7화 추가) ──
  - model_name: qwen3-vl-30b
    litellm_params:
      model: openai/qwen3-vl-30b
      api_base: http://cuda-node:8200/v1
      api_key: dummy
      # 비전 요청은 TTFT가 길므로 타임아웃 연장
      timeout: 120
      stream_timeout: 180

  - model_name: qwen3-vl-8b
    litellm_params:
      model: openai/qwen3-vl-8b
      api_base: http://cuda-node:8201/v1
      api_key: dummy
      timeout: 60

  # ── 비전 폴백 체인 ──
  - model_name: vision-auto
    litellm_params:
      model: openai/qwen3-vl-30b
      api_base: http://cuda-node:8200/v1
      api_key: dummy
      timeout: 120
    fallbacks:
      - model: openai/qwen3-vl-8b
        api_base: http://cuda-node:8201/v1
        timeout: 60

router_settings:
  routing_strategy: "usage-based-routing-v2"
  # 비전 요청 감지 시 자동으로 VL 모델로 라우팅
  enable_tag_filtering: true

general_settings:
  master_key: "sk-internal-gateway-key"

5-2. 스마트 라우팅 — 텍스트 vs 비전 자동 분기

클라이언트가 매번 모델 이름을 명시적으로 바꾸는 것은 번거롭습니다. 게이트웨이 앞에 얇은 라우팅 레이어를 두어, 요청에 이미지가 포함되어 있는지 자동 감지하고 적절한 모델로 전달하는 패턴이 실용적입니다.

# vision_router.py — 요청 내 이미지 존재 여부로 모델 자동 선택
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import httpx

app = FastAPI()

GATEWAY_URL = "http://localhost:4000"  # LiteLLM 게이트웨이

TEXT_MODEL = "qwen3-30b"
VISION_MODEL = "vision-auto"


def has_image_content(messages: list[dict]) -> bool:
    """메시지에 이미지 콘텐츠가 포함되어 있는지 확인합니다."""
    for msg in messages:
        content = msg.get("content")
        if isinstance(content, list):
            for block in content:
                if isinstance(block, dict):
                    if block.get("type") == "image_url":
                        return True
                    if block.get("type") == "image":
                        return True
    return False


@app.post("/v1/chat/completions")
async def route_completion(request: Request):
    """이미지 유무에 따라 텍스트/비전 모델로 자동 라우팅합니다."""
    body = await request.json()
    messages = body.get("messages", [])

    # 클라이언트가 명시적으로 모델을 지정한 경우 존중
    requested_model = body.get("model", "")
    if "vl" in requested_model.lower():
        target_model = requested_model
    elif has_image_content(messages):
        target_model = VISION_MODEL
    else:
        target_model = body.get("model", TEXT_MODEL)

    body["model"] = target_model
    is_stream = body.get("stream", False)

    headers = {
        "Authorization": f"Bearer sk-internal-gateway-key",
        "Content-Type": "application/json",
    }

    if is_stream:
        async def stream_proxy():
            async with httpx.AsyncClient(timeout=180.0) as client:
                async with client.stream(
                    "POST",
                    f"{GATEWAY_URL}/v1/chat/completions",
                    json=body,
                    headers=headers,
                ) as resp:
                    async for chunk in resp.aiter_bytes():
                        yield chunk

        return StreamingResponse(
            stream_proxy(),
            media_type="text/event-stream",
        )
    else:
        async with httpx.AsyncClient(timeout=180.0) as client:
            resp = await client.post(
                f"{GATEWAY_URL}/v1/chat/completions",
                json=body,
                headers=headers,
            )
            return resp.json()

이 라우터는 게이트웨이의 상위에 위치합니다. 전체 요청 흐름은 다음과 같습니다.

클라이언트
  → Vision Router (:5000)     # 이미지 유무 감지, 모델 자동 선택
    → LiteLLM Gateway (:4000) # 폴백·로드밸런싱·메트릭
      → vLLM VL (:8200)       # Qwen3-VL-30B-A3B 추론
      → vLLM VL (:8201)       # Qwen3-VL-8B (폴백)
      → vLLM Text (:8100)     # Qwen3-30B 텍스트 전용
      → MLX Text (:8000)      # Qwen3-14B 텍스트 (Mac Studio)
텍스트·비전 통합 라우팅 아키텍처

6. Visual Agent 아키텍처

6-1. Visual Agent란 — 정의와 핵심 개념

Visual Agent는 화면을 보고 행동하는 AI 에이전트입니다. 일반적인 비전 모델이 “이 이미지에 무엇이 있는가”를 설명하는 데 그친다면, Visual Agent는 한 발 더 나아가 “이 UI에서 다음 버튼을 클릭하려면 어디를 눌러야 하는가”, “이 에러 화면을 해결하려면 어떤 조치가 필요한가”를 판단하고 도구를 호출해 실제로 행동합니다.

Qwen3-VL은 UI 요소의 위치를 바운딩 박스(bounding box) 좌표로 출력하는 능력을 갖추고 있어, GUI 자동화의 핵심 인식 엔진으로 활용할 수 있습니다.

6-2. 아키텍처: 인식-판단-행동 루프

graph TD
    A[스크린 캡처] -->|이미지| B[Qwen3-VL 비전 분석]
    B -->|UI 요소 인식 + 좌표| C[행동 계획 수립]
    C -->|tool_call: click/type/scroll| D[GUI 자동화 도구]
    D -->|실행 결과| E[새 스크린 캡처]
    E -->|반복| B
    C -->|완료 판단| F[결과 반환]
    
    style B fill:#e1f5fe
    style D fill:#fff3e0

6-3. 구현: Qwen3-VL + Tool Use

# visual_agent.py — Qwen3-VL 기반 Visual Agent 코어 루프
import json
import base64
import httpx
from dataclasses import dataclass


@dataclass
class UIAction:
    """UI 행동을 나타냅니다."""
    action_type: str  # "click", "type", "scroll", "wait", "done"
    x: int = 0
    y: int = 0
    text: str = ""
    reasoning: str = ""


VISUAL_AGENT_SYSTEM = """당신은 GUI 자동화 에이전트입니다.
사용자의 요청을 수행하기 위해 화면을 분석하고 행동합니다.

사용 가능한 도구:
- click(x, y): 화면의 (x, y) 좌표를 클릭
- type(text): 현재 포커스된 입력 필드에 텍스트 입력
- scroll(direction): "up" 또는 "down" 스크롤
- wait(seconds): 지정 시간 대기
- done(result): 작업 완료, 결과 반환

화면 좌표는 이미지의 픽셀 좌표로 지정합니다.
각 단계마다 현재 화면 상태를 분석하고, 다음 행동 1개를 선택하세요.
반드시 JSON 형태로 응답하세요:
{"action": "click", "x": 150, "y": 300, "reasoning": "검색 버튼 위치"}
"""


TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "click",
            "description": "화면의 지정 좌표를 클릭합니다.",
            "parameters": {
                "type": "object",
                "properties": {
                    "x": {"type": "integer", "description": "X 좌표 (픽셀)"},
                    "y": {"type": "integer", "description": "Y 좌표 (픽셀)"},
                },
                "required": ["x", "y"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "type_text",
            "description": "현재 포커스된 필드에 텍스트를 입력합니다.",
            "parameters": {
                "type": "object",
                "properties": {
                    "text": {"type": "string", "description": "입력할 텍스트"},
                },
                "required": ["text"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "scroll",
            "description": "화면을 스크롤합니다.",
            "parameters": {
                "type": "object",
                "properties": {
                    "direction": {
                        "type": "string",
                        "enum": ["up", "down"],
                    },
                },
                "required": ["direction"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "done",
            "description": "작업이 완료되었음을 선언합니다.",
            "parameters": {
                "type": "object",
                "properties": {
                    "result": {
                        "type": "string",
                        "description": "작업 결과 요약",
                    },
                },
                "required": ["result"],
            },
        },
    },
]


async def run_visual_agent(
    task: str,
    capture_fn,     # () -> bytes (PNG 스크린샷 반환 함수)
    execute_fn,     # (UIAction) -> None (실제 GUI 조작 함수)
    max_steps: int = 15,
    vl_endpoint: str = "http://localhost:8200/v1/chat/completions",
) -> str:
    """Visual Agent 메인 루프를 실행합니다."""

    messages = [
        {"role": "system", "content": VISUAL_AGENT_SYSTEM},
        {"role": "user", "content": f"작업: {task}"},
    ]

    for step in range(max_steps):
        # 1. 현재 화면 캡처
        screenshot = capture_fn()
        b64_img = base64.standard_b64encode(screenshot).decode("utf-8")

        # 2. 스크린샷을 메시지에 추가
        messages.append({
            "role": "user",
            "content": [
                {
                    "type": "image_url",
                    "image_url": {
                        "url": f"data:image/png;base64,{b64_img}",
                        "detail": "high",
                    },
                },
                {
                    "type": "text",
                    "text": f"[Step {step + 1}] 현재 화면입니다. 다음 행동을 결정하세요.",
                },
            ],
        })

        # 3. Qwen3-VL에 판단 요청
        async with httpx.AsyncClient(timeout=60.0) as client:
            resp = await client.post(
                vl_endpoint,
                json={
                    "model": "qwen3-vl-30b",
                    "messages": messages,
                    "tools": TOOLS,
                    "tool_choice": "auto",
                    "max_tokens": 512,
                    "temperature": 0.1,
                },
            )
            result = resp.json()

        choice = result["choices"][0]
        assistant_msg = choice["message"]
        messages.append(assistant_msg)

        # 4. tool_call 처리
        if assistant_msg.get("tool_calls"):
            tool_call = assistant_msg["tool_calls"][0]
            fn_name = tool_call["function"]["name"]
            fn_args = json.loads(tool_call["function"]["arguments"])

            if fn_name == "done":
                return fn_args.get("result", "작업 완료")

            action = UIAction(
                action_type=fn_name,
                x=fn_args.get("x", 0),
                y=fn_args.get("y", 0),
                text=fn_args.get("text", fn_args.get("direction", "")),
                reasoning=fn_args.get("reasoning", ""),
            )

            # 5. 실제 GUI 조작 실행
            execute_fn(action)

            # 6. 도구 실행 결과를 대화에 추가
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call["id"],
                "content": f"{fn_name} 실행 완료",
            })
        else:
            # tool_call 없이 텍스트로 응답한 경우
            content = assistant_msg.get("content", "")
            if "done" in content.lower() or "완료" in content:
                return content

    return "최대 단계 초과 — 작업 미완료"

핵심 설계 포인트:

  • 매 스텝마다 새 스크린샷: 이전 캡처를 재사용하지 않습니다. 행동 후 화면 상태가 변했을 수 있으므로, 항상 최신 캡처를 기반으로 판단합니다.
  • tool_choice: “auto”: 모델이 자유롭게 도구를 선택하거나 텍스트로 응답할 수 있습니다. 작업 완료 시 done 도구를 호출하거나 직접 “완료”를 선언합니다.
  • max_steps 제한: 무한 루프 방지. 15스텝이면 대부분의 GUI 작업을 완료할 수 있습니다. 복잡한 워크플로는 태스크를 분할합니다.
  • 대화 히스토리 누적: 이전 단계의 화면과 행동이 컨텍스트에 남아, 모델이 진행 상황을 추적합니다. 다만 이미지 토큰이 누적되면 KV 캐시 폭주 위험 — 5스텝마다 오래된 이미지를 요약으로 교체하는 압축 전략을 8화에서 다룹니다.
Visual Agent 인식-판단-행동 루프

7. Visual Coding — 코드 생성을 위한 비전 활용

7-1. Visual Coding이란

Visual Coding은 시각 자료(UI 디자인, 와이어프레임, 스크린샷)를 입력으로 받아 코드를 생성하는 패턴입니다. Qwen3-VL은 UI 레이아웃의 구조를 파악하고, HTML/CSS/React 등의 프론트엔드 코드로 변환하는 능력을 보유합니다.

7-2. 실용 시나리오

  • 디자인 시안 → HTML 변환: Figma 캡처를 입력하면 구현 코드를 생성
  • 레거시 화면 리버스 엔지니어링: 오래된 데스크탑 앱의 화면을 캡처해 웹 UI로 재구현
  • 에러 화면 자동 분석: 스택트레이스가 담긴 스크린샷에서 에러를 추출하고 수정 코드 제안
# visual_coding_example.py — 디자인 시안에서 HTML 생성
async def design_to_code(design_image_path: str) -> str:
    """UI 디자인 이미지를 HTML/CSS 코드로 변환합니다."""
    b64_image = encode_image(design_image_path)

    payload = {
        "model": "qwen3-vl-30b",
        "messages": [
            {
                "role": "system",
                "content": (
                    "당신은 UI 디자인을 HTML/CSS 코드로 변환하는 전문가입니다. "
                    "이미지를 분석해 구조적으로 동일한 HTML을 생성하세요. "
                    "Tailwind CSS를 사용하고, 반응형 디자인을 적용하세요. "
                    "색상, 여백, 폰트 크기를 이미지에서 최대한 정확히 추출하세요."
                ),
            },
            {
                "role": "user",
                "content": [
                    {
                        "type": "image_url",
                        "image_url": {
                            "url": f"data:image/png;base64,{b64_image}",
                            "detail": "high",
                        },
                    },
                    {
                        "type": "text",
                        "text": "이 디자인 시안을 HTML/Tailwind CSS로 구현해 주세요.",
                    },
                ],
            },
        ],
        "max_tokens": 4096,
        "temperature": 0.2,
    }

    async with httpx.AsyncClient(timeout=90.0) as client:
        resp = await client.post(
            "http://localhost:8200/v1/chat/completions",
            json=payload,
        )
        data = resp.json()
        return data["choices"][0]["message"]["content"]

금융IT 환경에서 Visual Coding의 대표 활용 사례는 레거시 시스템의 화면 스펙 문서화입니다. 수십 년 된 코볼/VB 시스템의 화면을 캡처해 Qwen3-VL에 입력하면, 화면 내 필드·버튼·레이블의 구조를 파악하고 데이터 모델을 역추론할 수 있습니다. 물론 이 결과는 검증이 필요하지만, 수작업 대비 초기 문서화 속도를 크게 단축합니다.

8. 금융 문서 처리 파이프라인

8-1. 금융IT에서 멀티모달이 필요한 이유

금융 업무에서 다루는 문서의 상당수는 구조화되지 않은 시각 자료입니다.

  • 스캔된 계약서/약관: PDF지만 이미지로 스캔된 문서. OCR만으로는 표 구조와 체크박스 상태를 놓침
  • 실적 보고서 차트: 매출 추이, 수익률 그래프 — 숫자를 이미지에서 직접 읽어야 함
  • KYC(본인확인) 서류: 신분증, 통장 사본 — 이미지 내 특정 필드 추출
  • 규정 준수 증빙: 스크린샷 형태의 시스템 로그, 승인 화면 캡처

전통적으로 이런 문서는 OCR → 후처리 → NLP 파이프라인을 거칩니다. Qwen3-VL은 이 세 단계를 단일 모델 추론으로 대체할 수 있는 가능성을 제공합니다.

8-2. 파이프라인 아키텍처

graph LR
    A[문서 수신
PDF/이미지] --> B{페이지 분류} B -->|텍스트 PDF| C[텍스트 추출
PyMuPDF] B -->|스캔 PDF/
이미지| D[페이지별
이미지 변환] D --> E[Qwen3-VL
구조 분석] C --> F[Qwen3
텍스트 분석] E --> G[구조화 데이터
JSON/표] F --> G G --> H[검증 &
후처리] H --> I[결과 저장
+ 감사 로그] style E fill:#e1f5fe style F fill:#e8f5e9

8-3. 구현: 금융 문서 분석 파이프라인

# financial_doc_pipeline.py — 금융 문서 분석 파이프라인
import json
import base64
import fitz  # PyMuPDF
import httpx
from pathlib import Path
from dataclasses import dataclass, field
from enum import Enum


class PageType(Enum):
    TEXT_PDF = "text_pdf"        # 디지털 생성 PDF (텍스트 추출 가능)
    SCANNED = "scanned"         # 스캔 문서 (이미지만 존재)
    MIXED = "mixed"             # 텍스트 + 이미지 혼합


@dataclass
class PageAnalysis:
    """단일 페이지 분석 결과."""
    page_number: int
    page_type: PageType
    raw_text: str = ""
    vl_analysis: str = ""
    structured_data: dict = field(default_factory=dict)
    confidence: float = 0.0


@dataclass
class DocumentResult:
    """전체 문서 분석 결과."""
    file_name: str
    total_pages: int
    pages: list[PageAnalysis] = field(default_factory=list)
    summary: str = ""


def classify_page(page: fitz.Page) -> PageType:
    """PDF 페이지를 텍스트/스캔/혼합으로 분류합니다."""
    text = page.get_text().strip()
    images = page.get_images()

    if len(text) > 50 and not images:
        return PageType.TEXT_PDF
    elif len(text) < 10 and images:
        return PageType.SCANNED
    else:
        return PageType.MIXED


def page_to_image(page: fitz.Page, dpi: int = 200) -> bytes:
    """PDF 페이지를 PNG 이미지로 변환합니다."""
    mat = fitz.Matrix(dpi / 72, dpi / 72)
    pix = page.get_pixmap(matrix=mat)
    return pix.tobytes("png")


# ── 분석 프롬프트 템플릿 ──

TABLE_EXTRACTION_PROMPT = """이 금융 문서 이미지에서 모든 표(table)를 추출하세요.

요구사항:
1. 각 표의 헤더와 데이터 행을 정확히 구분
2. 숫자는 원본 그대로 (천 단위 콤마, 소수점 보존)
3. 통화 단위가 있으면 명시
4. 빈 셀은 null로 표시

JSON 형식으로 응답:
{
  "tables": [
    {
      "title": "표 제목 (있으면)",
      "headers": ["열1", "열2", ...],
      "rows": [["값1", "값2", ...], ...],
      "notes": "표 하단 주석 (있으면)"
    }
  ]
}"""

CHART_ANALYSIS_PROMPT = """이 차트/그래프를 분석하세요.

추출할 정보:
1. 차트 유형 (막대, 선, 파이, 기타)
2. X축/Y축 레이블과 단위
3. 데이터 포인트 (가능한 한 정확한 수치)
4. 추세 또는 패턴

JSON 형식으로 응답:
{
  "chart_type": "...",
  "title": "...",
  "x_axis": {"label": "...", "unit": "..."},
  "y_axis": {"label": "...", "unit": "..."},
  "data_points": [{"x": "...", "y": "..."}, ...],
  "trend": "..."
}"""

CONTRACT_REVIEW_PROMPT = """이 계약서/약관 페이지를 분석하세요.

추출할 정보:
1. 조항 번호와 제목
2. 핵심 의무사항 (갑/을 구분)
3. 금액·기한·조건이 명시된 항목
4. 특이사항이나 위험 조항

JSON 형식으로 응답:
{
  "clauses": [
    {
      "number": "...",
      "title": "...",
      "key_terms": ["..."],
      "obligations": {"party_a": "...", "party_b": "..."},
      "amounts": ["..."],
      "risk_level": "low|medium|high",
      "risk_note": "..."
    }
  ]
}"""


async def analyze_page_with_vl(
    image_bytes: bytes,
    analysis_type: str = "table",
    endpoint: str = "http://localhost:8200/v1/chat/completions",
) -> dict:
    """Qwen3-VL로 단일 페이지를 분석합니다."""

    prompt_map = {
        "table": TABLE_EXTRACTION_PROMPT,
        "chart": CHART_ANALYSIS_PROMPT,
        "contract": CONTRACT_REVIEW_PROMPT,
    }
    prompt = prompt_map.get(analysis_type, TABLE_EXTRACTION_PROMPT)

    b64_img = base64.standard_b64encode(image_bytes).decode("utf-8")

    payload = {
        "model": "qwen3-vl-30b",
        "messages": [
            {
                "role": "system",
                "content": (
                    "당신은 금융 문서 분석 전문가입니다. "
                    "이미지에 보이는 내용만을 기반으로 정확하게 데이터를 추출합니다. "
                    "확실하지 않은 수치에는 confidence 필드를 추가하세요."
                ),
            },
            {
                "role": "user",
                "content": [
                    {
                        "type": "image_url",
                        "image_url": {
                            "url": f"data:image/png;base64,{b64_img}",
                            "detail": "high",
                        },
                    },
                    {"type": "text", "text": prompt},
                ],
            },
        ],
        "max_tokens": 4096,
        "temperature": 0.05,  # 데이터 추출은 극히 낮은 temperature
        "response_format": {"type": "json_object"},
    }

    async with httpx.AsyncClient(timeout=90.0) as client:
        resp = await client.post(endpoint, json=payload)
        resp.raise_for_status()
        data = resp.json()
        content = data["choices"][0]["message"]["content"]

        try:
            return json.loads(content)
        except json.JSONDecodeError:
            return {"raw_response": content, "parse_error": True}


async def process_document(
    pdf_path: str,
    analysis_type: str = "table",
    endpoint: str = "http://localhost:8200/v1/chat/completions",
) -> DocumentResult:
    """PDF 문서 전체를 분석합니다."""
    path = Path(pdf_path)
    doc = fitz.open(str(path))

    result = DocumentResult(
        file_name=path.name,
        total_pages=len(doc),
    )

    for page_num in range(len(doc)):
        page = doc[page_num]
        page_type = classify_page(page)

        analysis = PageAnalysis(
            page_number=page_num + 1,
            page_type=page_type,
        )

        if page_type == PageType.TEXT_PDF:
            # 텍스트 PDF는 직접 추출 (VL 불필요)
            analysis.raw_text = page.get_text()
            analysis.confidence = 0.95  # 디지털 텍스트는 신뢰도 높음
        else:
            # 스캔/혼합 페이지는 VL로 분석
            image_bytes = page_to_image(page, dpi=200)
            vl_result = await analyze_page_with_vl(
                image_bytes, analysis_type, endpoint
            )
            analysis.structured_data = vl_result
            analysis.confidence = 0.80  # VL 추출은 검증 필요

            # 혼합 페이지는 텍스트도 함께 추출
            if page_type == PageType.MIXED:
                analysis.raw_text = page.get_text()

        result.pages.append(analysis)

    doc.close()
    return result

8-4. 규제 환경에서의 고려사항

금융IT에서 멀티모달 AI를 도입할 때 반드시 점검해야 할 항목들입니다.

  • 데이터 잔존(Data Residency): 모든 이미지가 온프레미스 서버 내부에서만 처리되어야 합니다. 외부 API 호출은 물론, 이미지가 임시 디렉토리에 남는 것도 통제해야 합니다. vLLM은 요청 처리 후 이미지 데이터를 메모리에서 해제하지만, 디버그 로그가 이미지를 base64로 기록하지 않도록 주의합니다.
  • PII 마스킹: KYC 문서(신분증, 통장 사본)를 처리할 때, VL 모델의 출력에 주민등록번호·계좌번호가 포함될 수 있습니다. 후처리 단계에서 PII 패턴을 탐지하고 마스킹하는 레이어를 반드시 추가합니다. 13화에서 자세히 다룹니다.
  • 감사 로그(Audit Trail): 어떤 문서를, 언제, 어떤 모델이 분석했고, 어떤 결과를 냈는지 기록합니다. 단, 로그에 원본 이미지나 민감 데이터를 포함하면 안 됩니다. 문서 해시값 + 분석 메타데이터만 기록합니다.
  • 모델 출력의 비결정성: 같은 이미지에 대해 VL 모델이 매번 동일한 결과를 보장하지 않습니다. temperature=0.0으로 설정해도 부동소수점 연산 순서에 따라 미세한 차이가 발생할 수 있습니다. 금액이나 날짜 같은 핵심 필드는 반드시 사람이 검증하는 Human-in-the-Loop 단계를 설계합니다.
# audit_logger.py — 감사 로그 (민감 데이터 미포함)
import hashlib
import json
import logging
from datetime import datetime, timezone

audit_log = logging.getLogger("audit.vl_analysis")


def log_document_analysis(
    document_path: str,
    document_hash: str,
    model_name: str,
    analysis_type: str,
    page_count: int,
    result_summary: dict,
    operator_id: str,
) -> None:
    """문서 분석 감사 로그를 기록합니다. 원본 데이터 미포함."""
    audit_entry = {
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "event": "document_analysis",
        "document": {
            "name": document_path.split("/")[-1],  # 파일명만
            "sha256": document_hash,
            "pages_analyzed": page_count,
        },
        "model": {
            "name": model_name,
            "analysis_type": analysis_type,
        },
        "result": {
            "tables_extracted": result_summary.get("table_count", 0),
            "fields_extracted": result_summary.get("field_count", 0),
            "confidence_avg": result_summary.get("avg_confidence", 0.0),
            "requires_review": result_summary.get("avg_confidence", 0.0) < 0.85,
        },
        "operator_id": operator_id,
    }
    audit_log.info(json.dumps(audit_entry, ensure_ascii=False))


def compute_file_hash(file_path: str) -> str:
    """파일의 SHA-256 해시를 계산합니다."""
    sha256 = hashlib.sha256()
    with open(file_path, "rb") as f:
        for chunk in iter(lambda: f.read(8192), b""):
            sha256.update(chunk)
    return sha256.hexdigest()

9. 멀티모달 서빙의 메모리 계획

9-1. VRAM 예산 배분

멀티모달 서빙에서는 텍스트 전용보다 메모리 관리가 훨씬 까다롭습니다. 이미지마다 수천 개의 패치 토큰이 KV 캐시를 소비하기 때문입니다.

구성요소 Qwen3-VL-30B-A3B FP8 Qwen3-VL-8B BF16
모델 가중치 ~32 GB ~18 GB
비전 인코더 ~1.2 GB ~1.2 GB
KV 캐시 (8 동시 요청, 이미지 포함) ~35 GB ~3.5 GB
활성화 메모리 ~4 GB ~1.5 GB
이미지 전처리 버퍼 ~2 GB ~1 GB
총계 ~74 GB ~25 GB
권장 GPU A100 80GB RTX 4090 24GB / A6000 48GB

핵심: A100 80GB에 30B-A3B FP8를 올리면 여유가 6GB밖에 없습니다. --gpu-memory-utilization=0.90(72GB 할당)으로 설정하면 KV 캐시 공간이 줄어 동시 요청 수가 4~6개로 제한됩니다. 고해상도 이미지가 다수 포함된 요청이 동시에 들어오면 요청이 대기열에 밀릴 수 있습니다.

9-2. 이미지 해상도 제한 전략

# image_preprocessor.py — 서빙 전 이미지 해상도 표준화
from PIL import Image
from io import BytesIO


def normalize_image(
    image_bytes: bytes,
    max_pixels: int = 1_344 * 1_344,
    target_format: str = "PNG",
) -> bytes:
    """이미지를 최대 해상도 이내로 조정합니다.
    
    KV 캐시 보호를 위해 서빙 전에 호출합니다.
    종횡비를 유지하면서 max_pixels 이내로 축소합니다.
    """
    img = Image.open(BytesIO(image_bytes))
    w, h = img.size
    current_pixels = w * h

    if current_pixels > max_pixels:
        # 종횡비 유지하면서 축소
        scale = (max_pixels / current_pixels) ** 0.5
        new_w = int(w * scale)
        new_h = int(h * scale)
        # 28의 배수로 정렬 (ViT 패치 경계)
        new_w = (new_w // 28) * 28
        new_h = (new_h // 28) * 28
        img = img.resize((new_w, new_h), Image.LANCZOS)

    buf = BytesIO()
    img.save(buf, format=target_format)
    return buf.getvalue()


def estimate_token_cost(width: int, height: int) -> int:
    """이미지의 예상 패치 토큰 수를 계산합니다."""
    patch_size = 14
    temporal_merge = 2
    eff_w = (round(width / 28) * 28) // patch_size
    eff_h = (round(height / 28) * 28) // patch_size
    return (eff_w * eff_h) // (temporal_merge ** 2)

10. 전체 아키텍처 — 텍스트 + 비전 통합 뷰

6화까지의 텍스트 전용 스택과 오늘의 비전 스택을 합치면, 다음과 같은 전체 아키텍처가 됩니다.

flowchart TB
    subgraph Clients["클라이언트"]
        C1[챗봇 UI]
        C2[문서 분석 앱]
        C3[Visual Agent]
        C4[API 클라이언트]
    end

    subgraph Router["비전 라우터 :5000"]
        VR[이미지 유무 감지
모델 자동 선택] end subgraph Gateway["LiteLLM 게이트웨이 :4000"] LB[로드밸런싱
폴백 · 메트릭] end subgraph CUDA["CUDA 노드"] subgraph TextServing["텍스트 서빙"] T1[vLLM :8100
Qwen3-30B-A3B FP8] end subgraph VisionServing["비전 서빙"] V1[vLLM :8200
Qwen3-VL-30B-A3B FP8] V2[vLLM :8201
Qwen3-VL-8B BF16] end end subgraph MacStudio["Mac Studio"] M1[mlx-lm :8000
Qwen3-14B MLX-4bit] end subgraph Storage["데이터"] DB[(PostgreSQL
세션 · 감사 로그)] VDB[(Qdrant
벡터 검색)] end C1 & C2 & C3 & C4 --> VR VR --> LB LB --> T1 & V1 & V2 & M1 V1 & V2 --> DB T1 --> VDB style VisionServing fill:#e1f5fe,stroke:#0277bd style TextServing fill:#e8f5e9,stroke:#2e7d32 style MacStudio fill:#fff3e0,stroke:#ef6c00

10-1. 라우팅 결정 요약

요청 유형 라우팅 대상 폴백
텍스트 대화 (일반) Qwen3-14B MLX (Mac Studio) Qwen3-30B vLLM
텍스트 대화 (복잡/Thinking) Qwen3-30B-A3B vLLM 없음
이미지 분석 (정밀) Qwen3-VL-30B-A3B vLLM Qwen3-VL-8B vLLM
이미지 분석 (실시간) Qwen3-VL-8B vLLM 없음
Visual Agent Qwen3-VL-30B-A3B vLLM Qwen3-VL-8B vLLM
Visual Coding Qwen3-VL-30B-A3B vLLM 없음

11. 운영 함정 (Pitfall) 미니 코너

이미지 토큰 폭주로 인한 KV 캐시 OOM

증상: 정상 운영 중 갑자기 vLLM이 요청을 거부하거나 극단적으로 느려집니다. GPU 메모리 사용량이 99%에 고정됩니다.

원인: 클라이언트가 고해상도 이미지 다수를 한 요청에 포함시켰습니다. 예를 들어 3840×2160 이미지 4장이면:

  • 이미지당 약 12,000 패치 토큰 × 4장 = 48,000 패치 토큰
  • 텍스트 토큰까지 합치면 KV 캐시가 단일 요청으로 수십 GB를 차지
  • 다른 동시 요청의 KV 캐시 할당이 불가능해져 전체 서비스 지연

방어책:

  1. --limit-mm-per-prompt=image=4로 요청당 이미지 수 제한 (필수)
  2. --mm-processor-kwargs={"max_pixels":1806336}로 이미지 해상도 상한 설정
  3. 서빙 앞단에 이미지 정규화 프록시를 두어, 과대 이미지를 사전 축소 (위 normalize_image 함수)
  4. --max-num-batched-tokens를 보수적으로 설정해 단일 배치의 토큰 총량 제한
  5. 게이트웨이 레벨에서 요청 크기(Content-Length) 제한 — base64 이미지 4장의 최대 크기를 역산해 상한 설정 (예: 50MB)

탐지: vLLM의 /metrics 엔드포인트에서 vllm:num_preemptions_total 카운터를 모니터링합니다. 이 값이 급증하면 KV 캐시 선점(preemption)이 발생하고 있다는 신호입니다. Prometheus + Grafana 알림을 설정해 임계치 초과 시 즉시 대응합니다.

# Prometheus alert rule — KV 캐시 선점 급증 탐지
groups:
  - name: vllm_vl_alerts
    rules:
      - alert: VLKVCachePreemptionSpike
        expr: rate(vllm:num_preemptions_total[5m]) > 0.5
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "Qwen3-VL KV 캐시 선점 급증"
          description: |
            최근 5분간 선점 빈도가 분당 0.5회를 초과했습니다.
            고해상도 이미지 과다 요청 또는 동시 요청 폭주 가능성.
            image limit 및 max_pixels 설정을 점검하세요.

12. 성능 벤치마크 — 비전 요청의 현실적 수치

멀티모달 서빙의 성능은 텍스트 전용과 프로파일이 상당히 다릅니다. 핵심 차이점과 측정 기준 환경을 정리합니다.

메트릭 Qwen3-VL-30B-A3B FP8
(A100 80GB)
Qwen3-VL-8B BF16
(RTX 4090 24GB)
TTFT (672×896 이미지 1장) ~2.1초 ~1.0초
TTFT (1344×1344 이미지 1장) ~3.8초 ~2.2초
디코딩 속도 (tok/s) ~35 ~42
동시 요청 (이미지 1장/요청) 6~8 10~14
텍스트 전용 대비 TTFT 증가 3~5배 2~3배

주목할 점: TTFT(첫 토큰까지 시간)가 텍스트 전용 대비 3~5배 증가합니다. 이는 비전 인코더의 이미지 처리 + 패치 토큰의 프리필에 시간이 걸리기 때문입니다. 반면 디코딩 속도(첫 토큰 이후 생성 속도)는 텍스트 전용과 거의 동일합니다 — 디코딩 단계에서는 이미지 패치가 이미 KV 캐시에 들어있어 추가 연산이 없기 때문입니다.

이 TTFT 특성은 UX 설계에 영향을 줍니다. 비전 요청에는 “이미지 분석 중…” 같은 진행 표시를 노출하고, 스트리밍을 반드시 활성화해 사용자가 첫 토큰까지의 대기 시간을 체감하지 않도록 설계합니다.

13. 멀티모달 서빙 체크리스트

7화의 내용을 실전에 적용할 때, 다음 체크리스트를 순서대로 점검하세요.

  • 모델 선택
    • [ ] 정밀 분석(문서·차트)은 30B-A3B, 실시간 분류·UI 에이전트는 8B로 분리했는가?
    • [ ] GPU VRAM에 비전 인코더 + KV 캐시 여유분이 충분한가?
  • 서빙 설정
    • [ ] --limit-mm-per-prompt로 요청당 이미지 수를 제한했는가?
    • [ ] --mm-processor-kwargs로 최대 해상도를 설정했는가?
    • [ ] --max-num-batched-tokens를 텍스트 전용보다 보수적으로 낮췄는가?
    • [ ] --enable-chunked-prefill로 프리필 스파이크를 완화했는가?
  • 게이트웨이 통합
    • [ ] 비전 모델을 LiteLLM 설정에 등록했는가?
    • [ ] 비전 라우터가 이미지 유무를 정확히 감지하는가?
    • [ ] 비전 요청의 타임아웃을 텍스트 대비 2~3배로 연장했는가?
  • 보안·규제
    • [ ] 디버그 로그에 이미지 데이터(base64)가 기록되지 않는가?
    • [ ] PII 후처리 마스킹 레이어를 설계했는가?
    • [ ] 감사 로그에 문서 해시만 기록하고 원본은 포함하지 않는가?
  • 모니터링
    • [ ] KV 캐시 선점(preemption) 메트릭을 감시하는가?
    • [ ] 비전 TTFT가 임계치(예: 10초)를 초과하면 알림이 오는가?

정리 — 텍스트에서 멀티모달로의 도약

오늘 7화에서 다룬 핵심을 정리합니다.

Qwen3-VL은 텍스트 전용 Qwen3와 동일한 디코더를 공유하면서 ViT 비전 인코더를 결합한 모델입니다. 8B Dense와 30B-A3B MoE 두 변형은 각각 실시간 처리와 정밀 분석이라는 서로 다른 운영 요구에 대응합니다. vLLM의 멀티모달 지원으로 OpenAI Vision API 호환 엔드포인트를 구축하고, 6화의 게이트웨이에 비전 라우팅을 추가해 텍스트↔비전 요청을 자동 분기했습니다.

Visual Agent는 화면 캡처 → VL 분석 → 도구 호출의 루프로 GUI 자동화를 실현하고, 금융 문서 파이프라인은 스캔본·차트의 구조화 추출을 단일 모델 추론으로 처리합니다. 다만 멀티모달 서빙은 이미지 패치 토큰으로 인한 KV 캐시 부담이 크므로, 해상도 제한·배치 토큰 상한·선점 모니터링이 필수 안전장치입니다.

4화(MLX) → 5화(vLLM) → 6화(게이트웨이) → 7화(멀티모달)로 이어진 Phase B “서빙”이 완성됩니다. 모델을 띄우고 연결하는 인프라가 갖춰졌으니, 이제 모델에게 무엇을 어떻게 말할 것인가의 문제로 넘어갑니다.

내일(8화) 예고: Qwen3 chat template 구조를 해부하고, 시스템 프롬프트 계층화·컨텍스트 조립 파이프라인·Thinking 모드의 reasoning 토큰 비용 관리까지 — Phase C “컨텍스트·지식·메모리”의 문을 엽니다.


📚 시리즈: 온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계 (총 14화 중 7화)
◀ 이전 6화  (다음 차수는 아직 게시되지 않았습니다)

참고 자료

  • Intelligent agent — Wikipedia — 자율적으로 환경을 인식하고 행동하는 지능형 에이전트의 정의와 분류를 다루는 문서
  • Qwen3-VL Collection — Hugging Face — Qwen3-VL 모델 공식 컬렉션 페이지로, 모델 변형별 사양과 사용법을 제공

Tags:

Qwen3-VL 서빙Visual AgentvLLM 멀티모달멀티모달 LLM연재:온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계온프레미스 AI온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계-7화
작성자

AICosmus

Follow Me
다른 기사
하이브리드 추론 게이트웨이 아키텍처 개념도
Previous

[온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계] 6/14화: LLM 게이트웨이 구축 — 모델 라우팅과 폴백 설계

opencode Plan 모드와 Build 모드 전환 개념
Next

[opencode 12일 집중 — 터미널 네이티브 AI 코딩 에이전트 완전 정복] 5/12화: opencode Plan 모드 완전 가이드 — 읽고 확인하고 고치는 안전 루틴

댓글 1개
  1. [온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계] 8/14화: Qwen3 프롬프트·컨텍스트 아키텍처 실전 설계 - AICosmus 댓글:
    2026년 07월 08일, 3:11 오전

    […] 온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계 (총 14화 중 8화)◀ 이전 7화  (다음 차수는 아직 게시되지 않았습니다) […]

    답글

답글 남기기 응답 취소

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

최신 글

  • [opencode 시즌 2 심화 — 나만의 도메인 특화 에이전트 만들기] 1/12화: opencode 에이전트 아키텍처 완전 해부 — 2026 Primary·Subagent 5계층 구조
  • Kotlin 코루틴 핵심 5가지 개념과 실전 활용법
  • [opencode 12일 집중 — 터미널 네이티브 AI 코딩 에이전트 완전 정복] 12/12화: opencode 로컬 모델 완전 가이드 2026 — Ollama·에어갭·규제 환경 도입 체크리스트
  • LLM 파인튜닝 실전 5단계 — 2026 LoRA 완벽 가이드
  • 금융 앱 생체인증 작동 원리, 지문·얼굴 보안 5단계 완전 해부

최신 댓글

  1. [온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계] 9/14화: 온프레미스 RAG 파이프라인 — bge-m3·Qdrant 자체 호스팅 실전의 Dockerfile 최적화 실전 가이드 — 빌드·크기·보안 총정리 - AICosmus
  2. RAG 평가 프레임워크, 답변 품질을 수치로 측정하는 법의 RAG 리랭킹 가이드, 검색 결과 정확도 높이는 법 - AICosmus
  3. RAG 평가 프레임워크, 답변 품질을 수치로 측정하는 법의 RAG 리랭킹 가이드, 검색 결과 정확도 높이는 법 - AICosmus
  4. gRPC Interceptor 완벽 가이드: 인증부터 로깅까지의 gRPC 데드라인과 재시도 정책으로 장애 전파 차단하기 - AICosmus
  5. gRPC Interceptor 완벽 가이드: 인증부터 로깅까지의 gRPC 데드라인과 재시도 정책으로 장애 전파 차단하기 - AICosmus
  • About
  • Contact
  • Disclaimer
  • Privacy - Policy
  • Terms of Service
Copyright 2026 — AICosmus. All rights reserved. Blogsy WordPress Theme