LLM 청구의 대부분은 출력 토큰이 아니라 입력 토큰에서 발생합니다. 의외라고 느낄 수도 있지만, 숫자를 따라가보면 명확해집니다. 일반적인 RAG 챗봇은 시스템 프롬프트(2K 토큰) + 검색된 문서 컨텍스트(8K 토큰) + 대화 히스토리(4K 토큰) + 사용자 질문(0.2K 토큰)으로 매 턴마다 14K 토큰을 입력합니다.
동일 사용자가 5턴 대화하면 약 70K 토큰이 청구되지만, 실제로 새로 추가된 정보는 5K 토큰뿐입니다. 나머지 65K는 동일한 컨텍스트를 반복 전송한 결과죠. 낭비가 맞습니다.
프롬프트 캐싱은 이 반복 전송 부분을 서버 측에 캐시해 두고, 후속 요청에서 캐시된 접두사(prefix)를 참조하기만 합니다. 결과적으로:
- 비용 절감: 캐시 히트 토큰은 정가의 10~50%로 청구됨
- 지연시간 감소: TTFT(Time To First Token)가 30~80% 개선됨
- 처리량 향상: 동일 GPU 자원으로 더 많은 요청 처리 가능
공급자별 캐싱 메커니즘 비교
Anthropic Claude: 명시적 cache_control 블록
Claude는 가장 세밀한 제어를 제공합니다. 메시지 내 특정 블록에 cache_control을 명시하면 해당 지점까지의 전체 접두사가 캐시됩니다. 2025년 말 기준 두 가지 TTL이 지원됩니다:
- 5분 캐시: 기본값, 캐시 쓰기 비용은 정가의 125%, 읽기는 10%
- 1시간 캐시(Extended): 캐시 쓰기 비용은 정가의 200%, 읽기는 10%
최소 캐시 가능 토큰은 모델별로 다릅니다. Claude Opus/Sonnet은 1024 토큰, Haiku는 2048 토큰이에요. 이 미만이면 cache_control을 지정해도 캐싱되지 않습니다 (조용히 무시되는 점이 처음엔 좀 헷갈립니다).
from anthropic import Anthropic
client = Anthropic()
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
system=[
{
"type": "text",
"text": "당신은 법률 문서 분석 전문 어시스턴트입니다...",
},
{
"type": "text",
"text": LARGE_LEGAL_CORPUS, # 50K 토큰
"cache_control": {"type": "ephemeral", "ttl": "1h"},
},
],
messages=[
{"role": "user", "content": "계약서의 해지 조항을 요약해줘"}
],
)
print(response.usage)
# cache_creation_input_tokens: 50000 (첫 호출만)
# cache_read_input_tokens: 50000 (두 번째 호출부터)
# input_tokens: 12 (사용자 메시지만)
OpenAI: 자동 캐싱 (코드 변경 불필요)
OpenAI는 2024년 10월부터 모든 GPT-4o, GPT-4.1, o-시리즈 모델에 자동 프롬프트 캐싱을 적용합니다. 1024 토큰 이상의 프롬프트 접두사가 동일하면 자동으로 캐시되며, 캐시 히트 토큰은 50% 할인됩니다. 코드 변경은 필요 없지만 — 솔직히 말해 — 제어권도 그만큼 적습니다.
from openai import OpenAI
client = OpenAI()
# 두 호출의 첫 N개 토큰이 동일하면 자동 캐시 적용
response = client.chat.completions.create(
model="gpt-4.1",
messages=[
{"role": "system", "content": LARGE_SYSTEM_PROMPT},
{"role": "user", "content": "질문 1"}
],
)
print(response.usage.prompt_tokens_details.cached_tokens)
# 8192 (자동으로 캐시됨)
핵심 제약은 접두사 일치(prefix match)입니다. 메시지 배열의 첫 부분이 바이트 단위로 동일해야 하며, 중간에 동적 콘텐츠가 끼면 그 이후 토큰은 캐시되지 않습니다. 이 부분에서 발이 걸리는 분들이 정말 많아요.
Google Gemini: 명시적 컨텍스트 캐시 API
Gemini는 별도의 CachedContent 리소스를 미리 생성하고, 이후 요청에서 cached_content ID를 참조합니다. TTL은 기본 1시간이지만 명시적으로 설정 가능하며, 캐시 저장 비용(시간당)이 별도로 부과되는 점이 다른 공급자와 다릅니다. 처음 청구서를 받았을 때 "어, 이건 뭐지?" 했던 항목이 바로 이거였습니다.
from google import genai
from google.genai import types
from datetime import timedelta
client = genai.Client()
cache = client.caches.create(
model="gemini-2.5-pro",
config=types.CreateCachedContentConfig(
system_instruction="당신은 의료 문서 검토 어시스턴트입니다...",
contents=[LARGE_MEDICAL_GUIDELINES], # 100K+ 토큰
ttl=timedelta(hours=2),
),
)
response = client.models.generate_content(
model="gemini-2.5-pro",
contents="이 환자 차트의 위험 요인을 분석해줘",
config=types.GenerateContentConfig(cached_content=cache.name),
)
print(response.usage_metadata)
# cached_content_token_count: 102400
# prompt_token_count: 18
요약 비교표
- Claude: 명시적 제어, 5분/1시간 TTL, 최소 1024 토큰, 캐시 읽기 10% / 쓰기 125%
- OpenAI: 자동, ~5~10분 TTL(공개 안 됨), 최소 1024 토큰, 캐시 읽기 50% / 쓰기 100%
- Gemini: 명시적 리소스, 사용자 정의 TTL, 최소 4096 토큰, 캐시 읽기 25% + 저장료
캐시 친화적 프롬프트 구조 설계
모든 공급자의 캐싱은 접두사 일치에 의존합니다. 따라서 프롬프트를 "안정적 → 가변적" 순서로 정렬해야 합니다. 잘못된 구조는 캐시를 그냥… 무력화합니다. 한 글자만 어긋나도요.
안티 패턴: 동적 콘텐츠를 앞에 두는 경우
# BAD: 타임스탬프가 시스템 프롬프트 앞에 있어 매번 캐시 미스
system_prompt = (
f"현재 시각: {datetime.now().isoformat()}\n"
f"{STATIC_INSTRUCTIONS}\n"
f"{LARGE_KNOWLEDGE_BASE}"
)
권장 패턴: 정적 콘텐츠를 최대한 앞으로
# GOOD: 동적 데이터는 사용자 메시지로 분리
system_blocks = [
{"type": "text", "text": STATIC_INSTRUCTIONS},
{"type": "text", "text": LARGE_KNOWLEDGE_BASE,
"cache_control": {"type": "ephemeral"}},
]
user_message = f"현재 시각: {datetime.now().isoformat()}\n질문: {question}"
다단계 캐시 브레이크포인트
Claude는 최대 4개의 cache_control 블록을 허용합니다. 이를 활용해 변경 빈도가 다른 콘텐츠를 계층화할 수 있어요. 개인적으로 이 패턴이 캐싱의 진짜 묘미라고 생각합니다.
system=[
# 1: 거의 변하지 않음 (월 단위)
{"type": "text", "text": SYSTEM_INSTRUCTIONS,
"cache_control": {"type": "ephemeral", "ttl": "1h"}},
# 2: 가끔 변함 (일 단위 RAG 인덱스)
{"type": "text", "text": KNOWLEDGE_BASE_SNAPSHOT,
"cache_control": {"type": "ephemeral", "ttl": "1h"}},
# 3: 세션별 (사용자 프로필)
{"type": "text", "text": user_profile_json,
"cache_control": {"type": "ephemeral"}}, # 5분
]
패턴 1: RAG 컨텍스트 재사용
RAG 시스템에서 검색된 문서를 캐싱할지 결정하는 기준은 재사용 확률입니다. 동일 사용자가 같은 문서 집합에 대해 후속 질문을 할 가능성이 높다면 캐시가 유효합니다. 반대로 검색 결과가 매번 완전히 다르면 캐시 쓰기 오버헤드(125%)가 손해입니다.
실용적 휴리스틱 하나 — 한 세션 내에서 평균 3회 이상 동일 컨텍스트가 재사용되면 캐싱이 이득입니다. (10% × 3 = 30%, 첫 쓰기 125% 포함 시 약 130% 청구되지만 캐시 없을 때 300%보다 저렴하죠.)
def build_rag_messages(retrieved_chunks, user_query, session_history):
# 검색 결과를 청크 ID 정렬 → 결정적 직렬화로 캐시 히트율 ↑
sorted_chunks = sorted(retrieved_chunks, key=lambda c: c.id)
context = "\n\n".join(c.text for c in sorted_chunks)
return {
"system": [
{"type": "text", "text": SYSTEM_PROMPT},
{"type": "text", "text": context,
"cache_control": {"type": "ephemeral"}},
],
"messages": session_history + [
{"role": "user", "content": user_query}
],
}
주의: 검색 결과 순서가 매번 달라지면 캐시 미스가 발생합니다. 리랭킹 후에도 결정적 정렬 키(예: 문서 ID 오름차순)로 한 번 더 정렬해 캐시 안정성을 확보해야 합니다. 이걸 놓쳐서 캐시 히트율이 0%였던 경험, 부끄럽지만 저도 있습니다.
패턴 2: 멀티턴 대화 누적 캐싱
챗봇은 대화가 길어질수록 캐시 가치가 커집니다. 핵심은 매 턴마다 캐시 브레이크포인트를 마지막 어시스턴트 응답 끝에 배치하는 것입니다.
def cache_last_turn(messages):
# 가장 최근 어시스턴트 메시지에 cache_control 부여
for msg in reversed(messages):
if msg["role"] == "assistant":
if isinstance(msg["content"], str):
msg["content"] = [{"type": "text", "text": msg["content"]}]
msg["content"][-1]["cache_control"] = {"type": "ephemeral"}
break
return messages
# 매 턴마다 누적 캐시
messages.append({"role": "user", "content": new_question})
response = client.messages.create(
model="claude-sonnet-4-6",
system=system_blocks,
messages=cache_last_turn(messages),
)
messages.append({"role": "assistant", "content": response.content[0].text})
이 패턴은 N턴 대화에서 캐시 히트율을 거의 100%로 끌어올립니다. 단, Claude는 최대 4개 브레이크포인트 제한이 있으므로 시스템 블록 2개 + 대화 캐시 2개 정도로 나누는 것이 안전합니다.
패턴 3: AI 에이전트의 도구 정의 캐싱
도구 호출(tool use)을 사용하는 에이전트는 도구 스키마가 시스템 프롬프트 다음으로 큰 토큰 덩어리입니다. 50개 도구 정의가 8K 토큰을 차지하는 일이 흔합니다. 도구 스키마는 거의 변하지 않으므로 강력한 캐싱 후보죠.
response = client.messages.create(
model="claude-sonnet-4-6",
system=[{"type": "text", "text": AGENT_SYSTEM,
"cache_control": {"type": "ephemeral"}}],
tools=AGENT_TOOLS, # 자동으로 system 다음에 캐싱됨
messages=messages,
)
Claude의 경우 시스템 프롬프트에 cache_control이 있으면 그 다음에 오는 tools 정의도 동일한 캐시 접두사에 포함됩니다. 멀티에이전트 시스템에서는 각 서브 에이전트마다 도구 집합을 재사용하므로 캐시 효과가 누적됩니다.
캐시 히트율 측정과 모니터링
"캐시를 켰다"는 것과 "캐시가 실제로 작동한다"는 건 완전히 다른 이야기입니다. 프로덕션에서는 반드시 메트릭을 수집해야 합니다. 안 그러면 한 달 뒤 청구서를 보고 깜짝 놀랄 수 있어요.
import logging
from dataclasses import dataclass
@dataclass
class CacheMetrics:
cache_creation: int
cache_read: int
input_tokens: int
@property
def hit_rate(self) -> float:
total = self.cache_creation + self.cache_read + self.input_tokens
return self.cache_read / total if total > 0 else 0.0
@property
def estimated_savings(self) -> float:
# Claude Sonnet 기준: 입력 $3/MTok, 캐시 읽기 $0.30/MTok
baseline_cost = (self.cache_creation + self.cache_read + self.input_tokens) * 3
actual_cost = (self.cache_creation * 3.75 +
self.cache_read * 0.30 +
self.input_tokens * 3)
return (baseline_cost - actual_cost) / baseline_cost
def log_usage(response):
u = response.usage
metrics = CacheMetrics(
cache_creation=u.cache_creation_input_tokens or 0,
cache_read=u.cache_read_input_tokens or 0,
input_tokens=u.input_tokens,
)
logging.info(f"hit_rate={metrics.hit_rate:.2%} "
f"savings={metrics.estimated_savings:.2%}")
return metrics
건강한 프로덕션 시스템의 캐시 히트율 벤치마크:
- 고정 시스템 프롬프트 챗봇: 85~95%
- RAG 챗봇 (사용자별 컨텍스트): 60~80%
- 일회성 분석 작업: 0~20% (캐싱 효과 미미)
40% 미만이면 캐시 구조가 잘못된 것입니다. 가장 흔한 원인은 동적 콘텐츠가 캐시 영역에 섞여 있는 경우입니다 — 타임스탬프, 랜덤 ID, 사용자별 데이터 등이 시스템 블록에 슬쩍 들어가 있지 않은지 확인하세요.
흔한 함정과 디버깅
1. JSON 직렬화 순서 비결정성
Python의 json.dumps()는 기본적으로 dict 순서를 유지하지만, 외부 데이터에서 온 dict는 순서가 다를 수 있습니다. sort_keys=True를 꼭 사용하세요.
# 같은 의미지만 다른 바이트 → 캐시 미스
json.dumps({"a": 1, "b": 2}) # '{"a": 1, "b": 2}'
json.dumps({"b": 2, "a": 1}) # '{"b": 2, "a": 1}'
# 해결
json.dumps(data, sort_keys=True, ensure_ascii=False)
2. 한국어 문자열 정규화 차이
한글이 포함된 프롬프트는 NFC/NFD 정규화 차이로 같아 보이는 문자열이 다른 바이트가 될 수 있습니다. macOS 파일 시스템에서 가져온 데이터가 특히 위험합니다 (Apple의 NFD 사랑은 알려진 골칫거리죠).
import unicodedata
def normalize_for_cache(text: str) -> str:
return unicodedata.normalize("NFC", text)
3. 캐시 TTL 만료 후 첫 요청 비용
5분 TTL은 트래픽이 균일하지 않은 서비스에서 자주 만료됩니다. 트래픽이 적은 새벽 시간에는 1시간 TTL("ttl": "1h")이 비용 효율적이에요. 다만 쓰기 비용이 125% → 200%로 올라가므로, 시간당 평균 3회 이상 요청이 있어야 손익분기를 넘습니다.
4. 스트리밍 응답에서 캐시 헤더 확인
스트리밍 모드에서는 usage 정보가 마지막 message_delta 이벤트에 도착합니다. 이전 SDK 버전에서는 누락되는 버그가 있었으니 최신 버전을 사용하세요.
비용 비교: 실제 시나리오 계산
10K 토큰 RAG 컨텍스트 + 200 토큰 사용자 질의 + 500 토큰 응답을 하루 10,000회 호출하는 시나리오 (Claude Sonnet 4.6 기준):
- 캐싱 없음: 10,200 × 10,000 × $3 / 1M = $306/일
- 5분 캐시 (히트율 80%): 첫 쓰기 + 8,000회 캐시 읽기 = 약 $72/일 (76% 절감)
- 1시간 캐시 (히트율 95%): $48/일 (84% 절감)
월 단위로는 $9,180 → $1,440. 한 줄짜리 코드 변경의 효과로는… 솔직히 충분히 의미 있는 수치죠.
언제 캐싱하지 말아야 하는가
모든 워크로드에 캐싱이 이득은 아닙니다. 다음 경우엔 오히려 캐시 쓰기 비용이 손해입니다:
- 일회성 배치 분석: 같은 프롬프트가 재사용되지 않음
- 매번 다른 사용자 콘텐츠가 시스템 영역에 들어가는 구조: 캐시 미스 보장
- 1024 토큰 미만의 작은 프롬프트: Claude/OpenAI 최소 한도 미달
- A/B 테스트 중인 시스템 프롬프트: 변형마다 다른 캐시 엔트리 생성
OpenAI 배치 API와 Anthropic 배치 API는 별도로 50% 할인을 제공하므로, 지연 시간이 중요하지 않은 작업은 캐싱보다 배치가 더 큰 절감 효과를 줄 수 있습니다.
FAQ
프롬프트 캐싱과 컨텍스트 캐싱은 같은 건가요?
용어가 공급자마다 다릅니다. Anthropic은 "프롬프트 캐싱", Google은 "컨텍스트 캐싱"이라고 부르지만 본질은 동일합니다 — 입력 토큰의 일부를 서버에 미리 저장해 후속 요청에서 재사용하는 기능이죠. OpenAI는 별도 명칭 없이 "Prompt Caching"으로 통칭합니다.
캐시된 데이터는 다른 사용자에게 공유되나요?
아닙니다. 세 공급자 모두 캐시는 API 키(또는 프로젝트) 단위로 격리됩니다. 다른 조직의 캐시 히트가 발생하지 않으며, Anthropic은 추가로 캐시 콘텐츠가 모델 학습에 사용되지 않음을 명시합니다. 다만 동일 키 내에서는 부지런히 같은 캐시를 공유하므로 멀티테넌트 SaaS는 사용자 ID를 접두사에 포함시켜 격리해야 합니다.
캐시 TTL이 만료되기 전에 갱신할 수 있나요?
Anthropic은 캐시된 접두사로 새 요청을 보낼 때마다 TTL이 자동 갱신됩니다(슬라이딩 윈도우). OpenAI도 유사하게 동작합니다. Gemini는 명시적으로 TTL을 업데이트하는 API(caches.update)를 제공합니다. 트래픽이 꾸준하다면 5분 캐시도 실질적으로 무기한 유지됩니다.
스트리밍에서도 캐시가 작동하나요?
네, 모든 공급자가 스트리밍과 캐싱을 동시에 지원합니다. 캐시 읽기는 TTFT를 단축시키므로 스트리밍 UX에 오히려 더 유리해요. 사용량 정보는 스트림 종료 시 마지막 이벤트(message_stop의 usage)에 포함되어 도착합니다.
로컬 오픈소스 모델(vLLM, llama.cpp)에서도 프롬프트 캐싱이 가능한가요?
가능합니다. vLLM은 --enable-prefix-caching 플래그로 자동 KV 캐시 재사용을 지원하며, llama.cpp는 --prompt-cache 옵션을 제공합니다. 클라우드 API보다 세밀한 제어가 가능하지만, 메모리 관리와 캐시 무효화 정책을 직접 설계해야 합니다. 자체 호스팅 LLM의 경우 GPU 메모리가 캐시 크기의 상한이 됩니다.
마치며
프롬프트 캐싱은 LLM 애플리케이션에서 가장 ROI가 높은 최적화 중 하나입니다. 코드 한두 줄로 비용을 70~90% 줄일 수 있고, 동시에 사용자 응답 시간까지 개선됩니다. 핵심은 캐시를 단순히 활성화하는 것이 아니라 — 프롬프트를 "정적 → 가변적" 순서로 구조화하고, 결정적 직렬화를 적용하며, 실제 히트율을 모니터링해 회귀를 조기에 잡는 것입니다.
다음 단계로 AI 에이전트 컨텍스트 엔지니어링 가이드를 통해 캐시 친화적인 컨텍스트 설계 원칙을 더 깊이 살펴보길 권장합니다.