본문으로 건너뛰기
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
하이브리드 추론 게이트웨이 아키텍처 개념도
IT기술

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

By AICosmus
2026년 07월 05일 19 Min Read
1

이 글은 「온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계」 시리즈 6일차로, LLM 게이트웨이에서 모델 라우팅과 폴백을 설계하는 방법을 다룹니다.

어제(5일차)는 vLLM 위에서 Qwen3를 FP8 양자화·텐서 병렬로 서빙하고, PagedAttention과 continuous batching으로 고처리량 추론 파이프라인을 안정화하는 과정을 다뤘습니다. 이제 Mac Studio(MLX)와 NVIDIA CUDA(vLLM), 두 개의 추론 백엔드가 모두 준비됐습니다.

문제는 여기서부터입니다. 클라이언트 애플리케이션이 “이 요청은 Mac Studio로, 저 요청은 CUDA 서버로”를 직접 판단해야 할까요? 한쪽 서버가 다운됐을 때 다른 쪽으로 자동 전환은? 간단한 분류 작업에 235B 모델을 돌리는 낭비는? 오늘은 이 모든 문제를 한 장의 게이트웨이 계층으로 해결합니다.

오늘의 핵심 3가지

  • 모델 라우팅 — 경량 Qwen3-8B가 요청을 분류하고, 복잡도에 따라 14B·30B-A3B·235B로 자동 분기하는 2단 추론 파이프라인
  • 통합 게이트웨이 — LiteLLM Proxy를 OpenAI 호환 단일 엔드포인트로 세우고, Mac Studio(MLX)와 CUDA(vLLM) 백엔드를 하나의 API 뒤에 통합
  • 폴백 체인 — 타임아웃·헬스체크·재시도·스트리밍 전파까지 포함한 장애 내성 설계로, 한 노드가 죽어도 응답이 끊기지 않는 구조

왜 게이트웨이 계층이 필요한가

4~5일차를 거치면서 우리에게는 최소 두 개의 추론 엔드포인트가 생겼습니다.

  • Mac Studio (MLX): http://mac-studio:8080/v1 — Qwen3-30B-A3B Q4 또는 Qwen3-14B Q8, 통합 메모리 192GB 안에서 저전력·저지연 서빙
  • CUDA 서버 (vLLM): http://cuda-node:8000/v1 — Qwen3-30B-A3B FP8 또는 Qwen3-235B-A22B FP8 (듀얼 GPU 텐서 병렬), 높은 동시 처리량

여기에 모델 크기까지 고려하면 조합이 폭발합니다. 클라이언트가 직접 백엔드를 선택하는 구조는 세 가지 근본적인 문제를 안고 있습니다.

문제 1: 클라이언트-백엔드 강결합

채팅 UI, RAG 파이프라인, 에이전트 오케스트레이터 — 각각이 백엔드 URL과 모델명을 하드코딩하면, 노드 추가·모델 교체·IP 변경 때마다 모든 클라이언트를 수정해야 합니다. 운영 환경에서 이건 사고의 씨앗입니다.

문제 2: 자원 낭비

“오늘 서울 날씨 알려줘” 같은 단순 질의에 Qwen3-235B를 호출하면, CUDA 서버의 GPU 메모리 160GB가 이 한 요청에 점유됩니다. 그 사이 복잡한 코드 리뷰 요청은 큐에서 대기하죠. 요청의 복잡도에 맞는 모델 크기를 선택하는 것만으로도 전체 처리량이 2~5배 달라집니다.

문제 3: 단일 장애점

Mac Studio가 macOS 업데이트로 재부팅되는 10분 동안, 모든 요청이 실패합니다. CUDA 서버의 GPU가 Xid 에러로 멈출 때도 마찬가지입니다. 하나의 백엔드에 장애가 생겨도 다른 백엔드로 자동 전환(fallback)되는 구조가 없으면, 온프레미스 시스템의 가용성은 클라우드 API보다 오히려 나빠집니다.

이 세 문제의 해법이 바로 추론 게이트웨이(Inference Gateway)입니다. 클라이언트는 게이트웨이의 단일 엔드포인트만 알면 되고, 게이트웨이가 라우팅·폴백·로드 밸런싱을 모두 처리합니다.

추론 게이트웨이 전체 아키텍처 다이어그램 - — 모델

아키텍처: 2단 추론 파이프라인

오늘 구축할 아키텍처의 핵심 아이디어는 “작은 모델이 판단하고, 큰 모델이 생성한다”입니다.

1단: 라우터 — 경량 Qwen3-8B의 요청 분류

모든 들어오는 요청은 먼저 Qwen3-8B를 거칩니다. 이 모델은 약 16GB 메모리(Q4 양자화 시 ~5GB)로 구동되며, 요청을 4개 복잡도 등급으로 분류합니다.

  • SIMPLE — 단순 인사, 날씨, 시간, 단위 변환 등. 8B 모델 자체가 직접 응답 가능.
  • MODERATE — 일반 대화, 요약, 번역, 간단한 질의응답. Qwen3-14B가 적합.
  • COMPLEX — 코드 생성, 논리 추론, 다단계 분석, 긴 문서 처리. Qwen3-30B-A3B 투입.
  • EXPERT — 수학 증명, 복잡한 코드 리팩토링, 다국어 동시 번역 등 최고 품질 요구. Qwen3-235B-A22B(가용 시) 또는 30B-A3B Thinking 모드.

분류에 걸리는 시간은 Qwen3-8B 기준 100~300ms(Mac Studio MLX, Q8 양자화)입니다. 이 오버헤드는 잘못된 모델에 요청을 보내 3~10초를 낭비하는 것보다 훨씬 저렴합니다.

2단: 생성기 — 복잡도별 모델 분기와 선택 기준

라우터의 분류 결과에 따라 게이트웨이가 적절한 백엔드·모델 조합으로 요청을 전달합니다.

# 복잡도-모델 매핑 (라우팅 테이블)
routing_table:
  SIMPLE:
    model: qwen3-8b
    backend: mac-studio      # MLX, 저전력
    max_tokens: 512
    timeout_s: 10
  MODERATE:
    model: qwen3-14b
    backend: mac-studio      # MLX, 통합 메모리에 14B Q8 적재
    max_tokens: 2048
    timeout_s: 30
  COMPLEX:
    model: qwen3-30b-a3b
    backend: cuda-node        # vLLM, FP8
    max_tokens: 4096
    timeout_s: 60
  EXPERT:
    model: qwen3-235b-a22b
    backend: cuda-node        # vLLM, 듀얼 GPU 텐서 병렬
    max_tokens: 8192
    timeout_s: 120

이 구조의 장점은 명확합니다. SIMPLE 등급 요청(전체 트래픽의 약 30~40%)은 Mac Studio의 8B 모델이 즉시 처리하므로, CUDA 서버는 COMPLEX 이상의 무거운 요청에 집중할 수 있습니다. 실측 기준으로 동일 요청량 대비 CUDA 서버의 GPU 점유율이 40~60% 감소합니다(Qwen3-30B-A3B FP8, RTX 4090 24GB × 2, 동시 요청 8개 기준).

라우터 프롬프트 설계

Qwen3-8B를 라우터로 쓸 때의 시스템 프롬프트입니다. 핵심은 JSON 출력을 강제하여 파싱 실패를 방지하는 것입니다.

ROUTER_SYSTEM_PROMPT = """You are a request complexity classifier.
Analyze the user's message and output ONLY a JSON object with these fields:
- "complexity": one of "SIMPLE", "MODERATE", "COMPLEX", "EXPERT"
- "reason": a brief explanation in English (max 20 words)
- "needs_vision": true if the request includes or references images/documents

Classification criteria:
- SIMPLE: greetings, factual lookups, unit conversions, simple Q&A
- MODERATE: summarization, translation, general conversation, short writing
- COMPLEX: code generation, multi-step reasoning, long document analysis, data extraction
- EXPERT: mathematical proofs, complex refactoring, research-level analysis

Output JSON only. No markdown, no explanation."""

이 프롬프트로 Qwen3-8B를 호출하면 아래와 같은 응답이 돌아옵니다.

{
  "complexity": "COMPLEX",
  "reason": "Code generation with multi-file refactoring",
  "needs_vision": false
}

