LLM 프롬프트 캐싱으로 API 비용 줄이기 — 캐시 경계를 어디에 그을 것인가

🌐 English

에이전트를 실제로 굴려 보면 청구서에서 이상한 걸 발견합니다. 입력 토큰이 출력 토큰의 수십 배입니다. 당연합니다. 매 턴마다 시스템 프롬프트 전체, 툴 정의 전체, 대화 히스토리 전체를 다시 보내니까요. 사용자가 "네"라고 한 마디 해도 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 직렬화 순서 불안정 — 키 순서가 흔들리면 바이트가 달라지고 캐시가 깨집니다.
  • 프롬프트 모드가 턴마다 바뀜 — 상황에 따라 프롬프트 섹션을 켜고 끄는 구조라면, 같은 세션 안에서는 모드를 고정하세요. 모드가 바뀌면 정적 구간 자체가 달라집니다.
  • 캐시 수명 착각 — 대부분의 캐시는 짧은 유휴 시간 뒤 만료됩니다. 몇 분 간격으로 도는 배치 작업이라면 이미 만료돼 매번 생성 비용만 낼 수 있습니다.

정리

  • 캐싱은 접두사 완전 일치 — 앞쪽 한 글자가 바뀌면 뒤가 전부 미스
  • 프롬프트에 경계 마커를 심고 어댑터가 잘라 지시자를 붙이게 하라
  • 판단 기준은 하나: "세션 안에서 턴마다 바뀌는가"
  • 툴 정의도 캐시 대상 — 단, 순서를 고정할 것
  • 제공자 차이는 능력 플래그로 숨기고, 확신 없으면 안 넣는다
  • 캐시 읽기/생성 토큰을 따로 집계해 히트율을 측정하라 — 측정 안 하면 안 먹고 있어도 모른다

프롬프트 캐싱은 알고리즘이 아니라 배치 문제입니다. 코드를 더 짜는 게 아니라, 이미 있는 문자열의 순서를 바꾸는 일이죠. 그런데 그 순서 하나로 입력 비용의 대부분이 사라집니다.

댓글

이 블로그의 인기 게시물

한국투자증권 KIS API로 실시간 시세 받기 (WebSocket 실전)

파이썬으로 업비트 API 연동하기 — 시세 조회부터 주문까지 기초

Go로 자동매매 신호봇 프레임워크 설계하기