- Published on
LLM API 비용 최적화 — 출력 토큰, 프롬프트 캐싱, 라우팅의 손익분기 계산
- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며 — 청구서는 토큰 단위로 옵니다
LLM 비용 최적화 논의는 대개 "더 싼 모델로 바꿉시다"에서 시작해서 "품질이 떨어질 것 같은데요"에서 끝납니다. 양쪽 다 근거가 없어서 결론이 안 납니다.
그런데 이 문제는 원래 근거를 만들기 쉬운 축에 속합니다. 청구액은 토큰 수와 단가의 곱이고, 두 값 모두 로그에 이미 있습니다. 필요한 건 어느 항이 청구서를 지배하는지 확인하고, 각 레버가 그 항을 몇 퍼센트 줄이는지 계산하는 것뿐입니다.
이 글은 그 계산을 순서대로 합니다. 먼저 비용의 구조를 분해하고, 레버를 절감폭이 큰 순서로 다루고, 각 레버의 손익분기를 식으로 제시합니다. 단가는 제공자마다 다르고 자주 바뀌므로 전부 변수로 두고, 예시에는 "100만 토큰당 입력 3달러, 출력 15달러"라는 흔한 비율을 넣겠습니다. 본인 단가를 넣어 다시 돌리시면 됩니다.
비용의 구조 — 입력과 출력의 단가는 다릅니다
첫 번째로 확인할 사실은 입력 토큰과 출력 토큰의 가격이 같지 않다는 것입니다. 출력이 입력보다 대개 3배에서 5배 비쌉니다. 이유는 구조적입니다. 입력은 프리필 단계에서 한 번에 병렬 처리되지만, 출력은 토큰마다 모델 전체를 다시 통과해야 하는 순차 작업이기 때문입니다.
이 비대칭 때문에 직관이 자주 어긋납니다.
def daily_cost(reqs, in_tok, out_tok, price_in, price_out):
"""price_*는 100만 토큰당 달러 단가"""
c_in = reqs * in_tok * price_in / 1e6
c_out = reqs * out_tok * price_out / 1e6
return c_in, c_out, c_in + c_out
c_in, c_out, total = daily_cost(
reqs=100_000, in_tok=2_000, out_tok=500,
price_in=3.0, price_out=15.0,
)
print(f"입력 {c_in:8.0f} 출력 {c_out:8.0f} 합계 {total:8.0f} (일)")
print(f"출력 비중 {c_out / total:.1%}, 월 {total * 30:,.0f}")
# 입력 600 출력 750 합계 1350 (일)
# 출력 비중 55.6%, 월 40,500
출력 토큰은 입력의 4분의 1인데 비용은 더 큽니다. 프롬프트를 다듬어 500 토큰을 줄이는 작업과, 응답을 125 토큰 줄이는 작업의 금전적 효과가 같습니다. 대부분의 팀은 프롬프트 다이어트에 시간을 쓰고 출력 길이는 방치합니다.
그래서 첫 번째 질문은 항상 이것입니다. 우리 출력은 왜 이렇게 긴가.
가장 큰 레버는 출력 길이입니다
출력이 길어지는 이유는 대개 세 가지입니다. 모델이 서론과 결론을 붙이고, 요청하지 않은 설명을 덧붙이고, 구조화된 결과를 산문으로 감쌉니다.
효과가 큰 순서대로 정리하면 이렇습니다.
첫째, 출력 형식을 산문에서 구조로 바꿉니다. 같은 정보를 JSON 스키마나 열거형으로 받으면 토큰 수가 몇 분의 일로 떨어집니다. "분류 결과를 설명과 함께 알려 주세요"와 "라벨 하나만 출력하세요"의 차이는 200 토큰과 3 토큰입니다. 안정적으로 받는 방법은 구조화된 출력 안정성 편에 정리했습니다.
둘째, max_tokens를 실제 필요치에 맞춰 내립니다. 이건 비용 절감이라기보다 사고 방지 장치입니다. 모델이 반복 루프에 빠져 상한까지 토큰을 뽑아내는 사고는 드물지 않고, 그 한 번이 정상 요청 수백 건 값이 됩니다.
셋째, 사고 사슬을 쓰는 구간을 좁힙니다. 단계적 추론은 어려운 문제에서 정확도를 올리지만 출력 토큰을 몇 배로 만듭니다. 모든 요청에 일괄 적용하지 말고, 난이도 판별 후 필요한 요청에만 붙이는 편이 낫습니다.
여기서 정직하게 짚을 것이 하나 있습니다. 출력을 줄이면 품질이 떨어지는 작업이 분명히 존재합니다. 요약이나 분류는 짧게 만들어도 손실이 거의 없지만, 추론이 필요한 작업에서 사고 과정을 잘라내면 정확도가 내려갑니다. 그러니 출력 길이 축소는 "무조건 하는 것"이 아니라 "작업별로 재고 하는 것"입니다. 재는 방법은 뒤에서 다시 다룹니다.
프롬프트 캐싱 — 접두사만 잡습니다
같은 시스템 프롬프트와 few-shot 예제를 매 요청 앞에 붙이는 서비스라면, 그 부분의 입력 비용은 원리적으로 반복 결제입니다. 프롬프트 캐싱은 이 반복을 없앱니다.
핵심 제약은 하나입니다. 캐시는 접두사 단위로만 잡힙니다. 프롬프트 앞에서부터 토큰이 완전히 일치하는 구간까지만 재사용되고, 한 토큰이라도 달라지는 순간 그 뒤는 전부 새로 계산됩니다. 중간에 낀 동일 문단은 잡히지 않습니다.
이 제약이 프롬프트 배치 순서를 사실상 강제합니다.
# 나쁜 순서: 캐시 적중 0
prompt = f"""오늘 날짜는 {today}입니다.
{SYSTEM_RULES} # 8,000 토큰짜리 고정 지침
{FEW_SHOT_EXAMPLES} # 4,000 토큰짜리 고정 예제
사용자 질문: {question}"""
# 좋은 순서: 앞의 12,000 토큰이 통째로 캐시 적중
prompt = f"""{SYSTEM_RULES}
{FEW_SHOT_EXAMPLES}
오늘 날짜는 {today}입니다.
사용자 질문: {question}"""
날짜 한 줄을 맨 앞에 두면 그 뒤 1만 2천 토큰이 매번 새로 계산됩니다. 위치만 바꿔서 전부 살릴 수 있습니다. 원칙은 단순합니다. 변하지 않는 것부터, 자주 변하는 것을 뒤로.
단가 구조는 대체로 캐시 읽기가 기본 입력가의 10분의 1 수준, 캐시 쓰기가 기본가보다 조금 비싼 형태입니다. 정확한 배수와 캐시 유지 시간은 제공자 문서에서 확인해야 합니다. 이 구조에서 손익분기를 계산하면 이렇습니다.
def caching_breakeven(write_mult=1.25, read_mult=0.1):
"""캐시 유지 시간 안에 몇 번 재사용해야 이득인가"""
# n회 사용 시: write_mult + (n-1) * read_mult vs n * 1.0
n = 1
while write_mult + (n - 1) * read_mult >= n:
n += 1
return n
print(caching_breakeven()) # 2
def cached_input_cost(reqs, static_tok, dynamic_tok, price_in, hit_rate,
write_mult=1.25, read_mult=0.1):
dyn = reqs * dynamic_tok * price_in / 1e6
stat_tok = reqs * static_tok
stat = (stat_tok * hit_rate * read_mult + stat_tok * (1 - hit_rate) * write_mult) * price_in / 1e6
return dyn + stat
base = 100_000 * 2_000 * 3.0 / 1e6
cached = cached_input_cost(100_000, static_tok=1_600, dynamic_tok=400,
price_in=3.0, hit_rate=0.9)
print(f"캐시 없음 {base:.0f} → 적중률 90%에서 {cached:.0f} (일 {base - cached:.0f} 절감)")
# 캐시 없음 600 → 적중률 90%에서 223 (일 377 절감)
두 번만 재사용해도 이득입니다. 그래서 캐싱은 "도입할까 말까"를 고민할 대상이 아니라, 적중률을 어떻게 올릴지를 고민할 대상입니다. 적중률을 죽이는 흔한 범인은 프롬프트 앞쪽의 타임스탬프, 요청 ID, 사용자 이름, 그리고 순서가 보장되지 않는 방식으로 직렬화된 도구 정의입니다.
RAG와 전체 컨텍스트 — 손익분기는 어디인가
컨텍스트 창이 커지면서 "전부 넣으면 되지 않나"라는 선택지가 생겼습니다. 비용만 놓고 보면 계산은 간단합니다.
def per_request_cost(tokens, price_in, cached=False, read_mult=0.1):
mult = read_mult if cached else 1.0
return tokens * price_in * mult / 1e6
DOC = 100_000 # 전체 문서 토큰
K, CHUNK = 5, 400 # RAG: 상위 5개 청크
full_raw = per_request_cost(DOC, 3.0)
full_cached = per_request_cost(DOC, 3.0, cached=True)
rag = per_request_cost(K * CHUNK, 3.0)
print(f"전체 {full_raw:.3f} / 전체+캐싱 {full_cached:.3f} / RAG {rag:.3f} (요청당 달러)")
# 전체 0.300 / 전체+캐싱 0.030 / RAG 0.006
# 손익분기 문서 크기: RAG 검색 오버헤드를 감안해도
print(f"RAG가 유리해지는 문서 크기: {K * CHUNK}토큰 초과")
숫자가 말하는 바는 두 가지입니다. 첫째, RAG는 전체 삽입 대비 50배 싸지만, 문서가 정적이어서 캐싱이 걸리면 그 격차가 5배로 줄어듭니다. 둘째, 5배는 여전히 큰 차이지만 "RAG 파이프라인을 만들고 유지하는 비용"과 비교해야 할 크기입니다. 하루 요청이 1천 건인 서비스에서 요청당 0.024달러 차이는 월 700달러 남짓입니다. 엔지니어 시간이 더 비쌉니다.
그래서 저는 이 결정을 비용이 아니라 다른 축으로 내리는 편이 맞다고 봅니다.
문서가 컨텍스트 창에 안 들어가면 RAG 외에 선택지가 없습니다. 문서가 사용자마다 다르면 캐시 적중률이 낮아 전체 삽입이 비싸집니다. 지연 시간이 중요하면 10만 토큰 프리필의 첫 토큰 지연이 문제가 됩니다. 반대로 문서가 작고(수만 토큰), 정적이고, 검색 실패의 대가가 크다면 전부 넣는 편이 단순하고 안전합니다. 그리고 컨텍스트가 길어질수록 중간에 놓인 정보를 놓치는 경향이 보고되어 왔으므로, "전부 넣으면 정확도가 항상 높다"는 가정도 검증 대상입니다.
모델 라우팅 — 절감액과 정확도 손실을 같은 표에 놓기
쉬운 요청을 작은 모델로 보내면 얼마나 아낄 수 있는지는 두 숫자로 결정됩니다. 위임 비율과 단가비입니다.
def routing_saving(p_small, price_ratio):
"""p_small: 작은 모델로 보내는 비율, price_ratio: 작은 모델 단가 / 큰 모델 단가"""
return p_small * (1 - price_ratio)
def cascade_saving(price_ratio, escalate_rate, judge_ratio=0.0):
"""작은 모델로 먼저 답하고 미달이면 큰 모델로 승급. 승급분은 두 번 낸다."""
cost = price_ratio + judge_ratio + escalate_rate * 1.0
return 1 - cost
print(f"라우팅 {routing_saving(0.7, 1/15):.1%}") # 65.3%
print(f"캐스케이드 {cascade_saving(1/15, 0.25, 0.02):.1%}") # 66.3%
# 캐스케이드가 성립하는 조건: 승급률이 (1 - 단가비 - 심사비) 미만
print(f"승급률 상한 {1 - 1/15 - 0.02:.1%}") # 91.3%
비용만 보면 두 방식 모두 크게 남습니다. 캐스케이드는 승급률이 91퍼센트를 넘지 않는 한 이득이니, 사실상 항상 이득입니다. 즉 이 결정의 병목은 비용이 아니라 품질입니다.
그러니 절감률과 같은 화면에 반드시 이 값을 놓아야 합니다.
def routed_accuracy(p_small, acc_small, acc_large):
return p_small * acc_small + (1 - p_small) * acc_large
acc = routed_accuracy(0.7, acc_small=0.88, acc_large=0.94)
print(f"라우팅 후 정확도 {acc:.3f} (단일 대형 0.940, 손실 {0.94 - acc:.3f})")
# 라우팅 후 정확도 0.898 (단일 대형 0.940, 손실 0.042)
여기 들어간 0.88과 0.94는 제가 만든 예시 값입니다. 실제로는 라우팅 대상 분포에서 두 모델의 정확도를 각각 측정해서 넣어야 합니다. 그리고 이 측정에는 함정이 하나 있습니다. 전체 평가셋에서의 정확도가 아니라 "작은 모델로 보내기로 한 요청들"에서의 정확도를 재야 합니다. 라우터가 제 역할을 한다면 그 부분집합에서는 두 모델의 격차가 훨씬 작아야 하고, 격차가 그대로라면 라우터가 난이도를 전혀 구분하지 못하고 있다는 신호입니다.
캐스케이드에서 추가로 볼 것은 승급 판정의 신뢰도입니다. 판정이 틀려서 나쁜 답을 승급시키지 않으면, 그건 절감이 아니라 그냥 품질 저하입니다. 판정기 자체의 정밀도와 재현율을 따로 재야 합니다.
| 레버 | 작용 지점 | 전형적 절감폭 | 품질 리스크 | 구현 부담 |
|---|---|---|---|---|
| 출력 형식 구조화 | 출력 토큰 | 30~70% | 낮음 (작업 의존) | 낮음 |
| max_tokens 하향 | 출력 토큰 | 사고 방지 위주 | 잘림 위험 | 매우 낮음 |
| 프롬프트 캐싱 | 입력 토큰 | 입력의 최대 90% | 없음 | 낮음 (순서 재배치) |
| RAG로 컨텍스트 축소 | 입력 토큰 | 5~50배 | 검색 실패 시 큼 | 높음 |
| 모델 라우팅 | 전체 | 50~70% | 측정 필수 | 중간 |
| 배치 처리 | 전체 | 대개 절반 | 없음 (지연만) | 낮음 |
배치 처리, 그리고 추정치와 청구서가 어긋나는 이유
지연 시간을 포기할 수 있는 작업은 별도의 비동기 처리 경로로 보내면 단가가 크게 내려갑니다. 많은 제공자가 시간 여유를 받는 대가로 절반 수준의 단가를 적용합니다. 야간 배치 분류, 로그 요약, 임베딩 재생성, 평가셋 채점처럼 사람이 기다리지 않는 작업이 전부 후보입니다. 대화형 경로와 배치 경로를 코드 수준에서 분리해 두면, 나중에 어떤 워크로드가 어느 쪽인지 다시 판단할 필요가 없어집니다.
마지막으로, 계산기가 뱉은 숫자와 실제 청구액이 안 맞는 흔한 원인들입니다.
시스템 프롬프트와 도구 정의도 매 요청 입력 토큰입니다. 도구를 20개 붙여 두면 그 스키마 전체가 매번 과금됩니다. 쓰지 않는 도구를 정리하는 것만으로 입력 토큰이 눈에 띄게 줄어드는 경우가 있습니다.
재시도와 중단도 과금됩니다. 스트리밍 중 사용자가 창을 닫아도 그 시점까지 생성된 토큰은 청구됩니다. JSON 파싱 실패로 재시도하면 그 요청은 두 번 결제됩니다. 파싱 실패율이 5퍼센트면 비용도 5퍼센트 늘어납니다.
토큰 수를 글자 수로 어림하면 언어에 따라 크게 빗나갑니다. 같은 의미의 문장이라도 한국어는 영어보다 토큰이 더 많이 나오는 경우가 흔합니다. 실제 토크나이저로 세거나 제공자의 토큰 카운트 응답 필드를 로그에 남기는 것이 유일하게 믿을 만한 방법입니다.
멀티모달 입력은 별도 환산식을 따릅니다. 이미지 한 장이 해상도에 따라 수백에서 수천 토큰이 되므로, 텍스트 기준 추정에 그대로 얹으면 크게 빗나갑니다.
그리고 로그에는 요청당 입력 토큰, 출력 토큰, 캐시 읽기 토큰, 캐시 쓰기 토큰, 모델 이름을 전부 남겨야 합니다. 이 다섯 필드가 없으면 어떤 최적화가 얼마를 아꼈는지 사후에 증명할 방법이 없습니다.
마치며 — 로그 다섯 필드가 먼저입니다
비용 최적화에서 가장 흔한 실패는 잘못된 레버를 당기는 것이 아니라, 당긴 뒤에 효과를 모르는 것입니다. 요청 단위로 토큰 종류별 사용량을 남기고 있지 않다면, 어떤 개선안도 "그런 것 같다"에서 끝납니다.
기록이 있다면 순서는 대체로 정해져 있습니다. 출력 토큰이 청구서를 지배하는지 먼저 확인하고, 지배한다면 출력 형식부터 손봅니다. 그다음 프롬프트를 고정 부분과 가변 부분으로 갈라 순서를 바로잡습니다. 여기까지는 품질 리스크가 거의 없는 구간입니다. 라우팅과 RAG는 품질을 걸고 하는 거래이므로, 절감액 옆에 정확도 손실을 같은 크기로 적어 놓고 결정하시면 됩니다.