분류 정확도는 테스트 세트 200건 기준 약 87%입니다(Qwen3-8B Q8, Mac Studio M2 Ultra). 오분류의 대부분은 MODERATE↔COMPLEX 경계에서 발생하며, 이 경우 한 단계 큰 모델로 보내는 것이 품질 손실보다 안전합니다. 따라서 실제 구현에서는 애매하면 올림(round-up) 전략을 적용합니다.

LiteLLM Proxy — 통합 OpenAI 호환 게이트웨이

게이트웨이를 밑바닥부터 구현할 수도 있지만, LiteLLM이라는 오픈소스 프로젝트가 이미 핵심 기능을 제공합니다. LiteLLM Proxy는 다중 LLM 백엔드를 단일 OpenAI 호환 API(/v1/chat/completions, /v1/completions, /v1/embeddings)로 통합하는 리버스 프록시입니다.

LiteLLM이 제공하는 것

  • 모델 별칭(alias) — 클라이언트가 model: "assistant"로 요청하면, 내부적으로 qwen3-30b-a3b를 호출
  • 다중 백엔드 통합 — vLLM, mlx-lm, Ollama, OpenAI API 등 이기종 백엔드를 하나의 설정 파일로 관리
  • 폴백(fallback) — 1차 백엔드 실패 시 2차 백엔드로 자동 전환
  • 로드 밸런싱 — 같은 모델을 여러 백엔드에 배포했을 때 라운드 로빈·가중치 분산
  • 레이트 리밋·비용 추적 — 사용자별·모델별 호출 제한과 토큰 사용량 기록
  • SSE 스트리밍 패스스루 — 백엔드의 스트리밍 응답을 클라이언트까지 그대로 전달

설치와 기본 구성

# Python 3.11+ 환경에서 설치
pip install 'litellm[proxy]'

# 또는 Docker로 실행 (권장 — 게이트웨이는 별도 프로세스로 격리)
docker pull ghcr.io/berriai/litellm:main-latest

LiteLLM Proxy의 핵심은 config.yaml 설정 파일입니다. 아래는 Mac Studio(MLX)와 CUDA(vLLM) 백엔드를 통합하는 실전 설정입니다.

LiteLLM 설정 파일 구조 다이어그램
# litellm_config.yaml — 하이브리드 추론 게이트웨이 설정
# LiteLLM Proxy v1.x 기준

model_list:
  # ── Mac Studio (MLX) 백엔드 ──
  - model_name: qwen3-8b           # 클라이언트가 사용하는 모델명
    litellm_params:
      model: openai/qwen3-8b       # "openai/" 접두사 = OpenAI 호환 백엔드
      api_base: http://192.168.1.10:8080/v1   # Mac Studio MLX 서버
      api_key: not-needed           # mlx-lm은 인증 불요, 더미 값
      stream: true
      max_tokens: 1024
      timeout: 15                   # 초 단위
    model_info:
      description: "Qwen3-8B Q8 on Mac Studio MLX — 라우팅/분류용"

  - model_name: qwen3-14b
    litellm_params:
      model: openai/qwen3-14b
      api_base: http://192.168.1.10:8080/v1
      api_key: not-needed
      stream: true
      max_tokens: 4096
      timeout: 45
    model_info:
      description: "Qwen3-14B Q8 on Mac Studio MLX — 일반 대화"

  # ── CUDA 서버 (vLLM) 백엔드 ──
  - model_name: qwen3-30b
    litellm_params:
      model: openai/Qwen/Qwen3-30B-A3B
      api_base: http://192.168.1.20:8000/v1   # CUDA vLLM 서버
      api_key: not-needed
      stream: true
      max_tokens: 8192
      timeout: 90
    model_info:
      description: "Qwen3-30B-A3B FP8 on vLLM — 복잡 생성"

  - model_name: qwen3-235b
    litellm_params:
      model: openai/Qwen/Qwen3-235B-A22B
      api_base: http://192.168.1.20:8000/v1
      api_key: not-needed
      stream: true
      max_tokens: 8192
      timeout: 180
    model_info:
      description: "Qwen3-235B-A22B FP8 on vLLM TP=2 — 전문가급 생성"

  # ── 폴백 배포: 같은 모델을 다른 백엔드에도 등록 ──
  - model_name: qwen3-30b
    litellm_params:
      model: openai/qwen3-30b-a3b-q4
      api_base: http://192.168.1.10:8080/v1   # Mac Studio에 Q4로도 적재
      api_key: not-needed
      stream: true
      max_tokens: 4096
      timeout: 120
    model_info:
      description: "Qwen3-30B-A3B Q4 on Mac Studio MLX — CUDA 폴백"

  # ── 모델 별칭 (클라이언트 편의) ──
  - model_name: assistant             # 클라이언트는 "assistant"로만 호출
    litellm_params:
      model: openai/Qwen/Qwen3-30B-A3B
      api_base: http://192.168.1.20:8000/v1
      api_key: not-needed
      stream: true
      max_tokens: 4096
      timeout: 90

  - model_name: assistant-fast        # 빠른 응답이 필요할 때
    litellm_params:
      model: openai/qwen3-14b
      api_base: http://192.168.1.10:8080/v1
      api_key: not-needed
      stream: true
      max_tokens: 2048
      timeout: 30

# ── 라우터 설정 ──
router_settings:
  routing_strategy: simple-shuffle    # 같은 model_name 다중 배포 시 분산 전략
  num_retries: 2                      # 실패 시 재시도 횟수
  timeout: 120                        # 글로벌 타임아웃
  retry_after: 5                      # 재시도 간격 (초)
  allowed_fails: 3                    # 이 횟수 초과 시 백엔드를 일시 제외 (cooldown)
  cooldown_time: 60                   # 제외된 백엔드 복귀까지 대기 시간 (초)
  enable_pre_call_checks: true        # 호출 전 헬스체크
  
# ── 일반 설정 ──
general_settings:
  master_key: sk-bridge-gateway-2026  # 게이트웨이 관리 API 인증 키
  database_url: null                  # SQLite/Postgres — 로그·비용 추적 시 설정
  alerting: null                      # Slack/Discord 알림 (선택)

litellm_settings:
  drop_params: true                   # 백엔드가 미지원 파라미터를 받으면 무시
  set_verbose: false                  # 운영 시 false, 디버깅 시 true
  request_timeout: 120
  fallbacks:
    - assistant:
        - assistant-fast              # assistant 실패 시 assistant-fast로 폴백
    - qwen3-235b:
        - qwen3-30b                   # 235B 실패 시 30B로 폴백
    - qwen3-30b:
        - qwen3-14b                   # 30B 실패 시 14B로 폴백 (품질 저하 감수)

게이트웨이 실행

# 직접 실행
litellm --config litellm_config.yaml --host 0.0.0.0 --port 4000

# Docker 실행 (운영 환경 권장)
docker run -d \
  --name litellm-gateway \
  --restart unless-stopped \
  -p 4000:4000 \
  -v $(pwd)/litellm_config.yaml:/app/config.yaml \
  ghcr.io/berriai/litellm:main-latest \
  --config /app/config.yaml --host 0.0.0.0 --port 4000

실행 후 http://gateway:4000/v1/models를 호출하면 등록된 모든 모델 목록이 반환됩니다.

curl http://localhost:4000/v1/models \
  -H "Authorization: Bearer sk-bridge-gateway-2026"

# 응답 (일부)
{
  "data": [
    {"id": "qwen3-8b", "object": "model"},
    {"id": "qwen3-14b", "object": "model"},
    {"id": "qwen3-30b", "object": "model"},
    {"id": "qwen3-235b", "object": "model"},
    {"id": "assistant", "object": "model"},
    {"id": "assistant-fast", "object": "model"}
  ]
}

클라이언트는 이제 http://gateway:4000/v1/chat/completions에 요청을 보내면 됩니다. 백엔드가 MLX인지 vLLM인지, GPU가 몇 장인지는 전혀 알 필요가 없습니다.

라우터 통합 — 게이트웨이 앞단의 분류기

LiteLLM 자체는 “클라이언트가 지정한 model로 전달”하는 프록시입니다. 요청의 복잡도를 자동 판단해서 모델을 선택하는 것은 라우터 계층이 담당합니다. 이 라우터를 LiteLLM 앞에 두는 방식은 두 가지입니다.

방법 A: 클라이언트-사이드 라우팅 (권장 — 단순·투명)

클라이언트(채팅 UI, RAG 파이프라인 등)가 먼저 게이트웨이의 qwen3-8b에 분류 요청을 보내고, 응답에 따라 적절한 모델명으로 본 요청을 보내는 2-call 패턴입니다.

