Claude/OpenAI API 비용 최적화 실전 가이드
프로덕션 LLM 비용을 70~90% 줄이는 네 가지 레버를 코드로 다룬다
LLM API 청구서는 어느 순간 갑자기 무서워진다. PoC 때 월 몇 달러였던 게, 트래픽이 붙고 컨텍스트가 길어지고 에이전트 루프가 돌기 시작하면 월 수백~수천 달러로 뛴다. 그런데 이 비용의 대부분은 아키텍처를 조금만 바꾸면 사라지는 낭비다. 같은 프롬프트 프리픽스를 매번 풀 가격으로 재처리하거나, 단순 분류에 최상위 모델을 쓰거나, 실시간일 필요 없는 배치 작업을 동기 API로 돌리는 식의 낭비다.
비용 최적화에는 네 개의 독립적인 레버가 있다. 각각이 곱셈으로 작동하기 때문에 같이 쓰면 효과가 누적된다:
| 레버 | 절감 폭 | 적용 비용 |
|---|---|---|
| 프롬프트 캐싱 | 캐시 히트 부분 ~90% (캐시 읽기 ≈ 입력가의 0.1배) | 낮음 (프롬프트 구조만 정리) |
| 배치 API | 토큰 사용량 전체 50% | 낮음 (지연 허용 작업만) |
| 모델 라우팅 | 작업당 최대 5배 (Opus→Haiku) | 중간 (복잡도 분류 로직) |
| 컨텍스트 관리 | 토큰 양 자체를 줄임 | 중간 (compaction·context editing) |
이 가이드는 Anthropic Claude API를 1차 기준으로 잡되, 핵심 개념(프리픽스 캐싱, 비동기 배치, 모델 티어, 토큰 카운팅)이 OpenAI에도 거의 1:1로 매핑되므로 차이가 나는 지점만 그때그때 짚는다. 코드는 전부 실제로 동작하는 형태이고, 청구서에 바로 나타나는 검증 방법(usage 필드 확인)까지 포함했다.
핵심 원칙 하나만 기억하자: 측정하지 않으면 최적화한 게 아니다. 모든 응답의 usage 객체에는 캐시 히트/미스, 입력/출력 토큰이 정확히 찍힌다. 코드를 바꿨는데 이 숫자가 안 바뀌면 최적화는 실패한 것이다. 아래 모든 섹션은 '바꾸는 법'과 '바뀌었는지 확인하는 법'을 짝으로 다룬다.
1. 먼저 가격표와 비용 구조를 정확히 이해한다
최적화 전에 무엇이 비싼지 알아야 한다. Claude 모델의 현재 가격(100만 토큰당, USD)은 다음과 같다:
| 모델 | 모델 ID | 컨텍스트 | 입력 $/1M | 출력 $/1M |
|---|---|---|---|---|
| Claude Opus 4.8 | claude-opus-4-8 | 1M | $5.00 | $25.00 |
| Claude Sonnet 4.6 | claude-sonnet-4-6 | 1M | $3.00 | $15.00 |
| Claude Haiku 4.5 | claude-haiku-4-5 | 200K | $1.00 | $5.00 |
여기서 비용 구조에 대한 가장 중요한 사실 세 가지:
1) 출력 토큰이 입력 토큰보다 5배 비싸다. 모든 모델에서 출력가는 입력가의 5배다 (Opus $5 vs $25). 따라서 "프롬프트를 줄이자"보다 "불필요하게 긴 출력을 줄이자"가 더 효과적인 경우가 많다. max_tokens를 낮추고, 모델에게 간결하게 답하라고 지시하고, 장황한 설명 대신 구조화된 출력을 요구하는 게 직접적인 절감이다.
2) 모델 간 가격 차이가 크다. Opus 입력가($5)는 Haiku($1)의 5배다. 단순 분류·추출에 Opus를 쓰는 건 5배 비싸게 같은 일을 하는 것이다 (4번 섹션 참조).
3) 캐시 읽기는 거의 공짜다. 캐시에서 읽은 토큰은 기본 입력가의 약 0.1배다. 50KB짜리 시스템 프롬프트를 100번 요청에 재사용하면, 첫 요청만 풀 가격이고 나머지 99번은 그 부분이 10분의 1로 떨어진다 (2번 섹션).
비용을 실제로 추정하려면 가격을 외워서 계산하지 말고, 요청 전에 토큰을 세라 (5번 섹션의 count_tokens). 가격은 변할 수 있으므로 코드에 하드코딩한 단가는 한 곳에 모아두고 주석으로 기준일을 남긴다:
# 가격 단가 (USD per 1M tokens, 2026-06 기준)
PRICING = {
"claude-opus-4-8": {"input": 5.00, "output": 25.00, "cache_read": 0.50, "cache_write_5m": 6.25},
"claude-sonnet-4-6": {"input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write_5m": 3.75},
"claude-haiku-4-5": {"input": 1.00, "output": 5.00, "cache_read": 0.10, "cache_write_5m": 1.25},
}
def estimate_cost(model: str, usage) -> float:
p = PRICING[model]
return (
usage.input_tokens * p["input"] / 1_000_000
+ usage.output_tokens * p["output"] / 1_000_000
+ usage.cache_read_input_tokens * p["cache_read"] / 1_000_000
+ usage.cache_creation_input_tokens * p["cache_write_5m"] / 1_000_000
)
흔한 함정: usage.input_tokens는 캐시되지 않은 나머지만 센다. 전체 프롬프트 크기 = input_tokens + cache_creation_input_tokens + cache_read_input_tokens다. 에이전트가 몇 시간 돌았는데 input_tokens가 4K로 찍혔다면 나머지는 캐시에서 읽힌 것이니, 단일 필드가 아니라 합을 봐야 한다.
2. 프롬프트 캐싱 — 가장 큰 레버, 프리픽스 매칭이 전부다
프롬프트 캐싱의 작동 원리는 단 하나의 불변식에서 전부 파생된다: 캐싱은 프리픽스(prefix) 매칭이다. 프리픽스 안 어디든 한 바이트라도 바뀌면 그 뒤 전부가 무효화된다.
렌더 순서는 tools → system → messages다. 캐시 키는 각 cache_control 중단점(breakpoint)까지의 정확한 바이트로 만들어진다. 따라서 안정적인 내용을 앞에, 변하는 내용을 뒤에 두는 게 설계의 전부다.
기본 사용법 (자동 캐싱)
가장 단순한 방법은 최상위 cache_control로 마지막 캐시 가능 블록을 자동 캐싱하는 것이다:
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=16000,
cache_control={"type": "ephemeral"}, # 마지막 캐시 가능 블록 자동 캐싱
system=large_document_text, # 예: 50KB 컨텍스트
messages=[{"role": "user", "content": "핵심을 요약해줘"}],
)
세밀한 제어 (블록 단위)
시스템 프롬프트가 크고 여러 요청에서 공유될 때는 마지막 시스템 블록에 중단점을 둔다. 도구가 있으면 시스템보다 먼저 렌더되므로, 이 마커 하나로 도구+시스템이 함께 캐싱된다:
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=16000,
system=[{
"type": "text",
"text": large_system_prompt,
"cache_control": {"type": "ephemeral"}, # 기본 TTL 5분
}],
messages=[{"role": "user", "content": question}],
)
1시간 TTL이 필요하면 {"type": "ephemeral", "ttl": "1h"}.
캐시 히트 검증 — 반드시 한다
print(response.usage.cache_creation_input_tokens) # 캐시에 쓴 토큰 (~1.25배 비용)
print(response.usage.cache_read_input_tokens) # 캐시에서 읽은 토큰 (~0.1배 비용)
print(response.usage.input_tokens) # 캐시 안 된 토큰 (풀 가격)
동일 프리픽스 요청을 반복하는데 cache_read_input_tokens가 0이면, 조용한 무효화 요인(silent invalidator)이 있다는 뜻이다.
캐싱 경제학 — 손익분기점
캐시 읽기는 기본 입력가의 ~0.1배, 캐시 쓰기는 5분 TTL에서 1.25배, 1시간 TTL에서 2배다. 따라서:
- 5분 TTL: 두 번째 요청이면 손익분기 (1.25 + 0.1 = 1.35배 vs 캐시 안 했을 때 2배)
- 1시간 TTL: 최소 세 번 읽어야 본전 (2 + 0.2 = 2.2배 vs 3배)
즉 캐시를 켰는데 재사용이 거의 없으면 오히려 손해다. 매 요청마다 첫 1K 토큰이 다르면 재사용할 프리픽스가 없으니 캐싱을 끄는 게 맞다.
최소 캐시 가능 크기 (모델별)
프리픽스가 너무 짧으면 마커를 달아도 조용히 캐싱이 안 된다 (에러 없음, cache_creation_input_tokens: 0):
| 모델 | 최소 토큰 |
|---|---|
| Opus 4.8 / 4.7 / Haiku 4.5 | 4096 |
| Sonnet 4.6 / Haiku 3.5 | 2048 |
3K 토큰 프롬프트는 Sonnet에서는 캐싱되지만 Opus 4.8에서는 조용히 안 된다 — 모델을 바꿀 때 주의할 함정이다.
3. 캐시를 조용히 깨뜨리는 것들 — 침묵의 무효화 감사
캐싱을 켰는데 청구서가 안 줄어드는 가장 흔한 원인은 코드 어딘가에서 프리픽스 바이트를 매 요청마다 바꾸고 있기 때문이다. cache_read_input_tokens가 0이면 아래 표를 grep하라:
| 패턴 | 왜 깨지는가 |
|---|---|
datetime.now() / Date.now() / time.time()를 시스템 프롬프트에 삽입 | 프리픽스가 매 요청 바뀜 |
uuid4() / randomUUID() / 요청 ID를 콘텐츠 앞쪽에 | 매 요청이 유니크 |
json.dumps(d)를 sort_keys=True 없이 / set 순회 | 비결정적 직렬화 → 바이트가 다름 |
| 시스템 프롬프트에 세션/유저 ID를 f-string으로 삽입 | 유저별 프리픽스 → 교차 공유 안 됨 |
조건부 시스템 섹션 (if flag: system += ...) | 플래그 조합마다 다른 프리픽스 |
tools=build_tools(user)로 유저마다 도구가 다름 | 도구는 위치 0에 렌더 → 아무것도 캐싱 안 됨 |
가장 흔한 실수: 시스템 프롬프트에 현재 시각 넣기
# 잘못됨 — 매 요청 프리픽스가 바뀌어 캐싱 0
system = f"You are a helpful assistant. Current date: {datetime.now()}"
# 올바름 — 시스템은 고정, 동적 컨텍스트는 메시지로 늦게 주입
system = [{"type": "text", "text": STABLE_SYSTEM, "cache_control": {"type": "ephemeral"}}]
messages = [
{"role": "user", "content": f"오늘은 {today}. {user_question}"}, # 동적 부분은 뒤에
]
무효화 계층 — 모든 변경이 전부를 깨뜨리진 않는다
API에는 세 개의 캐시 티어(tools / system / messages)가 있고, 변경은 자기 티어와 그 아래만 무효화한다:
| 변경 | tools 캐시 | system 캐시 | messages 캐시 |
|---|---|---|---|
| 도구 정의 (추가/삭제/순서변경) | ❌ | ❌ | ❌ |
| 모델 전환 | ❌ | ❌ | ❌ |
| 시스템 프롬프트 내용 | ✅ | ❌ | ❌ |
tool_choice, 이미지, thinking on/off | ✅ | ✅ | ❌ |
| 메시지 내용 | ✅ | ✅ | ❌ |
실무 함의: tool_choice를 요청마다 바꾸거나 thinking을 토글해도 tools+system 캐시는 살아 있다. 반대로 도구 정의 변경과 모델 전환은 전체를 무효화하므로 세션 중간에 절대 하면 안 된다. "모드"가 필요하면 도구 세트를 바꾸지 말고, 모드를 메시지 내용으로 전달하거나 도구로 모드 전환을 기록하게 한다.
도구 순서를 결정적으로 직렬화
# 도구 목록을 이름순으로 정렬해 매 요청 동일한 바이트 보장
tools = sorted(tool_definitions, key=lambda t: t["name"])
디버깅 절차
두 요청 사이 캐시가 안 맞으면, 렌더된 프롬프트 바이트를 두 요청 간에 diff해서 어디서 갈라지는지 찾는다. 보통 시스템 프롬프트 헤더의 타임스탬프나 정렬 안 된 JSON이 범인이다.
4. 멀티턴·에이전트 캐싱 — 긴 대화에서 캐시 누적시키기
단일 요청 캐싱은 쉽지만, 진짜 절감은 긴 대화와 에이전트 루프에서 나온다. 여기서는 캐시를 매 턴 누적시키는 게 핵심이다.
멀티턴 대화: 마지막 턴에 중단점
가장 최근에 추가된 턴의 마지막 콘텐츠 블록에 중단점을 둔다. 이후 매 요청이 이전 대화 프리픽스 전체를 재사용하고, 이전 중단점들도 유효한 읽기 지점으로 남아 대화가 길어질수록 히트가 누적된다:
# 마지막 유저 턴의 마지막 콘텐츠 블록에 마커
messages[-1]["content"][-1]["cache_control"] = {"type": "ephemeral"}
공유 프리픽스 + 변하는 접미사
많은 요청이 큰 고정 서두(few-shot 예제, 검색된 문서, 지침)를 공유하고 마지막 질문만 다른 경우, 중단점을 공유 부분 끝에 둔다. 전체 프롬프트 끝에 두면 매 요청이 별개 캐시 항목을 써서 아무것도 읽히지 않는다:
messages=[{"role": "user", "content": [
{"type": "text", "text": shared_context, "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": varying_question}, # 마커 없음 — 매번 다름
]}]
에이전트 캐싱의 세 가지 제약과 우회법
에이전트 루프는 프리픽스 매칭과 충돌하는 동작을 자주 한다. 표의 우회법을 쓴다:
| 제약 | 우회법 |
|---|---|
| 세션 중간 시스템 프롬프트 수정 → 캐시 무효화 | {"role": "system", ...} 메시지를 messages[]에 추가 (beta mid-conversation-system-2026-04-07, 지원 모델). 캐시된 프리픽스가 그대로 유지됨 |
| 세션 중간 모델 전환 → 캐시 무효화 | 서브에이전트를 더 싼 모델로 스폰하고 메인 루프는 한 모델 유지 (Claude Code의 Explore 서브에이전트가 Haiku를 이렇게 씀) |
| 도구 추가/삭제 → 캐시 무효화 | tool search로 동적 발견 — 도구 스키마를 교체가 아니라 추가하므로 프리픽스 보존 |
20블록 룩백 윈도우 함정
각 중단점은 이전 캐시 항목을 찾기 위해 최대 20개 콘텐츠 블록만 뒤로 탐색한다. 에이전트 루프에서 한 턴에 tool_use/tool_result 쌍을 20개 넘게 추가하면, 다음 요청의 중단점이 이전 캐시를 못 찾고 조용히 미스난다. 긴 턴에는 ~15블록마다 중간 중단점을 둔다.
동시 요청 타이밍
캐시 항목은 첫 응답이 스트리밍을 시작한 뒤에야 읽을 수 있다. 동일 프리픽스로 N개 병렬 요청을 동시에 쏘면 전부 풀 가격을 낸다. 팬아웃 패턴에서는 1개를 먼저 보내고 첫 토큰이 스트리밍되길 기다린 뒤 나머지 N−1개를 쏜다 — 그러면 첫 요청이 방금 쓴 캐시를 읽는다.
캐시 사전 워밍 (pre-warming)
첫 실제 요청의 캐시 미스 지연을 없애려면 시작 시 max_tokens: 0 요청을 보낸다. API가 prefill만 수행해 캐시를 쓰고 즉시 반환한다(content: [], 출력 토큰 0 청구):
client.messages.create(
model="claude-opus-4-8",
max_tokens=0,
system=[{"type": "text", "text": SYSTEM_PROMPT, "cache_control": {"type": "ephemeral"}}],
messages=[{"role": "user", "content": "warmup"}],
)
워밍은 (a) 첫 요청 지연이 사용자에게 보이고 (b) 프리픽스가 충분히 크고 (c) 트래픽 전에 쏠 순간이 있을 때만 가치 있다. 트래픽이 연속이면(요청 간격 ≤ TTL) 실제 요청이 캐시를 데우므로 별도 워밍은 순수 추가 비용이다.
5. 배치 API — 지연 허용 작업은 무조건 50% 할인
실시간 응답이 필요 없는 작업은 배치 API로 보내면 모든 토큰 사용량이 50% 할인된다. 분류, 임베딩 전처리, 대량 요약, 데이터 라벨링, 오프라인 평가처럼 "지금 당장 응답"이 필요 없는 모든 것이 후보다.
핵심 사실
- 배치당 최대 100,000 요청 또는 256MB
- 대부분 1시간 내 완료, 최대 24시간
- 결과는 생성 후 29일간 보관
- 모든 토큰 사용량 50% 절감
- 모든 Messages API 기능 지원 (비전, 도구, 캐싱 등)
- 배치 API는 프롬프트 캐싱과 함께 쓸 수 있다 → 50% 할인 위에 캐싱까지 누적
배치 생성
import anthropic
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
from anthropic.types.messages.batch_create_params import Request
client = anthropic.Anthropic()
# 분류 작업 1000건을 Haiku 배치로 — 정상가의 절반
requests = [
Request(
custom_id=f"classify-{i}",
params=MessageCreateParamsNonStreaming(
model="claude-haiku-4-5",
max_tokens=50,
messages=[{"role": "user",
"content": f"긍정/부정/중립 한 단어로 분류: {text}"}],
),
)
for i, text in enumerate(items_to_classify)
]
batch = client.messages.batches.create(requests=requests)
print(f"Batch ID: {batch.id}, status: {batch.processing_status}")
완료 폴링
import time
while True:
batch = client.messages.batches.retrieve(batch.id)
if batch.processing_status == "ended":
break
print(f"처리 중: {batch.request_counts.processing}")
time.sleep(60)
결과 수집
results = {}
for result in client.messages.batches.results(batch.id):
if result.result.type == "succeeded":
msg = result.result.message
results[result.custom_id] = next((b.text for b in msg.content if b.type == "text"), "")
elif result.result.type == "errored":
if result.result.error.type == "invalid_request":
print(f"[{result.custom_id}] 검증 오류 - 요청 수정 후 재시도")
else:
print(f"[{result.custom_id}] 서버 오류 - 재시도 안전")
배치 + 캐싱 동시 적용
여러 요청이 같은 큰 시스템 프롬프트를 공유하면, 공유 부분에 cache_control을 달아 배치 안에서도 캐싱을 받는다:
shared_system = [
{"type": "text", "text": "You are a literary analyst."},
{"type": "text", "text": large_document_text, "cache_control": {"type": "ephemeral"}},
]
batch = client.messages.batches.create(requests=[
Request(custom_id=f"q-{i}", params=MessageCreateParamsNonStreaming(
model="claude-opus-4-8", max_tokens=16000,
system=shared_system,
messages=[{"role": "user", "content": q}],
)) for i, q in enumerate(questions)
])
함정과 베스트 프랙티스
- 결과 순서는 보장되지 않는다. 반드시
custom_id로 결과를 입력과 매칭하라. 인덱스에 의존하지 말 것. invalid_request오류는 재시도해도 같은 결과다. 요청을 고쳐야 한다.errored지만 서버 오류면 그냥 재제출하면 된다.- 24시간 SLA를 견딜 수 있는 작업에만 쓴다. 대화형 UI 뒤에는 부적합.
- OpenAI도 동일하게
/v1/batches엔드포인트와 50% 할인을 제공한다 — JSONL 파일 업로드 방식이라는 점만 다르다. - refusal fallback과 배치는 함께 못 쓴다 — 배치 API는
fallbacks파라미터를 거부한다.
6. 모델 라우팅 — 작업 복잡도에 맞는 티어 선택
모든 작업에 최상위 모델을 쓰는 건 가장 비싼 실수다. Opus($5/$25)와 Haiku($1/$5) 사이엔 5배 가격 차가 있고, 많은 작업이 Haiku나 Sonnet으로 충분하다.
티어 선택 가이드
| 작업 유형 | 권장 모델 | 이유 |
|---|---|---|
| 단순 분류, 감성 분석, 라우팅 결정 | Haiku 4.5 | 빠르고 저렴, 정확도 충분 |
| 요약, 추출, 표준 콘텐츠 생성 | Sonnet 4.6 | 속도/지능 균형 |
| 복잡한 추론, 에이전트, 코드, 장기 작업 | Opus 4.8 | 최고 지능 |
def route_model(task_type: str) -> str:
return {
"classify": "claude-haiku-4-5",
"extract": "claude-haiku-4-5",
"summarize": "claude-sonnet-4-6",
"draft": "claude-sonnet-4-6",
"reason": "claude-opus-4-8",
"agent": "claude-opus-4-8",
}.get(task_type, "claude-sonnet-4-6") # 기본은 Sonnet
LLM 기반 라우터 (분류조차 싼 모델로)
복잡도를 미리 알 수 없으면, Haiku로 "이 요청이 복잡한가?"를 먼저 판단하고 결과에 따라 라우팅한다. 라우팅 판단 자체가 Haiku라 저렴하다:
def classify_complexity(query: str) -> str:
resp = client.messages.create(
model="claude-haiku-4-5",
max_tokens=10,
messages=[{"role": "user",
"content": f"이 요청이 'simple'(단순 사실/분류)인지 'complex'(다단계 추론)인지 "
f"한 단어로만 답: {query}"}],
)
label = next((b.text for b in resp.content if b.type == "text"), "complex").strip().lower()
return "claude-haiku-4-5" if "simple" in label else "claude-opus-4-8"
캐시와 라우팅의 충돌 주의
모델 전환은 캐시를 전부 무효화한다 (캐시는 모델별로 스코프됨). 세션 중간에 모델을 바꾸면 캐시된 프리픽스를 잃는다. 이걸 피하려면:
- 메인 에이전트 루프는 한 모델로 고정하고, 싼 서브작업은 별도 서브에이전트(다른 모델)로 분리한다. 서브에이전트는 자기 컨텍스트를 따로 캐싱한다.
- 라우팅 결정은 요청 시작 시점에 내리고, 한 대화 안에서는 모델을 바꾸지 않는다.
출력을 줄이는 게 모델 다운그레이드만큼 효과적이다
출력가가 입력가의 5배이므로, 같은 모델에서도 max_tokens를 합리적으로 제한하고 간결한 출력을 요구하면 큰 절감이다. 분류는 max_tokens=10, 추출은 필요한 만큼만:
# 분류엔 출력 토큰을 최소로
resp = client.messages.create(
model="claude-haiku-4-5",
max_tokens=10, # 한 단어 라벨이면 충분
messages=[{"role": "user", "content": f"긍정/부정 한 단어: {text}"}],
)
함정
- 다운그레이드를 '비용 때문에' 임의로 하지 말 것. 정확도가 떨어지면 재작업·재시도로 오히려 더 비싸진다. 작은 평가셋으로 Haiku/Sonnet이 그 작업에서 Opus만큼 정확한지 먼저 확인하라.
- 구조화 출력(
output_config.format)·strict 도구 사용은 싼 모델에서도 출력 형식을 보장해, 파싱 실패로 인한 재시도 비용을 없앤다.
7. 토큰 카운팅 — 요청 전에 비용을 추정하고 tiktoken을 쓰지 마라
비용을 추정하고 컨텍스트 예산을 관리하려면 토큰을 정확히 세야 한다. count_tokens 엔드포인트를 쓰고, tiktoken은 절대 쓰지 마라.
tiktoken을 쓰면 안 되는 이유
tiktoken은 OpenAI의 토크나이저다. Claude 토큰을 일반 텍스트에서 ~15~20% 적게 세고, 코드나 비영어 입력에서는 훨씬 더 어긋난다. tiktoken, gpt-tokenizer 같은 도구의 추정치는 Claude에 대해 틀렸다. 토큰 카운트는 모델별이므로 추론에 쓸 모델 ID를 그대로 넘겨야 한다.
파일/문자열 토큰 세기
from anthropic import Anthropic
client = Anthropic()
resp = client.messages.count_tokens(
model="claude-opus-4-8",
system=system_prompt,
messages=messages,
)
print(resp.input_tokens)
# 비용 추정
estimated_input_cost = resp.input_tokens * 5.00 / 1_000_000 # Opus 입력 $5/1M
print(f"예상 입력 비용: ${estimated_input_cost:.4f}")
TypeScript: await client.messages.countTokens({model, system, messages}) → .input_tokens.
게이트로 활용 — 큰 요청 사전 차단
요청 전에 토큰을 세서 예산을 넘는 요청을 거르거나 청크로 쪼갠다:
def guard_request(model: str, messages, system, max_input_tokens=100_000):
count = client.messages.count_tokens(model=model, system=system, messages=messages)
if count.input_tokens > max_input_tokens:
raise ValueError(
f"입력 {count.input_tokens} 토큰이 한도 {max_input_tokens} 초과 "
f"— 청킹 또는 요약 필요"
)
return count.input_tokens
파일을 두 버전 간 diff
엔드포인트는 stateless이므로 각 버전을 따로 세서 뺀다:
import subprocess
def count(text: str) -> int:
return client.messages.count_tokens(
model="claude-opus-4-8",
messages=[{"role": "user", "content": text}],
).input_tokens
before = subprocess.check_output(["git", "show", "HEAD:CLAUDE.md"], text=True)
after = open("CLAUDE.md").read()
print(f"토큰 증가: {count(after) - count(before)}")
함정
- 모델을 바꾸면 토큰 카운트가 달라질 수 있다. Opus 4.7 이후 토크나이저는 이전 모델과 다르게 토큰을 센다. 모델 마이그레이션 시 같은 텍스트가 다른 토큰 수를 내므로, 블랭킷 배수를 적용하지 말고 대표 프롬프트로
count_tokens를 새로 돌려 재기준한다. - count_tokens는 무료에 가깝지만 별도 API 호출이다. 매 요청마다 부르면 지연이 늘 수 있으니, 게이트나 추정이 필요할 때만 쓴다.
- 캐시를 고려한 실제 청구는
count_tokens가 아니라 응답의usage에서 본다 —count_tokens는 캐시 히트를 모른다.
8. 컨텍스트 관리 — 토큰 양 자체를 줄이는 compaction과 context editing
캐싱·라우팅은 같은 토큰을 더 싸게 처리하는 것이고, 컨텍스트 관리는 처리할 토큰 양 자체를 줄이는 것이다. 긴 에이전트 루프에서는 이게 가장 큰 절감일 수 있다 — 매 턴 전체 히스토리를 다시 보내기 때문이다.
Compaction — 컨텍스트 한계 근처에서 서버가 요약
대화가 컨텍스트 윈도우에 근접하면 compaction이 이전 컨텍스트를 서버에서 자동 요약한다 (beta compact-2026-01-12, Opus 4.8/4.7/4.6, Sonnet 4.6 지원):
client = anthropic.Anthropic()
messages = []
def chat(user_message: str) -> str:
messages.append({"role": "user", "content": user_message})
response = client.beta.messages.create(
betas=["compact-2026-01-12"],
model="claude-opus-4-8",
max_tokens=16000,
messages=messages,
context_management={"edits": [{"type": "compact_20260112"}]},
)
# 핵심: text만이 아니라 content 전체를 다시 넣어야 함
messages.append({"role": "assistant", "content": response.content})
return next(b.text for b in response.content if b.type == "text")
치명적 함정: 응답의 response.content(text가 아니라 전체)를 매 턴 messages에 다시 넣어야 한다. compaction 블록은 응답에 포함되고, API가 다음 요청에서 이걸로 압축된 히스토리를 대체한다. text 문자열만 뽑아 넣으면 compaction 상태를 조용히 잃는다.
Context editing — 오래된 도구 결과·thinking 블록 정리
compaction이 요약이라면 context editing은 가지치기다. 오래된 tool_result와 thinking 블록을 설정 가능한 임계치에 따라 제거한다. 완료된 도구 출력이 더는 필요 없을 때 트랜스크립트를 가볍게 유지하면서 대화 구조는 보존한다. compaction과 달리 요약하지 않고 잘라낸다.
둘 중 무엇을 쓸까
| 패턴 | 언제 |
|---|---|
| Context editing | 오래된 도구 결과/thinking이 무관해지고, 요약 없이 트랜스크립트를 가볍게 유지하고 싶을 때 |
| Compaction | 대화가 컨텍스트 윈도우 한계에 도달/초과할 가능성이 있을 때 |
| Memory | 세션을 넘어 상태를 영속해야 할 때 (세션 내가 아님) |
많은 장기 에이전트는 셋을 모두 쓴다 — editing으로 stale 턴을 쳐내고, compaction으로 한계 근처에서 요약하고, memory로 세션 간 영속.
직접 줄이는 단순 기법들
beta 기능 없이도 토큰을 줄이는 방법:
- Files API로 파일 한 번 업로드, 여러 번 참조. 같은 문서에 여러 질문을 할 때 매번 본문을 다시 보내지 말고
file_id로 참조한다. 업로드/삭제는 무료, 메시지에 쓴 콘텐츠만 입력 토큰으로 과금.
uploaded = client.beta.files.upload(file=("contract.pdf", open("contract.pdf", "rb"), "application/pdf"))
for question in questions: # 본문 재전송 없이 file_id 재사용
resp = client.beta.messages.create(
model="claude-opus-4-8", max_tokens=16000,
messages=[{"role": "user", "content": [
{"type": "text", "text": question},
{"type": "document", "source": {"type": "file", "file_id": uploaded.id}},
]}],
betas=["files-api-2025-04-14"],
)
- 불필요한 대화 히스토리를 클라이언트에서 잘라낸다. API는 stateless라 매번 전체 히스토리를 보낸다. 오래된 턴이 현재 작업과 무관하면 직접 슬라이딩 윈도우로 잘라 보낸다.
- 검색 결과를 컨텍스트에 넣기 전에 필터링한다. 큰 중간 결과를 프로그래매틱 도구 호출(PTC)로 코드에서 먼저 거르면, 최종 출력만 컨텍스트에 들어가 토큰이 절약된다.
9. 전부 합치기 — 비용 모니터링 래퍼와 우선순위
네 레버를 같이 쓰되, 매 요청의 비용을 추적하지 않으면 어디서 새는지 모른다. 모든 호출을 감싸 usage를 로깅하는 얇은 래퍼를 둔다:
import logging
logger = logging.getLogger("llm_cost")
def tracked_create(client, **kwargs):
model = kwargs["model"]
resp = client.messages.create(**kwargs)
u = resp.usage
cost = estimate_cost(model, u) # 1번 섹션의 함수
logger.info(
"model=%s in=%d out=%d cache_read=%d cache_write=%d cost=$%.5f",
model, u.input_tokens, u.output_tokens,
u.cache_read_input_tokens, u.cache_creation_input_tokens, cost,
)
# 캐시가 안 먹는지 즉시 경보
total_in = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
if total_in > 5000 and u.cache_read_input_tokens == 0:
logger.warning("캐시 미스 의심: 큰 프리픽스(%d)인데 cache_read=0", total_in)
return resp
이 래퍼 하나로 (a) 모델별 실제 비용 집계 (b) 캐시 무효화 조기 감지 (c) 출력 토큰 폭주 탐지가 다 된다.
적용 우선순위 — 투자 대비 효과 순서
- 토큰 카운팅·비용 로깅부터. 측정 없이 최적화하면 헛수고다. 위 래퍼를 먼저 깐다.
- 프롬프트 캐싱. 적용 비용이 가장 낮고 효과가 가장 크다. 큰 시스템 프롬프트/도구 정의가 있으면 즉시 켠다. 침묵 무효화 감사(3번)를 반드시 같이 한다.
- 배치 API. 지연 허용 작업을 식별해 옮긴다. 코드 변경 거의 없이 50%.
- 모델 라우팅. 작업 복잡도 분류 후 Haiku/Sonnet으로 내린다. 평가셋으로 정확도 먼저 검증.
- 컨텍스트 관리. 긴 에이전트 루프에만. compaction/context editing은 구현 부담이 있으니 토큰 양이 실제로 문제일 때.
레버 간 충돌 요약 (다시 한번)
- 모델 라우팅 ↔ 캐싱: 세션 중간 모델 전환은 캐시를 깬다. 한 대화는 한 모델, 라우팅은 시작 시점에.
- 배치 ↔ fallback: 배치는
fallbacks파라미터를 거부한다. - 캐싱 ↔ 동적 컨텍스트: 시각·UUID·유저ID를 시스템 프롬프트에 넣지 말 것.
OpenAI와의 매핑 (참고)
핵심 개념은 거의 1:1로 옮겨간다: 프롬프트 캐싱(OpenAI는 1024+ 토큰 프리픽스 자동 캐싱, 별도 마커 불필요), 배치 API(/v1/batches, 50% 할인, JSONL 업로드 방식), 모델 티어(작은 모델로 라우팅), 토큰 카운팅(단 Claude엔 count_tokens API를 쓰고 tiktoken 금지). 차이가 나는 곳은 캐시 제어 방식(Claude는 명시적 cache_control 마커, OpenAI는 자동)과 배치 제출 형식 정도다.