Skip to content
Published on

LLM 라우팅·캐스케이드 전략: 멀티 모델 오케스트레이션으로 비용 최적화

Authors
  • Name
    Twitter
LLM Routing

들어가며

LLM 기반 서비스를 운영하다 보면 모든 쿼리를 최고 성능 모델(GPT-4o, Claude Opus 등)에 전송하는 것이 가장 안전한 선택처럼 보인다. 하지만 현실은 다르다. 프로덕션 트래픽의 60~80%는 "오늘 날씨 알려줘", "이 텍스트 요약해줘" 같은 단순 쿼리로 구성되며, 이런 요청에 최상위 모델을 사용하는 것은 비용 낭비다. GPT-4o의 토큰당 비용은 GPT-4o-mini 대비 약 30배 이상이며, Claude Opus는 Haiku 대비 약 60배 비싸다.

이 문제를 해결하는 핵심 전략이 **LLM 라우팅(Routing)**과 **캐스케이드(Cascade)**다. 쿼리의 복잡도, 도메인, 요구 품질을 실시간으로 분석하여 최적의 모델로 분배하거나, 저비용 모델부터 순차적으로 시도하여 품질 기준을 충족하면 즉시 반환하는 방식이다. RouteLLM 벤치마크에 따르면, 학습된 라우터를 사용하면 GPT-4 대비 85%의 비용 절감을 달성하면서도 95%의 응답 품질을 유지할 수 있다.

이 글에서는 LLM 라우팅의 핵심 개념과 주요 접근법(RouteLLM, FrugalGPT, Semantic Router, Martian, Not Diamond)을 비교하고, 프로덕션에서 즉시 적용 가능한 멀티 모델 오케스트레이션 아키텍처를 코드와 함께 구축한다. 또한 운영 중 발생하는 실패 패턴과 복구 전략, 비용 최적화 체크리스트까지 포괄적으로 다룬다.

LLM 라우팅이란 무엇인가

라우팅의 정의와 필요성

LLM 라우팅은 사용자 쿼리를 분석하여 가장 적합한 모델로 요청을 전달하는 결정 계층이다. 네트워크 라우터가 패킷을 최적 경로로 전달하듯, LLM 라우터는 각 쿼리를 비용-품질-레이턴시 균형이 가장 좋은 모델로 분배한다.

라우팅이 필요한 근본적인 이유는 모델 간 비용-성능 격차가 크기 때문이다. 간단한 분류 작업에 GPT-4o를 사용하면 정확도는 약간 높아지지만 비용은 수십 배 증가한다. 반대로 복잡한 추론이 필요한 작업에 소형 모델을 사용하면 품질 저하로 재시도가 필요해져 오히려 총 비용이 증가할 수 있다.

라우팅 vs 캐스케이드 vs 앙상블

세 가지 접근법은 서로 다른 전략을 취한다.

라우팅(Routing): 쿼리를 분석한 뒤 단일 모델을 선택하여 한 번만 호출한다. 레이턴시 오버헤드가 낮고 구현이 상대적으로 단순하다. 라우터의 판단이 틀리면 품질 저하가 바로 발생하는 단점이 있다.

캐스케이드(Cascade): 저비용 모델부터 순차적으로 호출하고, 응답 품질을 평가하여 기준 미달이면 상위 모델로 에스컬레이션한다. 품질 보장에 유리하지만 평균 레이턴시가 증가할 수 있다.

앙상블(Ensemble): 여러 모델을 동시에 호출하고 응답을 종합한다. 품질은 가장 높지만 비용과 레이턴시가 모두 증가하므로, 의료/법률 등 고신뢰 도메인에서만 사용하는 것이 현실적이다.

프로덕션에서는 라우팅과 캐스케이드를 혼합 적용하는 것이 가장 효과적이다. 라우터가 1차 분류를 수행하고, 라우터 신뢰도가 낮은 경우에만 캐스케이드로 폴백하는 구조다.

주요 라우팅 접근법 비교

접근법별 상세 비교 테이블

접근법라우팅 방식비용 절감률품질 유지율레이턴시 오버헤드구현 난이도적합 시나리오
RouteLLM학습된 분류기(MF/BERT/SW)~85% (MT Bench)~95% GPT-4낮음 (5~15ms)중간Strong/Weak 2모델 라우팅
FrugalGPT캐스케이드 + 품질 판정기5075%9095%높음 (순차 호출)높음다단계 모델 파이프라인
Semantic Router임베딩 유사도 기반4060%~90%매우 낮음 (2~5ms)낮음도메인별 라우팅, Tool 선택
Martian메타 모델 행동 예측3060%~95%낮음낮음 (SaaS)엔터프라이즈 멀티 모델
Not Diamond메타 모델 + 200+ 모델3050%~95%+낮음낮음 (SaaS)최적 모델 자동 선택
xRouter강화학습 기반6080%9396%낮음높음비용 제약 최적화
규칙 기반키워드/정규식3050%가변적거의 없음낮음MVP, 초기 도입

RouteLLM: 학습 기반 라우터

RouteLLM은 LMSYS에서 개발한 오픈소스 라우팅 프레임워크다. Chatbot Arena의 선호도 데이터를 활용하여 "이 쿼리에 대해 강한 모델(GPT-4o)이 약한 모델(GPT-4o-mini)보다 더 나은 응답을 할 확률"을 예측하는 분류기를 학습한다.

네 가지 라우터를 제공한다. MF(Matrix Factorization) 라우터는 쿼리 임베딩과 모델 특성을 행렬 분해하여 승률을 예측한다. SW(Similarity-Weighted) 라우터는 유사 쿼리의 과거 승률을 가중 평균한다. BERT 라우터는 BERT 분류기로 직접 라우팅을 학습한다. Causal LLM 라우터는 LLM 자체를 미세조정하여 라우팅 판단을 수행한다.