"""
client_side_router.py — 클라이언트-사이드 요청 라우팅
게이트웨이(LiteLLM) 앞단에서 동작하는 라우터 모듈
"""
import json
import httpx
from typing import Any

GATEWAY_URL = "http://gateway:4000/v1/chat/completions"
GATEWAY_KEY = "sk-bridge-gateway-2026"

ROUTER_SYSTEM_PROMPT = """You are a request complexity classifier.
Analyze the user's message and output ONLY a JSON object:
{"complexity": "SIMPLE|MODERATE|COMPLEX|EXPERT", "reason": "...", "needs_vision": false}
SIMPLE: greetings, factual lookups, unit conversions
MODERATE: summarization, translation, general conversation
COMPLEX: code generation, multi-step reasoning, long analysis
EXPERT: proofs, complex refactoring, research-level tasks
Output JSON only."""

# 복잡도 → LiteLLM 모델명 매핑
COMPLEXITY_TO_MODEL: dict[str, str] = {
    "SIMPLE": "assistant-fast",    # Qwen3-14B (8B도 가능하지만 품질 마진 확보)
    "MODERATE": "assistant-fast",  # Qwen3-14B
    "COMPLEX": "assistant",        # Qwen3-30B-A3B
    "EXPERT": "qwen3-235b",       # Qwen3-235B-A22B (가용 시)
}

FALLBACK_MODEL = "assistant"  # 분류 실패 시 기본값


async def classify_request(
    user_message: str,
    client: httpx.AsyncClient,
) -> str:
    """Qwen3-8B로 요청 복잡도를 분류하고, 적합한 모델명을 반환한다."""
    resp = await client.post(
        GATEWAY_URL,
        headers={"Authorization": f"Bearer {GATEWAY_KEY}"},
        json={
            "model": "qwen3-8b",
            "messages": [
                {"role": "system", "content": ROUTER_SYSTEM_PROMPT},
                {"role": "user", "content": user_message},
            ],
            "max_tokens": 128,
            "temperature": 0.0,   # 분류는 결정론적으로
            "stream": False,
        },
        timeout=15.0,
    )
    resp.raise_for_status()

    content = resp.json()["choices"][0]["message"]["content"]

    # JSON 파싱 — Qwen3-8B가 마크다운 코드블록으로 감쌀 수 있음
    cleaned = content.strip()
    if cleaned.startswith("```"):
        # ```json ... ``` 형식 처리
        lines = cleaned.split("\n")
        cleaned = "\n".join(
            line for line in lines
            if not line.strip().startswith("```")
        )

    result = json.loads(cleaned)
    complexity = result.get("complexity", "COMPLEX").upper()

    return COMPLEXITY_TO_MODEL.get(complexity, FALLBACK_MODEL)


async def routed_chat(
    messages: list[dict[str, Any]],
    client: httpx.AsyncClient,
    *,
    stream: bool = True,
) -> httpx.Response:
    """
    자동 라우팅 채팅 — 마지막 user 메시지로 복잡도를 판단하고,
    적절한 모델로 본 요청을 보낸다.
    """
    # 마지막 user 메시지 추출
    last_user_msg = ""
    for msg in reversed(messages):
        if msg.get("role") == "user":
            last_user_msg = msg.get("content", "")
            break

    # 1단: 분류
    target_model = await classify_request(last_user_msg, client)

    # 2단: 생성
    return await client.post(
        GATEWAY_URL,
        headers={"Authorization": f"Bearer {GATEWAY_KEY}"},
        json={
            "model": target_model,
            "messages": messages,
            "stream": stream,
        },
        timeout=180.0,
    )


# ── 사용 예시 ──
async def main() -> None:
    async with httpx.AsyncClient() as client:
        # 간단한 요청 → assistant-fast (Qwen3-14B)로 자동 라우팅
        resp = await routed_chat(
            [{"role": "user", "content": "안녕, 오늘 기분이 어때?"}],
            client,
            stream=False,
        )
        print(resp.json()["choices"][0]["message"]["content"])

        # 복잡한 요청 → assistant (Qwen3-30B-A3B)로 자동 라우팅
        resp = await routed_chat(
            [{"role": "user", "content": "Python으로 B-tree 구현해줘. 삽입·삭제·검색 모두 포함."}],
            client,
            stream=False,
        )
        print(resp.json()["choices"][0]["message"]["content"])


if __name__ == "__main__":
    import asyncio
    asyncio.run(main())

이 패턴의 장점은 LiteLLM 설정을 건드리지 않고도 라우팅 로직을 자유롭게 바꿀 수 있다는 것입니다. 분류 프롬프트를 수정하거나, 규칙 기반 라우팅(키워드 매칭)을 혼합하거나, A/B 테스트를 하는 것 모두 클라이언트 코드에서 해결됩니다.

방법 B: 서버-사이드 라우팅 (LiteLLM Custom Callback)

클라이언트를 수정할 수 없는 상황(예: 서드파티 도구가 OpenAI API만 지원)에서는 LiteLLM의 커스텀 콜백을 사용해 서버 사이드에서 라우팅할 수 있습니다.

"""
server_side_router.py — LiteLLM 커스텀 콜백 기반 서버-사이드 라우팅

litellm_config.yaml에 다음을 추가:
  litellm_settings:
    callbacks: ["server_side_router.RouterCallback"]
"""
import json
import re
import litellm
from litellm.integrations.custom_logger import CustomLogger


# 키워드 기반 빠른 분류 (LLM 호출 없이)
SIMPLE_PATTERNS = re.compile(
    r"(안녕|반가워|고마워|날씨|시간|몇\s?시|환율|단위\s?변환)",
    re.IGNORECASE,
)
COMPLEX_PATTERNS = re.compile(
    r"(코드|구현|리팩토링|알고리즘|분석|설계|아키텍처|증명|수학)",
    re.IGNORECASE,
)


class RouterCallback(CustomLogger):
    """
    요청이 'auto' 모델로 들어오면 복잡도를 판단하여
    실제 모델명으로 교체하는 콜백.
    """

    async def async_pre_call_hook(
        self,
        user_api_key_dict: dict,
        cache: Any,
        data: dict,
        call_type: str,
    ) -> dict:
        model = data.get("model", "")
        if model != "auto":
            return data  # 명시적 모델 지정이면 패스스루

        messages = data.get("messages", [])
        last_msg = ""
        for msg in reversed(messages):
            if msg.get("role") == "user":
                last_msg = str(msg.get("content", ""))
                break

        # 1차: 규칙 기반 분류 (빠름, LLM 호출 불필요)
        if SIMPLE_PATTERNS.search(last_msg):
            data["model"] = "assistant-fast"
            return data
        if COMPLEX_PATTERNS.search(last_msg):
            data["model"] = "assistant"
            return data

        # 2차: 메시지 길이 휴리스틱
        if len(last_msg) < 50:
            data["model"] = "assistant-fast"
        elif len(last_msg) < 500:
            data["model"] = "assistant"
        else:
            data["model"] = "qwen3-235b"

        return data

서버-사이드 방식은 구현이 간결하지만, LLM 기반 분류를 콜백 안에서 수행하면 재귀 호출 위험이 있습니다(분류 요청이 다시 콜백을 트리거). 따라서 서버-사이드에서는 규칙 기반(키워드+길이 휴리스틱) 분류를 우선 적용하고, LLM 분류는 클라이언트-사이드에서 하는 것이 안전합니다.

두 방식 비교

항목 클라이언트-사이드 (방법 A) 서버-사이드 (방법 B)
분류 정확도 높음 (LLM 기반, ~87%) 보통 (규칙 기반, ~70%)
추가 지연 100~300ms (8B 호출) <1ms (규칙 매칭)
클라이언트 수정 필요 불필요 (model: "auto"만 지정)
LLM 분류 재귀 위험 없음 (별도 호출) 있음 (콜백 내 LLM 호출 시)
라우팅 로직 변경 클라이언트 배포 필요 서버 설정만 변경
추천 시나리오 자체 개발 클라이언트 서드파티 도구 연동

실무에서는 두 방식을 혼합합니다. 자체 채팅 UI는 방법 A로 정밀 라우팅하고, Cursor·Continue 같은 서드파티 IDE 플러그인은 방법 B의 auto 모델로 받아 규칙 기반 라우팅합니다.

폴백 체인 설계 — 장애에도 응답하는 시스템

