6 분 소요

💸 LLM API 비용 추적 6부작 — Claude · OpenAI · Gemini · Upstage, 네 곳에 흩어진 API 비용을 "과금 구조 이해 → 통합 트래커 → 공식 API 대사·알림 → 키별 귀속 → 대시보드 → 모델 라우팅"으로 끝까지 잡았습니다. 전체 6편.
  1. 과금 구조와 usage 필드 — 4사 가격표 한눈에
  2. 파이썬으로 4사 통합 비용 트래커 만들기
  3. 공식 비용 API 대사와 예산 알림 자동화
  4. 키가 난립할 때 — 키 인벤토리와 키별 비용 귀속
  5. Grafana 대시보드로 비용 한눈에 보기
  6. 모델 라우팅 자동화 — 쉬운 작업은 싼 모델로지금 글

Summary

5편까지로 비용은 보이게 됐어요. 얼마 쓰는지(2편), 청구서와 맞는지(3편), 누가 쓰는지(4편), 추세가 어떤지(5편)까지. 이제 남은 건 그 데이터를 근거로 줄이는 일입니다.

3편 절감 체크리스트에서 효과 2위로 꼽았던 게 모델 라우팅이었어요 — “분류·추출 같은 쉬운 작업을 플래그십에서 경량 모델로 내리기”. 이번 편은 그걸 사람 판단이 아니라 시스템이 하게 만듭니다. 라우팅 테이블 → 난이도 분류기 → 캐스케이드 순서로, 단순한 것부터 자동화 수준을 올려갈게요.

💡 이 글에서 다루는 것

  • 단가 차이가 만드는 기회 — 플래그십 대비 5~40배 싼 경량 모델
  • 트래커 데이터에서 “비싼 모델로 가는 쉬운 트래픽” 찾기
  • 1단계: 모델 하드코딩 걷어내기 — 라우팅 테이블
  • 2단계: 경량 모델 난이도 분류기 — 요청 단위 라우팅
  • 3단계: 캐스케이드 — 싼 모델 먼저, 실패하면 위로
  • LiteLLM 게이트웨이의 논리 모델 티어와 폴백
  • 승급률(escalation rate)로 품질 저하 감시하기



1. 단가 차이가 만드는 기회

1편 가격표에서 라우팅 관점으로 필요한 것만 다시 꺼내봅니다. 기준은 플래그십인 Claude Opus 4.8 이에요.

모델 입력/출력 ($/1M) Opus 4.8 대비
Claude Opus 4.8 $5.00 / $25.00 기준
Claude Haiku 4.5 $1.00 / $5.00 1/5
Gemini 2.5 Flash $0.30 / $2.50 1/10~1/17
gpt-5.4-nano $0.20 / $1.25 1/20~1/25
Solar Pro 3 $0.15 / $0.60 1/33~1/42

핵심은 단가 차이 자체가 아니라, 정답 품질이 차이 나지 않는 작업이 생각보다 많다는 점이에요. 스팸 분류, 태그 추출, 정형 요약 같은 작업은 플래그십과 경량 모델의 결과가 사실상 같습니다. 그 트래픽이 습관적으로 플래그십에 가고 있다면 — 같은 결과를 5~40배 비싸게 사고 있는 거예요.



2. 내릴 후보 찾기 — 트래커 데이터로

감이 아니라 데이터로 후보를 고릅니다. 2편 SQLite 에서 “어떤 작업이, 어떤 모델로, 어떤 모양의 호출을 하는지”를 뽑아요.

rows = conn.execute("""
    SELECT app, model,
           COUNT(*)                    AS calls,
           CAST(AVG(output_tokens) AS INTEGER) AS avg_out,
           ROUND(SUM(cost_usd), 2)    AS usd
    FROM llm_usage
    WHERE ts >= date('now', '-14 days')
    GROUP BY app, model
    HAVING usd > 1
    ORDER BY usd DESC
""").fetchall()
for r in rows:
    print(r)