# RouteLLM 서버 실행 및 클라이언트 사용 예시
# pip install routellm

# 1. 서버 실행 (OpenAI API 호환)
# python -m routellm.openai_server \
#   --routers mf \
#   --strong-model gpt-4o \
#   --weak-model gpt-4o-mini

# 2. 클라이언트에서 사용
import openai

client = openai.OpenAI(
    base_url="http://localhost:6060/v1",
    api_key="not-needed",  # RouteLLM 서버에서 관리
)

# 라우터가 자동으로 strong/weak 모델 선택
# threshold 값으로 라우팅 민감도 조절 (0.0~1.0)
response = client.chat.completions.create(
    model="router-mf-0.11593",  # router-{라우터명}-{threshold}
    messages=[
        {"role": "user", "content": "양자 컴퓨팅의 큐비트 오류 정정 방법을 설명해줘"}
    ],
)

# threshold가 낮을수록 strong 모델 사용 비율 증가
# 0.5 → ~50% strong 모델 사용
# 0.1 → ~90% strong 모델 사용 (품질 우선)
# 0.9 → ~10% strong 모델 사용 (비용 우선)
print(response.choices[0].message.content)
print(f"사용된 모델: {response.model}")

FrugalGPT: 캐스케이드 기반 비용 최적화

FrugalGPT는 스탠퍼드 대학에서 제안한 접근법으로, 모델 캐스케이드와 품질 판정기(Quality Estimator)를 결합한다. 핵심 아이디어는 가장 저렴한 모델부터 시도하고, 응답 품질이 충분하면 즉시 반환하는 것이다.

동작 흐름은 다음과 같다. 쿼리가 들어오면 먼저 가장 저비용 모델(예: GPT-4o-mini)에 전달한다. 응답을 품질 판정기가 평가하여 점수가 임계값 이상이면 반환한다. 미달이면 다음 상위 모델(예: Claude Sonnet)에 같은 쿼리를 전달하고, 다시 품질 판정을 수행한다. 최상위 모델(예: GPT-4o)까지 도달하면 무조건 반환한다.

# FrugalGPT 스타일 캐스케이드 구현
from openai import OpenAI
from anthropic import Anthropic
import time
from dataclasses import dataclass
from typing import Optional

@dataclass
class ModelTier:
    name: str
    provider: str
    cost_per_1k_tokens: float
    quality_threshold: float  # 이 점수 이상이면 응답 반환

# 비용 오름차순으로 모델 티어 정의
MODEL_CASCADE = [
    ModelTier("gpt-4o-mini", "openai", 0.00015, 0.7),
    ModelTier("claude-3-5-haiku-20241022", "anthropic", 0.001, 0.8),
    ModelTier("claude-sonnet-4-20250514", "anthropic", 0.003, 0.85),
    ModelTier("gpt-4o", "openai", 0.005, 0.0),  # 최종 단계: 항상 반환
]

openai_client = OpenAI()
anthropic_client = Anthropic()


def call_model(model: ModelTier, query: str) -> str:
    """모델 프로바이더에 따라 적절한 API 호출"""
    if model.provider == "openai":
        resp = openai_client.chat.completions.create(
            model=model.name,
            messages=[{"role": "user", "content": query}],
            temperature=0.3,
        )
        return resp.choices[0].message.content
    elif model.provider == "anthropic":
        resp = anthropic_client.messages.create(
            model=model.name,
            max_tokens=2048,
            messages=[{"role": "user", "content": query}],
        )
        return resp.content[0].text


def estimate_quality(query: str, response: str) -> float:
    """응답 품질 판정기 - 경량 모델로 품질 점수 반환"""
    judge_prompt = f"""다음 질문과 답변의 품질을 0.0~1.0 점수로 평가하세요.
평가 기준: 정확성, 완전성, 관련성, 명확성
질문: {query}
답변: {response}
점수만 숫자로 반환하세요 (예: 0.85)"""

    resp = openai_client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": judge_prompt}],
        temperature=0.0,
        max_tokens=10,
    )
    try:
        return float(resp.choices[0].message.content.strip())
    except ValueError:
        return 0.0  # 파싱 실패 시 낮은 점수 → 상위 모델로 에스컬레이션


def frugal_cascade(query: str) -> dict:
    """FrugalGPT 캐스케이드 실행"""
    results = []

    for tier in MODEL_CASCADE:
        start = time.time()
        response = call_model(tier, query)
        latency = time.time() - start

        # 최종 티어는 품질 판정 생략
        if tier.quality_threshold == 0.0:
            return {
                "response": response,
                "model": tier.name,
                "latency": latency,
                "cascade_depth": len(results) + 1,
                "total_cost_ratio": sum(r["cost"] for r in results) + tier.cost_per_1k_tokens,
            }

        quality = estimate_quality(query, response)

        results.append({
            "model": tier.name,
            "quality": quality,
            "cost": tier.cost_per_1k_tokens,
            "latency": latency,
        })

        if quality >= tier.quality_threshold:
            return {
                "response": response,
                "model": tier.name,
                "quality_score": quality,
                "latency": latency,
                "cascade_depth": len(results),
            }

    # 이론적으로 도달하지 않음 (마지막 티어에서 무조건 반환)
    return {"response": response, "model": MODEL_CASCADE[-1].name}

Semantic Router: 임베딩 기반 초고속 라우팅