온프레미스 환경에서 장애는 일상입니다. macOS 자동 업데이트, GPU 드라이버 충돌, 메모리 부족으로 인한 OOM kill, 네트워크 순단 — 클라우드 API라면 제공자가 알아서 처리할 일들을 우리가 직접 대응해야 합니다.

폴백 체인의 원칙

폴백은 "동일 품질 보장"이 아니라 "응답 가용성 보장"입니다. 235B 모델이 다운됐을 때 30B로 폴백하면 품질은 떨어지지만, 사용자에게 "서버 오류" 대신 실질적인 응답을 줄 수 있습니다.

폴백 체인 (품질 순):

  qwen3-235b  ──실패──▶  qwen3-30b (CUDA)  ──실패──▶  qwen3-30b (MLX Q4)  ──실패──▶  qwen3-14b  ──실패──▶  503
       │                      │                           │                       │
    [CUDA TP=2]           [CUDA FP8]               [Mac Studio Q4]           [Mac Studio Q8]
모델 폴백 체인 시퀀스 다이어그램

LiteLLM의 fallbacks 설정이 이 체인을 자동으로 처리합니다. 위의 litellm_config.yaml에서 이미 선언했던 부분을 다시 살펴봅시다.

litellm_settings:
  fallbacks:
    - assistant:           # 1차: Qwen3-30B-A3B (CUDA vLLM)
        - assistant-fast   # 2차: Qwen3-14B (Mac Studio MLX)
    - qwen3-235b:          # 1차: Qwen3-235B-A22B (CUDA vLLM TP=2)
        - qwen3-30b        # 2차: Qwen3-30B-A3B (CUDA or MLX — 둘 다 등록됨)
    - qwen3-30b:           # 1차: CUDA FP8, 2차: Mac Studio Q4 (같은 model_name 다중 배포)
        - qwen3-14b        # 3차: 최후의 보루

같은 model_name으로 여러 백엔드를 등록하면(qwen3-30b가 CUDA와 MLX에 모두 등록), LiteLLM이 자동으로 다른 백엔드를 시도합니다. 이것이 router_settings.routing_strategy: simple-shuffle과 결합되어 동작합니다.

폴백 트리거 조건

LiteLLM은 다음 상황에서 폴백을 트리거합니다.

  • 연결 실패 — 백엔드 서버가 응답하지 않음 (ConnectionError)
  • 타임아웃 — 설정된 시간 내에 응답이 오지 않음 (Timeout)
  • HTTP 5xx — 백엔드 내부 오류 (vLLM OOM, MLX 메모리 부족 등)
  • HTTP 429 — 백엔드 과부하 (vLLM의 동시 요청 제한 초과)

HTTP 4xx (400, 422 등)는 폴백하지 않습니다. 이는 클라이언트 요청 자체의 문제이므로 다른 백엔드로 보내도 같은 에러가 발생합니다.

헬스체크와 쿨다운

LiteLLM의 router_settings에서 핵심적인 세 가지 파라미터:

  • allowed_fails: 3 — 백엔드가 연속 3회 실패하면 쿨다운 상태로 전환
  • cooldown_time: 60 — 쿨다운 상태의 백엔드는 60초 동안 라우팅 대상에서 제외
  • enable_pre_call_checks: true — 요청 전에 백엔드의 /health 엔드포인트를 확인

이 조합으로 "죽은 백엔드에 계속 요청을 보내다 타임아웃으로 지연이 누적되는" 서킷 브레이커 패턴이 자동으로 구현됩니다.

시나리오: CUDA 서버의 vLLM이 OOM으로 죽은 경우

T+0s   요청 → qwen3-30b (CUDA) → 500 Internal Error  [fail 1/3]
T+5s   요청 → qwen3-30b (CUDA) → Connection Refused  [fail 2/3]  → 폴백 → qwen3-30b (MLX Q4) → 200 OK
T+10s  요청 → qwen3-30b (CUDA) → Connection Refused  [fail 3/3]  → 쿨다운 진입
T+10~70s  모든 qwen3-30b 요청 → Mac Studio MLX Q4로 자동 라우팅 (CUDA 제외)
T+70s  쿨다운 해제 → CUDA 재시도 → 200 OK → 정상 복귀

타임아웃 전략 — 모델 크기별 차등 설정

타임아웃은 "너무 짧으면 정상 응답을 끊고, 너무 길면 장애 감지가 늦어지는" 양날의 검입니다. 핵심 원칙은 모델 크기와 요청 유형에 따라 차등 적용하는 것입니다.

타임아웃 설계 기준

모델 백엔드 연결 타임아웃 첫 토큰 타임아웃 (TTFT) 총 타임아웃 근거
Qwen3-8B Mac Studio MLX 3s 5s 15s 분류용, 128토큰 이하
Qwen3-14B Mac Studio MLX 5s 10s 45s 일반 대화, 2K토큰
Qwen3-30B-A3B CUDA vLLM FP8 5s 15s 90s 복잡 생성, 4K토큰
Qwen3-30B-A3B Mac Studio MLX Q4 5s 20s 120s 폴백, MLX가 더 느림
Qwen3-235B-A22B CUDA vLLM FP8 TP=2 10s 30s 180s 최대 모델, 8K토큰

TTFT(Time To First Token, 첫 토큰 지연)는 특히 중요합니다. 스트리밍 응답에서 첫 토큰이 이 시간 안에 오지 않으면, 모델이 실제로 추론을 시작하지 못한 것(KV 캐시 구축 실패, OOM 직전 등)이므로 즉시 폴백하는 것이 합리적입니다.

스트리밍에서의 타임아웃 처리

비스트리밍(non-streaming) 요청은 단순합니다 — 총 타임아웃 안에 완전한 응답이 오면 성공, 아니면 실패. 하지만 스트리밍(SSE)에서는 상황이 복잡합니다.

"""
streaming_timeout.py — 스트리밍 응답의 세분화된 타임아웃 처리

SSE 스트림에서 세 단계 타임아웃을 적용한다:
1. 연결 타임아웃: 백엔드에 TCP 연결이 맺어지는 시간
2. TTFT 타임아웃: 연결 후 첫 번째 SSE 이벤트가 오는 시간
3. 청크 간 타임아웃: 연속된 SSE 이벤트 사이의 최대 간격
"""
import asyncio
import json
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Any

import httpx


@dataclass(frozen=True)
class StreamTimeouts:
    """모델별 스트리밍 타임아웃 설정"""
    connect: float = 5.0       # TCP 연결
    ttft: float = 15.0         # 첫 토큰까지
    between_chunks: float = 30.0  # 청크 사이 최대 간격
    total: float = 120.0       # 전체 스트림 최대 시간


# 모델별 타임아웃 프리셋
MODEL_TIMEOUTS: dict[str, StreamTimeouts] = {
    "qwen3-8b": StreamTimeouts(connect=3, ttft=5, between_chunks=10, total=15),
    "qwen3-14b": StreamTimeouts(connect=5, ttft=10, between_chunks=20, total=45),
    "qwen3-30b": StreamTimeouts(connect=5, ttft=15, between_chunks=30, total=90),
    "qwen3-235b": StreamTimeouts(connect=10, ttft=30, between_chunks=45, total=180),
}


class StreamTimeoutError(Exception):
    """스트리밍 타임아웃 발생 시 예외"""
    def __init__(self, phase: str, timeout: float):
        self.phase = phase
        self.timeout = timeout
        super().__init__(f"Stream timeout in {phase} phase after {timeout}s")


async def stream_with_timeout(
    client: httpx.AsyncClient,
    url: str,
    payload: dict[str, Any],
    headers: dict[str, str],
    timeouts: StreamTimeouts,
) -> AsyncIterator[str]:
    """
    세분화된 타임아웃이 적용된 SSE 스트리밍 제너레이터.
    
    각 단계(연결→TTFT→청크간→전체)에서 타임아웃이 초과되면
    StreamTimeoutError를 발생시켜 호출자가 폴백을 결정할 수 있게 한다.
    """
    deadline = asyncio.get_event_loop().time() + timeouts.total
    first_chunk_received = False

    async with client.stream(
        "POST",
        url,
        json=payload,
        headers=headers,
        timeout=httpx.Timeout(
            connect=timeouts.connect,
            read=timeouts.ttft,  # 초기값은 TTFT
            write=10.0,
            pool=10.0,
        ),
    ) as response:
        response.raise_for_status()

        async for line in response.aiter_lines():
            # 전체 타임아웃 체크
            if asyncio.get_event_loop().time() > deadline:
                raise StreamTimeoutError("total", timeouts.total)

            if not line.startswith("data: "):
                continue

            data = line[6:]  # "data: " 제거
            if data == "[DONE]":
                return

            if not first_chunk_received:
                first_chunk_received = True
                # TTFT 이후에는 청크 간 타임아웃으로 전환
                # httpx는 read timeout을 동적으로 변경할 수 없으므로
                # 여기서는 전체 타임아웃으로 보호

            yield data


