본문 바로가기

LLM

LLM 프롬프트 캐싱 완전 가이드 — 같은 말 두 번 하지 마세요, 비용 90% 줄이는 법

반응형

시스템 프롬프트가 매 요청마다 다시 처리되고 있습니다. 캐싱 하나로 비용의 90%를 날릴 수 있습니다.

[핵심 요약]
→ 문제: LLM API는 같은 시스템 프롬프트도 매번 토큰 비용 청구
→ 해결: 프롬프트 캐싱 — 한 번 처리된 컨텍스트를 재사용
→ 절감: 캐시 히트 시 입력 토큰 비용 90% 절감 (Claude 기준)
→ 지원: Claude (Anthropic), GPT-4o (OpenAI), Gemini 3.1 (Google)
→ 적합한 곳: 긴 시스템 프롬프트, 문서 분석, RAG, 멀티턴 대화
→ 주의: TTL 있음 (Claude 5분, GPT 1시간) — 전략적 설계 필요

프롬프트 캐싱이 왜 필요한가

# 캐싱 없을 때 — 매 요청마다 전체 토큰 과금
system_prompt = """
당신은 법률 전문 AI 어시스턴트입니다.
다음 법률 조항을 참고하여 질문에 답변하세요.

[민법 제1조~제200조 전문]
[상법 제1조~제150조 전문]
[특허법 제1조~제100조 전문]
... (총 50,000 토큰)
"""

# 사용자 질문 100개 → 100번 × 50,000 토큰 = 5,000,000 토큰 과금
# Claude Sonnet 4.6 기준: $3/1M tokens → $15.00

# 캐싱 있을 때
# 첫 번째 요청: 50,000 토큰 처리 (전액 과금)
# 2~100번째 요청: 캐시 히트 → 90% 할인
# 절감액: $15.00 → ~$1.65 (89% 절감)
[캐싱이 효과적인 상황]
→ 긴 시스템 프롬프트 (1,000 토큰 이상)
→ 반복 사용되는 문서/코드베이스 컨텍스트
→ RAG에서 동일 청크가 반복 참조될 때
→ 멀티턴 대화에서 누적되는 히스토리
→ 배치 처리 (같은 컨텍스트로 여러 사용자 서비스)

[캐싱이 효과 없는 상황]
→ 매 요청마다 다른 시스템 프롬프트
→ 짧은 시스템 프롬프트 (1,000 토큰 미만)
→ 요청 간격이 TTL 초과 (Claude 5분, GPT 1시간)

실전 1 — Claude 프롬프트 캐싱

Claude는 cache_control 파라미터로 캐싱을 명시적으로 제어합니다.

import anthropic

client = anthropic.Anthropic()

# ===== 기본 캐싱 =====
def ask_with_cache(question: str, legal_docs: str) -> str:
    """
    긴 법률 문서를 캐싱해서 반복 질문 처리
    """
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        system=[
            {
                "type": "text",
                "text": "당신은 법률 전문 AI입니다. 다음 법률 문서를 참고하세요.",
            },
            {
                "type": "text",
                "text": legal_docs,          # 긴 문서
                "cache_control": {"type": "ephemeral"}  # ← 여기까지 캐싱
            }
        ],
        messages=[
            {"role": "user", "content": question}  # 매번 다른 질문
        ]
    )

    # 캐시 사용 여부 확인
    usage = response.usage
    print(f"입력 토큰: {usage.input_tokens}")
    print(f"캐시 생성 토큰: {usage.cache_creation_input_tokens}")
    print(f"캐시 읽기 토큰: {usage.cache_read_input_tokens}")

    return response.content[0].text


# 첫 번째 호출 — 캐시 생성
result1 = ask_with_cache(
    question="계약 해지 조건이 뭔가요?",
    legal_docs=LEGAL_DOCS  # 50,000 토큰짜리 문서
)
# cache_creation_input_tokens: 50,000
# cache_read_input_tokens: 0