Semantic Router는 Aurelio Labs에서 개발한 라이브러리로, 쿼리의 의미적 유사도를 기반으로 라우팅 결정을 내린다. LLM을 호출하지 않고 임베딩 벡터 간 코사인 유사도만으로 판단하기 때문에 레이턴시 오버헤드가 2~5ms 수준으로 극히 낮다.

# Semantic Router를 활용한 도메인별 모델 라우팅
# pip install semantic-router

from semantic_router import Route, RouteLayer
from semantic_router.encoders import OpenAIEncoder

# 라우트 정의: 각 라우트에 대표 발화(utterance) 지정
simple_route = Route(
    name="simple",
    utterances=[
        "오늘 날씨 어때?",
        "서울의 인구는?",
        "안녕하세요",
        "이 단어의 뜻이 뭐야?",
        "1+1은?",
        "현재 시간 알려줘",
    ],
)

coding_route = Route(
    name="coding",
    utterances=[
        "Python으로 퀵소트 구현해줘",
        "React 컴포넌트에서 useEffect 메모리 누수 해결",
        "Kubernetes Pod OOMKilled 디버깅",
        "SQL 쿼리 최적화 방법",
        "gRPC와 REST API 성능 비교 코드",
    ],
)

reasoning_route = Route(
    name="reasoning",
    utterances=[
        "이 논문의 방법론을 비판적으로 분석해줘",
        "GDP 성장률과 실업률의 상관관계를 경제학적으로 설명해",
        "양자 얽힘과 양자 텔레포테이션의 차이를 수학적으로 유도해",
        "RLHF와 DPO의 이론적 한계를 비교 분석해줘",
    ],
)

# 인코더 및 라우트 레이어 초기화
encoder = OpenAIEncoder(name="text-embedding-3-small")
route_layer = RouteLayer(
    encoder=encoder,
    routes=[simple_route, coding_route, reasoning_route],
)

# 라우트에 따른 모델 매핑
MODEL_MAP = {
    "simple": "gpt-4o-mini",        # 저비용 모델
    "coding": "claude-sonnet-4-20250514",  # 코딩 특화
    "reasoning": "gpt-4o",          # 고성능 추론
    None: "claude-sonnet-4-20250514",      # 기본 폴백
}


def route_query(query: str) -> dict:
    """쿼리를 분석하여 최적 모델 결정"""
    route_result = route_layer(query)
    selected_model = MODEL_MAP.get(route_result.name, MODEL_MAP[None])

    return {
        "query": query,
        "route": route_result.name,
        "confidence": route_result.similarity_score,
        "model": selected_model,
    }


# 사용 예시
queries = [
    "파이썬에서 리스트 정렬하는 법",
    "트랜스포머 아키텍처의 어텐션 메커니즘을 수학적으로 설명해",
    "내일 부산 날씨",
]

for q in queries:
    result = route_query(q)
    print(f"쿼리: {q}")
    print(f"  라우트: {result['route']} → 모델: {result['model']}")
    print(f"  신뢰도: {result['confidence']:.3f}")

프로덕션 멀티 모델 오케스트레이션 아키텍처

전체 시스템 구조

프로덕션 환경에서 멀티 모델 오케스트레이션을 구축할 때는 단순 라우팅을 넘어 관측성(Observability), 폴백(Fallback), 캐싱, 레이트 리밋 등을 통합해야 한다. 아래는 프로덕션 수준의 오케스트레이션 계층 설계다.

사용자 요청 → API Gateway → 시맨틱 캐시 검색
                               (캐시 미스)
                         쿼리 분류기 (복잡도/도메인 분석)
                    ┌─────────┼─────────┐
                    ↓         ↓         ↓
               소형 모델   중형 모델   대형 모델
              (GPT-4o-mini) (Sonnet)  (GPT-4o/Opus)
                    ↓         ↓         ↓
                    └─────────┼─────────┘
                         품질 게이트 (캐스케이드 판단)
                    ┌── 품질 충족 → 응답 반환 + 캐시 저장
                    └── 품질 미달 → 상위 모델 에스컬레이션
                         메트릭 수집 (비용/레이턴시/품질)

TypeScript 기반 오케스트레이션 엔진

// multi-model-orchestrator.ts
import OpenAI from 'openai'
import Anthropic from '@anthropic-ai/sdk'

interface ModelConfig {
  id: string
  provider: 'openai' | 'anthropic'
  costPer1kInput: number
  costPer1kOutput: number
  maxTokens: number
  tier: 'small' | 'medium' | 'large'
}

interface RoutingDecision {
  model: ModelConfig
  reason: string
  confidence: number
}

interface OrchestratorResult {
  response: string
  model: string
  tier: string
  latencyMs: number
  estimatedCost: number
  cascadeDepth: number
  cacheHit: boolean
}

// 모델 카탈로그 정의
const MODEL_CATALOG: ModelConfig[] = [
  {
    id: 'gpt-4o-mini',
    provider: 'openai',
    costPer1kInput: 0.00015,
    costPer1kOutput: 0.0006,
    maxTokens: 16384,
    tier: 'small',
  },
  {
    id: 'claude-3-5-haiku-20241022',
    provider: 'anthropic',
    costPer1kInput: 0.001,
    costPer1kOutput: 0.005,
    maxTokens: 8192,
    tier: 'small',
  },
  {
    id: 'claude-sonnet-4-20250514',
    provider: 'anthropic',
    costPer1kInput: 0.003,
    costPer1kOutput: 0.015,
    maxTokens: 8192,
    tier: 'medium',
  },
  {
    id: 'gpt-4o',
    provider: 'openai',
    costPer1kInput: 0.005,
    costPer1kOutput: 0.015,
    maxTokens: 16384,
    tier: 'large',
  },
]

class QueryClassifier {
  private openai: OpenAI

