포스트

2026년 4월 기준: 토큰을 “덜 쓰고, 더 싸게” 쓰는 LLM Routing 설계 (비용 최적화 심층 가이드)

2026년 4월 기준: 토큰을 “덜 쓰고, 더 싸게” 쓰는 LLM Routing 설계 (비용 최적화 심층 가이드)

들어가며

프로덕션에서 LLM 비용이 터지는 패턴은 거의 고정입니다.

  • (1) 모든 요청을 비싼 모델로 보냄: “품질 안전”은 얻지만, 트래픽이 늘면 비용이 선형 폭증
  • (2) 컨텍스트가 무한히 비대해짐: system prompt/툴 스키마/RAG 문서/대화 히스토리가 매 요청마다 반복 전송
  • (3) 라우팅을 LLM에게 맡김: “어느 모델 쓸지 판단해줘” 자체가 고비용 토큰을 추가로 태움

이 글의 목표는 2026년 4월 시점 LLM API 운영에서 비용을 줄이는 2축을 실전 관점에서 정리하는 것입니다.

  • Token saving: Prompt Caching + Context Engineering(선별 주입/압축)
  • Routing: 요청 난이도/리스크/토큰예산 기반으로 싼 모델 먼저, 실패 시 승격(escalation)

언제 쓰면 좋은가

  • 월별 LLM 비용이 의미 있게 나오는 팀(예: 월 수백~수천 달러 이상)
  • 트래픽이 일정하거나 성장 중이며, 실패율/지연(P95/P99)을 계측 가능한 서비스
  • “요청의 난이도 분포”가 존재하는 제품(대부분 쉬운 요청, 일부만 어려운 요청)

언제 쓰면 안 되는가

  • 트래픽이 너무 적어 최적화 비용(개발/운영)이 더 큰 경우
  • 한 번의 실패도 치명적인 도메인(의료/법률/금융 자문 등)에서 자동 다운그레이드를 허용할 수 없는 경우
  • 품질 측정 기준(정답/규격/정책)이 불명확해 라우터 튜닝이 불가능한 경우

🔧 핵심 개념

1) 비용 최적화의 기본식: “입력 토큰이 진짜 문제”

대부분 앱에서 출력(output)도 비싸지만, 반복되는 입력(input) 이 상시로 누적됩니다.

  • system prompt(정책/역할)
  • tool definitions(JSON schema)
  • 대화 히스토리
  • RAG로 붙인 문서 원문(여기서 폭발)

즉, “모델을 바꿔치기” 전에 입력 토큰을 줄이거나, 반복 입력을 할인받는 구조가 먼저입니다.

2) Prompt Caching: 반복되는 prefix를 “싸게” 처리

OpenAI는 API에서 Prompt Caching을 제공하고, “최근에 본 프롬프트 prefix”를 재사용할 때 캐시된 입력 토큰에 할인가를 적용합니다. 캐시는 최장 prefix 기준으로 잡히며, 최소 1,024 tokens부터 동작하고, 일정 시간 비활성 시 정리되는 식의 운영 특성이 있습니다. (openai.com)

중요 포인트

  • 캐시는 “문자열이 같은 prefix”여야 유효합니다 → system prompt/툴 스키마/고정 지시문을 항상 동일한 순서/내용으로 유지해야 함
  • RAG 문서를 system 앞에 끼워 넣으면(prefix가 바뀜) 캐시 효율이 급락 → 고정 prefix를 앞에, 변동 컨텍스트는 뒤에 두는 식으로 설계해야 함

3) Routing: “싸게 시작하고, 필요할 때만 승격”

Routing은 보통 3종류로 나뉩니다.

1) cost-based routing: 가장 싼 모델/배포로 보내기 (품질 위험) 2) fallback routing: 실패(429/5xx/timeout) 시 다른 모델/프로바이더로 우회 3) semantic/complexity routing: 요청 난이도를 보고 모델을 선택 (가장 실전적)

LiteLLM Router는 cost-based routing 같은 전략을 지원하고, 외부 시스템(Rasa 등)도 이 라우팅 전략을 통합하는 흐름이 있습니다. (aidoczh.com)
OpenRouter도 provider 라우팅 옵션(가격 우선 정렬 등)과 fallback 개념을 문서화합니다. (openrouter.ai)