# 두 번째 호출 — 캐시 히트 (5분 이내)
result2 = ask_with_cache(
    question="손해배상 청구 기간은?",
    legal_docs=LEGAL_DOCS  # 동일한 문서
)
# cache_creation_input_tokens: 0
# cache_read_input_tokens: 50,000  ← 90% 할인 적용
# ===== 다중 캐시 포인트 =====
def analyze_codebase(question: str, codebase: str, guidelines: str) -> str:
    """
    코드베이스 + 가이드라인 동시 캐싱
    캐시 포인트는 최대 4개까지 가능
    """
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=2048,
        system=[
            {
                "type": "text",
                "text": "당신은 시니어 개발자입니다."
            },
            {
                "type": "text",
                "text": guidelines,           # 코딩 가이드라인
                "cache_control": {"type": "ephemeral"}  # 첫 번째 캐시 포인트
            },
            {
                "type": "text",
                "text": codebase,             # 전체 코드베이스
                "cache_control": {"type": "ephemeral"}  # 두 번째 캐시 포인트
            }
        ],
        messages=[{"role": "user", "content": question}]
    )
    return response.content[0].text
[Claude 캐싱 스펙]
→ 최소 캐시 크기: 1,024 토큰 (미만 시 캐싱 안 됨)
→ TTL: 5분 (기본값)
→ 캐시 생성 비용: 일반 입력 토큰의 25% 추가
→ 캐시 히트 비용: 일반 입력 토큰의 10%
→ 캐시 포인트: 시스템 프롬프트당 최대 4개
→ 지원 모델: Claude Haiku 4.5, Sonnet 4.6, Opus 4.7

실전 2 — OpenAI 프롬프트 캐싱

OpenAI는 자동 캐싱을 지원합니다. 별도 설정 없이 동일한 프롬프트 앞부분이 캐싱됩니다.

from openai import OpenAI

client = OpenAI()

def ask_gpt_with_cache(question: str, context: str) -> str:
    """
    OpenAI 자동 캐싱 활용
    별도 설정 없이 동일 프롬프트 앞부분 자동 캐싱
    """
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {
                "role": "system",
                "content": context  # 동일한 컨텍스트 → 자동 캐싱
            },
            {
                "role": "user",
                "content": question
            }
        ]
    )

    # 캐시 사용 확인
    usage = response.usage
    cached_tokens = usage.prompt_tokens_details.cached_tokens
    total_tokens  = usage.prompt_tokens

    print(f"전체 프롬프트 토큰: {total_tokens}")
    print(f"캐시된 토큰: {cached_tokens}")
    print(f"캐시 적중률: {cached_tokens/total_tokens*100:.1f}%")

    return response.choices[0].message.content
# 멀티턴 대화에서 캐싱 극대화
class CachedConversation:
    """캐싱을 고려한 멀티턴 대화"""

    def __init__(self, system_prompt: str):
        self.system_prompt = system_prompt  # 항상 동일 → 캐싱
        self.history = []

    def chat(self, user_message: str) -> str:
        self.history.append({
            "role": "user",
            "content": user_message
        })

        response = client.chat.completions.create(
            model="gpt-4o",
            messages=[
                {"role": "system", "content": self.system_prompt},
                *self.history  # 누적 히스토리
            ]
        )

        assistant_message = response.choices[0].message.content
        self.history.append({
            "role": "assistant",
            "content": assistant_message
        })

        return assistant_message
[OpenAI 캐싱 스펙]
→ 방식: 자동 (별도 설정 불필요)
→ 최소 캐시 크기: 1,024 토큰
→ TTL: 1시간 (Claude보다 훨씬 김)
→ 캐시 히트 비용: 일반 가격의 50%
→ 지원 모델: gpt-4o, gpt-4o-mini, o1 시리즈
→ 캐싱 범위: 프롬프트 앞부분부터 정확히 일치하는 부분

실전 3 — Gemini 프롬프트 캐싱

Gemini는 CachedContent 객체로 명시적으로 캐시를 생성합니다.

import google.generativeai as genai
from google.generativeai import caching
import datetime