  constructor(openai: OpenAI) {
    this.openai = openai
  }

  async classify(query: string): Promise<RoutingDecision> {
    // 규칙 기반 1차 분류 (LLM 호출 없이 즉시 판단)
    const ruleResult = this.ruleBasedClassify(query)
    if (ruleResult) return ruleResult

    // 경량 모델로 2차 분류
    const resp = await this.openai.chat.completions.create({
      model: 'gpt-4o-mini',
      messages: [
        {
          role: 'system',
          content: `쿼리 복잡도를 분류하세요.
JSON으로 응답: {"tier": "small|medium|large", "reason": "...", "confidence": 0.0~1.0}
- small: 단순 질의, 인사, 번역, 요약
- medium: 코딩, 분석, 비교, 구조화된 설명
- large: 복합 추론, 수학 증명, 멀티스텝 분석, 창의적 장문`,
        },
        { role: 'user', content: query },
      ],
      response_format: { type: 'json_object' },
      temperature: 0.0,
      max_tokens: 100,
    })

    const parsed = JSON.parse(resp.choices[0].message.content || '{}')
    const tier = parsed.tier || 'medium'
    const model = MODEL_CATALOG.find((m) => m.tier === tier) || MODEL_CATALOG[2]

    return {
      model,
      reason: parsed.reason || 'LLM 분류기 판단',
      confidence: parsed.confidence || 0.5,
    }
  }

  private ruleBasedClassify(query: string): RoutingDecision | null {
    const len = query.length

    // 매우 짧은 쿼리 → small
    if (len < 30) {
      return {
        model: MODEL_CATALOG[0],
        reason: '짧은 쿼리 (규칙 기반)',
        confidence: 0.9,
      }
    }

    // 코드 관련 키워드 → medium
    const codeKeywords = /\b(코드|구현|함수|클래스|디버그|에러|API|SQL|React|Python)\b/i
    if (codeKeywords.test(query)) {
      return {
        model: MODEL_CATALOG[2], // claude-sonnet
        reason: '코딩 관련 쿼리 (규칙 기반)',
        confidence: 0.8,
      }
    }

    // 복잡한 추론 키워드 → large
    const reasoningKeywords = /\b(증명|분석|비교.*차이|수학적|논리적|전략.*수립|아키텍처.*설계)\b/
    if (reasoningKeywords.test(query)) {
      return {
        model: MODEL_CATALOG[3], // gpt-4o
        reason: '복합 추론 쿼리 (규칙 기반)',
        confidence: 0.75,
      }
    }

    return null // 규칙으로 판단 불가 → LLM 분류기 사용
  }
}

시맨틱 캐시 통합

동일하거나 유사한 쿼리에 대해 이전 응답을 재사용하면 비용을 극적으로 줄일 수 있다. 정확한 문자열 매칭이 아닌 임베딩 기반 유사도 검색을 사용하면 "Python 리스트 정렬 방법"과 "파이썬에서 리스트를 어떻게 정렬하나요"를 동일 쿼리로 인식할 수 있다.

# 시맨틱 캐시 구현 (Redis + 벡터 유사도)
import hashlib
import json
import time
import numpy as np
from openai import OpenAI
from redis import Redis

client = OpenAI()
redis_client = Redis(host="localhost", port=6379, db=0)

CACHE_TTL = 3600  # 1시간
SIMILARITY_THRESHOLD = 0.92  # 유사도 임계값


def get_embedding(text: str) -> list[float]:
    """텍스트의 임베딩 벡터 생성"""
    resp = client.embeddings.create(
        model="text-embedding-3-small",
        input=text,
    )
    return resp.data[0].embedding


def cosine_similarity(a: list[float], b: list[float]) -> float:
    """코사인 유사도 계산"""
    a_np, b_np = np.array(a), np.array(b)
    return float(np.dot(a_np, b_np) / (np.linalg.norm(a_np) * np.linalg.norm(b_np)))


class SemanticCache:
    def __init__(self, namespace: str = "llm_cache"):
        self.namespace = namespace

    def _cache_key(self, idx: int) -> str:
        return f"{self.namespace}:entry:{idx}"

    def _counter_key(self) -> str:
        return f"{self.namespace}:counter"

    def get(self, query: str) -> dict | None:
        """유사 쿼리의 캐시된 응답 검색"""
        query_embedding = get_embedding(query)
        counter = int(redis_client.get(self._counter_key()) or 0)

        best_match = None
        best_similarity = 0.0

        for i in range(counter):
            entry_raw = redis_client.get(self._cache_key(i))
            if not entry_raw:
                continue

            entry = json.loads(entry_raw)
            similarity = cosine_similarity(query_embedding, entry["embedding"])

            if similarity > best_similarity and similarity >= SIMILARITY_THRESHOLD:
                best_similarity = similarity
                best_match = entry

        if best_match:
            return {
                "response": best_match["response"],
                "model": best_match["model"],
                "similarity": best_similarity,
                "cached_at": best_match["timestamp"],
            }

        return None

    def put(self, query: str, response: str, model: str):
        """응답을 캐시에 저장"""
        embedding = get_embedding(query)
        counter = int(redis_client.get(self._counter_key()) or 0)

        entry = {
            "query": query,
            "response": response,
            "model": model,
            "embedding": embedding,
            "timestamp": time.time(),
        }

        redis_client.setex(
            self._cache_key(counter),
            CACHE_TTL,
            json.dumps(entry),
        )
        redis_client.incr(self._counter_key())

비용 최적화 심화 전략

토큰 비용 분석 프레임워크