그리고 2026년 들어 “토큰 예산 기반 라우팅(token-budget routing)”이 서버/플릿 레벨에서도 활발합니다. 짧은 요청이 대부분인데, 모두를 worst-case 컨텍스트 길이에 맞춰 서빙하면 낭비가 크다는 문제의식이죠. (arxiv.org)
이 아이디어는 API 비용 최적화에도 그대로 이식할 수 있습니다: “이번 요청은 컨텍스트/출력 예산이 작다 → 싼 모델로”, “길고 위험하다 → 비싼 모델로”.

4) 2026년 4월 “현실”을 바꾼 변수: tool/agent 사용량 과금 이슈

특히 개발 도구/에이전트 생태계에서는 서드파티 도구가 구독 한도 밖 과금으로 전환되며 “라우팅 + 토큰 절약”을 직접 운영해야 하는 압력이 커졌습니다(예: 2026-04-04 전환 이슈 보도). (techradar.com)


💻 실전 코드

아래 예제는 “toy”가 아니라, 실제로 운영에서 흔한 Support 티켓 자동 분류 + 답변 초안 + 필요 시 승격 시나리오입니다.

  • 1차: 저렴한 모델로 분류/요약(짧은 출력)
  • 2차(조건부): 고급 모델로 최종 답변 생성(긴 출력/고난도)
  • 토큰 절약: (a) 고정 prefix를 안정적으로 유지해 Prompt Caching을 노리고 (b) RAG 결과는 “압축된 bullet”만 주입

OpenAI Prompt Caching 동작/usage 필드(cached_tokens 등) 확인은 공식 문서 기준으로 로깅합니다. (platform.openai.com)
가격표는 OpenAI 공식 pricing 페이지에서 모델별 토큰 단가/캐시 입력 단가 등을 확인할 수 있습니다. (platform.openai.com)

0) 의존성/환경

1
2
pip install openai pydantic tiktoken
export OPENAI_API_KEY="..."

1) 라우팅 + 토큰 예산 + 캐시 친화 프롬프트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
from __future__ import annotations

import os
import json
from typing import Literal, Optional, Tuple
from pydantic import BaseModel, Field
from openai import OpenAI

client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])

# --- 고정 prefix (캐시 타게 "절대" 자주 바꾸지 말 것) ---
SYSTEM_PREFIX = """You are a senior support engineer.
Follow policy:
- Never leak secrets.
- Ask for missing info if needed.
- Produce action-oriented steps.
Output must be in Korean.
"""

TOOLS_PREFIX = """You may reference internal runbooks summarized below.
Do not quote them verbatim; use them as guidance.
"""

class TriageResult(BaseModel):
    severity: Literal["low", "medium", "high"]
    category: Literal["billing", "bug", "outage", "howto", "security", "other"]
    needs_escalation: bool = Field(description="True if high-risk, security, outage, or ambiguous")
    short_summary: str = Field(description="1-2 sentences")
    missing_info: list[str] = Field(default_factory=list)

def estimate_risk(ticket: str) -> bool:
    # 규칙 기반 1차 게이트: 라우터 자체를 LLM에 맡기면 토큰이 늘어남
    keywords = ["결제", "환불", "보안", "해킹", "개인정보", "장애", "다운", "데이터 손실"]
    return any(k in ticket for k in keywords) or len(ticket) > 1500

def call_llm(model: str, messages: list[dict], max_output: int) -> Tuple[str, dict]:
    resp = client.responses.create(
        model=model,
        input=messages,
        max_output_tokens=max_output,
        # 실무: temperature 낮춰 재시도/캐시/평가 안정성 확보
        temperature=0.2,
    )
    text = resp.output_text
    usage = resp.usage.model_dump() if resp.usage else {}
    return text, usage

def triage(ticket: str, runbook_bullets: str) -> Tuple[TriageResult, dict]:
    # 분류/요약은 "싼 모델 + 짧은 출력"이 정석
    model = "gpt-4.1-nano"  # 예시: 고볼륨 파이프라인용
    messages = [
        {"role": "system", "content": SYSTEM_PREFIX + "\n" + TOOLS_PREFIX},
        {"role": "user", "content": f"[RUNBOOK_SUMMARY]\n{runbook_bullets}\n\n[TICKET]\n{ticket}\n\nReturn JSON only."},
    ]
    text, usage = call_llm(model, messages, max_output=220)
    data = json.loads(text)
    return TriageResult(**data), usage

