LLM 추론 비용 70% 절감 실전: 모델 라우팅·프롬프트 캐싱·배치 API·시맨틱 캐시 조합 설계

월 800만 원짜리 API 청구서를 받아든 날
2025년 4분기, 우리 팀의 LLM API 청구액이 처음으로 800만 원을 돌파했습니다. 서비스가 성장하고 있다는 신호이기도 했지만, 동시에 "이대로 두면 스케일이 두 배가 될 때 비용도 두 배가 되는 구조"라는 사실을 직시해야 했습니다. 애플리케이션 코드를 뜯어보니 문제는 명확했습니다. 모든 요청이 동일하게 가장 비싼 모델로, 시스템 프롬프트를 매번 새로 전송하며, 동의어 수준의 중복 요청조차 캐시 없이 반복 호출되고 있었습니다.
그로부터 두 달 뒤, 같은 트래픽에서 월 청구액은 220만 원으로 떨어졌습니다. 코드 품질이나 응답 정확도의 저하 없이 말입니다. 이 글은 그 과정에서 실무적으로 검증한 네 가지 레버—모델 라우팅, 프롬프트 캐싱, 배치 API, 시맨틱 캐시—를 조합하는 방법을 단계적으로 설명합니다. 비용 절감 외에도 LLM 평가 파이프라인을 CI에 통합하는 방법과 함께 읽으면 품질 회귀 없이 절감하는 전체 그림이 완성됩니다.
1. LLM 운영 비용 구조 — 입력·출력 토큰·캐시 적중·배치 할인의 가격 모델
비용을 줄이려면 먼저 무엇에 돈이 나가는지를 정확히 알아야 합니다. 2026년 5월 기준 주요 모델의 과금 구조는 토큰 단위 종량제입니다(가격은 제공사 정책에 따라 변동될 수 있으니 OpenAI 공식 가격 페이지와 Anthropic 콘솔에서 반드시 최신 정보를 확인하십시오).
| 모델 | 입력 토큰 ($/1M) | 출력 토큰 ($/1M) | 캐시 적중 할인 | 배치 할인 |
|---|---|---|---|---|
| Claude Haiku 3.5 | $0.80 | $4.00 | 최대 90% | 50% |
| Claude Sonnet 4 | $3.00 | $15.00 | 최대 90% | 50% |
| Claude Opus 4 | $15.00 | $75.00 | 최대 90% | 50% |
| GPT-4o mini | $0.15 | $0.60 | — | 50% |
| GPT-4o | $2.50 | $10.00 | — | 50% |
| text-embedding-3-small | $0.02 | — | — | 50% |
이 표에서 세 가지 사실이 즉각적으로 눈에 들어옵니다. 첫째, 출력 토큰 단가가 입력 토큰보다 3~5배 높습니다. 응답 길이를 통제하는 것이 생각보다 큰 레버입니다. 둘째, Anthropic의 프롬프트 캐싱은 캐시 적중 시 입력 토큰 비용을 최대 90%까지 줄여줍니다. 셋째, 배치 API는 모든 모델에서 50% 정률 할인이 적용됩니다.
우리 팀의 비용 구조를 분석했을 때, 전체 비용의 62%가 동일하거나 거의 동일한 시스템 프롬프트를 반복 전송하는 데서 발생하고 있었습니다. 시스템 프롬프트 캐싱만으로도 절반 이상의 비용이 움직인다는 의미입니다.
2. 토큰 절감의 첫 단추: 시스템 프롬프트·Few-shot 예시·문서를 캐시 가능한 형태로 재배치
Anthropic의 프롬프트 캐싱이 동작하려면 캐시 가능한 콘텐츠가 메시지의 앞부분(prefix)에 위치해야 합니다. 캐시는 최초 1,024 토큰 이상 블록에 대해서만 적용되며, 요청마다 달라지는 동적인 내용—사용자 입력, 세션 컨텍스트—은 캐시 블록 뒤에 배치해야 합니다.
가장 흔한 실수는 이 순서를 뒤집는 것입니다. 시스템 프롬프트 안에 사용자 이름이나 세션 ID 같은 동적 값을 인터폴레이션하면 매 요청이 새로운 캐시 키를 생성해 캐시 적중이 0%가 됩니다. 올바른 구조는 변하지 않는 정책 문서·few-shot 예시를 시스템 프롬프트의 앞 블록에 고정하고, 사용자별 맥락을 별도 메시지 턴으로 분리하는 것입니다. 이 재배치만으로 캐시 적중 가능한 토큰 볼륨이 극적으로 늘어납니다.
실무에서 캐시 가능한 블록을 설계할 때 체크하는 기준은 다음과 같습니다. "이 텍스트가 1,000번의 요청 동안 한 글자도 변하지 않는가?" — 그렇다면 캐시 블록 후보입니다. 내부 정책 문서, 예외 처리 규칙, few-shot 예시, OpenAPI 스펙 등이 여기에 해당합니다. RAG 기반 에이전트 구축에서 사용하는 청크 문서들도 마찬가지 원칙으로 캐시 가능 블록으로 묶을 수 있습니다.
3. Anthropic Prompt Caching 적용 패턴과 5분 TTL의 함정
Anthropic 공식 문서에 따르면 프롬프트 캐시의 TTL(Time To Live)은 기본 5분입니다. 이 5분 제한을 놓치면 캐시 히트율이 기대보다 크게 낮아질 수 있습니다. 특히 배치 처리나 야간 스케줄 작업처럼 요청 사이 간격이 길어지는 패턴에서는 TTL 내에 충분한 후속 요청이 발생하지 않아 캐시 효율이 떨어집니다.
아래 코드는 Anthropic SDK를 사용해 시스템 프롬프트와 장문 문서를 캐시 가능 블록으로 마킹하고, 응답 헤더에서 캐시 적중 여부를 로깅하는 실제 운영 코드입니다.
import anthropic
import logging
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger(__name__)
POLICY_DOCUMENT = """
[회사 환불·교환 정책 v3.2 — 2026년 1월 갱신]
1. 일반 상품: 구매일로부터 14일 이내 영수증 지참 시 전액 환불.
2. 전자 제품: 7일 이내 미개봉 시 전액 환불, 개봉 후 불량 시 교환.
3. 해외 직구 상품: 관세법상 반송 처리 제한으로 환불 불가, 불량품 한해 교환 가능.
4. 주문 제작 상품: 계약 성립 후 취소·환불 불가.
5. 디지털 콘텐츠: 다운로드 완료 이후 환불 불가, 사용 전 오류 시 재발급.
...이하 생략(실제 운영 시 2,000~5,000 토큰 분량의 정책 문서)
"""
FEW_SHOT_EXAMPLES = """
[예시 1]
사용자: 어제 산 블루투스 스피커 소리가 이상해요.
어시스턴트: 안녕하세요. 전자 제품 불량 관련 문의이시군요. 구매일이 7일 이내라면 교환 처리가 가능합니다.
[예시 2]
사용자: 3주 전에 산 티셔츠 환불할 수 있나요?
어시스턴트: 안타깝게도 일반 상품의 환불 기간은 구매일로부터 14일 이내입니다.
"""
@dataclass
class CacheMetrics:
cache_creation_input_tokens: int = 0
cache_read_input_tokens: int = 0
input_tokens: int = 0
output_tokens: int = 0
@property
def cache_hit_ratio(self) -> float:
total_cacheable = self.cache_creation_input_tokens + self.cache_read_input_tokens
if total_cacheable == 0:
return 0.0
return self.cache_read_input_tokens / total_cacheable
@property
def estimated_cost_usd(self) -> float:
"""Claude Sonnet 4 기준 추정 비용 (2026년 5월 가격 기준, 변동 가능)"""
return (
self.cache_creation_input_tokens * 3.75 / 1_000_000
+ self.cache_read_input_tokens * 0.30 / 1_000_000
+ self.input_tokens * 3.00 / 1_000_000
+ self.output_tokens * 15.00 / 1_000_000
)
def create_customer_support_message(user_query: str) -> dict:
"""캐시 가능한 블록을 앞에 배치하고 동적 쿼리를 마지막 user 메시지로 분리."""
return {
"model": "claude-sonnet-4-5",
"max_tokens": 1024,
"system": [
{
"type": "text",
"text": "당신은 쇼핑몰 고객 응대 전문 어시스턴트입니다. 아래 정책 문서와 예시에만 근거하여 답변하십시오.",
},
{
"type": "text",
"text": POLICY_DOCUMENT,
"cache_control": {"type": "ephemeral"},
},
{
"type": "text",
"text": FEW_SHOT_EXAMPLES,
"cache_control": {"type": "ephemeral"},
},
],
"messages": [
{"role": "user", "content": user_query},
],
}
def call_with_cache_logging(
client: anthropic.Anthropic,
user_query: str,
) -> tuple[str, CacheMetrics]:
payload = create_customer_support_message(user_query)
response = client.messages.create(**payload)
usage = response.usage
metrics = CacheMetrics(
cache_creation_input_tokens=getattr(usage, "cache_creation_input_tokens", 0),
cache_read_input_tokens=getattr(usage, "cache_read_input_tokens", 0),
input_tokens=usage.input_tokens,
output_tokens=usage.output_tokens,
)
logger.info(
"prompt_cache | hit_ratio=%.2f | cache_read=%d | cache_write=%d | "
"input=%d | output=%d | est_cost_usd=%.4f",
metrics.cache_hit_ratio,
metrics.cache_read_input_tokens,
metrics.cache_creation_input_tokens,
metrics.input_tokens,
metrics.output_tokens,
metrics.estimated_cost_usd,
)
answer = response.content[0].text
return answer, metrics
5분 TTL 함정을 피하려면 워밍업 요청(warmup request) 전략이 필요합니다. 트래픽이 적은 새벽 시간대에도 4분 30초마다 더미 요청을 보내 캐시를 갱신하는 방식입니다. 다소 역설적으로 들리지만, 캐시 생성 비용($3.75/1M)은 일반 입력 비용($3.00/1M)보다 약간 높은 반면 캐시 히트 비용($0.30/1M)은 10분의 1 수준이므로, 히트율 90% 이상을 유지하면 워밍업 비용을 감안해도 충분히 수지가 맞습니다.
4. 모델 라우팅 설계 — 작업 난이도 기준으로 Haiku·Sonnet·Opus 분기
모든 요청을 Opus 4로 처리하는 것은 모든 배달에 퀵서비스를 쓰는 것과 같습니다. 우리 팀의 요청 로그를 분석한 결과, 전체 요청의 약 58%가 의도 분류, 간단한 FAQ 응답, 감정 분석처럼 Haiku 3.5 수준에서 충분히 처리할 수 있는 작업이었습니다. Opus 4가 필요한 복잡한 다단계 추론은 전체의 12% 미만이었습니다.
라우팅 결정은 크게 두 단계로 이루어집니다. 첫째, 요청 텍스트의 복잡도 신호를 추출합니다. 둘째, 신호를 기반으로 모델과 호출 방식을 결정합니다. 복잡도 신호로는 입력 토큰 수, 문장 내 절 개수, 전문 용어 밀도, 다단계 지시 여부, 코드·수식 포함 여부 등을 사용합니다. 이 신호들을 직접 규칙 기반으로 처리하거나, Haiku에게 "이 요청은 쉬운가 어려운가"를 먼저 묻는 계층형 라우팅(hierarchical routing) 방식을 씁니다.
import anthropic
import re
from enum import Enum
from dataclasses import dataclass
class ModelTier(Enum):
EMBEDDING = "text-embedding-3-small"
FAST = "claude-haiku-4-5"
BALANCED = "claude-sonnet-4-5"
POWERFUL = "claude-opus-4-5"
@dataclass
class RoutingDecision:
tier: ModelTier
reason: str
estimated_tokens: int
class LLMRouter:
SIMPLE_PATTERNS = re.compile(
r"(분류|카테고리|태그|감정|긍정|부정|중립|번역|요약|"
r"FAQ|자주 묻는|예약 확인|주문 상태|배송 조회)",
re.IGNORECASE,
)
COMPLEX_PATTERNS = re.compile(
r"(분석|전략|설계|아키텍처|최적화|비교 검토|법적|계약|"
r"코드 작성|디버깅|수학|증명|다단계|step.by.step)",
re.IGNORECASE,
)
def __init__(self, anthropic_client: anthropic.Anthropic):
self.client = anthropic_client
self._routing_log: list[RoutingDecision] = []
def _estimate_tokens(self, text: str) -> int:
return int(len(text) * 0.6)
def route(self, user_input: str, context: dict | None = None) -> RoutingDecision:
token_estimate = self._estimate_tokens(user_input)
context = context or {}
if context.get("task_type") == "embedding":
decision = RoutingDecision(
tier=ModelTier.EMBEDDING,
reason="임베딩 전용 작업으로 분류",
estimated_tokens=token_estimate,
)
elif token_estimate < 80 and self.SIMPLE_PATTERNS.search(user_input):
decision = RoutingDecision(
tier=ModelTier.FAST,
reason=f"단순 패턴 매칭 + 토큰 {token_estimate} < 80",
estimated_tokens=token_estimate,
)
elif self.COMPLEX_PATTERNS.search(user_input) or token_estimate > 600:
tier = self._hierarchical_classify(user_input)
decision = RoutingDecision(
tier=tier,
reason="복잡 패턴 감지 → 계층형 분류기 위임",
estimated_tokens=token_estimate,
)
else:
decision = RoutingDecision(
tier=ModelTier.BALANCED,
reason="기본 중간 난이도 라우팅",
estimated_tokens=token_estimate,
)
self._routing_log.append(decision)
return decision
def _hierarchical_classify(self, text: str) -> ModelTier:
classify_prompt = (
"다음 요청의 복잡도를 평가하여 SIMPLE / MEDIUM / COMPLEX 중 하나만 반환하세요.\n"
"SIMPLE: 단순 질의, 단답, 분류\n"
"MEDIUM: 요약·번역·중간 길이 생성\n"
"COMPLEX: 다단계 추론, 코드 작성, 전문 분석\n\n"
f"요청: {text[:300]}"
)
resp = self.client.messages.create(
model="claude-haiku-4-5",
max_tokens=10,
messages=[{"role": "user", "content": classify_prompt}],
)
label = resp.content[0].text.strip().upper()
tier_map = {
"SIMPLE": ModelTier.FAST,
"MEDIUM": ModelTier.BALANCED,
"COMPLEX": ModelTier.POWERFUL,
}
return tier_map.get(label, ModelTier.BALANCED)
def get_routing_stats(self) -> dict:
from collections import Counter
counts = Counter(d.tier.name for d in self._routing_log)
total = len(self._routing_log)
return {
tier: {"count": cnt, "ratio": round(cnt / total, 3) if total else 0}
for tier, cnt in counts.items()
}
우리 팀 기준으로 이 라우터를 도입한 후 라우팅 분포는 FAST 61%, BALANCED 27%, POWERFUL 12%로 안정화되었습니다. 이전에 모든 요청이 Sonnet 이상 모델로 처리되던 구조 대비 평균 모델 비용이 요청당 약 73% 감소했습니다.
| 라우팅 전략 | 평균 요청 비용 | 응답 품질(내부 EVALS) | P95 지연(ms) |
|---|---|---|---|
| 단일 Sonnet (도입 전) | $0.0041 | 87.3점 | 2,100 |
| 모델 라우팅 (도입 후) | $0.0011 | 86.8점 | 1,480 |
| 라우팅 + 캐싱 | $0.0006 | 86.8점 | 890 |
| 라우팅 + 캐싱 + 배치 | $0.00035 | 86.9점 | — (비동기) |
5. 시맨틱 캐시(Embedding 기반) — 의미가 비슷한 요청을 재사용
"배송은 얼마나 걸리나요?"와 "배달 기간이 어떻게 되나요?"는 어휘는 다르지만 의미는 동일합니다. 단순 문자열 해시 기반 캐시는 이 두 요청을 서로 다른 키로 처리합니다. 시맨틱 캐시는 요청을 임베딩 벡터로 변환하고, Redis에 저장된 기존 임베딩과의 코사인 유사도를 계산해 일정 임계값 이상이면 캐시된 응답을 반환합니다.
임계값 설정이 핵심입니다. 0.95 이상으로 설정하면 캐시 히트율이 낮고, 0.80 이하로 낮추면 의미가 다른 요청에 잘못된 응답을 반환하는 오탐(false positive)이 발생합니다. 우리 팀에서는 도메인별 골든 데이터셋을 기준으로 임계값을 0.88~0.92 사이에서 튜닝했고, 최종적으로 0.90이 정확도와 히트율의 균형점이었습니다.
import json
import hashlib
import numpy as np
import redis
from openai import OpenAI
import anthropic
from typing import Optional
from datetime import datetime, timezone
SEMANTIC_CACHE_TTL = 3600 * 24
SIMILARITY_THRESHOLD = 0.90
EMBEDDING_MODEL = "text-embedding-3-small"
CACHE_KEY_PREFIX = "sem_cache:"
INDEX_KEY = "sem_cache:index"
class SemanticCache:
"""
Redis 기반 시맨틱 캐시.
요청 임베딩 ↔ 저장된 임베딩 코사인 유사도 >= threshold이면 캐시 히트.
"""
def __init__(
self,
redis_url: str = "redis://localhost:6379",
openai_client: Optional[OpenAI] = None,
threshold: float = SIMILARITY_THRESHOLD,
):
self.redis = redis.from_url(redis_url, decode_responses=False)
self.openai = openai_client or OpenAI()
self.threshold = threshold
def _embed(self, text: str) -> np.ndarray:
response = self.openai.embeddings.create(model=EMBEDDING_MODEL, input=text)
vec = np.array(response.data[0].embedding, dtype=np.float32)
return vec / np.linalg.norm(vec)
def _cosine_similarity(self, a: np.ndarray, b: np.ndarray) -> float:
return float(np.dot(a, b))
def get(self, query: str) -> Optional[dict]:
query_vec = self._embed(query)
cached_keys = self.redis.smembers(INDEX_KEY)
best_score = 0.0
best_key: Optional[bytes] = None
for key in cached_keys:
raw = self.redis.hget(key, "embedding")
if raw is None:
continue
stored_vec = np.frombuffer(raw, dtype=np.float32)
score = self._cosine_similarity(query_vec, stored_vec)
if score > best_score:
best_score = score
best_key = key
if best_score >= self.threshold and best_key is not None:
payload = self.redis.hget(best_key, "response")
if payload:
result = json.loads(payload)
result["_cache_hit"] = True
result["_similarity"] = round(best_score, 4)
return result
return None
def set(self, query: str, response: dict) -> None:
query_vec = self._embed(query)
cache_id = hashlib.sha256(query.encode()).hexdigest()[:16]
key = f"{CACHE_KEY_PREFIX}{cache_id}".encode()
pipe = self.redis.pipeline()
pipe.hset(key, mapping={
"query": query.encode("utf-8"),
"embedding": query_vec.tobytes(),
"response": json.dumps(response, ensure_ascii=False).encode("utf-8"),
"created_at": datetime.now(timezone.utc).isoformat().encode(),
})
pipe.expire(key, SEMANTIC_CACHE_TTL)
pipe.sadd(INDEX_KEY, key)
pipe.execute()
class BatchQueueWorker:
"""
24시간 대기 가능한 작업을 Anthropic Message Batches API로 처리.
시맨틱 캐시를 먼저 확인하고, 미스 시 배치 큐에 적재.
"""
def __init__(
self,
anthropic_client: anthropic.Anthropic,
semantic_cache: SemanticCache,
):
self.client = anthropic_client
self.cache = semantic_cache
self._pending: list[dict] = []
def submit(self, request_id: str, query: str, system_prompt: str) -> Optional[str]:
cached = self.cache.get(query)
if cached:
return cached["text"]
self._pending.append({
"custom_id": request_id,
"params": {
"model": "claude-sonnet-4-5",
"max_tokens": 1024,
"system": system_prompt,
"messages": [{"role": "user", "content": query}],
},
})
return None
def flush_batch(self) -> str:
if not self._pending:
raise ValueError("배치 큐가 비어 있습니다.")
batch = self.client.messages.batches.create(requests=self._pending)
self._pending.clear()
return batch.id
6. Batch API로 24시간 대기 가능한 작업 50% 할인 받기
Anthropic Message Batches API와 OpenAI Batch API는 공통적으로 24시간 이내 처리를 보장하는 대신 50% 할인을 제공합니다. 두 API 모두 비동기 방식으로 동작하며, 폴링 또는 웹훅으로 완료 상태를 확인합니다.
배치 API에 적합한 작업의 기준은 결과가 필요한 시점이 즉각적이지 않은 모든 작업입니다. 대표적인 사례는 다음과 같습니다.
- 야간 대량 문서 요약·분류 (수만 건 규모의 상품 설명 태깅 등)
- 주간 고객 피드백 감정 분석 리포트 생성
- 콘텐츠 SEO 메타 설명 일괄 생성
- 에이전트 하네스 파이프라인의 오프라인 평가 실행
배치 API를 도입할 때 가장 흔한 실수는 실시간 응답이 필요한 사용자 대화를 배치로 처리하려는 것입니다. 사용자가 기다리는 인터랙티브 요청은 반드시 동기 API를 사용해야 합니다. 배치 적합성 판단 기준을 업스트림에서 명시적으로 태깅하는 것이 안전합니다.
우리 팀의 경우 전체 API 호출 중 배치로 전환 가능한 비율이 약 34%였고, 이 전환만으로 월 약 120만 원을 추가 절감했습니다.
7. 스트리밍 응답 최적화 — 사용자 인지 지연 vs. 실제 토큰 절감
스트리밍(streaming)은 첫 토큰 응답 시간(TTFT, Time To First Token)을 줄여 사용자가 응답을 빠르게 느끼게 합니다. 그러나 스트리밍 자체는 토큰 비용을 줄이지 않습니다. 스트리밍 여부와 관계없이 생성된 토큰 수는 동일하게 과금됩니다.
비용 관점에서 스트리밍이 오히려 불리해지는 경우가 있습니다. 스트리밍 응답 중 클라이언트가 연결을 끊으면, 서버 측에서는 이미 생성된 토큰이 과금되지만 사용자는 결과를 받지 못합니다. 모바일 환경이나 불안정한 네트워크에서 특히 문제가 됩니다. 이를 방지하기 위해 일정 길이 이하의 응답(예: 예상 출력 256토큰 미만)은 스트리밍을 비활성화하고 완전한 응답이 생성된 후 전달하는 적응형 스트리밍(adaptive streaming) 전략이 효과적입니다.
응답 길이 자체를 줄이는 것도 중요합니다. max_tokens 파라미터를 실제 필요한 최대 길이보다 크게 설정하면, 모델이 불필요하게 장황한 응답을 생성합니다. 작업별 평균 출력 토큰을 측정하고 max_tokens를 95th 백분위수 기준으로 설정하면 평균 출력 토큰을 15~25% 줄일 수 있습니다.
8. Fine-tuning vs. RAG vs. 더 큰 모델 — 비용 관점의 의사결정
"성능이 부족하다"는 문제를 해결하는 방법으로 흔히 언급되는 세 가지 경로를 비용 관점에서 비교합니다.
| 접근법 | 초기 비용 | 런타임 비용 | 적합한 경우 | 주의점 |
|---|---|---|---|---|
| 더 큰 모델로 교체 | 없음 | 높음 (3~20배) | 즉각 성능 개선 필요 | 비용 지속 증가 |
| RAG 추가 | 중간 (인프라) | 임베딩 비용 추가 | 최신 정보·내부 문서 필요 | 청킹·검색 품질 관리 |
| Fine-tuning | 높음 (학습비) | 낮음 (소형 모델 가능) | 특정 포맷·스타일 고정 | 데이터 준비 비용 |
Fine-tuning은 비용 절감 수단으로 자주 오해됩니다. 실제로는 학습 비용이 선행되어야 하며, 학습 데이터가 충분하지 않으면 기반 모델보다 품질이 떨어질 수 있습니다. "Sonnet 수준의 성능을 Haiku 비용으로"는 이상적인 목표지만, 이를 달성하려면 도메인 특화 학습 데이터 1,000건 이상과 체계적인 평가 파이프라인이 선행 조건입니다.
대부분의 비용 문제는 Fine-tuning보다 프롬프트 엔지니어링, 모델 라우팅, 캐싱으로 먼저 해결 가능합니다. RAG 아키텍처에 대한 더 자세한 내용은 RAG 완벽 가이드에서 확인할 수 있습니다.
9. 비용·품질 모니터링 대시보드 구축 — 라우팅 분포·캐시 Hit-ratio·P95 Latency
최적화 작업이 지속적인 효과를 내려면 측정이 필수입니다. 우리 팀이 운영하는 모니터링 지표 체계는 크게 세 레이어로 구성됩니다.
비용 레이어: 모델 티어별 일일 토큰 소비량, 캐시 적중률, 배치 비율을 트래킹합니다. 캐시 적중률이 48시간 연속으로 70% 미만으로 떨어지면 알림이 발생하도록 설정했습니다. 대부분의 경우 TTL 내 트래픽 감소나 시스템 프롬프트 변경이 원인입니다.
품질 레이어: 라우팅 결정의 정확도를 주간 단위로 샘플링 평가합니다. FAST 티어로 라우팅된 요청 중 응답 품질이 기준 이하인 케이스 비율(under-routing rate)과, POWERFUL 티어로 과도 라우팅된 케이스 비율(over-routing rate)을 별도로 추적합니다. 이 두 지표의 균형이 라우터 임계값 튜닝의 근거가 됩니다.
레이턴시 레이어: 모델 티어별 P50·P95·P99 레이턴시를 분리 측정합니다. 캐시 히트 요청은 임베딩 계산 시간(통상 50~100ms)만 소요되므로 P95가 200ms 이내입니다. 동기 API 호출은 P95 기준 FAST 800ms, BALANCED 2,000ms, POWERFUL 5,000ms를 SLO(Service Level Objective)로 설정합니다.
모니터링 데이터는 Prometheus + Grafana 조합으로 수집·시각화하며, 각 API 호출마다 다음 메타데이터를 structured log로 남깁니다: request_id, model_tier, cache_hit, cache_similarity, is_batch, input_tokens, output_tokens, latency_ms, estimated_cost_usd.
10. 결론: 우리 팀이 800만 원에서 220만 원으로 줄인 9단계 체크리스트
두 달간의 최적화 작업을 돌아보면, 단 하나의 마법 같은 해결책은 없었습니다. 각 레버가 서로 다른 비용 구조에 작용하며, 조합했을 때 상승 효과가 발생합니다. 아래는 우리 팀이 실제로 밟은 순서이자, 비슷한 상황에 있는 팀에 권장하는 실무 체크리스트입니다.
실무 체크리스트
- 1단계 — 비용 구조 분석: 모델 티어별·엔드포인트별 토큰 소비량을 분리 측정한다. "어디서 돈이 나가는지 모른다"는 상태로는 최적화가 불가능합니다.
- 2단계 — 시스템 프롬프트 재배치: 캐시 가능한 정적 콘텐츠(정책 문서, few-shot, 스펙)를 메시지 앞 블록으로 이동하고, 동적 콘텐츠와 분리합니다.
cache_control: { type: "ephemeral" }마킹 후 캐시 적중 로깅을 활성화합니다. - 3단계 — 캐시 TTL 관리: 5분 TTL 내 후속 요청이 충분한지 확인하고, 트래픽이 낮은 시간대에 워밍업 요청 전략을 도입합니다.
- 4단계 — 모델 라우터 도입: 요청 복잡도 신호(토큰 수, 키워드, 컨텍스트 태그)를 기반으로 Haiku/Sonnet/Opus를 분기합니다. 초기에는 규칙 기반으로 시작하고, 충분한 데이터가 쌓인 후 계층형 분류기를 추가합니다.
- 5단계 — 시맨틱 캐시 구축: Redis + 임베딩 기반 코사인 유사도 캐시를 도입합니다. 임계값은 반드시 도메인별 골든 데이터셋으로 검증합니다(0.88~0.92 범위에서 시작).
- 6단계 — 배치 전환 가능 작업 식별: 전체 API 호출 목록에서 결과가 즉각 필요하지 않은 작업을 태깅합니다. 야간 배치 파이프라인으로 전환하면 50% 정률 할인을 받습니다.
- 7단계 — 출력 토큰 상한 설정: 작업 유형별 평균 출력 토큰을 측정하고,
max_tokens를 95th 백분위수로 제한합니다. 불필요하게 큰 상한값이 장황한 응답을 유도합니다. - 8단계 — 모니터링 대시보드 구축: 라우팅 분포, 캐시 히트율, P95 레이턴시, 추정 비용을 실시간으로 추적합니다. 지표 없이는 최적화 효과를 검증할 수 없습니다.
- 9단계 — 품질 회귀 방지: 비용을 줄이는 모든 변경 후 내부 평가 파이프라인을 실행합니다. 라우팅 임계값 조정 한 번이 특정 사용 사례의 품질을 예상치 못하게 떨어뜨릴 수 있습니다. LLM 평가 파이프라인 CI 연동을 통해 품질 회귀를 자동으로 감지하는 체계가 필수입니다.
비용 최적화와 품질 유지는 트레이드오프가 아닙니다. 적절한 계층화와 측정 체계를 갖추면, 더 낮은 비용으로 동등하거나 더 높은 품질을 달성하는 것이 가능합니다. 우리 팀의 사례가 그 증거입니다.