비용 최적화의 첫 단계는 현재 비용 구조를 정확히 파악하는 것이다. 모델별, 기능별, 시간대별 비용을 추적하여 최적화 여지가 큰 영역을 식별해야 한다.

# 비용 추적 및 분석 시스템
from dataclasses import dataclass, field
from collections import defaultdict
from datetime import datetime, timedelta
import json


@dataclass
class TokenUsage:
    model: str
    input_tokens: int
    output_tokens: int
    timestamp: datetime
    route: str  # 어떤 라우트로 분류되었는지
    cascade_depth: int = 1
    quality_score: float = 0.0


class CostAnalyzer:
    # 2026년 3월 기준 주요 모델 가격 (USD per 1K tokens)
    PRICING = {
        "gpt-4o": {"input": 0.0025, "output": 0.01},
        "gpt-4o-mini": {"input": 0.00015, "output": 0.0006},
        "claude-opus-4-20250514": {"input": 0.015, "output": 0.075},
        "claude-sonnet-4-20250514": {"input": 0.003, "output": 0.015},
        "claude-3-5-haiku-20241022": {"input": 0.001, "output": 0.005},
    }

    def __init__(self):
        self.usage_log: list[TokenUsage] = []

    def log(self, usage: TokenUsage):
        self.usage_log.append(usage)

    def calculate_cost(self, usage: TokenUsage) -> float:
        """단건 비용 계산"""
        pricing = self.PRICING.get(usage.model)
        if not pricing:
            return 0.0

        input_cost = (usage.input_tokens / 1000) * pricing["input"]
        output_cost = (usage.output_tokens / 1000) * pricing["output"]
        return input_cost + output_cost

    def daily_report(self, date: datetime = None) -> dict:
        """일별 비용 리포트 생성"""
        date = date or datetime.now()
        day_start = date.replace(hour=0, minute=0, second=0)
        day_end = day_start + timedelta(days=1)

        day_logs = [
            u for u in self.usage_log
            if day_start <= u.timestamp < day_end
        ]

        model_costs = defaultdict(float)
        route_costs = defaultdict(float)
        total_cost = 0.0
        total_requests = len(day_logs)

        for usage in day_logs:
            cost = self.calculate_cost(usage)
            model_costs[usage.model] += cost
            route_costs[usage.route] += cost
            total_cost += cost

        # 라우팅 없이 모든 요청을 GPT-4o로 보냈을 때 비용 추정
        baseline_cost = sum(
            (u.input_tokens / 1000) * self.PRICING["gpt-4o"]["input"]
            + (u.output_tokens / 1000) * self.PRICING["gpt-4o"]["output"]
            for u in day_logs
        )

        return {
            "date": date.strftime("%Y-%m-%d"),
            "total_requests": total_requests,
            "total_cost_usd": round(total_cost, 4),
            "baseline_cost_usd": round(baseline_cost, 4),
            "savings_pct": round((1 - total_cost / baseline_cost) * 100, 1) if baseline_cost > 0 else 0,
            "cost_by_model": dict(model_costs),
            "cost_by_route": dict(route_costs),
            "avg_cost_per_request": round(total_cost / total_requests, 6) if total_requests > 0 else 0,
        }

프롬프트 압축과 토큰 절약

라우팅 외에도 프롬프트 자체를 최적화하여 토큰 사용량을 줄일 수 있다. 시스템 프롬프트 압축, 불필요한 컨텍스트 제거, 출력 토큰 제한 등을 적용하면 동일 모델에서도 20~40%의 비용을 절감할 수 있다.

주요 기법은 다음과 같다. 시스템 프롬프트 캐싱: Anthropic의 Prompt Caching 기능을 활용하면 반복되는 시스템 프롬프트의 비용을 90% 절감할 수 있다. LLMLingua 프롬프트 압축: 원본 프롬프트에서 중요도가 낮은 토큰을 제거하여 2~5배 압축한다. 출력 길이 제어: max_tokens를 작업에 맞게 제한하고, 간결한 응답을 유도하는 프롬프트를 작성한다.

실패 패턴과 복구 전략

주요 실패 시나리오

1. 라우터 오분류 (Misrouting)

라우터가 복잡한 쿼리를 단순 쿼리로 잘못 분류하여 소형 모델로 보내면 품질이 크게 저하된다. 반대로 단순 쿼리를 복잡하다고 판단하면 불필요한 비용이 발생한다.

복구 전략: 사용자 피드백 루프를 구축하여 오분류 사례를 수집하고, 라우터를 주기적으로 재학습한다. 신뢰도가 낮은 분류 결과(confidence < 0.6)에 대해서는 자동으로 중간 티어 모델을 선택하는 안전장치를 둔다.

2. 캐스케이드 레이턴시 폭발

캐스케이드 방식에서 저비용 모델이 연속으로 품질 기준을 충족하지 못하면 모든 티어를 순회하게 되어 레이턴시가 급증한다. 4단계 캐스케이드에서 매 단계 1~2초가 걸리면 최악의 경우 8초 이상의 응답 시간이 발생한다.

복구 전략: 캐스케이드 최대 깊이를 제한한다(보통 2~3단계). 전체 타임아웃을 설정하고, 타임아웃 발생 시 현재까지의 최선 응답을 반환한다. 캐스케이드 깊이 분포를 모니터링하여 평균 깊이가 1.5를 초과하면 라우터를 재보정한다.

3. 프로바이더 장애 (Provider Outage)

특정 모델 프로바이더(OpenAI, Anthropic, Google 등)에 장애가 발생하면 해당 프로바이더를 사용하는 모든 라우팅 경로가 중단된다.

복구 전략: 프로바이더별 헬스체크를 구현하고, 장애 감지 시 동일 티어의 대체 모델로 자동 폴백한다. 서킷 브레이커 패턴을 적용하여 연속 실패 시 해당 프로바이더를 일시적으로 차단한다.

