프롬프트 캐싱으로 AI 비용과 지연 줄이기 — 긴 시스템 프롬프트·문서 재사용
긴 시스템 프롬프트, 수십 페이지짜리 문서, 누적된 대화 히스토리를 매 요청마다 처음부터 다시 처리하면 비용과 지연(latency)이 빠르게 늘어납니다. 프롬프트 캐싱(prompt caching)은 변하지 않는 앞부분(prefix)을 한 번만 처리해 캐시에 저장하고, 이후 요청에서는 그 부분을 0.1배 가격에 재사용하게 해 줍니다. 잘 적용하면 같은 시스템 프롬프트를 반복 사용하는 챗봇·문서 Q&A·RAG·에이전트에서 입력 비용을 대폭 줄이고 첫 토큰까지의 시간을 크게 단축할 수 있습니다.
이 글에서는 Claude(Anthropic)를 기준으로 캐시가 어떻게 동작하는지(prefix 일치 원리, 렌더 순서), cache_control을 어디에 배치해야 하는지, 손익분기는 어디인지, 그리고 캐시를 조용히 무효화시키는 함정들을 실제 동작하는 코드와 함께 정리합니다. Python을 1차로, Laravel/PHP 예제도 포함합니다.
프롬프트 캐싱은 "prefix 일치"가 전부다
프롬프트 캐싱을 이해하는 핵심은 단 하나입니다. 캐시는 prefix(앞부분) 일치다. prefix의 어느 위치든 한 바이트라도 바뀌면 그 지점 이후의 캐시가 전부 무효화된다. 캐시 키는 각 cache_control 지점까지 렌더된 프롬프트의 정확한 바이트로부터 만들어집니다. 위치 N에서 한 글자가 달라지면, 위치 N 이후의 모든 캐시 지점이 무효화됩니다.
그래서 렌더 순서를 반드시 알아야 합니다. Claude는 프롬프트를 tools → system → messages 순서로 렌더합니다. 이 순서가 캐시 설계의 기준입니다.
- tools가 맨 앞(position 0)에 옵니다. 도구를 추가·삭제·재정렬하면 그 뒤의 system·messages 캐시까지 전부 깨집니다.
- system이 그 다음입니다. system의 마지막 블록에 breakpoint를 두면 tools + system이 함께 캐시됩니다.
- messages가 마지막입니다. 매 턴 바뀌는 사용자 질문은 여기 끝에 와야 합니다.
설계 원칙은 자연스럽게 도출됩니다. 안정적인 콘텐츠(고정 시스템 프롬프트, 결정적 도구 목록)는 먼저, 변동 콘텐츠(타임스탬프, 요청별 ID, 그때그때 다른 질문)는 마지막 breakpoint 뒤에 둡니다. 순서만 맞으면 캐싱은 거의 공짜로 동작하고, 순서가 틀리면 cache_control 마커를 아무리 붙여도 소용없습니다.
cache_control ephemeral 기본 배치
캐싱을 켜는 방법은 콘텐츠 블록에 cache_control={"type": "ephemeral"}를 붙이는 것입니다. 가장 흔한 패턴은 긴 시스템 프롬프트를 한 번 처리해 두고 매 요청에서 재사용하는 것입니다. system 텍스트 블록의 마지막에 breakpoint를 둡니다.
from anthropic import Anthropic
client = Anthropic() # ANTHROPIC_API_KEY 환경변수 사용
# 변하지 않는 긴 시스템 프롬프트 (정책, 톤, 예시 등) — 수천 토큰 이상일 때 효과적
SYSTEM_PROMPT = """당신은 사내 규정 도우미입니다.
다음 규정 문서 전체를 근거로만 답변하세요.
... (수천 토큰 분량의 안정적인 지침과 문서) ...
"""
def ask(question: str):
msg = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
system=[
{
"type": "text",
"text": SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"}, # 안정 prefix를 캐시
}
],
messages=[
{"role": "user", "content": question} # 변동 부분 — 마커 없음, 매번 다름
],
)
# 캐시 동작 검증 (다음 섹션에서 자세히)
u = msg.usage
print(f"write={u.cache_creation_input_tokens} "
f"read={u.cache_read_input_tokens} "
f"uncached={u.input_tokens}")
# 응답은 블록 리스트
for block in msg.content:
if block.type == "text":
print(block.text)
ask("연차는 며칠까지 이월되나요?") # 1회차: write 발생
ask("출장비 정산 기한은?") # 2회차 이후: read로 재사용
위 코드의 핵심은 SYSTEM_PROMPT가 요청마다 동일한 바이트라는 점입니다. 질문만 messages 끝에서 바뀌므로, system + (tools가 있다면 tools) 부분은 캐시에서 그대로 읽힙니다.
긴 문서 Q&A — 공유 prefix, 변동 질문
긴 문서를 두고 여러 질문을 던지는 경우, breakpoint를 공유되는 문서 부분의 끝에 둡니다. 전체 프롬프트의 맨 끝에 두면 질문이 매번 달라 매 요청이 서로 다른 캐시 엔트리를 쓰기만 하고 읽지는 못합니다.
from anthropic import Anthropic
client = Anthropic()
# 50페이지 분량의 계약서 등 — 질문이 여러 개 들어와도 문서는 고정
CONTRACT_TEXT = open("contract.txt", encoding="utf-8").read()
def ask_about_contract(question: str):
msg = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": CONTRACT_TEXT,
"cache_control": {"type": "ephemeral"}, # 공유 부분 끝에 마커
},
{
"type": "text",
"text": f"질문: {question}", # 변동 부분 — 마커 없음
},
],
}
],
)
return msg
# 같은 문서에 대한 두 번째 질문부터 문서 토큰이 read로 재사용됨
ask_about_contract("해지 통보 기한은 며칠인가요?")
ask_about_contract("손해배상 조항의 상한은 얼마인가요?")
cache_control은 system 텍스트 블록뿐 아니라 message의 text, image, document, tool_use, tool_result 블록 등 어디에나 붙일 수 있습니다. 요청당 최대 4개의 breakpoint를 쓸 수 있습니다.
최소 캐시 길이 — 짧으면 조용히 캐시되지 않는다
캐시 가능한 최소 prefix 길이가 모델마다 정해져 있습니다. 이보다 짧으면 마커를 붙여도 오류 없이 조용히 캐시되지 않습니다(즉 cache_creation_input_tokens가 0). 예를 들어 3K 토큰짜리 prefix는 Sonnet 4.6에서는 캐시되지만 Opus 4.8에서는 캐시되지 않습니다.
| 모델 | 최소 캐시 가능 토큰 |
|---|---|
| Opus 4.8 / Opus 4.7 / Haiku 4.5 | 4096 |
| Fable 5 / Sonnet 4.6 | 2048 |
비용 손익분기 — write 1.25x, read 0.1x
캐싱은 공짜가 아닙니다. 첫 요청에서 캐시를 쓸 때는 기본 입력 가격의 1.25배(5분 TTL 기준)를 냅니다. 이후 캐시를 읽을 때는 0.1배만 냅니다. 따라서 같은 prefix를 충분히 자주 재사용해야 이득입니다.
5분 TTL(기본값)에서 손익분기는 2회 요청입니다. 같은 prefix를 두 번 쓰면 캐싱한 쪽이 더 쌉니다.
- 캐싱 없음, 2회:
1.0x + 1.0x = 2.0x - 캐싱 있음, 2회:
1.25x(write) + 0.1x(read) = 1.35x
1시간 TTL을 쓰면 write가 2배로 올라가는 대신 캐시가 더 오래 살아남습니다. 이 경우 손익분기는 3회 이상입니다(2.0x + 0.2x = 2.2x vs 캐싱 없음 3.0x). 트래픽이 드문드문 들어오지만(버스트) 5분보다 긴 간격이 생긴다면 1시간 TTL이 유리할 수 있습니다.
# 1시간 TTL — 버스트 트래픽 사이 간격이 5분보다 길 때
system=[
{
"type": "text",
"text": SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral", "ttl": "1h"},
}
]
Claude 모델별 입력/출력 기준 가격($/1M)은 다음과 같습니다. 캐시 read는 이 입력 가격의 약 0.1배, write는 1.25배(5분)/2배(1시간)로 계산됩니다.
| 모델 | 입력 $/1M | 출력 $/1M | 컨텍스트 |
|---|---|---|---|
| Opus 4.8 | $5 | $25 | 1M |
| Sonnet 4.6 | $3 | $15 | 1M |
| Haiku 4.5 | $1 | $5 | 200K |
| Fable 5 | $10 | $50 | 1M |
한 가지 함의: prefix가 매 요청 첫 1K 토큰부터 달라진다면 재사용할 공유 부분이 없으므로 캐싱하지 마세요. cache_control만 붙이면 read 없이 write 프리미엄(1.25배)만 내게 됩니다.
usage로 캐시 동작 검증하기
캐싱은 "켰다"고 끝이 아니라 실제로 읽히는지 응답의 usage로 확인해야 합니다. 세 필드의 의미는 다음과 같습니다.
| 필드 | 의미 |
|---|---|
cache_creation_input_tokens | 이번 요청에서 캐시에 쓴 토큰 (write, 약 1.25배) |
cache_read_input_tokens | 이번 요청에서 캐시에서 읽은 토큰 (read, 약 0.1배) |
input_tokens | 캐시되지 않아 정가로 처리된 나머지 토큰 |
가장 중요한 신호: 동일한 prefix로 반복 요청했는데 cache_read_input_tokens가 계속 0이라면, 어딘가에서 캐시를 조용히 무효화하는 무언가가 있다는 뜻입니다. 두 요청의 렌더된 프롬프트 바이트를 직접 diff해서 원인을 찾으세요.
또 하나 자주 하는 오해: input_tokens는 캐시되지 않은 나머지만 가리킵니다. 전체 프롬프트 크기는 세 값의 합입니다. 에이전트를 몇 시간 돌렸는데 input_tokens가 4K로 보인다면 나머지는 캐시에서 읽힌 것이니 단일 필드가 아니라 합계를 봐야 합니다.
def report_cache(msg):
u = msg.usage
total = u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens
hit_rate = (u.cache_read_input_tokens / total * 100) if total else 0.0
print(
f"총 {total} 토큰 | "
f"정가 {u.input_tokens} | "
f"write {u.cache_creation_input_tokens} | "
f"read {u.cache_read_input_tokens} | "
f"캐시 적중 {hit_rate:.1f}%"
)
first = ask("연차 이월 규정?") # 예) write 6500, read 0
second = ask("출장비 정산 기한?") # 예) write 0, read 6500 ← read가 잡히면 정상
report_cache(second)
1회차에서 write가 잡히고 2회차부터 read가 잡히면 정상입니다. 2회차에도 read가 0이면 다음 섹션의 함정을 의심하세요.
무효화 함정 — 캐시를 조용히 깨는 것들
캐시가 안 먹는 가장 흔한 원인은 prefix 안에 매 요청 달라지는 값이 섞여 들어가는 것입니다. 다음은 프롬프트 prefix를 만드는 코드에서 grep으로 찾아야 할 패턴들입니다.
| 패턴 | 왜 캐시가 깨지나 |
|---|---|
시스템 프롬프트에 datetime.now() / 현재 시각 | prefix가 매 요청 달라짐 |
앞부분에 uuid4() / 요청 ID 삽입 | 매 요청이 고유 — 재사용 불가 |
json.dumps(d)를 sort_keys 없이 / set 순회 | 직렬화가 비결정적 → prefix 바이트가 달라짐 |
| 시스템 프롬프트에 세션/사용자 ID f-string 삽입 | 사용자별 prefix — 사용자 간 공유 불가 |
조건부 시스템 섹션(if flag: system += ...) | 플래그 조합마다 다른 prefix |
사용자별로 도구 집합이 달라짐(tools=build_tools(user)) | tools는 position 0 — 아무것도 공유되지 않음 |
대표적인 안티패턴은 시스템 프롬프트 머리말에 타임스탬프를 끼워 넣는 것입니다.
# 나쁜 예 — 캐시가 절대 안 먹는다
import datetime
system_prompt = f"""현재 시각: {datetime.datetime.now().isoformat()}
당신은 도우미입니다.
... (이하 안정적인 긴 지침) ...
"""
# 머리말의 시각이 매 요청 달라지므로 그 뒤 전체가 캐시 불가
고치는 방법은 동적인 조각을 prefix에서 빼서 마지막 breakpoint 뒤로 옮기거나, 결정적으로 만들거나, 꼭 필요하지 않으면 제거하는 것입니다. 현재 시각처럼 동적인 컨텍스트는 시스템 프롬프트가 아니라 messages 뒤쪽(사용자 메시지 안)에 넣습니다.
# 좋은 예 — 시스템 프롬프트는 고정, 동적 컨텍스트는 메시지 끝으로
import datetime
SYSTEM_PROMPT = """당신은 도우미입니다.
... (안정적인 긴 지침 — 매 요청 동일) ...
"""
msg = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
system=[
{"type": "text", "text": SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"}} # 고정 prefix 캐시
],
messages=[
{"role": "user", "content": [
# 동적 컨텍스트는 캐시 경계 뒤, 사용자 메시지 안에
{"type": "text",
"text": f"(참고: 현재 시각 {datetime.datetime.now().isoformat()})"},
{"type": "text", "text": "오늘 마감인 작업이 있나요?"},
]}
],
)
JSON을 prefix에 직렬화해 넣을 때는 반드시 키를 정렬하세요. 같은 dict라도 직렬화 순서가 달라지면 바이트가 달라져 캐시가 깨집니다.
import json
# 나쁜 예: 키 순서가 보장되지 않음 → 비결정적 prefix
context_block = json.dumps(context_dict)
# 좋은 예: 정렬된 JSON으로 결정적 직렬화
context_block = json.dumps(context_dict, sort_keys=True, ensure_ascii=False)
구조적 함정도 기억하세요. 대화 중간에 도구나 모델을 바꾸지 마세요. 도구를 추가·삭제·재정렬하면 전체 캐시가 깨지고, 모델을 바꿔도(캐시는 모델별로 분리됨) 마찬가지입니다. "모드"가 필요하면 도구 집합을 갈아끼우는 대신 모드를 메시지 콘텐츠로 전달하거나 모드 전환을 기록하는 도구를 주세요. 또한 fork 작업(요약·압축·서브에이전트 등 별도 API 호출)은 부모의 prefix를 그대로 재사용해야 합니다. fork가 system/tools/model을 조금이라도 다르게 다시 만들면 부모의 캐시를 전혀 못 읽습니다.
Laravel(PHP)에서 프롬프트 캐싱
Laravel 애플리케이션에서도 동일한 원리가 적용됩니다. PHP SDK에서는 system에 텍스트 블록 배열을 넘기고 마지막 블록에 cacheControl(camelCase)을 설정합니다.
<?php
use Anthropic\Client;
$client = new Client(apiKey: getenv("ANTHROPIC_API_KEY"));
// 변하지 않는 긴 시스템 프롬프트 (사내 규정 등)
$systemPrompt = file_get_contents(storage_path('prompts/policy.txt'));
$message = $client->messages->create(
model: "claude-opus-4-8",
maxTokens: 1024,
system: [
[
"type" => "text",
"text" => $systemPrompt,
"cacheControl" => ["type" => "ephemeral"], // 안정 prefix 캐시
],
],
messages: [
["role" => "user", "content" => "연차 이월 규정을 알려주세요."],
],
);
// 캐시 동작 검증 (camelCase 프로퍼티)
$u = $message->usage;
logger()->info("cache", [
"write" => $u->cacheCreationInputTokens,
"read" => $u->cacheReadInputTokens,
"uncached" => $u->inputTokens,
]);
// 응답 블록 순회
foreach ($message->content as $block) {
if ($block->type === "text") {
echo $block->text;
}
}
1시간 TTL이 필요하면 "cacheControl" => ["type" => "ephemeral", "ttl" => "1h"]로 설정합니다. PHP에서 캐시 적중은 $message->usage->cacheReadInputTokens로 확인합니다. 동적 값(현재 시각, 요청 ID, 사용자 ID)을 $systemPrompt 안에 str_replace나 보간으로 끼워 넣지 마세요 — Python과 똑같이 캐시가 깨집니다.
RAG와 에이전트에 적용하기
RAG(검색 증강 생성)에서는 한 가지 주의가 필요합니다. 검색된 청크가 질의마다 달라지면 그 부분은 캐시 대상이 아닙니다. 대신 안정적인 부분만 캐시하세요. 즉 고정된 시스템 지침·역할·few-shot 예시·출력 형식 규칙을 prefix에 모아 캐시하고, 매번 달라지는 검색 청크와 사용자 질문은 마지막 breakpoint 뒤에 둡니다. 여러 질의가 같은 큰 문서 집합을 공유한다면, 그 공유 문서를 별도 breakpoint로 캐시하고 변동 질의를 뒤에 붙이는 식으로 단계적으로 캐시 적중을 쌓을 수 있습니다.
참고로 Anthropic은 임베딩 API를 제공하지 않습니다. RAG의 임베딩은 OpenAI text-embedding-3-small/-large나 Voyage AI 같은 외부 임베딩으로 만들고, 생성(generation) 단계만 Claude로 처리합니다. 캐싱은 이 생성 단계의 프롬프트에 적용됩니다.
멀티턴 대화·에이전트에서는 가장 최근에 추가된 턴의 마지막 콘텐츠 블록에 breakpoint를 둡니다. 이후 요청은 직전까지의 전체 대화 prefix를 재사용하고, 이전 breakpoint들도 유효한 read 지점으로 남아 대화가 길어질수록 적중이 누적됩니다.
def step(messages):
# 직전까지 누적된 대화를 prefix로 재사용
messages[-1]["content"][-1]["cache_control"] = {"type": "ephemeral"}
msg = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
messages=messages,
)
report_cache(msg)
return msg
다만 에이전트 루프에서는 두 가지 한도를 더 신경 써야 합니다. 첫째, 각 breakpoint는 직전 캐시 엔트리를 찾기 위해 최대 20개 콘텐츠 블록까지만 거슬러 봅니다. 한 턴에 도구 호출/결과 쌍이 많아 20블록을 넘기면 다음 요청이 이전 캐시를 못 찾고 조용히 미스가 납니다. 긴 턴에서는 ~15블록마다 중간 breakpoint를 추가하세요. 둘째, 캐시 엔트리는 첫 응답이 스트리밍을 시작한 뒤에야 읽을 수 있습니다. 동일 prefix로 N개 요청을 동시에 보내면 아무도 서로의 캐시를 읽지 못하니, 먼저 1개를 보내 첫 토큰을 받은 뒤 나머지 N−1개를 발사하면 그 캐시를 읽습니다.
자주 묻는 질문 (FAQ)
Q. cache_control을 시스템 프롬프트에 붙였는데 cache_read_input_tokens가 계속 0입니다. 왜죠?
거의 항상 prefix 안에 매 요청 달라지는 값이 섞여 있기 때문입니다. 시스템 프롬프트 머리말의 타임스탬프나 UUID, 사용자 ID, 정렬되지 않은 JSON 직렬화가 대표적입니다. 또는 prefix가 모델의 최소 캐시 길이(Opus 4.8 기준 4096 토큰)보다 짧아 조용히 캐시되지 않는 경우입니다. 두 요청의 렌더된 프롬프트 바이트를 직접 비교해 어디가 다른지 찾으세요.
Q. 손익분기는 몇 번 재사용해야 넘나요?
기본 5분 TTL에서는 2회입니다. write가 1.25배, read가 0.1배이므로 같은 prefix를 두 번만 써도(1.25x + 0.1x = 1.35x) 캐싱 없이 두 번(2.0x) 처리하는 것보다 쌉니다. 1시간 TTL은 write가 2배라 손익분기가 3회 이상으로 올라가지만, 5분보다 긴 간격으로 들어오는 버스트 트래픽에서 캐시를 살려 둘 수 있습니다.
Q. 대화 중간에 도구를 추가하거나 모델을 바꾸면 캐시는 어떻게 되나요?
둘 다 전체 캐시가 무효화됩니다. 도구는 렌더 순서상 position 0(가장 앞)이라 추가·삭제·재정렬 시 그 뒤 system·messages 캐시까지 깨지고, 캐시는 모델별로 분리되어 있어 모델을 바꾸면 새 모델에서 캐시를 처음부터 다시 씁니다. "모드 전환"이 필요하면 도구 집합을 갈아끼우지 말고 메시지 콘텐츠로 전달하세요. 더 저렴한 모델로 일부 작업을 처리하려면 메인 루프 모델은 고정하고 서브에이전트를 별도로 띄우는 편이 캐시를 보존합니다.
Q. input_tokens가 작게 나오는데 비용 계산을 어떻게 해야 하나요?
input_tokens는 캐시되지 않은 나머지만 가리킵니다. 전체 프롬프트 크기는 input_tokens + cache_creation_input_tokens + cache_read_input_tokens의 합입니다. 비용은 정가 토큰(input_tokens) × 1.0, write 토큰 × 1.25(5분), read 토큰 × 0.1을 각각 더해 계산합니다. 단일 필드가 아니라 세 값을 모두 보세요.
Q. RAG에서 검색 청크가 매번 달라지는데 캐싱이 의미가 있나요?
있습니다. 변동 부분(검색 청크·질문)은 캐싱 대상이 아니지만, 고정된 시스템 지침·역할·few-shot 예시·출력 형식 규칙은 보통 토큰이 크고 매 요청 동일합니다. 이 안정 prefix만 캐시하고 검색 청크와 질문을 마지막 breakpoint 뒤에 두면, 변동 부분만 정가로 처리되고 큰 고정 부분은 0.1배에 재사용됩니다.
AI 개발이 필요하신가요?
DevTeam은 OpenAI·Claude 등 AI API 연동, 챗봇, RAG, 업무 자동화를 설계부터 배포까지 구현합니다. AI 연동 개발 또는 무료 견적 문의.