def draft_answer(ticket: str, runbook_bullets: str, triage_result: TriageResult) -> Tuple[str, dict]:
    # 승격 조건: high risk or needs_escalation or 긴 출력 필요
    if triage_result.needs_escalation or triage_result.severity == "high":
        model = "gpt-4.1"     # 더 비싸지만 확실히
        max_out = 900
    else:
        model = "gpt-4.1-mini"
        max_out = 500

    messages = [
        {"role": "system", "content": SYSTEM_PREFIX + "\n" + TOOLS_PREFIX},
        {"role": "user", "content": f"""
[RUNBOOK_SUMMARY]
{runbook_bullets}

[TICKET]
{ticket}

[TRIAGE]
severity={triage_result.severity}
category={triage_result.category}
missing_info={triage_result.missing_info}

Write a response draft:
- Start with acknowledgement
- Then numbered troubleshooting steps
- Ask only necessary questions
""".strip()},
    ]
    return call_llm(model, messages, max_output=max_out)

def run_pipeline(ticket: str, retrieved_docs: list[str]) -> None:
    # RAG 원문을 그대로 넣지 않고 "압축 bullets"로 넣는게 핵심(토큰 절약 + 품질 안정)
    runbook_bullets = "\n".join(f"- {d[:300]}" for d in retrieved_docs[:6])

    risk = estimate_risk(ticket)

    triage_result, triage_usage = triage(ticket, runbook_bullets)

    # 규칙+LLM triage를 둘 다 쓰는 이유:
    # - 규칙은 0토큰에 가까운 안전장치
    # - triage는 케이스별로 더 정교
    if risk:
        triage_result.needs_escalation = True

    answer, answer_usage = draft_answer(ticket, runbook_bullets, triage_result)

    print("=== TRIAGE ===")
    print(triage_result.model_dump_json(indent=2, ensure_ascii=False))
    print("usage:", json.dumps(triage_usage, indent=2))

    print("\n=== DRAFT ANSWER ===")
    print(answer)
    print("usage:", json.dumps(answer_usage, indent=2))

if __name__ == "__main__":
    sample_ticket = """
어제부터 결제 페이지에서 카드 결제가 실패합니다.
에러 메시지는 'payment_intent_failed'이고, 고객이 여러 번 시도해서 불만이 커요.
서버 로그에는 502가 간헐적으로 보입니다. 우선순위 높게 봐주세요.
""".strip()

    retrieved = [
        "결제 502 발생 시: upstream PSP 상태 페이지 확인 → 재시도 백오프(지수) 적용 → idempotency key 강제 ...",
        "payment_intent_failed: 카드사 승인 거절/PSP validation/3DS 플로우 누락 가능 ...",
        "장애 공지 템플릿: 영향 범위, 완화책, 다음 업데이트 시간 포함 ...",
    ]
    run_pipeline(sample_ticket, retrieved)

예상 출력(요약)

  • TRIAGE JSON에 needs_escalation=true, severity=high, category=billing/outage 등이 찍히고
  • Answer 단계에서 자동으로 상위 모델로 승격되어 장애 대응 톤/체크리스트가 포함된 초안이 생성됩니다.
  • 각 호출의 usage를 로그로 남겨 prompt_tokens / completion_tokens / cached_tokens(있다면) 를 대시보드로 보냅니다. (platform.openai.com)

⚡ 실전 팁 & 함정

Best Practice 1) “라우팅 판단”은 LLM이 아니라 규칙 + 얕은 분류

모델 선택을 LLM에게 묻는 순간:

  • 라우팅 프롬프트 자체가 토큰을 먹고
  • 라우팅이 흔들리면 품질/비용 모두 불안정해집니다

추천 패턴

  • 규칙 기반(키워드/길이/도메인) 1차 게이트(0원에 가까움)
  • 그 다음에 싼 모델로 triage(짧은 출력) → 필요하면 승격

