(3/4) 프롬프트 캐싱 — 같은 히스토리를 매번 보내면서 돈 아끼기
🧠 AWS Bedrock 챗봇 — 대화 맥락 유지 설계 4부작 — Bedrock 으로 챗봇을 만들면 가장 먼저 부딪히는 벽이 “모델이 대화를 하나도 기억하지 못한다” 예요. API 가 스테이트리스라서 맥락은 전부 우리가 들고 다녀야 하죠. 이 시리즈는 그 맥락을 어디에 쌓고(저장), 어떻게 싸게 다시 보내고(캐싱), 넘칠 때 어떻게 줄이는지(압축) 를 순서대로 설계합니다. 전체 그림과 Bedrock 특유의 제약을 먼저 잡고(1편), DynamoDB 세션 스토어와 메시지 스키마를 짜고(2편), 프롬프트 캐싱으로 재전송 비용을 걷어내고(3편), 컨텍스트 한계에서 압축·요약·장기기억으로 버티는 것(4편)까지 이어집니다. 전체 4편.
- 대화 맥락은 어떻게 유지하나 — 전체 설계도
- 대화 저장소 — DynamoDB 세션 스토어와 메시지 스키마
- 프롬프트 캐싱 — 같은 히스토리를 매번 보내면서 돈 아끼기 ← 지금 글
- 컨텍스트가 꽉 찰 때 — 압축·정리·요약과 장기 기억
Summary
2편에서 대화를 안 잃어버리게 쌓는 것까지 했어요. 이제 비용 문제가 남았습니다.
챗봇의 비용 구조는 조금 배신감이 들어요. 사용자가 짧은 질문 하나를 던졌을 뿐인데, 우리는 앞의 대화 전체를 입력 토큰으로 다시 실어 보내야 하니까요. 20턴짜리 대화의 20번째 응답은 첫 번째 응답보다 훨씬 비쌉니다.
프롬프트 캐싱이 이 곡선을 눌러줘요. 원리는 단순해서 30초면 이해되는데, 실무에서 캐시가 안 먹는 이유는 거의 다 한 가지 규칙을 어겨서예요. 이번 글은 그 규칙과, 그걸 어기게 만드는 범인들을 다룹니다.
💡 이 글에서 다루는 것
- 캐싱의 유일한 규칙 — prefix 가 완전히 같아야 한다
- 렌더 순서
tools → system → messages가 왜 중요한가- Bedrock 은 자동 캐싱이 없어요 — 직접 배치하는 법
- 멀티턴 챗봇의 브레이크포인트 배치 패턴 (코드)
- 캐시를 조용히 죽이는 범인 목록
- 무효화 계층 — 무엇이 무엇까지 날리나
- 20블록 룩백 창 함정
- 계측 방법과 손익 어림셈
1. 캐싱의 유일한 규칙
먼저 이 한 문장만 확실히 하고 가면 나머지는 다 따라와요.
프롬프트 캐싱은 앞부분(prefix) 완전 일치예요. 앞에서 한 바이트라도 다르면, 그 뒤의 캐시는 전부 무효입니다.
캐시 키는 프롬프트를 렌더한 실제 바이트열로 만들어져요. 그래서 “내용상 거의 같다”는 건 아무 의미가 없어요. 공백 하나, 따옴표 순서 하나만 달라도 완전히 다른 캐시가 됩니다.
그리고 프롬프트가 렌더되는 순서가 정해져 있어요.
tools → system → messages
이 순서가 곧 무효화의 전파 방향이에요. tools 는 맨 앞에 있으니까, 도구 목록을 하나만 바꿔도 그 뒤 전부(system + 대화 전체) 가 무효가 됩니다. 반대로 마지막 사용자 메시지를 바꾸는 건 그 앞의 아무것도 건드리지 않아요.
이걸 알면 설계 원칙이 자동으로 나옵니다.
✅ 안 바뀌는 건 앞에, 자주 바뀌는 건 뒤에. 시스템 프롬프트는 얼려두고, 매 요청 달라지는 값(현재 시각, 요청 ID, 사용자 이름 같은 것)은 최대한 뒤로 밀어요.
2. Bedrock 에서는 직접 배치해야 해요
Anthropic 직접 API 에는 편한 옵션이 하나 있어요. 요청 최상단에 cache_control 을 한 번 던져두면 마지막 캐시 가능 블록에 알아서 붙여주는 자동 캐싱이요. Bedrock 에는 이게 없습니다.
그래서 우리가 “어느 블록에서 캐시를 끊을지” 직접 지정해야 해요. 붙이는 모양은 이렇습니다.
system=[{
"type": "text",
"text": SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"}, # 기본 5분 TTL
}]
system=[{
"type": "text",
"text": SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral", "ttl": "1h"}, # 1시간 TTL
}]
여기서 cache_control 의 의미를 정확히 잡고 가야 해요. “이 블록을 캐싱해라”가 아니라 “여기까지가 재사용 구간이다” 라는 경계 표시예요. 그래서 붙이는 위치가 곧 캐시 경계가 됩니다.
두 가지 제약이 있어요.
- 요청당 최대 4개입니다. 그래서 아무 데나 뿌릴 수 없고, 전략적으로 배치해야 해요.
- 최소 길이가 있어요. 경계 앞의 내용이 일정 토큰 수보다 짧으면 에러 없이 그냥 캐싱이 안 됩니다. 모델마다 다르고, 세대가 올라간다고 계속 낮아지는 것도 아니에요.
| 모델 | 최소 토큰 |
|---|---|
| Claude Opus 5 | 512 |
| Claude Opus 4.8 · Sonnet 5 · Sonnet 4.6 |
1024 |
| Claude Opus 4.7 | 2048 |
| Claude Opus 4.6 · Haiku 4.5 |
4096 |
⚠️ 이 표가 왜 중요한지 예를 들어볼게요. 시스템 프롬프트가 3,000 토큰인 챗봇이 있다고 해봐요. Opus 5 에서는 캐싱이 되는데, Haiku 4.5 로 모델만 바꾸면 최소치(4096)에 못 미쳐서 조용히 캐싱이 멈춰요. 에러도 안 나고
cache_creation_input_tokens가 0 으로 나올 뿐입니다. 모델을 바꿀 때는 캐시 계측을 꼭 다시 보세요.
3. 멀티턴 챗봇의 브레이크포인트 배치
이제 실전이에요. 챗봇에서 4개를 어떻게 쓸까요.
3-1. 기본형 — 두 개로 시작하기
가장 단순하고 대부분의 챗봇에서 잘 먹는 배치예요.
[ tools ]
[ system ......................... ] ← ① 여기 (고정 경계)
[ 1턴 사용자 ]
[ 1턴 어시스턴트 ]
...
[ N턴 사용자 ] ← ② 여기 (이동 경계)
- ① 시스템 끝 — 도구 목록과 시스템 프롬프트를 함께 캐싱합니다.
tools가system보다 먼저 렌더되니까, system 마지막 블록에 경계를 붙이면 둘 다 한 번에 들어가요. 따로 붙일 필요가 없습니다. - ② 이번 턴 끝 — 이번 요청까지의 히스토리를 캐시에 써둡니다. 다음 요청에서 이걸 읽으니까, 턴이 쌓일수록 재사용 구간이 계속 자라요.
3-2. 실전형 — 이동 경계를 두 개 두기
②만 두면 매 턴 새 캐시를 쓰고 그 다음 턴에서 한 번 읽는 구조라, 스트리밍이 겹치거나 사용자가 빠르게 두 번 보내면 적중률이 흔들려요. 그래서 이동 경계를 두 개로 두고 번갈아 쓰는 방식을 권해요.
def build_messages(history: list, user_blocks: list) -> list:
"""이동 경계 2개 + 시스템 경계 1개 = 총 3개를 씁니다."""
messages = [dict(m) for m in history] # 원본 훼손 방지
messages.append({"role": "user", "content": user_blocks})
# 이동 경계를 붙일 두 지점: 몇 턴 전 + 지금
marks = []
if len(messages) >= 5:
marks.append(len(messages) - 5) # 읽기용 (안정된 지점)
marks.append(len(messages) - 1) # 쓰기용 (이번 턴 끝)
for idx in marks:
blocks = messages[idx]["content"]
if not isinstance(blocks, list) or not blocks:
continue
last = dict(blocks[-1])
last["cache_control"] = {"type": "ephemeral"}
messages[idx] = {
**messages[idx],
"content": blocks[:-1] + [last],
}
return messages
여기서 중요한 디테일 하나가 있어요. 경계를 붙일 때 원본 히스토리를 훼손하면 안 돼요. 위 코드가 dict(m) 과 blocks[:-1] + [last] 로 복사를 뜨는 이유예요. 저장된 블록에 cache_control 이 박혀버리면, 다음 턴에서 그 블록의 바이트가 달라져서 캐시가 영구히 안 먹게 됩니다. 2편에서 저장본과 표시본을 분리하라고 했던 것과 같은 이야기예요.
3-3. 왜 “안정된 지점”이 필요한가
이전 요청에서 붙였던 경계는 다음 요청에서도 읽기 지점으로 계속 유효해요. 그래서 경계를 여러 개 두면 적중이 조금씩 누적되는 효과가 있습니다.
1번째 요청: [system|경계①] 턴1 [경계②] → ①,② 쓰기
2번째 요청: [system|경계①] 턴1 턴2 [경계②] → ① 읽기, ② 쓰기
3번째 요청: [system|경계①] 턴1 [경계] 턴2 턴3 [경계②] → ①,중간 읽기
4. 캐시를 조용히 죽이는 범인들
캐싱은 실패해도 에러를 안 내요. 그냥 제값을 낼 뿐입니다. 그래서 “붙였는데 왜 안 싸지나” 하는 상황이 자주 생겨요. 범인은 거의 항상 이 목록에 있습니다.
| 범인 | 왜 죽나 |
|---|---|
| 시스템 프롬프트에 현재 시각 넣기 |
매 요청 prefix 가 달라짐 |
| 요청 ID·UUID 를 앞쪽에 끼우기 |
같은 이유 |
json.dumps 를정렬 없이 쓰기 |
키 순서가 매번 달라짐 |
| 시스템 프롬프트에 사용자 이름·세션ID |
사용자마다 다른 캐시 |
if 로 시스템섹션 붙이기 |
조합마다 다른 prefix |
| 사용자별로 도구 목록 생성 |
tools 가 맨 앞 → 전부 무효 |
| 대화 중간에 모델 바꾸기 |
캐시는 모델 단위 |
가장 흔한 두 개만 코드로 볼게요.
# ❌ 이러면 캐싱이 절대 안 먹어요
SYSTEM = f"""당신은 어시스턴트입니다.
현재 시각: {datetime.now().isoformat()}
사용자: {user.name}
"""
# ✅ 시스템은 얼려두고, 변하는 값은 사용자 턴 쪽으로
SYSTEM = """당신은 어시스턴트입니다.
답변은 한국어로 하세요."""
user_blocks = [
{"type": "text",
"text": f"[컨텍스트] 현재 시각 {now}, 사용자 {user.name}"},
{"type": "text", "text": user_text},
]
# ❌ 딕셔너리 순서가 매번 달라질 수 있어요
tool_schema = json.dumps(schema)
# ✅ 정렬해서 직렬화하면 바이트가 고정돼요
tool_schema = json.dumps(schema, sort_keys=True, ensure_ascii=False)
4-1. 무효화 계층 — 무엇이 무엇까지 날리나
모든 변경이 전부를 날리는 건 아니에요. 세 층이 있고, 변경은 자기 층과 그 뒤만 무효화합니다.
| 무엇을 바꿨나 | tools | system | messages |
|---|---|---|---|
| 도구 목록 | ❌ | ❌ | ❌ |
| 모델 교체 | ❌ | ❌ | ❌ |
| 시스템 내용 | ✅ | ❌ | ❌ |
tool_choice· 사고 on/off |
✅ | ✅ | ❌ |
| 메시지 내용 | ✅ | ✅ | ❌ |
(✅ = 캐시 유지, ❌ = 무효)
이 표에서 실무적으로 유용한 건 아래 두 줄이에요. tool_choice 를 요청마다 바꾸거나 사고를 껐다 켜는 건 tools+system 캐시를 안 깨요. 그러니 그건 마음 편히 쓰면 됩니다. 반대로 위 두 줄, 도구 목록 변경과 모델 교체는 전부를 날려요. 이 둘만 조심하면 됩니다.
5. Bedrock 전용 우회로 — 대화 중간 지시
대화 중간에 운영자 지시를 넣고 싶은 상황이 꼭 생겨요. “지금부터 간결 모드”, “이 사용자는 관리자 권한”, “오늘 날짜는 이거” 같은 것들이요.
Anthropic 직접 API 에는 이걸 위한 깔끔한 방법이 있어요. messages 배열에 role: "system" 항목을 끼워넣는 거예요. 히스토리 뒤에 붙으니까 캐시된 prefix 를 안 깨면서 운영자 권한으로 지시가 들어갑니다.
그런데 Bedrock 에서는 이 기능이 안 됩니다. 그러면 어떻게 하나요? 하지 말아야 할 것부터 명확히 해두면요.
# ❌ 최상단 system 을 고치기 — 캐시가 통째로 날아갑니다
system = SYSTEM + "\n\n지금부터 간결 모드로 답하세요."
system 은 messages 보다 앞에 렌더되니까, 여기를 건드리면 대화 전체 캐시가 무효가 돼요. 20턴 쌓인 대화에서 이걸 하면 그 순간 비용이 몇 배로 튑니다.
대신 사용자 턴 안에 구분된 블록으로 끼워넣습니다.
def build_user_turn(user_text: str, directives: list[str]) -> list:
blocks = []
if directives:
note = "\n".join(directives)
blocks.append({
"type": "text",
"text": f"<system-reminder>\n{note}\n</system-reminder>",
})
blocks.append({"type": "text", "text": user_text})
return blocks
user_blocks = build_user_turn(
"이거 요약해줘",
["지금부터 간결 모드로 답하세요. 3문장 이내.",
f"오늘 날짜는 {today} 입니다."],
)
캐싱 관점에서는 최상단 system 을 고치는 것과 비교가 안 될 만큼 좋아요. 이 블록은 대화 맨 끝에 붙으니까 앞의 캐시를 하나도 건드리지 않습니다.
⚠️ 다만 성격 차이는 알고 쓰셔야 해요. 이건 결국 사용자 턴 안의 텍스트라서, 진짜 시스템 권한이 아니에요. 사용자 입력이 그대로 흘러드는 자리라면 사용자가
<system-reminder>태그를 흉내 내서 지시를 위조할 수 있습니다. 그래서 사용자 입력을 이 블록에 절대 그대로 넣지 말고, 사용자 텍스트 안에 이 태그 문자열이 있으면 이스케이프하거나 제거하는 처리를 넣어두세요.
6. 20블록 룩백 창
이건 도구를 많이 쓰는 챗봇에서만 나오는 함정인데, 걸리면 원인 찾기가 정말 어려워요.
경계를 하나 붙일 때, 시스템은 거기서 뒤로 최대 20개 블록까지만 훑어서 이전 캐시 항목을 찾아요. 그보다 멀면 못 찾고 조용히 새로 씁니다.
평범한 대화에서는 한 턴이 블록 1~2개라 문제가 없어요. 그런데 에이전트형 챗봇에서 한 턴에 도구를 여러 번 부르면 블록이 훅 늘어나요.
어시스턴트: [text][tool_use][tool_use][tool_use] → 4블록
사용자: [tool_result][tool_result][tool_result] → 3블록
어시스턴트: [text][tool_use][tool_use] → 3블록
사용자: [tool_result][tool_result] → 2블록
... 이런 게 몇 번 반복되면 한 턴에 20블록을 쉽게 넘어요
이렇게 되면 다음 요청의 경계가 이전 캐시를 못 찾아서, 캐싱을 켜뒀는데 계속 쓰기만 하는 상태가 됩니다. 계측하면 cache_creation 은 계속 올라가고 cache_read 는 0 이에요.
해법은 긴 턴 안에 중간 경계를 넣는 것이에요. 대략 15블록마다 하나씩 두면 안전합니다.
def mark_every_n_blocks(messages: list, n: int = 15, budget: int = 3) -> list:
"""블록 누적 개수를 세면서 n 개마다 경계를 붙입니다 (최대 budget 개)."""
out, count, used = [], 0, 0
for m in messages:
blocks = m["content"] if isinstance(m["content"], list) else []
count += len(blocks)
if count >= n and used < budget and blocks:
last = dict(blocks[-1])
last["cache_control"] = {"type": "ephemeral"}
m = {**m, "content": blocks[:-1] + [last]}
count, used = 0, used + 1
out.append(m)
return out
budget 을 두는 이유는 경계가 요청당 4개로 제한되어 있기 때문이에요. 시스템에 하나 쓰고 나면 messages 에는 3개까지만 쓸 수 있습니다.
7. 계측 — 먹었는지 확인하는 법
캐싱은 조용히 실패하니까 계측이 필수예요. 응답의 usage 세 필드를 보면 됩니다.
| 필드 | 의미 | 단가 |
|---|---|---|
cache_read_input_tokens |
캐시에서 읽어온 토큰 |
아주 저렴 |
cache_creation_input_tokens |
캐시에 새로 쓴 토큰 |
조금 비쌈 |
input_tokens |
캐시와 무관한 나머지 |
제값 |
u = response.usage
total = u.input_tokens + u.cache_creation_input_tokens + u.cache_read_input_tokens
hit = u.cache_read_input_tokens / total if total else 0
print(f"전체 {total:,} 토큰 / 캐시 적중 {hit:.0%}")
print(f" 읽기 {u.cache_read_input_tokens:,}")
print(f" 쓰기 {u.cache_creation_input_tokens:,}")
print(f" 생짜 {u.input_tokens:,}")
⚠️ 여기서 자주 틀리는 게 있어요.
input_tokens는 전체 입력이 아니에요. 캐시에 안 걸린 나머지일 뿐입니다. 그래서 20턴 대화인데input_tokens가 800 으로 나온다고 “입력이 작구나” 하면 안 돼요. 전체 프롬프트 크기는 세 필드를 더한 값입니다. 이걸 모르고 대시보드를 만들면 실제 사용량을 크게 과소집계해요.
진단 기준은 이렇게 잡으면 편해요.
cache_read가 계속 0 → prefix 가 매번 달라지고 있어요. 4절 범인 목록을 훑으세요.cache_creation만 계속 올라가고cache_read는 0 → 룩백 창(6절) 이거나, 요청 간격이 TTL(기본 5분)보다 길어요.cache_read는 나오는데 기대보다 작음 → 경계가 너무 앞에 있어요. 이동 경계를 뒤로 미세요.
7-1. 손익 어림셈
대략적인 단가 비율은 이래요. 캐시 읽기는 원가의 10분의 1 수준, 캐시 쓰기는 1.25배 정도(1시간 TTL 은 2배)입니다.
그러면 손익분기가 계산돼요.
캐싱 안 함, 2번 요청 = 1.0 + 1.0 = 2.00
캐싱 함(5분), 2번 = 1.25 + 0.1 = 1.35 ← 이미 이득
캐싱 함(1시간), 2번 = 2.0 + 0.2 = 2.20 ← 아직 손해
캐싱 함(1시간), 3번 = 2.0 + 0.2 + 0.2 = 2.40 (안 하면 3.00) ← 이득
즉 5분 TTL 은 두 번만 재사용해도 이득이고, 1시간 TTL 은 세 번 이상은 되어야 해요. 챗봇은 사용자가 연달아 말하는 게 보통이니 기본 5분으로 충분한 경우가 많고, “사용자가 몇 분 생각하다 다시 물어보는” 패턴이 잦으면 1시간을 검토하면 됩니다.
20턴 대화 하나를 어림셈해보면 감이 잡혀요. 시스템 5,000 토큰에 턴마다 500 토큰씩 쌓이는 대화라고 하면요.
| 구분 | 입력 토큰 환산 |
|---|---|
| 캐싱 없음 | 약 195,000 |
| 캐싱 적용 | 약 38,000 |
대략 8할이 줄어요. 대화가 길어질수록 격차가 더 벌어집니다. 단, 실제 절감액은 Bedrock 요금표의 실단가로 다시 계산하세요 — Anthropic 직접 API 와 단가가 다릅니다.
8. 두 가지 타이밍 함정
마지막으로 운영에서 만나는 타이밍 이슈 두 개만 짚어요.
첫째, 동시 요청은 서로의 캐시를 못 읽어요. 캐시 항목은 첫 응답이 생성되기 시작한 뒤에야 읽을 수 있어요. 그래서 같은 prefix 로 요청 5개를 동시에 던지면 5개가 다 제값을 냅니다. 팬아웃 패턴(같은 컨텍스트로 여러 갈래 질문)에서는 하나를 먼저 보내고 첫 토큰이 오는 걸 본 다음 나머지를 던지세요.
둘째, TTL 은 마지막 사용 기준이 아니라 그냥 만료예요. 기본 5분인데, 사용자가 6분 뒤에 다시 말하면 캐시가 없어요. 첫 응답부터 캐시를 데워두고 싶으면 max_tokens: 0 으로 프리필 전용 요청을 보내는 방법도 있어요. 출력 토큰 과금 없이 캐시만 써둡니다.
# 캐시 데우기 — 출력 없이 prefix 만 캐싱
client.messages.create(
model="anthropic.claude-opus-5",
max_tokens=0,
system=[{"type": "text", "text": SYSTEM,
"cache_control": {"type": "ephemeral"}}],
messages=[{"role": "user", "content": "warmup"}],
)
💡 데우기가 항상 이득은 아니에요. 트래픽이 끊이지 않는 서비스라면 실제 요청이 알아서 캐시를 유지하니까, 데우기 요청은 순수한 추가 비용이에요. 데우기가 의미 있는 건 (1) 첫 응답 지연이 사용자에게 보이고, (2) 공용 prefix 가 크고, (3) 트래픽 전에 데울 순간이 있는 경우 — 이 셋이 다 맞을 때예요. 그리고
max_tokens: 0은 스트리밍이나 일부 옵션과 함께 쓰면 거절되니, 데우기 요청은 옵션을 최소로 두세요.
9. 정리 — 이 층의 체크리스트
- 시스템 프롬프트에 시각·UUID·사용자 정보가 없나요?
- 시스템 프롬프트를 조건부로 조립하고 있지 않나요?
- 도구 목록이 요청마다 달라지지 않나요? (정렬해서 고정)
- 시스템 마지막 블록에 경계 하나를 붙였나요?
- messages 에 이동 경계를 붙이면서 저장본을 훼손하지 않나요?
- 경계가 4개를 넘지 않나요?
- 도구를 많이 쓰는 턴에 중간 경계를 넣었나요? (20블록 룩백)
- 대화 중간 지시를
system수정이 아니라 사용자 턴으로 넣나요? cache_read_input_tokens를 로그에 남기나요?- 사용량 집계에서 세 필드를 합산하나요?
여기까지 하면 비용 곡선이 눌립니다. 그런데 히스토리 자체는 계속 자라고 있어요. 캐싱은 “다시 계산하는 값”을 싸게 만들어줄 뿐, 프롬프트를 작게 만들어주지는 않아요. 언젠가는 창이 차고, 그 전에 지연이 늘어납니다.
일단 오늘은 여기까지…..
다음 글에서는 그 히스토리를 실제로 줄이는 방법을 봅니다. 자르기·정리·압축·요약 네 가지를 언제 무엇으로 쓸지, 그리고 압축이 캐시와 어떻게 충돌하는지까지 정리할게요.