genai.configure(api_key="YOUR_API_KEY")

# ===== 캐시 생성 =====
def create_document_cache(document_text: str, ttl_minutes: int = 60):
    """
    긴 문서를 캐시로 저장
    """
    cache = caching.CachedContent.create(
        model="gemini-3.1-flash",
        display_name="법률문서 캐시",
        system_instruction="당신은 법률 전문가입니다. 다음 문서를 참고하세요.",
        contents=[
            {
                "role": "user",
                "parts": [{"text": document_text}]
            }
        ],
        ttl=datetime.timedelta(minutes=ttl_minutes)
    )
    print(f"캐시 생성됨: {cache.name}")
    return cache


# ===== 캐시 사용 =====
def ask_with_cached_doc(cache, question: str) -> str:
    """캐시된 문서로 질문 처리"""
    model = genai.GenerativeModel.from_cached_content(cached_content=cache)

    response = model.generate_content(question)

    # 사용량 확인
    print(f"캐시 토큰: {response.usage_metadata.cached_content_token_count}")
    print(f"전체 토큰: {response.usage_metadata.total_token_count}")

    return response.text


# 실전 사용
legal_cache = create_document_cache(
    document_text=LEGAL_DOCS,
    ttl_minutes=60
)

# 같은 캐시로 여러 질문 처리
questions = [
    "계약 해지 조건이 뭔가요?",
    "손해배상 청구 기간은?",
    "계약 위반 시 패널티는?"
]

for q in questions:
    answer = ask_with_cached_doc(legal_cache, q)
    print(f"Q: {q}\nA: {answer[:100]}...\n")


# ===== 캐시 관리 =====
def list_caches():
    """현재 생성된 캐시 목록 조회"""
    for cache in caching.CachedContent.list():
        print(f"이름: {cache.name}")
        print(f"만료: {cache.expire_time}")

def delete_cache(cache):
    """캐시 삭제"""
    cache.delete()
    print("캐시 삭제됨")
[Gemini 캐싱 스펙]
→ 방식: 명시적 (CachedContent 객체 생성)
→ 최소 캐시 크기: 32,768 토큰 (다른 모델보다 훨씬 큼)
→ TTL: 기본 1시간, 최대 1일
→ 캐시 히트 비용: 일반 가격의 25%
→ 캐시 저장 비용: 토큰당 시간당 요금 추가
→ 지원 모델: Gemini 3.1 Pro, Flash, Flash-Lite

실전 4 — RAG 파이프라인에서 캐싱 전략

RAG에서 캐싱을 적용하면 특히 큰 효과를 볼 수 있습니다.

import anthropic
from typing import Optional

class CachedRAGPipeline:
    """캐싱 최적화된 RAG 파이프라인"""

    def __init__(self):
        self.client = anthropic.Anthropic()
        self.cached_chunks = {}  # 청크별 캐시 추적

    def build_cached_prompt(
        self,
        retrieved_chunks: list[str],
        query: str
    ) -> dict:
        """
        검색된 청크를 캐싱 구조로 배치
        자주 등장하는 청크를 앞에 배치해 캐시 히트율 극대화
        """
        content_blocks = []

        # 시스템 안내 (항상 캐싱)
        content_blocks.append({
            "type": "text",
            "text": "다음 문서를 참고하여 질문에 답변하세요.",
            "cache_control": {"type": "ephemeral"}
        })

        # 검색된 청크들 (자주 나오는 것 앞에 배치)
        for i, chunk in enumerate(retrieved_chunks):
            block = {"type": "text", "text": chunk}

            # 마지막 청크에 캐시 포인트 설정
            # (앞에서부터 일치하는 부분이 캐싱됨)
            if i == len(retrieved_chunks) - 1:
                block["cache_control"] = {"type": "ephemeral"}

            content_blocks.append(block)

        return content_blocks

    def query(self, retrieved_chunks: list[str], question: str) -> str:
        content_blocks = self.build_cached_prompt(retrieved_chunks, question)

        response = self.client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=1024,
            system=content_blocks,
            messages=[{"role": "user", "content": question}]
        )

        # 캐시 효율 출력
        usage = response.usage
        total_input = usage.input_tokens
        cache_read  = usage.cache_read_input_tokens or 0
        cache_created = usage.cache_creation_input_tokens or 0

        if total_input > 0:
            hit_rate = cache_read / (total_input + cache_read) * 100
            print(f"캐시 히트율: {hit_rate:.1f}%")

        return response.content[0].text