Best Practice 2) Prompt Caching을 “설계로” 만든다

캐시는 공짜가 아니라, 프롬프트 구조를 강제해야 효과가 납니다.

  • system/tool prefix는 가능한 한 “버전 고정” (ex. SYSTEM_PREFIX_v7)
  • 변동 컨텍스트(RAG 결과/최근 대화)는 뒤로 미루기
  • prefix에 날짜/요청ID 같은 변동 문자열을 넣는 순간 캐시 히트율이 떨어짐

OpenAI Prompt Caching은 prefix 기반으로 동작하고, 캐시 토큰은 usage에 노출되어 관측 가능하니(=튜닝 가능) 반드시 로깅하세요. (platform.openai.com)

Best Practice 3) RAG는 “원문 주입”이 아니라 “압축 후 주입”

RAG가 비용을 터뜨리는 가장 흔한 실수는 “검색된 문서 원문 10개를 그대로 컨텍스트에 붙이기”입니다.

  • Top-k를 줄이는 것보다 효과적인 건 요약/정규화(불릿화) 입니다
  • 라우터 관점에서 보면, 토큰이 줄어들면 싼 모델로 커버 가능한 요청 비율이 늘어 승격 빈도도 감소합니다

흔한 함정/안티패턴

  • fallback 폭주: timeout 기준이 너무 빡세면 정상 응답도 “실패”로 간주되어 상위 모델로 우회 → 비용 폭발
    (특히 aggregator/provider routing에서 네트워크 지연 변동이 큰 환경은 조심)
  • 출력 토큰 무제한: max_output_tokens를 크게 주면, 작은 모델도 장황하게 써서 결국 출력 비용이 늘고, 사용자 경험도 나빠짐
  • “항상 최상위 모델 + 캐싱이면 되겠지”: 캐시는 반복 입력만 줄입니다. RAG 원문, 유저가 붙인 긴 로그/스택트레이스는 그대로 비용입니다.

비용/성능/안정성 트레이드오프 체크리스트

  • 비용 ↓: nano/mini 비율을 늘리되, 승격 기준(리스크/정확도/규정) 을 명확히
  • 성능(지연) ↓: 캐싱/압축으로 input 토큰을 줄이면 대체로 TTFT도 개선(공식 문서도 캐싱이 지연 개선에 도움됨을 언급) (platform.openai.com)
  • 안정성 ↑: provider fallback은 좋지만, “오탐 fallback”이 비용을 올릴 수 있음 → 타임아웃/재시도 정책을 계측 기반으로 튜닝

🚀 마무리

핵심 정리 1) Token saving이 1순위: Prompt Caching이 먹히는 고정 prefix를 만들고, RAG/히스토리는 압축·선별 주입 2) Routing은 2순위가 아니라 “함께”: 쉬운 요청은 싼 모델, 위험/복잡/긴 요청만 승격 3) 관측 없이는 최적화도 없다: 요청별 usage(input/output/cached) + 라우팅 결정 + 실패율/지연을 함께 로그로 남겨야 튜닝이 가능

도입 판단 기준(실무용)

  • 한 달에 “반복되는 프롬프트/툴 스키마/에이전트 루프”가 많다 → Prompt Caching 설계부터
  • 요청 난이도 편차가 크다(대부분 단순, 일부만 어려움) → 라우팅/승격이 큰 효과
  • 장애/보안/결제 등 리스크 도메인이 있다 → 규칙 기반 승격(다운그레이드 금지 영역)부터 정의

다음 학습 추천(바로 도움이 되는 순서)

  • OpenAI Prompt Caching 문서: 캐시 조건/usage 관측/프롬프트 구조 (platform.openai.com)
  • LiteLLM Router의 routing 전략(특히 cost-based, fallback) (aidoczh.com)
  • OpenRouter 라우팅 옵션(가격 우선 정렬, provider 라우팅 동작) (openrouter.ai)

원하시면, 위 파이프라인을 (1) 실제 비용 계산기(모델별 $/1M 토큰) 포함, (2) 캐시 히트율 대시보드 지표 설계, (3) 라우팅 품질 평가(offline eval)까지 확장한 “운영 템플릿” 버전으로 이어서 작성해드릴게요.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.