Alejandro Rioja.
AI Agents Operations

Claude API의 프롬프트 캐싱: 모델을 바꾸지 않고 입력 비용 줄이기

Alejandro Rioja
Alejandro Rioja
7 분 읽기
TL;DR

프롬프트 캐싱은 크고 안정적인 입력 — 시스템 프롬프트, 도구 정의, 퓨샷 예시 — 의 비용을 반복 요청 시 일반 입력 가격의 약 10% 수준으로 낮춥니다. 작동 원리는 접두사 일치입니다. 안정적인 콘텐츠의 끝에 cache_control 마커를 두고, 그 뒤에 변동되는 모든 것을 배치하세요. 캐시 적중률을 망치는 실수는 타임스탬프나 UUID가 접두사 안으로 흘러 들어가게 두는 것입니다.

무료 뉴스레터

매주 수요일. 28,400명+ 구독자. 핵심만.

Table of contents

Open Table of contents

프롬프트 캐싱이 실제로 하는 일

Claude API에 대한 모든 호출은 토큰을 전송합니다. 캐싱이 없으면 요청 안의 모든 토큰 — 시스템 프롬프트, 도구 정의, 퓨샷 예시, 사용자 메시지 — 이 일반 입력 요율로 과금됩니다. 캐싱을 사용하면 첫 요청 이후 그 토큰의 접두사가 Anthropic 서버에 저장됩니다. 정확히 동일한 접두사를 공유하는 이후 요청에서는 토큰을 처음부터 다시 처리하는 대신 캐시 읽기 가격을 지불합니다.

비용 차이는 실제로 큽니다:

손익분기점을 넘기면 — 하루에 몇 번 이상 실행되는 에이전트라면 금세 넘깁니다 — 추가되는 모든 캐시 적중은 해당 토큰에 대해 약 90%의 할인을 의미합니다.

접두사 일치 불변식

이것이 나머지 모든 것을 좌우하는 단 하나의 규칙입니다: 캐시 키는 렌더링된 프롬프트의 접두사 일치다.

Anthropic 서버는 프롬프트의 시작부터 cache_control 마커까지 렌더링된 콘텐츠를 저장합니다. 다음 요청에서 캐시 적중이 일어나려면 프롬프트 시작부터 그 마커까지의 모든 토큰이 동일해야 합니다 — 바이트 단위로 똑같아야 합니다.

접두사 일치를 위한 렌더링 순서는 다음과 같습니다: tools → system → messages. 즉, tools 배열이 먼저 해시되고, 그다음 system 블록, 그다음 messages가 순서대로 처리됩니다.

이것이 실무에서 의미하는 바: 안정적인 콘텐츠가 먼저 와야 합니다. 시스템 프롬프트가 무언가 동적인 것 — 현재 날짜, 사용자 ID, 요청 추적 ID — 을 참조하고 그것이 cache_control 마커보다 앞에 나타나면, 접두사가 계속 바뀌기 때문에 모든 요청에서 캐시가 빗나갑니다.

어디에 캐시 마커를 둘 것인가

가장 효과가 큰 대상은 다음과 같습니다:

1. 시스템 프롬프트

시스템 프롬프트는 보통 가장 큰 안정적 블록입니다. 상세한 에이전트 페르소나, 행동 규칙 목록, 출력 형식 지침 — 이 모든 것은 같은 에이전트를 호출할 때마다 동일합니다. 마커를 다세요:

typescript
import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

const response = await client.messages.create({
  model: "claude-opus-4-8",
  max_tokens: 1024,
  system: [
    {
      type: "text",
      text: `You are a content operations agent for alejandrorioja.com.
Your job is to draft blog posts in Alejandro's voice: direct, practitioner, 
first-person, numbered lists, honest caveats. No hedging. No filler. 
Every section must earn its place.

[... 2000 more tokens of stable instructions ...]`,
      cache_control: { type: "ephemeral" },
    },
  ],
  messages: [
    {
      role: "user",
      content: "Draft a post about prompt caching.",
    },
  ],
});

system 블록의 cache_control: { type: "ephemeral" }는 그 블록까지 포함한 모든 것을 캐싱하라고 Claude에게 지시합니다. messages 배열은 변동적이어서 — 요청마다 다릅니다 — 캐시 경계 바깥에 머뭅니다.

2. 도구 정의

에이전트가 도구를 사용한다면 그 정의는 꽤 클 수 있습니다. 설명, 매개변수 이름, 열거형 값을 잘 갖춘 도구 스키마는 도구 하나당 500~1,000토큰에 이를 수 있습니다. 도구가 5개라면 호출마다 다시 처리하는 데 비용을 치르는 토큰이 최대 5,000토큰입니다:

typescript
const response = await client.messages.create({
  model: "claude-opus-4-8",
  max_tokens: 1024,
  tools: [
    {
      name: "search_airtable",
      description: "Search the Airtable content queue...",
      input_schema: { type: "object", properties: { query: { type: "string" } } },
    },
    // ... more tools ...
    {
      name: "post_to_kit",
      description: "Schedule a broadcast via the Kit API...",
      input_schema: { /* ... */ },
      // Mark the last tool to cache the entire tools array
    } as Anthropic.Tool & { cache_control: { type: "ephemeral" } },
  ],
  system: "...",
  messages: [...],
});

배열의 마지막 도구에 마커를 다세요. 접두사 일치가 그 지점부터 전체 tools 배열을 포괄합니다.

3. messages 안의 퓨샷 예시

정적인 퓨샷 예시를 messages 배열의 앞쪽 메시지로 전달한다면 그것도 캐싱할 수 있습니다. 이를 처음 N개의 메시지로 구성하고 마지막 예시 턴에 마커를 다세요:

typescript
const messages: Anthropic.MessageParam[] = [
  {
    role: "user",
    content: [
      {
        type: "text",
        text: "Here are examples of posts in my voice:\n\n[Example 1...]\n\n[Example 2...]",
        cache_control: { type: "ephemeral" },
      } as Anthropic.TextBlockParam & { cache_control: { type: "ephemeral" } },
    ],
  },
  {
    role: "assistant",
    content: "Understood. I'll follow that voice.",
  },
  // The actual user turn follows — this is volatile, no cache marker
  {
    role: "user",
    content: actualUserRequest,
  },
];

무엇을 캐싱하지 말 것인가 (조용한 무효화 요인)

이들은 안정적으로 보이지만 그렇지 않은 것들입니다 — 그리고 적중률을 조용히 망가뜨립니다. API는 경고해 주지 않습니다. 매 요청마다 cache_creation_input_tokens가 보이는 것을 그저 지켜보며 왜 그런지 의아해할 뿐입니다.

시스템 프롬프트의 타임스탬프. 가장 흔한 단일 실수:

typescript
// This invalidates the cache on every request
const system = `You are an agent. Current time: ${new Date().toISOString()}`;

타임스탬프는 마땅히 있어야 할 곳, 즉 사용자 메시지로 옮기세요:

typescript
// Stable system prompt — cacheable
const system = `You are an agent. Use the current time provided by the user.`;

// Volatile user message — not cached
const userMessage = `Current time: ${new Date().toISOString()}. Run the daily brief.`;

무작위 UUID와 추적 ID. 같은 문제입니다. 로깅을 위해 추적 ID를 system 블록에 주입하면 매 요청마다 새로운 접두사가 생깁니다.

비결정적 JSON 직렬화. 객체를 시스템 프롬프트로 직렬화할 때 키 순서가 보장되지 않으면, 기저 데이터가 같더라도 렌더링된 문자열이 달라질 수 있습니다. 안정적인 키 순서로 직렬화하거나 템플릿 문자열을 사용하세요.

동적 퓨샷 선택. 현재 쿼리에 따라 퓨샷 예시를 골라 캐싱된 접두사에 넣고 있다면, “안정적” 접두사를 쿼리 의존적으로 만든 셈입니다. 캐시 계층에는 고정 예시를 쓰기로 결정하거나, 동적 예시는 캐싱되지 않는 메시지 턴으로 옮기세요.

캐시 적중률 확인하기

모든 응답에는 사용량 메타데이터가 포함됩니다. 그것을 확인하세요:

typescript
const response = await client.messages.create({ /* ... */ });

console.log({
  inputTokens: response.usage.input_tokens,
  cacheRead: response.usage.cache_read_input_tokens,
  cacheWrite: response.usage.cache_creation_input_tokens,
  outputTokens: response.usage.output_tokens,
});

첫 요청에서는: cache_creation_input_tokens가 0이 아니고 cache_read_input_tokens는 0입니다. 그것이 쓰기입니다.

캐시 적중 시: cache_read_input_tokens가 0이 아니고 cache_creation_input_tokens는 0입니다. 그것이 읽기입니다.

매 요청마다 cache_creation_input_tokens가 보인다면 접두사가 바뀌고 있는 것입니다. 각 호출 전에 렌더링된 시스템 프롬프트의 처음 200자를 출력하는 로그 문장을 추가하세요 — 떠다니는 타임스탬프가 즉시 눈에 띌 것입니다.

1시간 TTL: 추가 쓰기 비용이 가치 있을 때

기본 TTL은 5분입니다. 에이전트가 저빈도로 — 5분에 한 번 미만으로 — 실행된다면, 읽기를 얻지 못한 채 대부분의 요청에서 캐시 쓰기 비용을 치르게 됩니다.

typescript
// Opt into a 1-hour TTL
cache_control: { type: "ephemeral", ttl: "1h" }