# 사용 예시
rag = CachedRAGPipeline()

# 같은 문서 청크로 여러 질문 처리
common_chunks = [
    "Chapter 1: Introduction to AI...",
    "Chapter 2: Machine Learning...",
    "Chapter 3: Deep Learning..."
]

queries = [
    "AI의 기본 개념은?",
    "머신러닝과 딥러닝의 차이는?",
    "딥러닝 모델 학습 방법은?"
]

for q in queries:
    answer = rag.query(common_chunks, q)
[RAG 캐싱 최적화 팁]
→ 공통 청크 앞 배치: 여러 쿼리에 공통으로 사용되는 청크를 앞에 배치
→ 청크 순서 고정: 청크 순서가 바뀌면 캐시 미스 발생
→ 배치 처리: 비슷한 쿼리를 묶어서 처리 (캐시 TTL 안에 처리)
→ 청크 크기: 1,024 토큰 이상 청크만 캐싱 효과 있음

실전 5 — 비용 모니터링 및 최적화

from dataclasses import dataclass
from collections import defaultdict

@dataclass
class CacheMetrics:
    total_requests:         int = 0
    cache_hits:             int = 0
    total_input_tokens:     int = 0
    cache_creation_tokens:  int = 0
    cache_read_tokens:      int = 0
    total_cost_usd:         float = 0.0
    saved_cost_usd:         float = 0.0

class CostOptimizedClient:
    """비용 추적 + 캐싱 최적화 Claude 클라이언트"""

    # Claude Sonnet 4.6 가격
    PRICES = {
        "input":          3.00 / 1_000_000,
        "output":        15.00 / 1_000_000,
        "cache_create":   3.75 / 1_000_000,  # input × 1.25
        "cache_read":     0.30 / 1_000_000,  # input × 0.10
    }

    def __init__(self):
        self.client  = anthropic.Anthropic()
        self.metrics = CacheMetrics()

    def complete(self, system_blocks: list, user_message: str, **kwargs) -> str:
        response = self.client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=1024,
            system=system_blocks,
            messages=[{"role": "user", "content": user_message}],
            **kwargs
        )

        # 비용 계산
        usage = response.usage
        input_tokens  = usage.input_tokens
        output_tokens = usage.output_tokens
        cache_created = getattr(usage, "cache_creation_input_tokens", 0) or 0
        cache_read    = getattr(usage, "cache_read_input_tokens", 0) or 0

        actual_cost = (
            input_tokens  * self.PRICES["input"]  +
            output_tokens * self.PRICES["output"] +
            cache_created * self.PRICES["cache_create"] +
            cache_read    * self.PRICES["cache_read"]
        )

        # 캐싱 없었을 때 예상 비용
        no_cache_cost = (
            (input_tokens + cache_read) * self.PRICES["input"] +
            output_tokens * self.PRICES["output"]
        )

        saved = no_cache_cost - actual_cost

        # 메트릭 누적
        self.metrics.total_requests         += 1
        self.metrics.total_input_tokens     += input_tokens
        self.metrics.cache_creation_tokens  += cache_created
        self.metrics.cache_read_tokens      += cache_read
        self.metrics.total_cost_usd         += actual_cost
        self.metrics.saved_cost_usd         += saved
        if cache_read > 0:
            self.metrics.cache_hits += 1

        return response.content[0].text

    def print_report(self):
        m = self.metrics
        hit_rate = (m.cache_hits / m.total_requests * 100
                   if m.total_requests > 0 else 0)

        print(f"""
=== 캐싱 효율 보고서 ===
총 요청 수:      {m.total_requests:,}
캐시 히트:       {m.cache_hits:,} ({hit_rate:.1f}%)
총 비용:         ${m.total_cost_usd:.4f}
절감 비용:       ${m.saved_cost_usd:.4f}
절감률:          {m.saved_cost_usd/(m.total_cost_usd+m.saved_cost_usd)*100:.1f}%
""")