4. 품질 판정기 드리프트

FrugalGPT의 품질 판정기가 시간이 지남에 따라 부정확해지는 현상이다. 모델 업데이트, 데이터 분포 변화, 판정기 자체의 편향 등이 원인이다.

복구 전략: 주기적으로 인간 평가자의 라벨과 판정기 점수를 비교하여 드리프트를 감지한다. 판정기 모델도 주기적으로 재학습하거나, A/B 테스트로 판정기 버전을 비교한다.

서킷 브레이커 패턴 구현

# 프로바이더별 서킷 브레이커 구현
import time
from enum import Enum
from threading import Lock


class CircuitState(Enum):
    CLOSED = "closed"      # 정상 상태
    OPEN = "open"          # 차단 상태
    HALF_OPEN = "half_open"  # 시험 상태


class CircuitBreaker:
    def __init__(
        self,
        failure_threshold: int = 5,
        recovery_timeout: int = 60,
        half_open_max_calls: int = 3,
    ):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.half_open_max_calls = half_open_max_calls

        self.state = CircuitState.CLOSED
        self.failure_count = 0
        self.success_count = 0
        self.last_failure_time = 0.0
        self.half_open_calls = 0
        self._lock = Lock()

    def can_execute(self) -> bool:
        """현재 상태에서 요청 실행 가능 여부"""
        with self._lock:
            if self.state == CircuitState.CLOSED:
                return True

            if self.state == CircuitState.OPEN:
                # 복구 타임아웃 경과 시 HALF_OPEN으로 전환
                if time.time() - self.last_failure_time >= self.recovery_timeout:
                    self.state = CircuitState.HALF_OPEN
                    self.half_open_calls = 0
                    return True
                return False

            if self.state == CircuitState.HALF_OPEN:
                return self.half_open_calls < self.half_open_max_calls

            return False

    def record_success(self):
        """성공 기록"""
        with self._lock:
            if self.state == CircuitState.HALF_OPEN:
                self.success_count += 1
                if self.success_count >= self.half_open_max_calls:
                    self.state = CircuitState.CLOSED
                    self.failure_count = 0
                    self.success_count = 0
            elif self.state == CircuitState.CLOSED:
                self.failure_count = 0

    def record_failure(self):
        """실패 기록"""
        with self._lock:
            self.failure_count += 1
            self.last_failure_time = time.time()

            if self.state == CircuitState.HALF_OPEN:
                self.state = CircuitState.OPEN  # 다시 차단
            elif self.failure_count >= self.failure_threshold:
                self.state = CircuitState.OPEN


# 프로바이더별 서킷 브레이커 관리
provider_circuits: dict[str, CircuitBreaker] = {
    "openai": CircuitBreaker(failure_threshold=5, recovery_timeout=60),
    "anthropic": CircuitBreaker(failure_threshold=5, recovery_timeout=60),
    "google": CircuitBreaker(failure_threshold=3, recovery_timeout=120),
}

# 동일 티어 폴백 매핑
FALLBACK_MAP = {
    "gpt-4o-mini": ["claude-3-5-haiku-20241022"],
    "claude-3-5-haiku-20241022": ["gpt-4o-mini"],
    "claude-sonnet-4-20250514": ["gpt-4o"],
    "gpt-4o": ["claude-sonnet-4-20250514"],
}


def get_available_model(
    primary_model: str,
    primary_provider: str,
) -> tuple[str, str]:
    """사용 가능한 모델 반환 (서킷 브레이커 확인 포함)"""
    # 1차: 기본 모델의 프로바이더 확인
    if provider_circuits[primary_provider].can_execute():
        return primary_model, primary_provider

    # 2차: 폴백 모델 탐색
    fallbacks = FALLBACK_MAP.get(primary_model, [])
    for fb_model in fallbacks:
        fb_provider = "anthropic" if "claude" in fb_model else "openai"
        if provider_circuits[fb_provider].can_execute():
            return fb_model, fb_provider

    # 3차: 모든 프로바이더 장애 시 에러
    raise RuntimeError(
        f"모든 프로바이더 서킷 OPEN 상태. "
        f"primary={primary_model}, fallbacks={fallbacks}"
    )

트러블슈팅 가이드

라우팅 품질 저하 진단

증상: 사용자 불만족도 증가, 재시도율 상승, 특정 쿼리 유형에서 품질 급락

진단 절차는 다음과 같다.

  1. 라우팅 분포 확인: 각 모델 티어별 트래픽 비율이 예상 범위인지 확인한다. 소형 모델 비율이 80%를 초과하면 라우터가 과도하게 비용 최적화되어 있을 가능성이 높다.

  2. 오분류율 측정: 무작위 샘플링으로 100~500건의 쿼리를 인간 평가자가 재분류하고, 라우터 분류 결과와 비교한다. 오분류율이 15%를 초과하면 라우터 재학습이 필요하다.

  3. 티어별 품질 점수 비교: 각 티어에서 처리된 쿼리의 평균 품질 점수를 비교한다. 소형 모델 티어의 평균 점수가 0.7 미만이면 임계값 조정이 필요하다.

  4. 캐스케이드 깊이 분석: 평균 캐스케이드 깊이가 1.5를 초과하면 1차 라우팅의 정확도가 낮다는 의미다.

비용 급증 원인 분석

증상: 일별 비용이 갑자기 2배 이상 증가