async def routed_stream_with_fallback(
    messages: list[dict[str, Any]],
    model: str,
    fallback_models: list[str],
    client: httpx.AsyncClient,
    gateway_url: str,
    gateway_key: str,
) -> AsyncIterator[str]:
    """
    폴백 체인이 적용된 스트리밍 요청.
    1차 모델이 타임아웃/에러 시 다음 모델로 자동 전환.
    """
    models_to_try = [model] + fallback_models

    for i, target_model in enumerate(models_to_try):
        timeouts = MODEL_TIMEOUTS.get(
            target_model,
            StreamTimeouts(),  # 기본값
        )
        try:
            async for chunk in stream_with_timeout(
                client=client,
                url=gateway_url,
                payload={
                    "model": target_model,
                    "messages": messages,
                    "stream": True,
                },
                headers={"Authorization": f"Bearer {gateway_key}"},
                timeouts=timeouts,
            ):
                yield chunk
            return  # 정상 완료

        except (StreamTimeoutError, httpx.HTTPStatusError, httpx.ConnectError) as exc:
            is_last = i == len(models_to_try) - 1
            if is_last:
                raise  # 모든 폴백 소진
            # 폴백 알림 이벤트를 스트림에 삽입
            fallback_event = json.dumps({
                "object": "chat.completion.chunk",
                "choices": [{
                    "delta": {"content": ""},
                    "finish_reason": None,
                    "index": 0,
                }],
                "model": target_model,
                "x_fallback": {
                    "from": target_model,
                    "to": models_to_try[i + 1],
                    "reason": str(exc),
                },
            })
            yield fallback_event
            continue  # 다음 모델 시도

위 코드에서 주목할 점은 폴백 발생 시 SSE 스트림에 x_fallback 메타데이터를 삽입한다는 것입니다. 클라이언트 UI는 이를 감지해서 "모델이 전환되었습니다" 같은 알림을 표시할 수 있습니다.

재시도 전략 — 언제 재시도하고 언제 포기할 것인가

폴백과 재시도는 다릅니다. 폴백은 다른 모델/백엔드로 전환하는 것이고, 재시도는 같은 모델/백엔드에 다시 시도하는 것입니다. LiteLLM의 num_retries는 재시도 횟수를 제어합니다.

재시도해야 하는 경우

  • HTTP 429 (Too Many Requests) — 일시적 과부하. 잠시 후 재시도하면 성공 가능
  • HTTP 503 (Service Unavailable) — vLLM이 모델 로딩 중일 수 있음
  • 연결 타임아웃 — 네트워크 순단으로 발생 가능

재시도하면 안 되는 경우

  • HTTP 400/422 — 요청 형식 오류. 재시도해도 같은 에러
  • OOM (HTTP 500 + CUDA out of memory) — 메모리 부족은 재시도로 해결 안 됨. 즉시 폴백
  • 모델 미로딩 (HTTP 500 + model not found) — 설정 오류. 재시도 무의미

지수 백오프(Exponential Backoff) 설정

# litellm_config.yaml — 재시도 설정 상세
router_settings:
  num_retries: 2                # 최대 2회 재시도 (원본 포함 총 3회 시도)
  retry_after: 5                # 첫 재시도까지 5초 대기
  # LiteLLM은 내부적으로 지수 백오프를 적용:
  # 1차 재시도: 5초, 2차 재시도: 10초 (2배)
  
  allowed_fails: 3              # 연속 3회 실패 → 쿨다운
  cooldown_time: 60             # 쿨다운 60초

재시도 횟수는 2회 이하를 권장합니다. 온프레미스 환경에서 3회 이상 재시도하면 장애가 "눈덩이 효과"로 확대됩니다 — 실패한 요청이 큐에 쌓이고, 새 요청과 재시도 요청이 동시에 밀려와 백엔드가 완전히 마비됩니다.

워크로드 분산 — Mac Studio와 CUDA의 역할 분담

두 하드웨어 플랫폼의 특성이 다르므로, 워크로드를 어떻게 분산하느냐에 따라 전체 시스템의 효율이 크게 달라집니다.

각 플랫폼의 강점

특성 Mac Studio (M2 Ultra, MLX) CUDA 서버 (RTX 4090 × 2, vLLM)
최대 메모리 192GB 통합 48GB (24GB × 2 VRAM)
메모리 대역폭 800 GB/s 1,008 GB/s (504 × 2)
동시 처리량 보통 (단일 요청 최적화) 높음 (continuous batching)
지연 (단일 요청) 낮음 보통~낮음
전력 소모 ~60W (idle ~20W) ~600W (idle ~100W)
소음 거의 무소음 팬 소음 있음
24/7 상시 운영 적합 (저전력) 조건부 (전력·발열 관리 필요)

권장 워크로드 배치

┌─────────────────────────────────────────────────────────────────┐
│                    Mac Studio (MLX) — 상시 ON                   │
│                                                                 │
│  • Qwen3-8B Q8  — 라우터/분류기 (항상 메모리 상주)               │
│  • Qwen3-14B Q8 — 일반 대화, 요약, 번역                         │
│  • Qwen3-30B-A3B Q4 — CUDA 폴백용 (메모리 여유 시 적재)        │
│                                                                 │
│  역할: 경량·저지연 요청 + 24/7 가용성 보장 + CUDA 장애 폴백     │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│              CUDA 서버 (vLLM) — 업무 시간 또는 요청 시 기동      │
│                                                                 │
│  • Qwen3-30B-A3B FP8 — 주력 생성 모델                          │
│  • Qwen3-235B-A22B FP8 TP=2 — 전문가급 추론                    │
│  • Qwen3-VL 30B-A3B — 멀티모달 (7일차 예정)                    │
│                                                                 │
│  역할: 고복잡도·고처리량 요청 집중 + 배치 처리                   │
└─────────────────────────────────────────────────────────────────┘

이 배치의 핵심 아이디어는 Mac Studio를 "항상 응답 가능한 기저 계층"으로 두고, CUDA 서버를 "필요 시 투입되는 고성능 계층"으로 분리하는 것입니다. 야간이나 주말에 CUDA 서버를 절전 모드로 전환해도 Mac Studio가 모든 요청을 처리합니다(품질은 30B Q4 수준으로 약간 하락하지만 가용성은 유지).

LiteLLM 가중치 분산

동일 모델이 양쪽에 배포된 경우, LiteLLM의 weight 파라미터로 트래픽 비율을 조절할 수 있습니다.

# litellm_config.yaml — 가중치 기반 로드 밸런싱
model_list:
  - model_name: qwen3-30b
    litellm_params:
      model: openai/Qwen/Qwen3-30B-A3B
      api_base: http://192.168.1.20:8000/v1    # CUDA vLLM
      api_key: not-needed
      weight: 3                                 # 가중치 3
    model_info:
      description: "FP8 on CUDA — 고처리량"

  - model_name: qwen3-30b
    litellm_params:
      model: openai/qwen3-30b-a3b-q4
      api_base: http://192.168.1.10:8080/v1    # Mac Studio MLX
      api_key: not-needed
      weight: 1                                 # 가중치 1
    model_info:
      description: "Q4 on MLX — 폴백/저전력"

router_settings:
  routing_strategy: usage-based-routing-v2      # 현재 부하 기반 분산

위 설정에서 weight: 3 vs weight: 1이므로 약 75%의 qwen3-30b 요청이 CUDA로, 25%가 Mac Studio로 분산됩니다. routing_strategy: usage-based-routing-v2를 사용하면 LiteLLM이 각 백엔드의 현재 처리 중인 요청 수(TPM/RPM)를 추적해서 덜 바쁜 쪽으로 보내기도 합니다.

스트리밍 전파 — 게이트웨이를 관통하는 SSE

사용자 경험에서 스트리밍은 필수입니다. 30B 모델이 4096 토큰을 생성하는 데 20~40초가 걸리는데, 이 시간 동안 빈 화면을 보여주면 사용자는 "고장 났나" 하고 떠납니다. 토큰이 생성되는 즉시 화면에 표시해야 합니다.

