Python에서의 프롬프트 캐싱(Prompt Caching) 및 비용 제어
요약
LLM 애플리케이션 개발 시 비용을 엔지니어링 변수로 취급하여 관리하는 방법을 다룹니다. 프롬프트 캐싱, 배치 API 활용, 모델 라우팅 등 비용과 지연 시간을 최적화하기 위한 네 가지 핵심 레버를 소개합니다.
핵심 포인트
- 공유 접두사(Shared Prefix)가 LLM 비용 발생의 주된 원인임
- 프롬프트 캐싱을 통해 시스템 프롬프트 및 RAG 컨텍스트 비용 절감 가능
- 출력 토큰은 입력보다 비싸므로 간결한 출력이 비용 제어에 유리함
- 모델 라우팅을 통해 저렴한 모델로 요청을 분류하고 상위 모델로 에스컬레이션 가능
서론
https://pg-blogs.netlify.app/posts/10-building-reliable-llm-apps-in-python/은 작업별로 적절한 모델을 선택하고 공유 접두사(shared prefix)를 캐싱하는 섹션으로 마무리되었습니다. 이는 더 큰 규율로 들어가는 입구였습니다: LLM 지출은 고정된 청구서가 아니라 엔지니어링 변수입니다 — 즉, 쿼리 지연 시간(query latency)이나 메모리 사용량(memory footprint)에 적용하는 것과 동일한 엄격함으로 측정하고 줄일 수 있는 변수라는 뜻입니다.
이 포스트에서는 네 가지 레버(levers)에 대해 더 깊이 다룹니다: 입력/출력 가격 책정이 실제로 어떻게 작동하는지, 그리고 왜 _접두사(prefix)_가 보통 비용이 발생하는 지점인지, 정확한 cache_control 형태와 캐시 히트(cache hit)를 가정하는 대신 어떻게 증명할 수 있는지, 지연 시간에 민감하지 않은 작업을 위한 배치 API(Batches API), 그리고 저렴한 모델이 요청을 분류하고 어려운 요청만 상위 모델로 에스컬레이션(escalating)하는 모델 라우팅(model routing)입니다. 관통하는 핵심은 정직합니다: 최적화하기 전에 측정하십시오. 여기 있는 모든 레버에는 각자의 비용이 따릅니다. 잘못 적용하면 더 저렴해지는 것이 아니라 더 느려지거나 더 비싸질 수 있습니다.
토큰 경제학: 왜 접두사(Prefix)가 비용의 원인인가
LLM 제공업체는 입력(input)과 출력(output) 토큰을 별도로 가격 책정하며, 출력은 항상 더 많은 비용이 듭니다 — 생성(generation)은 자기회귀적(autoregressive, 각 토큰이 이전의 모든 토큰에 의존함)인 반면, 입력은 병렬로 처리될 수 있기 때문입니다. 현재 모델 카탈로그의 대표적인 가격 책정 예시는 다음과 같습니다:
| 모델 | 입력 | 출력 |
|---|---|---|
| Claude Opus 4.8 | $5.00 / MTok | $25.00 / MTok |
| ... |
두 가지 결과가 뒤따릅니다:
- 길고 긴 시스템 프롬프트(System Prompt), 도구 목록(Tool List), 또는 RAG 컨텍스트(Context)는 한 번만 작성되는 것이 아니라, 모든 요청마다 입력(Input)으로 청구됩니다. 20K 토큰의 시스템 프롬프트를 10,000번의 요청에 보낸다면 이는 2억(200M) 개의 입력 토큰이 되며, Opus 4.8 요율을 적용하면 모델이 단 하나의 출력 토큰을 생성하기도 전에 $1,000가 발생합니다. 사용자의 실제 질문이 아니라, 보통 **공유 접두사(Shared Prefix)**가 비용의 주된 원인이 됩니다.
- 장황한 출력(Verbose Output)은 비용을 두 배로 발생시킵니다 — 한 번은 직접적으로(더 높은 요율로 더 많은 출력 토큰이 청구됨), 그리고 또 한 번은 다음 턴의 히스토리가 그 장황함을 입력(Input)으로서 그대로 전달하기 때문입니다. 간결한 출력을 요청하고 합리적인
max_tokens를 설정하는 것은 단순한 스타일의 선택이 아니라 비용 제어(Cost Control)의 수단입니다.
이것이 바로 아래의 두 가지 기술 — 안정적인 접두사를 캐싱(Caching)하는 것과, 모든 요청을 가장 비싼 모델로 실행하지 않는 것 — 이 (캐싱이) 첫 번째, (모델 선택이) 두 번째 순서로 가장 영향력이 큰 레버(Lever)인 이유입니다.
프롬프트 캐싱 (Prompt Caching): 한 번 쓰고, 저렴하게 읽기
프롬프트 캐싱(Prompt Caching)은 요청의 안정적인 접두사(Stable Prefix) — 시스템 프롬프트, 도구 정의(Tool Definitions), 검색된 RAG 컨텍스트 — 를 표시하여, 동일한 접두사를 가진 이후의 요청이 이를 다시 처리(Reprocessing)하는 대신 저렴하게 읽어올 수 있도록 합니다. 이는 번들로 제공되는 claude-api 스킬의 shared/prompt-caching.md에 근거합니다:
- 접두사 일치 (Prefix match) 방식입니다. 캐시 키는 각
cache_control중단점(breakpoint)까지의 정확한 바이트(bytes)로부터 파생됩니다. 접두사 내 어디에서든 단 1바이트라도 다르면 — 보간된 타임스탬프(interpolated timestamp), 순서가 바뀐 JSON 키, 순서가 바뀐 도구(tool) 목록 등 — 그 이후의 모든 데이터는 무효화됩니다. - 렌더링 순서는
tools→system→messages입니다. 마지막system블록에 중단점을 설정하면 도구(tools)와 시스템(system)이 함께 캐싱됩니다. - 캐시 읽기(Cache reads) 비용은 기본 입력 가격의 약 0.1배이며, 캐시 쓰기(Cache writes) 비용은 1.25배(5분 TTL) 또는 2배(1시간 TTL)입니다. 5분 TTL의 경우, 두 번의 요청만으로도 이미 손익분기점에 도달합니다 (캐싱되지 않은 2배 vs. 1.25배 + 0.1배). 1시간 TTL은 더 높은 쓰기 비용을 상쇄하기 위해 대략 세 번의 요청이 필요합니다.
- 최소 캐싱 가능 접두사는 모델에 따라 다릅니다 — Opus 4.8은 최소 4,096개의 토큰이 필요합니다. 이보다 적으면
cache_control은 오류 없이 조용히 아무 동작도 하지 않으며, 단지cache_creation_input_tokens: 0으로 표시됩니다.
정확한 형태
import anthropic
client = anthropic.Anthropic() # 환경 변수에서 ANTHROPIC_API_KEY를 읽어옵니다
...
5분 이상의 간격을 두고 발생하는 간헐적인 트래픽(bursty traffic)에서 재사용되는 접두사의 경우, 대신 명시적인 1시간 TTL을 전달하세요:
"cache_control": {"type": "ephemeral", "ttl": "1h"}
캐시 히트(Cache hit) 확인 — 추측하지 말고 필드를 확인하세요
응답의 usage 객체가 진실(ground truth)입니다. input_tokens는 캐싱되지 않은 나머지 부분만 보고합니다. 전체 프롬프트 크기는 다음 세 필드의 합계입니다:
usage = response.usage
print("cache write:", usage.cache_creation_input_tokens) # 약 1.25배 지불
print("cache read: ", usage.cache_read_input_tokens) # 약 0.1배 지불
...
새로운 접두사(prefix)에 대한 첫 번째 호출은 cache_creation_input_tokens > 0 및 cache_read_input_tokens == 0을 나타내며, 해당 요청은 쓰기 프리미엄(write premium)을 지불합니다. TTL(Time To Live) 내에서 동일한 접두사를 사용하는 이후의 모든 호출은 cache_read_input_tokens > 0 및 cache_creation_input_tokens == 0을 보여야 합니다. 만약 겉보기에 동일한 요청이 반복됨에도 불구하고 cache_read_input_tokens가 계속 0으로 유지된다면, 접두사의 무언가가 조용히 달라져 있다는 뜻입니다. 예를 들어 시스템 프롬프트에 포함된 datetime.now(), 앞부분에 위치한 uuid4(), sort_keys=True 옵션이 없는 json.dumps(d), 또는 순서가 정해지지 않은 set에서 구성된 도구(tool) 목록 등이 원인일 수 있습니다. 캐싱이 "여기서는 도움이 되지 않는다"라고 결론 내리기 전에, 두 호출 사이의 렌더링된 프롬프트 바이트(bytes)를 비교(diff)하여 원인을 찾아내십시오.
마커 배치보다 더 중요한 아키텍처 규칙: 세션 중간에 도구 목록(tool list)이나 모델을 변경하지 마십시오. 두 요소 모두 요청의 앞부분에 렌더링되므로, 변경 사항이 발생하면 전체 접두사가 무효화됩니다. 시스템 프롬프트에 타임스탬프나 사용자 ID를 보간(interpolate)하지 마십시오. 요청마다 변하는 모든 요소는 messages의 끝부분, 즉 마지막 cache_control 마커 뒤로 밀어내십시오.
Batches API: 지연 시간(Latency)이 중요하지 않을 때 절반 가격으로 이용하기
모든 호출이 2초 이내에 응답을 받을 필요는 없습니다. 야간 보고서 요약, 대량 문서 분류, 임베딩(embeddings) 메타데이터 백필링(backfilling), 평가 세트(eval set) 재점수화 등은 지연 시간에 민감하지 않으며, 모두 Message Batches API가 타겟팅하는 정확한 작업 부하입니다. 이 API는 비동기 처리(대부분의 배치 작업은 1시간 이내에 완료되며, 최대 제한 시간은 24시간입니다. 결과는 29일 동안 유지됩니다)를 제공하는 대신 표준 토큰 가격의 50% 할인된 가격으로 제공됩니다.
번들로 포함된 claude-api 스킬의 python/claude-api/batches.md를 기반으로 합니다. 나중에 결과를 매칭하기 위해 직접 선택한 custom_id를 포함하여 요청 목록을 제출하십시오:
import anthropic
import time
from anthropic.types.message_create_params import MessageCreateParamsNonStreaming
...
프롬프트 캐싱 (Prompt Caching)을 포함한 모든 Messages API 기능은 배치 (batch) 내에서 작동합니다. 하나의 거대한 시스템 프롬프트를 공유하는 10,000개의 분류 호출 배치는 50%의 배치 할인과 공유 접두사 (shared prefix)에 대한 캐시 읽기 (cache-read) 할인을 모두 받게 되며, 이들은 각각 독립적으로 적용됩니다.
shared_system = [
{"type": "text", "text": "You are a support-ticket classifier."},
{"type": "text", "text": large_policy_document, "cache_control": {"type": "ephemeral"}},
...
솔직하게 따져봐야 할 비용 트레이드오프 (cost tradeoff): 배칭 (batching)은 지연 시간 (latency)을 대가로 50%의 확정 할인과 운영 복잡성을 교환하는 것입니다. 제출(submit), 폴링 (poll, 또는 웹훅 대기), custom_id를 통한 대조(reconcile), 그리고 동기식 호출 (synchronous call)에서는 발생하지 않는 errored/expired 항목 처리 과정이 필요합니다. 이는 대량의 비대화형 작업에는 확실한 이점이지만, 사람이 응답을 기다리고 있는 상황에서는 잘못된 도구입니다. 배칭을 사용하기 전에 실제 트래픽 데이터를 바탕으로 워크로드가 실제로 대량인지, 그리고 실제로 지연 시간에 민감하지 않은지 확인하십시오.
모델 라우팅 (Model Routing): 저렴한 모델로 분류(Triage)하기
캐싱 및 배칭과 직교하는(orthogonal) 또 다른 레버는 모든 요청을 가장 비싼 모델로 보내지 않는 것입니다. 감성 분류 (sentiment classification), 의도 탐지 (intent detection), "이 티켓이 긴급한가", 단순 추출 (simple extraction) 등 실제 트래픽의 상당 부분은 저렴한 모델의 역량 범위 내에 있습니다. 이러한 요청은 claude-haiku-4-5로 라우팅하고, claude-opus-4-8은 실제로 필요한 요청을 위해 남겨두십시오.
단순하고 정직한 패턴은 다음과 같습니다: 저렴한 모델을 먼저 실행하고, 모델 스스로 불확실성을 표시하게 한 뒤, 불확실성이 감지될 때만 상위 모델로 에스컬레이션 (escalate)하는 것입니다.
def triage_with_haiku(ticket_text: str) -> tuple[str, bool]:
response = client.messages.create(
model="claude-haiku-4-5",
...
만약 티켓의 80%가 MTok당 $1/$5 비용의 Haiku에 의해 확신을 가지고 분류(triage)되고, 나머지 20%만이 $5/$25 비용의 Opus로 에스컬레이션(escalate)된다면, 혼합 비용(blended cost)은 모든 것을 Opus로 라우팅하는 비용의 극히 일부에 불과합니다. 쉬운 대다수의 작업에서는 품질 저하가 발생하지 않는데, 이는 에스컬레이션 경로가 저렴한 모델이 "확신할 수 없습니다"라고 말하는 바로 그 케이스들을 위해 존재하기 때문입니다. 경계해야 할 실패 모드(failure mode)는 저렴한 모델이 과도하게 확신(overconfident)하는 경우입니다. 분할(split) 방식을 신뢰하기 전에 라벨링된 샘플(labeled sample)을 대상으로 분류의 정확도를 측정하고, 막연한 느낌(vibes)이 아닌 해당 측정값을 바탕으로 신뢰 임계값(confidence threshold)을 조정하십시오.
최적화하기 전에 측정하십시오
위의 모든 기술에는 각자의 비용이 따릅니다. 캐시 쓰기 프리미엄(cache write premium), 배치 운영 오버헤드(batch operational overhead), "실제" 호출 전의 추가적인 분류(triage) 호출 등이 그것입니다. 맹목적으로 적용할 경우, 이 중 어떤 것이든 시스템을 더 비싸게 만들 수 있습니다:
- 매 요청마다 변경되는 접두사(prefix)를 캐싱하는 것은 읽기(read)가 전혀 없는 상태에서 쓰기 프리미엄만 지불하는 것이므로, 캐싱을 하지 않는 것보다 상황이 더 나쁩니다.
- 지연 시간(latency)에 민감한 트래픽을 배치(batching) 처리하는 것은 제품을 망가뜨립니다. 사용자가 로딩 스피너를 바라보고 있다면 50%의 비용 절감은 의미가 없습니다.
- 실제 트래픽 분포에서의 정확도를 측정하지 않고 저렴한 모델로 라우팅하는 것은, 청구서상으로는 비용 승리처럼 보일지 몰라도 품질을 조용히 저하시킬 수 있습니다.
먼저 계측(instrument)하십시오. 요청당 cache_read_input_tokens / cache_creation_input_tokens / input_tokens를 로그로 남기고, 요청 유형별 비용을 추적하며, 이러한 레버(levers)를 사용하기 전에 실제 지연 시간 요구 사항을 파악하십시오. 추측하는 워크로드가 아니라, 실제로 비용이 많이 드는 워크로드를 최적화하십시오.
실무 체크리스트
| 관행 | 중요한 이유 |
|---|---|
| 안정적인 접두사(시스템 프롬프트, 도구, RAG 컨텍스트)를 앞에 두고, 변동성이 큰 콘텐츠를 마지막에 배치 | 접두사 일치(Prefix match) — 1바이트라도 변경되면 그 이후의 모든 내용은 캐싱되지 않음 |
| ... | := |
마치며
LLM 애플리케이션을 위한 비용 제어(Cost control)는 정확성(Correctness)과 별개의 분야가 아닙니다. 이는 새롭고 비용이 많이 드는 형태의 외부 호출(External call)에 적용되는 동일한 엔지니어링 분야입니다. 접두사(Prefix)는 청구서와 같습니다. 캐싱(Caching)은 반복적으로 읽히는 접두사를 저렴한 읽기 작업으로 바꾸고, 배치(Batching)는 긴급하지 않은 대량 작업을 50% 할인된 작업으로 바꾸며, 라우팅(Routing)은 "항상 가장 강력한 모델을 호출한다"를 "작업에 실제로 필요한 모델을 호출한다"로 바꿉니다. 이 중 그 어떤 것도 측정(Measurement)을 대체할 수는 없습니다. 사용량 필드(Usage fields)를 계측하고, 지연 시간(Latency) 요구 사항을 파악하며, 가정이 아닌 실제 수치를 바탕으로 어떤 레버를 당길지 결정하십시오.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기