LLM 프롬프트 캐싱으로 API 비용 줄이기 — 캐시 경계를 어디에 그을 것인가
에이전트를 실제로 굴려 보면 청구서에서 이상한 걸 발견합니다. 입력 토큰이 출력 토큰의 수십 배입니다. 당연합니다. 매 턴마다 시스템 프롬프트 전체, 툴 정의 전체, 대화 히스토리 전체를 다시 보내니까요. 사용자가 "네"라고 한 마디 해도 2만 토큰짜리 프롬프트가 같이 날아갑니다.
프롬프트 캐싱은 이 문제를 정면으로 겨냥합니다. 프롬프트의 앞부분이 지난번과 똑같으면, 제공자가 그 부분의 계산 결과를 재사용하고 요금을 크게 깎아 줍니다. 개념은 단순한데, 실제로 히트율을 올리는 건 경계를 어디에 긋느냐에 달려 있습니다. 이 글은 그 경계 이야기입니다.
캐싱은 접두사(prefix) 매칭이다
가장 먼저 이해할 것. 프롬프트 캐싱은 앞에서부터 이어지는 완전 일치로 동작합니다. 프롬프트를 긴 문자열이라고 보면, 캐시는 "앞에서 몇 글자까지 지난번과 같은가"를 봅니다. 중간에 한 글자라도 다르면 그 지점부터 뒤는 전부 캐시 미스입니다.
여기서 치명적인 결론이 나옵니다. 자주 바뀌는 값을 프롬프트 앞쪽에 두면 캐싱이 통째로 무력화됩니다. 현재 시각을 시스템 프롬프트 두 번째 줄에 넣었다면, 그 뒤에 오는 2만 토큰은 매번 새로 계산됩니다.
// 나쁜 예 — 매 턴 바뀌는 값이 맨 앞에 있다
시스템 프롬프트:
현재 시각: 2026-10-15 14:32:07 ← 매번 다름
당신은 ... (에이전트 설명 2만 토큰) ← 전부 캐시 미스
// 좋은 예 — 안정적인 것부터, 바뀌는 것은 뒤로
시스템 프롬프트:
당신은 ... (에이전트 설명 2만 토큰) ← 캐시 히트
현재 시각: 2026-10-15 14:32:07 ← 여기만 새로 계산
경계를 명시적으로 표시하기
프롬프트를 조립하는 코드가 여기저기 흩어져 있으면, "이 섹션이 캐시 위인가 아래인가"를 사람이 매번 판단해야 합니다. 실수하기 딱 좋습니다. 그래서 프롬프트 안에 경계 마커를 문자열로 심어 두고, 제공자 어댑터가 그 지점에서 잘라 캐시 지시자를 붙이게 합니다.
// 정적 구간과 동적 구간을 가르는 마커.
// 프롬프트 빌더와 제공자 어댑터가 이 상수를 공유한다.
const CacheBoundaryMarker = "<!-- CACHE_BOUNDARY -->"
// 시스템 프롬프트를 경계에서 두 블록으로 쪼갠다.
// 마커가 있으면: 정적(캐시 지정) + 동적(지정 없음)
// 마커가 없으면: 통째로 한 블록에 캐시 지정 (하위 호환)
func splitSystemPromptForCache(content string) []map[string]any {
ephemeral := map[string]any{"type": "ephemeral"}
idx := strings.Index(content, CacheBoundaryMarker)
if idx == -1 {
return []map[string]any{
{"type": "text", "text": content, "cache_control": ephemeral},
}
}
stable := strings.TrimSpace(content[:idx])
dynamic := strings.TrimSpace(content[idx+len(CacheBoundaryMarker):])
blocks := []map[string]any{
{"type": "text", "text": stable, "cache_control": ephemeral},
}
if dynamic != "" {
// 동적 블록에는 cache_control을 붙이지 않는다
blocks = append(blocks, map[string]any{"type": "text", "text": dynamic})
}
return blocks
}
마커를 HTML 주석 형태로 쓴 데는 이유가 있습니다. 어쩌다 잘리지 않고 모델에게 그대로 전달되더라도, 모델이 의미 있는 지시로 오해할 가능성이 가장 낮은 형태입니다.
무엇이 위로 가고 무엇이 아래로 가는가
이게 실무의 핵심입니다. 판단 기준은 딱 하나 — "이 값이 같은 세션 안에서 턴마다 바뀌는가?"
경계 위(캐시 대상)
- 에이전트 역할·페르소나 정의
- 행동 규칙, 응답 스타일 가이드
- 에이전트 단위 설정 파일 (팀 규칙, 도구 사용 규약, 역량 정의)
- 툴 정의 스키마 — 대개 프롬프트 전체에서 가장 큰 덩어리입니다
- 스킬 목록 요약
경계 아래(캐시 제외)
- 현재 시각, 날짜
- 사용자별 프로필 파일 (사용자가 바뀌면 달라짐)
- 이번 턴에 검색해 주입한 메모리 컨텍스트
- 세션 누적 토큰 같은 실행 시점 수치
- 채널·대화방 정보처럼 실행마다 달라지는 값
설정 파일을 두 그룹으로 나누는 코드는 이렇게 단순해집니다.
// 에이전트 단위 설정 — 거의 안 바뀜 → 경계 위
var stableFiles = map[string]bool{
"AGENTS.md": true,
"TOOLS.md": true,
"CAPABILITIES.md": true,
}
// 사용자별·세션별 파일은 자동으로 동적 그룹으로 떨어진다
func splitStableDynamic(files []ContextFile) (stable, dynamic []ContextFile) {
for _, f := range files {
if stableFiles[filepath.Base(f.Path)] {
stable = append(stable, f)
} else {
dynamic = append(dynamic, f)
}
}
return
}
툴 정의도 캐시하라 — 놓치기 쉬운 큰 덩어리
많은 사람이 시스템 프롬프트만 캐시하고 끝냅니다. 그런데 툴이 30개쯤 되면 툴 스키마가 시스템 프롬프트보다 클 수도 있습니다. JSON 스키마는 장황하니까요.
Anthropic API에서는 툴 배열의 마지막 툴에 캐시 지시자를 붙이면 그 앞의 모든 툴 정의가 캐시 접두사에 포함됩니다.
// 마지막 툴에 캐시 브레이크포인트를 찍어 툴 정의 전체를 캐시한다
if len(tools) > 0 {
tools[len(tools)-1]["cache_control"] = map[string]any{"type": "ephemeral"}
}
다만 전제가 있습니다. 툴 목록의 순서가 매 요청마다 같아야 합니다. 툴을 map에서 꺼내 배열로 만든다면 Go의 맵 순회 순서는 무작위이므로 캐시가 매번 깨집니다. 정렬을 넣으세요. 이건 실제로 자주 나오는 버그입니다.
제공자마다 방식이 다르다 — 능력 플래그로 분기
캐싱 방식은 제공자별로 제각각입니다. 블록 단위 지시자를 쓰는 곳이 있고, 요청 본문에 캐시 키를 넣는 방식도 있고, 아예 지원하지 않거나 서버가 알아서 처리하는 곳도 있습니다. 이 차이를 호출부에 노출하면 안 됩니다.
type ProviderCapabilities struct {
Streaming bool
ToolCalling bool
Thinking bool
CacheControl bool // 블록 단위 캐시 지시자를 지원하는가
MaxContextWindow int
}
// 요청 본문에 캐시 키를 넣는 방식의 제공자용 미들웨어.
// 지원하지 않는 엔드포인트에는 조용히 통과시킨다.
func CacheMiddleware(body map[string]any, cfg MiddlewareConfig) map[string]any {
cacheKey, hasKey := cfg.Options[OptPromptCacheKey]
if !hasKey {
return body
}
if !isNativeEndpoint(cfg.APIBase) { // 프록시 경유면 건너뛴다
return body
}
body["prompt_cache_key"] = cacheKey
return body
}
여기서 "프록시면 건너뛴다"가 중요합니다. 호환 API를 표방하는 프록시가 모르는 필드를 받으면 400을 뱉는 경우가 흔합니다. 캐싱은 최적화지 필수 기능이 아니므로, 확신이 없으면 안 넣는 쪽이 맞습니다.
효과를 반드시 측정하라
캐싱을 붙였다면 정말 히트하고 있는지 확인해야 합니다. 응답의 사용량 필드에 캐시 생성 토큰과 캐시 읽기 토큰이 따로 옵니다. 이걸 집계에서 빠뜨리면 캐시가 안 먹고 있어도 모릅니다.
if resp.Usage != nil {
total.PromptTokens += resp.Usage.PromptTokens
total.CompletionTokens += resp.Usage.CompletionTokens
total.CacheCreationTokens += resp.Usage.CacheCreationTokens // 캐시에 쓴 양(비쌈)
total.CacheReadTokens += resp.Usage.CacheReadTokens // 캐시에서 읽은 양(쌈)
}
// 히트율 — 이 값이 안 오르면 경계 위치가 틀린 것이다
hitRate := float64(total.CacheReadTokens) /
float64(total.CacheReadTokens+total.CacheCreationTokens)
주의할 점: 캐시 생성은 일반 입력보다 비쌉니다. 캐시를 만들어 놓고 재사용을 못 하면 오히려 손해입니다. 그래서 턴이 한두 번으로 끝나는 짧은 대화에는 캐싱이 이득이 아닐 수 있습니다. 긴 대화, 반복 호출, 여러 사용자가 같은 에이전트를 쓰는 경우에 효과가 큽니다.
캐시를 조용히 깨뜨리는 흔한 실수 5가지
- 타임스탬프를 앞쪽에 — 가장 흔하고 가장 치명적입니다. 시각·날짜는 무조건 경계 아래로.
- 툴 순서가 매번 달라짐 — 맵 순회로 배열을 만들면 순서가 무작위입니다. 정렬하세요.
- JSON 직렬화 순서 불안정 — 키 순서가 흔들리면 바이트가 달라지고 캐시가 깨집니다.
- 프롬프트 모드가 턴마다 바뀜 — 상황에 따라 프롬프트 섹션을 켜고 끄는 구조라면, 같은 세션 안에서는 모드를 고정하세요. 모드가 바뀌면 정적 구간 자체가 달라집니다.
- 캐시 수명 착각 — 대부분의 캐시는 짧은 유휴 시간 뒤 만료됩니다. 몇 분 간격으로 도는 배치 작업이라면 이미 만료돼 매번 생성 비용만 낼 수 있습니다.
정리
- 캐싱은 접두사 완전 일치 — 앞쪽 한 글자가 바뀌면 뒤가 전부 미스
- 프롬프트에 경계 마커를 심고 어댑터가 잘라 지시자를 붙이게 하라
- 판단 기준은 하나: "세션 안에서 턴마다 바뀌는가"
- 툴 정의도 캐시 대상 — 단, 순서를 고정할 것
- 제공자 차이는 능력 플래그로 숨기고, 확신 없으면 안 넣는다
- 캐시 읽기/생성 토큰을 따로 집계해 히트율을 측정하라 — 측정 안 하면 안 먹고 있어도 모른다
프롬프트 캐싱은 알고리즘이 아니라 배치 문제입니다. 코드를 더 짜는 게 아니라, 이미 있는 문자열의 순서를 바꾸는 일이죠. 그런데 그 순서 하나로 입력 비용의 대부분이 사라집니다.
댓글
댓글 쓰기