문제는 스트리밍이 게이트웨이 계층을 거치면서 깨지기 쉽다는 것입니다.

SSE 스트리밍 경로

클라이언트  ←── SSE ───  LiteLLM Gateway  ←── SSE ───  vLLM/mlx-lm
                        (4000)                         (8000/8080)

세 구간 모두 SSE를 지원해야 하며, 어느 한 구간이라도
버퍼링하면 체감 지연이 수 초~수십 초 증가한다.

게이트웨이에서 흔히 발생하는 스트리밍 문제

문제 증상 원인 해결
응답 버퍼링 스트리밍인데 한꺼번에 도착 nginx/LB의 proxy_buffering on proxy_buffering off; X-Accel-Buffering: no
연결 끊김 긴 응답 중간에 끊김 프록시의 read timeout 초과 모델별 timeout 차등 설정
청크 병합 여러 토큰이 한 청크로 TCP Nagle 알고리즘 TCP_NODELAY 활성화
인코딩 깨짐 한글이 깨져서 도착 UTF-8 멀티바이트가 청크 경계에서 분리 바이트 단위가 아닌 라인 단위 스트리밍

LiteLLM 앞에 nginx를 두는 경우의 SSE 설정

LiteLLM Gateway 앞에 추가로 nginx 리버스 프록시를 둘 때(예: TLS 종단, LAN 진입점) SSE가 깨지지 않도록 하는 설정입니다.

# /etc/nginx/conf.d/litellm-gateway.conf
upstream litellm_backend {
    server 127.0.0.1:4000;
    keepalive 32;
}

server {
    listen 18380;
    server_name _;

    # SSE/스트리밍 필수 설정
    proxy_buffering off;              # 응답 버퍼링 비활성화
    proxy_cache off;                  # 캐시 비활성화
    proxy_http_version 1.1;           # HTTP/1.1 (chunked transfer)
    proxy_set_header Connection "";    # keep-alive

    # 타임아웃 — 가장 큰 모델(235B)의 총 타임아웃보다 넉넉하게
    proxy_connect_timeout 10s;
    proxy_read_timeout 300s;          # 스트리밍 중 토큰 간 간격 포함
    proxy_send_timeout 60s;

    location /v1/ {
        proxy_pass http://litellm_backend/v1/;

        # SSE 헤더 전파
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
        
        # 클라이언트에 버퍼링 비활성화 힌트
        add_header X-Accel-Buffering no always;
    }

    location /health {
        proxy_pass http://litellm_backend/health;
        access_log off;
    }
}

핵심은 proxy_buffering off와 proxy_read_timeout 300s입니다. 전자는 SSE 이벤트가 즉시 클라이언트로 전달되게 하고, 후자는 긴 추론 동안 nginx가 연결을 끊지 않게 합니다.

모니터링 — 게이트웨이는 관측의 허브

게이트웨이 계층의 숨은 장점은 모든 요청이 한 곳을 통과하므로 관측(observability)이 쉽다는 것입니다.

LiteLLM이 기본 제공하는 메트릭

  • 요청/응답 로그 — 모델명, 입출력 토큰 수, 지연, 상태 코드
  • 토큰 사용량 — 모델별·사용자별 누적 토큰
  • 에러율 — 백엔드별 실패 횟수와 비율
  • 폴백 발생 횟수 — 어떤 모델에서 어떤 모델로 몇 회 폴백했는지

Prometheus + Grafana 연동

LiteLLM Proxy는 /metrics 엔드포인트로 Prometheus 형식의 메트릭을 노출합니다.

# litellm_config.yaml에 추가
general_settings:
  enable_prometheus: true    # /metrics 엔드포인트 활성화
# prometheus.yml — 스크래핑 설정
scrape_configs:
  - job_name: 'litellm-gateway'
    scrape_interval: 15s
    static_configs:
      - targets: ['gateway:4000']
    metrics_path: /metrics

Grafana 대시보드에서 주시해야 할 핵심 패널 4가지:

  1. 모델별 P50/P95/P99 지연 — 특정 모델의 지연이 갑자기 튀면 백엔드 이상 신호
  2. 백엔드별 에러율 — 쿨다운 진입 전에 선제 대응
  3. 분당 요청 수(RPM) by 모델 — 라우팅 분포가 의도대로인지 확인
  4. 폴백 발생 빈도 — 폴백이 잦으면 1차 백엔드의 안정성 문제

전체 아키텍처 다이어그램

지금까지 설계한 하이브리드 추론 게이트웨이의 전체 구조를 Mermaid 다이어그램으로 정리합니다.