# 사용
client = CostOptimizedClient()

system = [
    {"type": "text", "text": LONG_SYSTEM_PROMPT,
     "cache_control": {"type": "ephemeral"}}
]

for question in questions:
    answer = client.complete(system, question)

client.print_report()
# === 캐싱 효율 보고서 ===
# 총 요청 수:      100
# 캐시 히트:       97 (97.0%)
# 총 비용:         $0.1823
# 절감 비용:       $1.4177
# 절감률:          88.6%

3사 캐싱 스펙 비교

항목 Claude OpenAI GPT-4o Gemini 3.1

방식 명시적 (cache_control) 자동 명시적 (CachedContent)
최소 크기 1,024 토큰 1,024 토큰 32,768 토큰
TTL 5분 1시간 1시간~1일
캐시 히트 비용 입력의 10% 입력의 50% 입력의 25%
캐시 생성 비용 입력의 125% 없음 별도 저장 요금
최대 캐시 포인트 4개 자동 1개
설정 복잡도 중간 쉬움 높음

마무리

✅ 프롬프트 캐싱 써야 할 때
→ 시스템 프롬프트가 1,000 토큰 이상
→ 같은 문서/코드베이스를 반복 참조
→ RAG 파이프라인에서 동일 청크가 자주 검색
→ 멀티턴 대화 서비스 (누적 히스토리)
→ 배치 처리 (수백~수천 요청을 같은 컨텍스트로)

❌ 효과 없는 경우
→ 시스템 프롬프트가 매번 다름
→ 1,000 토큰 미만 짧은 프롬프트
→ TTL 초과 간격으로 요청 (Claude 5분, GPT 1시간)
→ 단발성 요청 (캐시 생성 비용이 절감액보다 큼)

[Claude 캐싱 빠른 설정 요약]
→ 시스템 프롬프트 마지막 블록에 cache_control 추가
→ 최소 1,024 토큰 이상인지 확인
→ 5분 이내 반복 요청 패턴인지 확인
→ usage.cache_read_input_tokens로 히트 여부 확인

 

관련 글:

https://cell-devlog.tistory.com/106

 

Claude Opus 4.7 토크나이저 함정 — 같은 가격, 더 많은 비용

Anthropic이 4월 16일 Opus 4.7을 출시하면서 이렇게 말했어요."가격 변동 없음. Opus 4.6과 동일한 $5/$25 per MTok"맞아요. 토큰당 가격은 그대로예요.근데 같은 텍스트가 더 많은 토큰으로 쪼개집니다.Anthro

cell-devlog.tistory.com

https://cell-devlog.tistory.com/107

 

Opus 4.7 에이전트 비용 제어 실전 — effort + Task Budget 완전 가이드

에이전트를 Opus 4.7로 돌리면 비용이 예측 불가예요.왜 예측이 안 되냐:→ 에이전트 루프: 생각 + 툴 호출 + 툴 결과 + 출력이 쌓임→ xhigh 기본값: 더 많이 생각함→ 새 토크나이저: 같은 텍스트도

cell-devlog.tistory.com

https://cell-devlog.tistory.com/91

 

LLM 모델 라우팅 완전 가이드 — 분류기, 캐스케이딩, 시맨틱 캐시 실전

LLM을 프로덕션에 올리면 첫 달 청구서가 이렇게 나와요.예상: $300/월실제: $2,400/월원인 분석해보면 이래요.고객: "배송 얼마나 걸려요?"→ Claude Opus 4.6 응답 ($0.015/1K토큰)고객: "안녕하세요"→ Claud

cell-devlog.tistory.com

 

 

반응형