확인 사항은 다음과 같다.

  1. 트래픽 급증: 전체 요청량이 증가했는지 확인한다.
  2. 라우팅 분포 이동: 대형 모델 비율이 급증했는지 확인한다. 라우터 업데이트나 쿼리 분포 변화가 원인일 수 있다.
  3. 캐시 적중률 하락: 캐시 TTL 만료, 캐시 서버 장애, 새로운 유형의 쿼리 유입 등을 확인한다.
  4. 캐스케이드 루프: 품질 판정기 오작동으로 모든 쿼리가 최상위 모델까지 에스컬레이션되는 경우를 확인한다.
  5. 프롬프트 팽창: 시스템 프롬프트나 컨텍스트가 비정상적으로 커진 경우를 확인한다.

레이턴시 최적화

라우팅 레이어 자체의 레이턴시를 최소화하는 것이 중요하다. 라우터가 50ms 이상 걸리면 사용자 체감 성능에 영향을 준다.

최적화 방법은 다음과 같다. 규칙 기반 분류를 1차로 수행하여 70%의 쿼리를 즉시 라우팅한다(레이턴시 < 1ms). 나머지 30%에 대해서만 임베딩 기반 또는 LLM 기반 분류를 수행한다. 분류기 모델은 가능하면 로컬에 배포하여 네트워크 레이턴시를 제거한다. Semantic Router의 임베딩 연산은 배치 처리하여 처리량을 높인다.

운영 주의사항

비용-품질 트레이드오프 관리

라우팅 전략을 도입하면 비용과 품질 사이에 지속적인 긴장이 발생한다. 비용을 공격적으로 줄이면 품질이 하락하고, 품질을 높이면 라우팅의 비용 절감 효과가 감소한다.

운영 원칙은 다음과 같다.

  • SLA 기반 임계값 설정: 비용 절감 목표가 아닌 품질 SLA(예: 95%ile 만족도 4.0 이상)를 기준으로 라우팅 임계값을 설정한다. 비용 절감은 결과이지 목표가 아니다.
  • 점진적 롤아웃: 새 라우팅 정책은 전체 트래픽의 5% → 20% → 50% → 100%로 단계적으로 적용한다. 각 단계에서 품질 지표를 확인한다.
  • 자동 롤백 기준: 품질 점수가 기준 대비 10% 이상 하락하면 자동으로 이전 라우팅 정책으로 롤백한다.

모델 업데이트 대응

LLM 프로바이더가 모델을 업데이트(GPT-4o → GPT-4o-2024-11-20 등)하면 라우터의 학습 데이터와 현재 모델 동작이 불일치할 수 있다. 모델 업데이트 시에는 다음 절차를 따른다.

  1. 스테이징 환경에서 새 모델 버전의 벤치마크를 수행한다.
  2. 라우터의 모델 프로필(비용, 성능 특성)을 업데이트한다.
  3. A/B 테스트로 기존 라우팅과 새 모델 기반 라우팅을 비교한다.
  4. 유의미한 차이가 있으면 라우터를 재학습한다.

멀티 프로바이더 API 키 관리

여러 프로바이더를 사용하면 API 키 관리 복잡도가 증가한다. 환경 변수, 시크릿 매니저, 키 로테이션 등을 체계적으로 관리해야 한다.

// 안전한 멀티 프로바이더 API 키 관리
// provider-config.ts
import { SecretManagerServiceClient } from '@google-cloud/secret-manager'

interface ProviderCredentials {
  apiKey: string
  orgId?: string
  rateLimit: number // RPM
  lastRotated: Date
}

class ProviderKeyManager {
  private secretClient: SecretManagerServiceClient
  private cache: Map<string, ProviderCredentials> = new Map()
  private cacheTTL = 300_000 // 5분
  private lastFetch: Map<string, number> = new Map()

  constructor() {
    this.secretClient = new SecretManagerServiceClient()
  }

  async getCredentials(provider: string): Promise<ProviderCredentials> {
    const now = Date.now()
    const lastFetched = this.lastFetch.get(provider) || 0

    // 캐시가 유효하면 캐시에서 반환
    if (now - lastFetched < this.cacheTTL && this.cache.has(provider)) {
      return this.cache.get(provider)!
    }

    // Secret Manager에서 키 조회
    const secretName = `projects/my-project/secrets/llm-${provider}-key/versions/latest`
    const [version] = await this.secretClient.accessSecretVersion({
      name: secretName,
    })

    const apiKey = version.payload?.data?.toString() || ''
    const credentials: ProviderCredentials = {
      apiKey,
      rateLimit: this.getDefaultRateLimit(provider),
      lastRotated: new Date(),
    }

    this.cache.set(provider, credentials)
    this.lastFetch.set(provider, now)

    return credentials
  }

  private getDefaultRateLimit(provider: string): number {
    const limits: Record<string, number> = {
      openai: 500,
      anthropic: 300,
      google: 200,
    }
    return limits[provider] || 100
  }
}

프로덕션 체크리스트

도입 전 체크리스트

  • 현재 LLM 비용 구조 분석 완료 (모델별, 기능별, 시간대별)
  • 쿼리 복잡도 분포 분석 완료 (단순/중간/복잡 비율)
  • 품질 SLA 정의 완료 (최소 품질 점수, 최대 레이턴시)
  • 라우팅 전략 선택 (규칙 기반 / 학습 기반 / 캐스케이드 / 하이브리드)
  • 후보 모델 카탈로그 구성 (최소 3개 모델, 가격-성능 분산)
  • 벤치마크 데이터셋 구축 (도메인별 100+ 쿼리, 기대 품질 라벨)
  • 비용 절감 목표 설정 (baseline 대비 목표 절감률)