('mlops/doc-tagging',   'claude-opus-4-8', 3120,   84, 18.9)
('data/report-writer',  'claude-opus-4-8',  240, 2210, 11.2)
('mlops/mail-classify', 'gpt-5.5',         1890,   12,  6.4)
('data/bulk-summary',   'claude-sonnet-4-6', 610,  310,  3.1)

읽는 법은 이렇습니다.

  • 1행 doc-tagging — 평균 출력 84토큰짜리 태깅 작업을 Opus 로 3천 번. 전형적인 1순위 강등 후보예요.
  • 3행 mail-classify — 출력 12토큰(라벨 하나)에 플래그십. 역시 후보입니다.
  • 2행 report-writer — 평균 2천 토큰 넘는 긴 생성. 이런 건 건드리지 않아요. 라우팅은 품질 차이가 안 나는 곳에서만 이겨요.

정리하면 후보의 지문은 “출력이 짧고 정형적인데 모델은 플래그십” 입니다.



3. 1단계 — 모델 하드코딩 걷어내기

자동화의 첫걸음은 허무할 정도로 단순해요. 코드 곳곳에 박힌 model="claude-opus-4-8" 을 전부 걷어내고, 작업 이름 → 모델 매핑 한 곳으로 모읍니다.

# routes.py — 모델 선택은 이 파일만 안다
ROUTES = {
    "doc-tagging":   ("anthropic", "claude-haiku-4-5"),
    "mail-classify": ("openai",    "gpt-5.4-nano"),
    "bulk-summary":  ("google",    "gemini-2.5-flash"),
    "report-writer": ("anthropic", "claude-opus-4-8"),   # 긴 생성은 그대로
}
DEFAULT_ROUTE = ("anthropic", "claude-sonnet-4-6")

def route(task):
    return ROUTES.get(task, DEFAULT_ROUTE)

print(route("doc-tagging"))
print(route("새로운-작업"))
('anthropic', 'claude-haiku-4-5')
('anthropic', 'claude-sonnet-4-6')

호출부는 route(task) 로 받은 (provider, model) 을 2편의 track() 래퍼에 그대로 넘기면 됩니다. 이 상태가 되면 모델 교체가 배포 없는 설정 변경이 돼요. 2장의 후보를 하나씩 내려보고, 이상하면 딕셔너리 한 줄만 되돌리면 됩니다.

작업 유형별로 난이도가 균질하다면 사실 여기서 끝이에요. 저는 이 단계만으로 2장의 1·3행이 정리돼서 일 비용이 눈에 띄게 떨어졌습니다.



4. 2단계 — 경량 모델 난이도 분류기

문제는 같은 작업 안에 쉬운 요청과 어려운 요청이 섞여 있는 경우예요. 고객 문의 응대가 대표적입니다 — “영업시간이 언제예요?”와 “3년치 거래 내역 기준으로 환불 규정 해석해줘”가 같은 엔드포인트로 들어와요.

이럴 땐 요청 단위로 라우팅해야 하고, 그 판정을 경량 모델에게 시킵니다. Haiku 4.5 에 구조화 출력으로 easy/hard 만 뽑게 해요.

import json
import anthropic

client = anthropic.Anthropic()

DIFFICULTY_SCHEMA = {
    "type": "object",
    "properties": {"difficulty": {"type": "string", "enum": ["easy", "hard"]}},
    "required": ["difficulty"],
    "additionalProperties": False,
}

def classify_difficulty(user_input):
    """요청 난이도를 경량 모델로 판정한다."""
    response = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=64,
        output_config={"format": {"type": "json_schema", "schema": DIFFICULTY_SCHEMA}},
        messages=[{
            "role": "user",
            "content": (
                "다음 요청을 처리하는 데 여러 단계의 추론이나 해석이 필요하면 hard, "
                "단순 조회·분류·정형 요약이면 easy 로 판정해줘.\n\n"
                f"<요청>\n{user_input}\n</요청>"
            ),
        }],
    )
    return json.loads(response.content[0].text)["difficulty"]