flowchart TB
    subgraph Clients ["클라이언트 계층"]
        UI["채팅 UI"]
        RAG["RAG 파이프라인"]
        Agent["에이전트 오케스트레이터"]
        IDE["IDE 플러그인
(Cursor, Continue)"] end subgraph Gateway ["추론 게이트웨이 (LiteLLM Proxy :4000)"] direction TB Router["라우터
규칙 기반 + model='auto'"] LB["로드 밸런서
usage-based-routing-v2"] FB["폴백 매니저
allowed_fails=3, cooldown=60s"] HC["헬스체커
pre_call_checks"] Metrics["메트릭 수집
/metrics (Prometheus)"] end subgraph MacStudio ["Mac Studio (MLX) — 상시 ON"] M8B["Qwen3-8B Q8
라우터/분류기"] M14B["Qwen3-14B Q8
일반 대화"] M30BQ4["Qwen3-30B-A3B Q4
CUDA 폴백"] end subgraph CUDA ["CUDA 서버 (vLLM) — 고성능"] C30B["Qwen3-30B-A3B FP8
주력 생성"] C235B["Qwen3-235B-A22B FP8 TP=2
전문가급"] CVL["Qwen3-VL 30B-A3B
멀티모달 (7일차)"] end subgraph Monitoring ["관측 계층"] Prom["Prometheus"] Graf["Grafana"] end UI & RAG & Agent -->|"model: auto
또는 명시적 모델명"| Gateway IDE -->|"model: assistant"| Gateway Router --> LB LB --> FB FB --> HC HC -->|":8080/v1"| MacStudio HC -->|":8000/v1"| CUDA Metrics --> Prom --> Graf M30BQ4 -.->|"폴백"| C30B C30B -.->|"폴백"| M30BQ4 C235B -.->|"폴백"| C30B classDef gateway fill:#4a90d9,stroke:#2c5aa0,color:#fff classDef mac fill:#34a853,stroke:#1e7e34,color:#fff classDef cuda fill:#ea4335,stroke:#c5221f,color:#fff classDef monitor fill:#fbbc04,stroke:#f29900,color:#000 class Gateway gateway class MacStudio mac class CUDA cuda class Monitoring monitor

실전 통합 테스트 스크립트

게이트웨이가 정상 동작하는지 확인하는 통합 테스트 스크립트입니다. 이 스크립트는 라우팅·폴백·스트리밍을 모두 검증합니다.

"""
test_gateway.py — 하이브리드 추론 게이트웨이 통합 테스트

실행: python test_gateway.py --gateway http://localhost:4000
"""
import argparse
import asyncio
import json
import sys
import time
from dataclasses import dataclass

import httpx


@dataclass
class TestResult:
    name: str
    passed: bool
    latency_ms: float
    model_used: str
    detail: str


GATEWAY_KEY = "sk-bridge-gateway-2026"


async def test_model_availability(
    client: httpx.AsyncClient,
    gateway: str,
) -> TestResult:
    """등록된 모델 목록 확인"""
    t0 = time.monotonic()
    resp = await client.get(
        f"{gateway}/v1/models",
        headers={"Authorization": f"Bearer {GATEWAY_KEY}"},
        timeout=10.0,
    )
    latency = (time.monotonic() - t0) * 1000

    if resp.status_code != 200:
        return TestResult("model_list", False, latency, "-", f"HTTP {resp.status_code}")

    models = [m["id"] for m in resp.json().get("data", [])]
    expected = {"qwen3-8b", "qwen3-14b", "qwen3-30b", "assistant", "assistant-fast"}
    missing = expected - set(models)

    return TestResult(
        "model_list",
        len(missing) == 0,
        latency,
        "-",
        f"OK ({len(models)} models)" if not missing else f"Missing: {missing}",
    )


async def test_non_streaming(
    client: httpx.AsyncClient,
    gateway: str,
    model: str,
    prompt: str,
) -> TestResult:
    """비스트리밍 요청 테스트"""
    t0 = time.monotonic()
    try:
        resp = await client.post(
            f"{gateway}/v1/chat/completions",
            headers={"Authorization": f"Bearer {GATEWAY_KEY}"},
            json={
                "model": model,
                "messages": [{"role": "user", "content": prompt}],
                "max_tokens": 256,
                "stream": False,
            },
            timeout=60.0,
        )
        latency = (time.monotonic() - t0) * 1000

        if resp.status_code != 200:
            return TestResult(f"non_stream_{model}", False, latency, model, f"HTTP {resp.status_code}")

        data = resp.json()
        content = data["choices"][0]["message"]["content"]
        model_used = data.get("model", model)

        return TestResult(
            f"non_stream_{model}",
            bool(content.strip()),
            latency,
            model_used,
            f"OK ({len(content)} chars)",
        )
    except Exception as exc:
        latency = (time.monotonic() - t0) * 1000
        return TestResult(f"non_stream_{model}", False, latency, model, str(exc))


async def test_streaming(
    client: httpx.AsyncClient,
    gateway: str,
    model: str,
    prompt: str,
) -> TestResult:
    """SSE 스트리밍 요청 테스트"""
    t0 = time.monotonic()
    ttft = 0.0
    chunks = 0
    total_content = ""

    try:
        async with client.stream(
            "POST",
            f"{gateway}/v1/chat/completions",
            headers={"Authorization": f"Bearer {GATEWAY_KEY}"},
            json={
                "model": model,
                "messages": [{"role": "user", "content": prompt}],
                "max_tokens": 256,
                "stream": True,
            },
            timeout=httpx.Timeout(connect=10, read=120, write=10, pool=10),
        ) as resp:
            if resp.status_code != 200:
                latency = (time.monotonic() - t0) * 1000
                return TestResult(f"stream_{model}", False, latency, model, f"HTTP {resp.status_code}")

            async for line in resp.aiter_lines():
                if not line.startswith("data: "):
                    continue
                data_str = line[6:]
                if data_str == "[DONE]":
                    break

                if chunks == 0:
                    ttft = (time.monotonic() - t0) * 1000

                chunks += 1
                try:
                    chunk_data = json.loads(data_str)
                    delta = chunk_data["choices"][0].get("delta", {})
                    total_content += delta.get("content", "")
                except (json.JSONDecodeError, KeyError, IndexError):
                    pass

        latency = (time.monotonic() - t0) * 1000
        return TestResult(
            f"stream_{model}",
            chunks > 0 and bool(total_content.strip()),
            latency,
            model,
            f"OK ({chunks} chunks, TTFT={ttft:.0f}ms, {len(total_content)} chars)",
        )
    except Exception as exc:
        latency = (time.monotonic() - t0) * 1000
        return TestResult(f"stream_{model}", False, latency, model, str(exc))


async def run_all_tests(gateway: str) -> list[TestResult]:
    """모든 테스트 실행"""
    results: list[TestResult] = []

    async with httpx.AsyncClient() as client:
        # 1. 모델 목록
        results.append(await test_model_availability(client, gateway))

        # 2. 각 모델 비스트리밍 테스트
        test_cases = [
            ("assistant-fast", "안녕하세요, 간단한 인사입니다."),
            ("assistant", "Python으로 퀵소트를 구현해주세요."),
            ("qwen3-8b", "이 요청은 SIMPLE 등급입니다."),
        ]
        for model, prompt in test_cases:
            results.append(await test_non_streaming(client, gateway, model, prompt))

        # 3. 스트리밍 테스트
        results.append(
            await test_streaming(
                client, gateway, "assistant", "HTTP/2의 장점을 3가지 설명해주세요."
            )
        )

    return results


def print_results(results: list[TestResult]) -> None:
    """결과 출력"""
    print("\n" + "=" * 80)
    print(f"{'Test':<25} {'Status':<8} {'Latency':<12} {'Model':<20} Detail")
    print("-" * 80)
    for r in results:
        status = "PASS" if r.passed else "FAIL"
        print(f"{r.name:<25} {status:<8} {r.latency_ms:>8.0f}ms  {r.model_used:<20} {r.detail}")
    print("=" * 80)

    passed = sum(1 for r in results if r.passed)
    total = len(results)
    print(f"\nResult: {passed}/{total} passed")


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("--gateway", default="http://localhost:4000")
    args = parser.parse_args()

    results = asyncio.run(run_all_tests(args.gateway))
    print_results(results)
    sys.exit(0 if all(r.passed for r in results) else 1)

비용·전력 최적화 — 게이트웨이가 가능하게 하는 것들

게이트웨이 계층이 가져다주는 또 다른 이점은 비용과 전력을 의식한 라우팅입니다.

시간대별 라우팅

CUDA 서버(RTX 4090 × 2)의 전력 소모는 풀로드 시 약 600W입니다. 월 전기요금으로 환산하면 한국 가정용 누진제 기준 약 7~10만 원 수준. 야간·주말에 CUDA 서버를 절전 모드로 두고 Mac Studio(풀로드 60W)만 운영하면 전력 비용을 50~70% 절감할 수 있습니다.

"""
time_based_routing.py — 시간대별 라우팅 로직

업무 시간(09~18시): CUDA 서버 활성 → qwen3-30b FP8 우선
야간/주말: Mac Studio만 → qwen3-30b Q4 (폴백 모드)
"""
from datetime import datetime


def get_preferred_model(complexity: str) -> str:
    """시간대와 복잡도에 따라 최적 모델을 반환한다."""
    now = datetime.now()
    is_business_hours = (
        now.weekday() < 5         # 월~금
        and 9 <= now.hour < 18    # 09:00~17:59
    )

    if complexity in ("SIMPLE", "MODERATE"):
        # 경량 요청은 항상 Mac Studio
        return "assistant-fast"

    if is_business_hours:
        # 업무 시간: CUDA 서버의 FP8 모델 사용
        if complexity == "EXPERT":
            return "qwen3-235b"
        return "assistant"       # qwen3-30b FP8

    # 야간/주말: Mac Studio의 Q4 폴백
    return "assistant"           # LiteLLM이 CUDA 쿨다운 시 MLX Q4로 폴백

더 정교하게 구현하면 LiteLLM의 router_settings를 동적으로 변경하거나, CUDA 서버의 Wake-on-LAN(WoL)과 연동하여 EXPERT 요청이 들어왔을 때만 CUDA 서버를 깨우는 것도 가능합니다.

토큰 예산 관리

LiteLLM은 사용자별·모델별 토큰 사용량을 추적합니다. 일일 토큰 예산을 설정하면 과도한 사용을 방지할 수 있습니다.

# litellm_config.yaml — 토큰 예산 설정 예시
general_settings:
  database_url: "sqlite:///litellm_usage.db"   # 사용량 영속화

litellm_settings:
  max_budget: 0          # 0 = 무제한 (온프레미스이므로 금전 비용 없음)
  budget_duration: "1d"  # 예산 리셋 주기

온프레미스는 API 호출 비용이 없지만, GPU 시간이 유한한 자원입니다. 한 사용자가 235B 모델로 8K 토큰 요청을 연속 발사하면 다른 사용자의 요청이 큐에서 수 분간 대기합니다. 사용자별 RPM(분당 요청 수) 제한이 이 문제를 완화합니다.

고급 라우팅 패턴

패턴 1: 컨텍스트 길이 기반 라우팅

요청의 입력 토큰 수에 따라 모델을 분기하는 패턴입니다. 긴 컨텍스트(예: 10K+ 토큰)는 KV 캐시 메모리 소모가 크므로 VRAM이 넉넉한 CUDA 서버로 보내는 것이 합리적입니다.

"""context_length_router.py — 토큰 수 기반 라우팅"""
import tiktoken


def estimate_tokens(text: str) -> int:
    """간이 토큰 수 추정 (정확한 토크나이저 대신 근사치 사용)"""
    # Qwen3의 토크나이저는 tiktoken 호환이 아니지만,
    # 라우팅 목적의 근사치로는 충분
    # 한국어는 대략 글자당 1.5~2 토큰
    # 영어는 대략 단어당 1.3 토큰
    return max(len(text) // 2, len(text.split()))


def route_by_context_length(
    messages: list[dict],
    complexity: str,
) -> str:
    """컨텍스트 길이와 복잡도를 조합한 라우팅"""
    total_text = " ".join(
        str(m.get("content", "")) for m in messages
    )
    token_estimate = estimate_tokens(total_text)

    # 8K+ 토큰: 무조건 CUDA (KV 캐시 메모리)
    if token_estimate > 8000:
        return "qwen3-235b" if complexity == "EXPERT" else "assistant"

    # 4K~8K 토큰: CUDA 우선, MLX 폴백
    if token_estimate > 4000:
        return "assistant"  # CUDA의 qwen3-30b

    # 4K 이하: 복잡도 기반 기본 라우팅
    return {
        "SIMPLE": "assistant-fast",
        "MODERATE": "assistant-fast",
        "COMPLEX": "assistant",
        "EXPERT": "qwen3-235b",
    }.get(complexity, "assistant")

패턴 2: 멀티모달 라우팅

요청에 이미지가 포함되어 있으면 Qwen3-VL로 자동 전환합니다. 이 부분은 7일차에서 Qwen3-VL 서빙을 다룬 후 완성됩니다.

def route_multimodal(messages: list[dict]) -> str:
    """이미지 포함 여부에 따른 라우팅"""
    for msg in messages:
        content = msg.get("content", "")
        if isinstance(content, list):
            # OpenAI vision 형식: [{"type": "image_url", ...}, ...]
            for part in content:
                if isinstance(part, dict) and part.get("type") == "image_url":
                    return "qwen3-vl-30b"  # 7일차에서 설정
    return "assistant"  # 텍스트 전용

패턴 3: Thinking 모드 선택적 활성화

Qwen3의 Thinking 에디션은 reasoning 토큰을 생성하여 답변 품질을 높이지만, 토큰 소모와 지연이 2~5배 증가합니다. EXPERT 등급 요청에만 Thinking 모드를 활성화하는 것이 비용 효율적입니다.

def apply_thinking_mode(
    payload: dict,
    complexity: str,
) -> dict:
    """EXPERT 등급에만 Thinking 모드 활성화"""
    if complexity == "EXPERT":
        # Qwen3 Thinking 모드: chat_template에서
        # enable_thinking=True가 시스템 프롬프트 또는
        # 모델 설정으로 주입됨
        payload.setdefault("extra_body", {})
        payload["extra_body"]["enable_thinking"] = True
        # Thinking 토큰은 max_tokens에 포함되지 않으므로
        # 별도로 thinking_budget을 설정
        payload["extra_body"]["thinking_budget"] = 4096
    return payload

운영 함정 (Pitfall) 미니 코너

함정: LiteLLM의 model_name 충돌과 의도치 않은 라우팅

증상: assistant 모델로 요청을 보냈는데, 기대한 CUDA의 Qwen3-30B가 아니라 Mac Studio의 Qwen3-30B Q4가 응답합니다. 응답 품질이 눈에 띄게 떨어지는데 에러는 없습니다.

원인: litellm_config.yaml에서 model_name: qwen3-30b로 CUDA와 MLX 두 개의 백엔드를 등록했습니다. assistant 별칭은 CUDA의 qwen3-30b를 가리키지만, LiteLLM의 라우터는 model_name이 같은 모든 배포를 같은 풀로 취급합니다. routing_strategy: simple-shuffle에서는 두 배포가 무작위로 선택됩니다.

해결: 폴백용 배포의 model_name을 분리합니다.

# 잘못된 예 — 같은 model_name으로 두 백엔드 등록
- model_name: qwen3-30b        # CUDA FP8
  litellm_params:
    api_base: http://cuda:8000/v1
- model_name: qwen3-30b        # MLX Q4 (폴백인데 같은 이름)
  litellm_params:
    api_base: http://mac:8080/v1

# 올바른 예 — 폴백 배포는 별도 이름
- model_name: qwen3-30b        # 1차: CUDA FP8
  litellm_params:
    api_base: http://cuda:8000/v1
- model_name: qwen3-30b-fallback  # 2차: MLX Q4 (별도 이름)
  litellm_params:
    api_base: http://mac:8080/v1

# fallbacks 설정에서 체인 연결
litellm_settings:
  fallbacks:
    - qwen3-30b:
        - qwen3-30b-fallback

교훈: 같은 model_name의 다중 배포는 "동일 품질의 수평 확장"에만 사용하세요. 품질이 다른 배포(FP8 vs Q4)를 같은 이름으로 등록하면 라우팅이 비결정적(non-deterministic)이 됩니다. 폴백은 반드시 별도 model_name + fallbacks 설정으로 구현합니다.

게이트웨이 배포 체크리스트

오늘 구축한 하이브리드 추론 게이트웨이를 운영 환경에 올리기 전 확인할 항목들입니다.

  • LiteLLM Proxy가 모든 백엔드에 연결 가능한가? (/v1/models 확인)
  • 각 모델의 비스트리밍·스트리밍 응답이 정상인가? (test_gateway.py 통과)
  • 폴백 체인이 동작하는가? (CUDA 서버 정지 후 MLX 자동 전환 확인)
  • 타임아웃이 모델 크기에 맞게 차등 설정됐는가?
  • nginx 앞단의 proxy_buffering off + proxy_read_timeout 설정 완료?
  • master_key가 기본값이 아닌 안전한 값으로 변경됐는가?
  • Prometheus 메트릭이 수집되고 있는가? (/metrics 확인)
  • 라우터의 분류 정확도를 테스트 세트로 검증했는가?
  • CUDA 서버 장애 시 Mac Studio 단독 운영 모드가 가능한가?
  • 로그에 사용자 프롬프트 전문이 기록되지 않는가? (프라이버시)
게이트웨이 배포 점검 체크리스트

마무리 — 6일차 정리

오늘 우리는 단순히 "여러 모델을 써보자"를 넘어, 시스템 수준의 추론 인프라를 설계했습니다.

  • 2단 추론 파이프라인: Qwen3-8B가 0.1~0.3초 만에 요청 복잡도를 분류하고, 적합한 크기의 모델로 분기
  • LiteLLM 통합 게이트웨이: Mac Studio(MLX)와 CUDA(vLLM)를 단일 OpenAI 호환 API로 추상화
  • 폴백 + 쿨다운: 백엔드 장애 시 자동 전환, 서킷 브레이커 패턴으로 장애 전파 차단
  • 차등 타임아웃: 모델 크기별·백엔드별로 세분화된 타임아웃으로 불필요한 대기 제거
  • 관측성: Prometheus 메트릭으로 라우팅 분포·에러율·지연을 실시간 모니터링

4일차의 MLX, 5일차의 vLLM이 "엔진"이었다면, 오늘의 게이트웨이는 그 엔진들을 하나의 차체로 묶는 작업이었습니다. 클라이언트는 이제 http://gateway:4000/v1/chat/completions이라는 단 하나의 주소만 알면 됩니다.

내일 7일차에서는 드디어 Qwen3-VL을 서빙합니다. 문서·차트·UI 스크린샷을 이해하는 멀티모달 추론을 vLLM과 MLX에서 각각 띄우고, 오늘 구축한 게이트웨이에 VL 모델을 통합하는 과정을 다룹니다. 금융IT 문서(표·스캔본)를 Qwen3-VL로 처리하는 실전 파이프라인도 함께 구성합니다.


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

참고 자료

  • LiteLLM 공식 문서 — 다중 LLM 프로바이더를 단일 OpenAI 호환 API로 통합하는 프록시·라우팅·폴백 설정 가이드
  • Wikipedia — API Gateway — API 게이트웨이 패턴의 정의·역할·라우팅·로드밸런싱 개념 개요

Tags:

LiteLLMLLM 게이트웨이Qwen3모델 라우팅연재:온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계온프레미스 AI온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계-6화
작성자

AICosmus

Follow Me
다른 기사
opencode TUI 키보드 중심 워크플로우
Previous

[opencode 12일 집중 — 터미널 네이티브 AI 코딩 에이전트 완전 정복] 4/12화: opencode TUI 단축키 완전 정복 — 키보드만으로 코딩하기

Qwen3-VL 멀티모달 AI 서빙 서버
Next

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

댓글 1개
  1. [온프레미스 AI Assistant 아키텍처 — Qwen3·Qwen3-VL 14일 설계] 7/14화: Qwen3-VL 서빙 실전 — 멀티모달 추론과 Visual Agent 통합 설계 - AICosmus 댓글:
    2026년 07월 06일, 3:08 오전

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

    답글

답글 남기기 응답 취소

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

최신 글

  • [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