구현 체크리스트

  • 라우터 모듈 구현 및 단위 테스트
  • 프로바이더별 서킷 브레이커 적용
  • 시맨틱 캐시 구축 및 TTL 설정
  • 비용 추적 메트릭 수집 파이프라인 구축
  • 품질 판정기 구현 및 캘리브레이션
  • 폴백 체인 설정 (프로바이더 장애 시 대체 경로)
  • 레이트 리밋 관리 (프로바이더별 RPM/TPM 제한 준수)
  • 로깅: 라우팅 결정, 모델 응답, 비용, 레이턴시 기록

운영 체크리스트

  • 일별 비용 리포트 대시보드 구축
  • 라우팅 분포 모니터링 (티어별 트래픽 비율)
  • 품질 점수 추이 모니터링 (7일 이동 평균)
  • 캐시 적중률 모니터링 (목표: 20~40%)
  • 캐스케이드 깊이 분포 모니터링 (평균 < 1.5)
  • 서킷 브레이커 상태 알림 설정
  • 주간 오분류 샘플 리뷰 (50~100건)
  • 월간 라우터 재학습 / 임계값 재보정
  • 모델 업데이트 시 벤치마크 재실행 프로세스
  • 비용 급증 자동 알림 (전일 대비 50% 이상 증가)

성능 목표 가이드라인

지표목표값위험 임계값
비용 절감률 (baseline 대비)40~70%< 20%
품질 유지율 (baseline 대비)> 93%< 88%
라우터 레이턴시< 15ms> 50ms
캐시 적중률25~40%< 10%
평균 캐스케이드 깊이< 1.3> 1.8
프로바이더 가용성> 99.5%< 98%
오분류율< 10%> 20%

xRouter와 강화학습 기반 라우팅의 미래

최근 연구에서 주목할 만한 접근법은 xRouter다. xRouter는 라우팅을 순차적 의사결정 문제로 정식화하고, 강화학습(RL)으로 라우터를 학습한다. 기존 방식이 쿼리와 모델 간의 정적 매핑을 학습하는 반면, xRouter는 비용 예산 제약 하에서 전체 세션의 누적 보상을 최대화하는 정책을 학습한다.

이 접근법의 핵심은 비용을 명시적 제약으로 모델링한다는 점이다. "총 비용 $X 이하에서 품질을 최대화하라"는 목표를 직접 최적화하므로, 예산 내에서 자동으로 비용-품질 트레이드오프를 조절한다. 비용 예산이 넉넉할 때는 대형 모델을 적극 활용하고, 예산이 소진되어 갈 때는 소형 모델을 더 많이 사용하는 적응적 행동을 보인다.

Pick and Spin 프레임워크도 주목할 만하다. Kubernetes 기반 셀프호스팅 LLM 환경에서 적응형 scale-to-zero 자동화와 하이브리드 라우팅 모듈을 결합하여, 정적 배포 대비 21.6% 높은 성공률, 30% 낮은 레이턴시, 33% 낮은 GPU 비용을 달성했다.

에이전틱 AI 워크플로우에서는 라우팅의 중요성이 더욱 커진다. 에이전트가 여러 단계를 순차적으로 실행하는 구조에서 각 단계마다 오류가 누적되므로, 단계별로 최적 모델을 선택하는 라우팅이 전체 성공률에 결정적 영향을 미친다. Martian의 Expert Orchestration AI Architecture는 "판사(judge)" 모델이 "전문가(expert)" 모델의 능력을 평가하고, 라우터가 가장 신뢰할 수 있는 전문가에게 쿼리를 할당하는 구조를 제안한다.

실전 도입 시나리오별 가이드

시나리오 1: SaaS 챗봇 서비스 (월 비용 5,0005,000 → 1,500 목표)

가장 일반적인 도입 시나리오다. 고객 지원 챗봇에서 전체 쿼리의 70%가 FAQ 수준의 단순 질의인 경우가 많다.

추천 전략은 다음과 같다. Semantic Router로 FAQ/일반/전문 3가지 라우트를 정의한다. FAQ는 캐시 + GPT-4o-mini, 일반은 Claude Haiku, 전문은 Claude Sonnet으로 라우팅한다. 시맨틱 캐시를 적용하여 FAQ 반복 쿼리의 캐시 적중률 50% 이상을 목표로 한다. 이 구성으로 일반적으로 60~70%의 비용 절감을 달성할 수 있다.

시나리오 2: 코드 리뷰 도구 (품질 > 비용)

코드 분석은 정확도가 핵심이므로 공격적인 비용 절감보다 품질 보장에 초점을 맞춘다.

추천 전략은 다음과 같다. 규칙 기반 분류로 변경 줄 수와 파일 유형에 따라 라우팅한다. 10줄 미만의 단순 변경은 GPT-4o-mini로, 50줄 이상의 복합 변경이나 보안 관련 파일은 GPT-4o 또는 Claude Opus로 라우팅한다. 캐스케이드는 2단계로 제한하고, 첫 번째 모델의 응답에서 "확신 없음", "추가 검토 필요" 등의 불확실성 마커가 있으면 상위 모델로 에스컬레이션한다.

시나리오 3: RAG 파이프라인 (대량 문서 처리)

RAG 시스템에서는 검색된 문서 chunk 수와 쿼리 복잡도에 따라 필요한 모델 성능이 달라진다.

추천 전략은 다음과 같다. 검색 결과가 1~2개 chunk이고 직접 답변 가능한 경우 GPT-4o-mini로 처리한다. 5개 이상 chunk를 종합해야 하거나 비교/분석이 필요한 경우 중형 모델을 사용한다. 다중 문서 간 충돌 해결이나 추론이 필요한 경우 대형 모델을 사용한다. 문서 요약과 임베딩 생성은 항상 소형 모델로 처리한다.

참고자료