print(classify_difficulty("영업시간이 언제예요?"))
print(classify_difficulty("3년치 거래 내역을 근거로 이 건이 환불 규정 몇 조에 해당하는지 해석해줘"))
easy
hard

“분류 호출 자체가 비용 아닌가?”가 당연한 의문인데, 계산해보면 걱정할 수준이 아니에요. 입력 300토큰 + 출력 10토큰이면 Haiku 단가로 호출당 $0.00035 입니다. easy 판정 하나가 Opus→Haiku 강등으로 아끼는 금액(수 센트)의 1% 수준이에요.

⚠️ 단, 분류기는 비싼 작업 앞에만 붙이세요. 이미 nano/Flash 로 가는 호출에까지 붙이면 절감액보다 분류비가 커지는, 배보다 배꼽인 상황이 됩니다.



5. 3단계 — 캐스케이드: 싼 모델 먼저, 실패하면 위로

분류기는 “시도 전 예측”이라 가끔 틀립니다. 더 공격적인 전략은 일단 싼 모델로 시도하고, 결과가 검증에 실패하면 상위 모델로 승급하는 캐스케이드예요.

def cascade(messages, validate, task="doc-tagging"):
    """싼 모델부터 시도하고, 검증 실패 시 상위 모델로 승급한다."""
    tiers = ["claude-haiku-4-5", "claude-opus-4-8"]
    for i, model in enumerate(tiers):
        tag = task + (":escalated" if i > 0 else "")
        response = track("anthropic", model, client.messages.create(
            model=model,
            max_tokens=1024,
            messages=messages,
        ), app=tag)
        if validate(response):
            return response
    return response   # 최상위 모델 결과는 그대로 반환

validate 가 이 패턴의 성립 조건입니다. 기계적으로 검증 가능한 작업에만 쓰세요.

  • ✅ JSON 파싱이 되는가, 라벨이 허용 목록 안인가, 필수 필드가 다 있는가, 추출값이 원문에 존재하는가
  • 🚨 “글이 좋은가” 같은 열린 생성 — 검증 함수를 못 만들면 캐스케이드도 없습니다. 그런 작업은 3장의 정적 라우팅으로 플래그십에 두세요.

포인트 하나는 승급 시 app 태그에 :escalated 를 붙여 승급 자체를 트래커에 기록한다는 거예요. 이게 다음 장의 품질 감시 지표가 됩니다.



6. 게이트웨이에서 라우팅 — 논리 모델 티어

3~4편의 LiteLLM 게이트웨이를 쓰고 있다면, 라우팅 테이블을 앱 코드가 아니라 게이트웨이 설정으로 올릴 수 있어요. 물리 모델명 대신 easy-tier/hard-tier 같은 논리 이름을 만들어두는 방식입니다.

# litellm_config.yaml (발췌)
model_list:
  # 같은 model_name 을 여러 개 두면 그 안에서 로드밸런싱됩니다
  - model_name: easy-tier
    litellm_params:
      model: anthropic/claude-haiku-4-5
      api_key: os.environ/ANTHROPIC_API_KEY
  - model_name: easy-tier
    litellm_params:
      model: gemini/gemini-2.5-flash
      api_key: os.environ/GEMINI_API_KEY
  - model_name: hard-tier
    litellm_params:
      model: anthropic/claude-opus-4-8
      api_key: os.environ/ANTHROPIC_API_KEY

router_settings:
  fallbacks: [{"easy-tier": ["hard-tier"]}]

앱은 이제 model="easy-tier" 처럼 논리명만 호출해요. 얻는 것이 세 가지입니다.