1시간 쓰기는 1.25배가 아니라 기본 입력 가격의 약 2배가 듭니다. 계산은 이렇습니다: 시간당 3회 이상 캐시에 적중한다면 1시간 TTL이 비용을 절약합니다. 에이전트가 하루에 한 번 실행된다면(제 데일리 브리핑처럼) 1시간 TTL조차 도움이 되지 않습니다 — 매번 쓰기 비용을 치르기 때문입니다. 그런 경우 시스템 프롬프트가 엄청나게 크지 않은 한 캐싱의 이점은 미미합니다.

제 데일리 브리핑 에이전트는 3,000토큰짜리 시스템 프롬프트가 있지만 하루에 한 번 실행됩니다. 캐싱이 도움이 되지 않습니다. 제 뉴스레터 에이전트는 초안을 작성하는 동안 세션당 수십 번 실행됩니다 — 캐싱이 상당한 비용을 절약합니다.

사전 예열: 첫 요청을 저렴하게 만들기

알려진 트래픽 급증이 예정되어 있다면 — 배치 작업, API 출시 등 — 저비용 더미 요청으로 캐시를 미리 예열할 수 있습니다:

typescript
// Pre-warm: write the cache at near-zero output cost
await client.messages.create({
  model: "claude-opus-4-8",
  max_tokens: 1, // minimal output
  system: [{ type: "text", text: stableSystemPrompt, cache_control: { type: "ephemeral" } }],
  messages: [{ role: "user", content: "ping" }],
});

// Now the real requests read from cache

이는 주로 여러 병렬 요청을 띄우면서 각각이 캐시를 쓰려고 경쟁하기보다 따뜻한 캐시에 적중하기를 원하는 배치 처리에 유용합니다.

에이전트 루프에서의 프롬프트 캐싱

다중 턴 에이전트 루프에서는 대화 기록이 매 턴마다 늘어납니다. 캐시는 이를 처리할 만큼 영리합니다: 20블록 룩백 윈도를 사용해 마지막 20개 콘텐츠 블록 안에서 가장 긴 일치 접두사를 찾습니다.

실무적 함의: 안정적인 콘텐츠(시스템 프롬프트, 도구 정의)를 맨 위에 고정해 두세요. messages 배열 끝에서 늘어나는 대화 기록은 안정적 블록의 접두사 일치를 깨뜨리지 않습니다 — 그것들은 변동 콘텐츠보다 앞에 있고, 접두사 일치는 맨 위에서 시작하기 때문입니다.

실무에서 제 에이전트는 턴을 이렇게 구성합니다:

code
System (cached) → Tools (cached) → Few-shot (cached) → Turn 1 → Turn 2 → ... → Current turn

캐시는 퓨샷 마커까지의 모든 것을 포괄합니다. 그 뒤에서 늘어나는 턴 기록은 매번 다시 처리되지만, 그래도 괜찮습니다 — 그 토큰들은 세션 고유이며 안정적 접두사에 비해 작습니다.

청구서에서는 어떻게 보이는가

고빈도 에이전트를 예로 들어봅시다: 하루 100회 호출, 4,000토큰짜리 시스템 프롬프트, Sonnet 가격.

캐싱 없이:

캐싱 사용 (5분 TTL, 피크 시 시간당 50회 호출 가정):

해당 입력 토큰에 대해 대략 90% 절감입니다. 규모가 커지면 — 하루 1,000회 호출 — 그 차이는 더욱 누적됩니다. 그리고 이것은 Haiku 대 Sonnet 계산에서 나오는 모델 라우팅 절감 위에 추가되는 것입니다: 캐싱은 모든 등급에서 작동합니다.

운영자의 결론

프롬프트 캐싱은 Claude API에서 가장 쉬운 비용 최적화입니다: 이미 작성하고 있는 콘텐츠 블록에 필드 하나를 더하는 것뿐입니다. 제약은 접두사 안정성에 대한 규율 — 캐시 마커 앞에 동적인 것을 두지 않는 것 — 입니다. 시스템 프롬프트, 도구, 정적 예시를 변동 콘텐츠로부터 깨끗하게 유지할 수 있다면, 모든 캐시 적중에서 일반 입력 비용의 약 10%만 지불하게 됩니다. 크고 안정적인 프롬프트를 쓰는 고빈도 에이전트에게 이것은 모델 등급을 바꾸는 것보다 더 큰 지렛대입니다.


관련 글: AI 에이전트 비용 계산: Haiku가 Sonnet을 이길 때 · 이벤트 트리거 vs 예약 에이전트 · 내 비즈니스를 운영하는 데 실제로 쓰는 5가지 AI 도구

계속 읽기

AI 플레이북을 받아보세요

매주 수요일. 28,400명+ 구독자. 핵심만.

↵ 전체 결과 보기 esc esc 닫기