LLM 기반 애플리케이션을 운영하다 보면 두 가지 비용이 동시에 커집니다. 하나는 토큰 단위로 청구되는 금전적 비용이고, 다른 하나는 사용자가 첫 토큰을 받기까지 기다리는 지연(latency)입니다. RAG 파이프라인처럼 매 요청마다 수천~수만 토큰의 컨텍스트(시스템 프롬프트, 검색 문서, 예시)를 다시 보내는 구조라면, 같은 내용을 반복해서 처리하느라 두 비용이 함께 폭증합니다.
이 글에서는 LLM 응답 캐싱을 두 층위로 나눠 다룹니다. 하나는 프로바이더가 제공하는 프롬프트 캐시(prompt caching)로 프리픽스를 재사용해 비용·지연을 줄이는 방법이고, 다른 하나는 애플리케이션 레벨에서 완성된 응답 자체를 캐싱하는 방법입니다. 두 전략은 배타적이지 않고 함께 쓸 때 효과가 큽니다. 예시는 Anthropic Claude API를 기준으로 설명하지만, 프리픽스 캐시라는 개념은 다른 프로바이더에도 대체로 적용됩니다.
프롬프트 캐시의 핵심: 프리픽스 매치
프롬프트 캐싱을 이해하는 데 필요한 단 하나의 불변식이 있습니다. 프롬프트 캐시는 프리픽스(접두부) 매치라는 것입니다. 캐시 키는 각 브레이크포인트까지 렌더링된 프롬프트의 정확한 바이트로부터 파생됩니다. 프리픽스 어딘가에서 1바이트라도 바뀌면, 그 지점 이후의 캐시는 전부 무효화됩니다.
렌더 순서는 tools → system → messages입니다. 따라서 캐싱을 설계할 때 원칙은 명확합니다. 절대 바뀌지 않는 내용은 앞에, 매 요청 달라지는 내용은 뒤에 두는 것입니다.
- 안정적(고정 시스템 프롬프트, 결정적 도구 목록, 고정 예시): 프리픽스 앞쪽, 브레이크포인트 이전.
- 세션 단위로 변하는 것: 전역 프리픽스 뒤, 세션별 캐시.
- 매 요청 변하는 것(타임스탬프, UUID, 사용자 질문): 마지막 브레이크포인트 뒤로 몰아넣기.
Anthropic API에서는 cache_control 마커로 캐시 경계를 지정합니다. 가장 단순한 방법은 마지막 시스템 블록에 마커를 다는 것으로, tools와 system을 함께 캐싱합니다.
import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
system=[
{
"type": "text",
"text": LARGE_SHARED_CONTEXT, # 매 요청 동일한 큰 프리픽스
"cache_control": {"type": "ephemeral"}, # 여기까지 캐싱
}
],
messages=[{"role": "user", "content": user_question}], # 매번 달라짐 → 뒤
)
TTL과 최소 캐시 크기, 그리고 경제성
cache_control의 ttl은 캐시 수명을 정합니다. 기본값은 5분이고, 오래 유지하려면 1시간을 명시할 수 있습니다.
# 5분 TTL (기본값)
"cache_control": {"type": "ephemeral"}
# 1시간 TTL — 트래픽 간격이 5분보다 클 때 유용
"cache_control": {"type": "ephemeral", "ttl": "1h"}
주의할 점이 몇 가지 있습니다.
- 최소 캐시 프리픽스: 프리픽스가 일정 토큰 수 미만이면 마커를 달아도 조용히 캐싱되지 않습니다(에러 없이
cache_creation_input_tokens: 0). 모델별로 최소값이 다릅니다(대략 1024~4096 토큰 범위). 짧은 프롬프트를 캐싱하려다 아무 효과 없이 넘어가는 경우가 흔합니다. - 브레이크포인트 개수: 요청당 최대 4개.
- 모델 스코프: 캐시는 모델별로 분리됩니다. 대화 도중 모델을 바꾸면 캐시가 통째로 무효화됩니다.
경제성 계산은 TTL에 따라 손익분기점이 달라집니다. 캐시 읽기는 기본 입력 가격의 약 0.1배로 저렴하지만, 캐시 쓰기는 프리미엄이 붙습니다. 5분 TTL은 약 1.25배, 1시간 TTL은 약 2배입니다.
- 5분 TTL: 두 번만 요청해도 이득(1.25배 쓰기 + 0.1배 읽기 = 1.35배 < 캐시 없이 2배).
- 1시간 TTL: 쓰기 비용이 두 배라 최소 세 번은 읽어야 손익분기(2배 + 0.2배 = 2.2배 < 3배). 대신 트래픽에 긴 공백이 있어도 엔트리가 살아 있습니다.
캐시가 정말 히트하는지 검증하기
캐싱 코드를 넣었다고 캐시가 작동한다고 믿으면 안 됩니다. 응답의 usage 필드로 반드시 확인해야 합니다.
print(response.usage.cache_creation_input_tokens) # 이번에 캐시에 쓴 토큰(~1.25x)
print(response.usage.cache_read_input_tokens) # 캐시에서 읽은 토큰(~0.1x)
print(response.usage.input_tokens) # 캐시 안 된 나머지(정가)
동일한 프리픽스로 반복 요청했는데 cache_read_input_tokens가 계속 0이라면, 어딘가에서 조용한 무효화(silent invalidator)가 일어나고 있는 것입니다. 대표적인 원인은 다음과 같습니다.
- 시스템 프롬프트에
datetime.now()나 UUID를 끼워 넣어 매 요청 프리픽스가 달라짐. json.dumps()를sort_keys=True없이 써서 직렬화 순서가 비결정적임.- 요청마다 도구 집합(tools)이 달라짐 — tools는 위치 0에서 렌더되므로 전체 캐시가 깨짐.
고치는 방법은 동적 요소를 마지막 브레이크포인트 뒤로 옮기거나, 직렬화를 결정적으로 만들거나, 로드-베어링이 아니라면 그냥 제거하는 것입니다. 특히 “현재 시각: X”, “사용자: Y” 같은 값을 시스템 프롬프트 헤더에 넣지 말고, 뒤쪽 메시지로 주입해야 합니다.
애플리케이션 레벨 응답 캐싱: 완성된 답을 재사용
프롬프트 캐시가 “프리픽스 처리 비용”을 줄인다면, 애플리케이션 레벨 캐시는 “동일 질문에 대한 완성 응답”을 통째로 재사용합니다. FAQ, 자주 반복되는 요약 요청, 결정적 분류 작업처럼 같은 입력이 자주 반복되는 워크로드에서 특히 효과적입니다. 가장 단순한 형태는 입력을 정규화한 해시를 키로 삼는 완전 일치(exact-match) 캐시입니다.
import hashlib
import json
import redis
r = redis.Redis(host="localhost", port=6379, db=0)
def cache_key(model: str, system: str, messages: list) -> str:
payload = json.dumps(
{"model": model, "system": system, "messages": messages},
sort_keys=True, ensure_ascii=False, # 결정적 직렬화
)
return "llm:" + hashlib.sha256(payload.encode("utf-8")).hexdigest()
def cached_completion(model, system, messages, ttl=3600):
key = cache_key(model, system, messages)
hit = r.get(key)
if hit is not None:
return json.loads(hit) # 캐시 히트 — API 호출 0회
resp = client.messages.create(
model=model, max_tokens=1024, system=system, messages=messages,
)
text = next(b.text for b in resp.content if b.type == "text")
r.setex(key, ttl, json.dumps({"text": text}))
return {"text": text}
완전 일치 캐시의 한계는 “표현만 다르고 뜻은 같은” 질문을 놓친다는 점입니다. 이때는 시맨틱 캐시로 확장할 수 있습니다. 질문을 임베딩으로 바꿔 벡터 저장소에서 코사인 유사도가 임계치 이상인 이전 질문을 찾고, 있으면 그 답을 재사용하는 방식입니다.
# 시맨틱 캐시의 개념 (의사코드에 가까움)
def semantic_cached(query, threshold=0.92):
q_vec = embed(query) # 질문 임베딩
hit = vector_store.search(q_vec, top_k=1) # 가장 가까운 과거 질문
if hit and hit.score >= threshold:
return hit.cached_answer # 유사 질문 → 캐시 응답
answer = call_llm(query)
vector_store.upsert(q_vec, answer) # 새 질문·답 저장
return answer
다만 시맨틱 캐시는 임계치를 잘못 잡으면 다른 질문에 엉뚱한 답을 내놓는 위험이 있습니다. 임계치는 보수적으로 높게 잡고, 정확성이 중요한 도메인(의료·법률·금융)에서는 완전 일치만 쓰는 편이 안전합니다.
무엇을 캐싱하면 안 되는가
캐싱은 강력하지만 아무 응답에나 적용하면 사고가 납니다. 다음은 캐싱을 피하거나 매우 조심해야 하는 경우입니다.
- 개인화·시점 의존 응답: 사용자별 데이터나 “오늘 날씨” 같은 시점 정보가 섞인 답은 캐시하면 다른 사용자·다른 시점에 오답이 됩니다. 키에 사용자·시점을 반드시 포함하거나 캐시하지 않습니다.
- 도구 호출/부수효과가 있는 응답: 이메일 전송·DB 수정 같은 부수효과를 유발하는 흐름은 결과 텍스트만 캐시해도 실제 동작이 재실행되지 않아 상태 불일치가 생깁니다.
- 프리픽스가 매번 처음부터 다른 프롬프트: 앞 1K 토큰이 요청마다 다르면 재사용 가능한 프리픽스가 없습니다.
cache_control을 달아도 쓰기 프리미엄만 내고 읽기는 0입니다. 이럴 땐 아예 캐싱을 끕니다.
또한 시맨틱 캐시를 쓸 때는 민감 정보가 임베딩·저장소에 남는다는 점을 고려해야 합니다. 프롬프트에 비밀 값이나 PII를 넣지 말고, 저장소 접근 통제와 보존 정책을 명확히 해야 합니다.
두 층위를 함께 설계하기
실무에서는 애플리케이션 캐시와 프롬프트 캐시를 계층으로 겹쳐 씁니다. 요청이 들어오면 다음 순서로 처리하는 것이 전형적인 패턴입니다.
- 1차: 애플리케이션 캐시 — 완전 일치(또는 보수적 임계치의 시맨틱) 히트면 API를 아예 호출하지 않고 즉시 응답. 비용·지연 모두 0에 수렴.
- 2차: 프롬프트 캐시 — 캐시 미스로 API를 호출해야 하면, 공유 프리픽스(시스템 프롬프트·검색 문서 템플릿)를
cache_control로 캐싱해 프리픽스 처리 비용을 절감. - 사전 워밍(선택) — 첫 요청의 콜드 미스 지연이 사용자에게 보이는 인터랙티브 서비스라면, 시작 시
max_tokens=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"}],
)
사전 워밍은 “지금 캐시 쓰기 비용을 내고 다음 실요청의 지연을 낮추는” 트레이드오프이므로, 트래픽이 연속적이라면(요청 간격이 TTL보다 짧다면) 굳이 필요 없습니다. 실요청이 알아서 캐시를 데워 주기 때문입니다. 반대로 트래픽에 긴 공백이 있으면 TTL 직전에 재워밍하거나 1시간 TTL로 전환합니다.
마무리
LLM 응답 캐싱은 “비용과 지연을 동시에 잡는” 몇 안 되는 레버입니다. 프롬프트 캐시는 프리픽스 매치라는 단 하나의 불변식 위에 서 있으니, 안정적인 내용을 앞에 두고 동적 요소를 뒤로 몰며 usage.cache_read_input_tokens로 히트를 검증하는 것이 전부입니다. 여기에 애플리케이션 레벨의 완전 일치·시맨틱 캐시를 얹으면, 반복되는 요청은 API를 건드리지도 않고 응답할 수 있습니다. 다만 개인화·시점 의존·부수효과가 있는 응답은 캐싱에서 제외하고, 시맨틱 캐시의 임계치는 보수적으로 잡는 것이 안전의 핵심입니다.