효과 내용
교체 무배포 티어 구성 변경이 yaml 수정으로 끝 — 앱 코드는 논리명만 앎
로드밸런싱 easy-tier 안의 Haiku·Flash 로 트래픽 분산, 한쪽 장애·rate limit 흡수
장애 폴백 easy-tier 가 에러로 죽으면 hard-tier 로 자동 재시도

💡 헷갈리기 쉬운 구분 하나 — 게이트웨이의 fallbacks호출이 에러로 실패했을 때의 승급이에요. 5장의 캐스케이드는 호출은 성공했는데 결과 품질이 검증에 걸렸을 때의 승급입니다. 전자는 게이트웨이가, 후자는 앱 코드가 담당하는 게 자연스러워요.

4편의 가상 키와 조합하면 “이 팀 키는 easy-tier 만 호출 가능” 같은 정책도 가능해집니다. 비용 통제가 모델 선택 단계까지 내려오는 거예요.



7. 효과 측정과 품질 감시

라우팅을 켰으면 두 가지를 지켜봅니다. 돈이 실제로 줄었는가, 그리고 품질을 대가로 치르고 있지 않은가.

돈은 이미 도구가 다 있어요. 2편 일별 리포트와 5편 Grafana 의 모델별 패널에서, 라우팅 적용일을 기점으로 Opus 막대가 줄고 Haiku·Flash 가 늘어나는 그림이 보이면 성공입니다. 제 경우 2장의 1·3행을 내린 것만으로 일 비용이 $7.60 에서 $3.90 수준으로 내려왔어요.

품질은 5장에서 심어둔 :escalated 태그로 감시합니다.

SELECT date(ts) AS day,
       ROUND(100.0 * SUM(app LIKE '%:escalated') / COUNT(*), 1) AS escalated_pct
FROM llm_usage
WHERE app LIKE 'mlops/doc-tagging%'
GROUP BY day
ORDER BY day;

승급률(escalation rate) 이 라우팅의 건강 지표예요.

  • 5% 안팎 — 건강합니다. 경량 모델이 대부분 처리하고 가끔 승급.
  • 30% 이상 — 경량 모델이 그 작업을 사실 못 하는 거예요. 승급이 잦으면 호출을 두 번씩 하는 셈이라 오히려 비싸집니다. 라우팅을 되돌리거나 티어를 한 단계 올리세요.

5편 대시보드에 이 쿼리를 패널로 하나 추가해두면, 품질 저하가 감이 아니라 숫자로 잡힙니다. 여기에 주 1회 승급된 케이스 몇 개를 눈으로 훑는 루틴을 더하면 감시 체계는 충분해요.



8. 시리즈 정리

여섯 편의 전체 그림입니다.

계층 도구 답하는 질문
구조 이해 (1편) 가격표 · usage 필드 뭐가 얼마인가
상시 관측 (2편) track() + SQLite 지금 어디에 쓰고 있나
정산 대사 (3편) 공식 Usage/Cost API 청구서와 맞나
통제 (3편) 게이트웨이 예산 초과를 막을 수 있나
귀속 (4편) 키 인벤토리 · api_key_id 그룹핑 · 가상 키 누가 쓰고 있나
시각화 (5편) Grafana + SQLite/Infinity 추세가 보이나
최적화 (6편) 라우팅 테이블 · 분류기 · 캐스케이드 덜 쓸 수 있나

“비용이 폭발했는데 추적할 방법이 없다”에서 출발한 시리즈가, 추적을 넘어 비용을 구조적으로 줄이는 데까지 왔어요. 관측이 없으면 라우팅할 근거도, 라우팅이 망가졌을 때 알아챌 방법도 없다는 점에서 — 결국 1~5편이 만든 데이터가 6편을 가능하게 한 셈입니다. 시리즈는 여기서 완결이에요. 🎉

일단 오늘은 여기까지….. 여섯 편을 따라와 주셔서 감사해요. 다음에는 새로운 주제로 찾아뵐게요.


← 이전 글: (5/6) LLM API 비용 추적 — Grafana 대시보드로 비용 한눈에 보기