콘텐츠로 이동
Data Prep
상세

LLM 캐싱 전략 가이드

개요

LLM 서빙에서 캐싱은 비용 절감과 응답 속도 개선의 핵심이다. 적절한 캐싱 전략으로 70-90%의 비용 절감과 10-100배의 속도 향상이 가능하다.

왜 LLM 캐싱이 중요한가?

문제 영향 캐싱 효과
API 비용 토큰당 과금 중복 호출 제거
응답 지연 1-10초 소요 밀리초 수준으로
Rate Limit 동시 요청 제한 요청 수 감소
일관성 동일 질문 다른 답 동일 답변 보장

캐싱 계층

llm-caching diagram


1. Exact Match Cache

구현

import hashlib
import redis
import json
from typing import Optional, Dict, Any

class ExactMatchCache:
    """
    Exact Match Cache

    동일한 프롬프트 + 모델 + 파라미터 조합에 대해 캐싱
    """

    def __init__(
        self, 
        redis_client: redis.Redis,
        default_ttl: int = 3600  # 1시간
    ):
        self.redis = redis_client
        self.default_ttl = default_ttl

    def _hash_key(
        self, 
        prompt: str, 
        model: str, 
        params: Dict[str, Any]
    ) -> str:
        """
        캐시 키 생성

        모델, 프롬프트, 파라미터를 조합하여 고유 해시 생성
        """
        # 파라미터 정렬하여 일관된 해시 보장
        sorted_params = sorted(params.items())
        content = f"{model}:{prompt}:{sorted_params}"
        return f"llm_cache:{hashlib.sha256(content.encode()).hexdigest()}"

    def get(
        self, 
        prompt: str, 
        model: str, 
        params: Dict[str, Any]
    ) -> Optional[str]:
        """캐시에서 응답 조회"""
        key = self._hash_key(prompt, model, params)
        cached = self.redis.get(key)

        if cached:
            # 메트릭 기록
            self.redis.incr("llm_cache:hits")
            return json.loads(cached)

        self.redis.incr("llm_cache:misses")
        return None

    def set(
        self, 
        prompt: str, 
        model: str, 
        params: Dict[str, Any],
        response: str,
        ttl: Optional[int] = None
    ) -> None:
        """응답을 캐시에 저장"""
        key = self._hash_key(prompt, model, params)
        self.redis.setex(
            key,
            ttl or self.default_ttl,
            json.dumps(response)
        )

    def get_hit_rate(self) -> float:
        """캐시 적중률 계산"""
        hits = int(self.redis.get("llm_cache:hits") or 0)
        misses = int(self.redis.get("llm_cache:misses") or 0)
        total = hits + misses
        return hits / total if total > 0 else 0.0


# 사용 예시
redis_client = redis.Redis(host='localhost', port=6379)
cache = ExactMatchCache(redis_client, default_ttl=3600)

# 캐시 조회
prompt = "Python에서 리스트 정렬하는 방법"
model = "gpt-4"
params = {"temperature": 0.7, "max_tokens": 500}

cached_response = cache.get(prompt, model, params)
if cached_response:
    print("Cache hit!")
    response = cached_response
else:
    print("Cache miss - calling LLM")
    response = llm_client.generate(prompt, **params)
    cache.set(prompt, model, params, response)

적용 시나리오별 설정

시나리오 예상 히트율 권장 TTL 설명
FAQ 봇 60-80% 24시간 질문이 반복됨
코드 자동완성 10-20% 1시간 컨텍스트 다양
문서 요약 5-10% 무제한 문서별 캐싱
자유 대화 1-5% 미사용 거의 중복 없음

2. Semantic Cache

아키텍처

llm-caching diagram

구현

import numpy as np
from typing import Optional, Dict, List, Tuple
from dataclasses import dataclass

@dataclass
class CacheEntry:
    """캐시 엔트리"""
    query: str
    embedding: np.ndarray
    response: str
    metadata: Dict

class SemanticCache:
    """
    Semantic Cache

    임베딩 유사도 기반 캐싱
    """

    def __init__(
        self,
        embedding_model,
        vector_store,  # Pinecone, Redis Vector, Chroma 등
        similarity_threshold: float = 0.90
    ):
        self.embedding_model = embedding_model
        self.vector_store = vector_store
        self.threshold = similarity_threshold

    def _get_embedding(self, text: str) -> np.ndarray:
        """텍스트 임베딩 생성"""
        return self.embedding_model.encode(text)

    def search(
        self, 
        query: str,
        top_k: int = 1
    ) -> Optional[Tuple[str, float]]:
        """
        유사한 캐시 항목 검색

        Returns:
            (response, similarity) 또는 None
        """
        query_embedding = self._get_embedding(query)

        # 벡터 DB에서 유사 항목 검색
        results = self.vector_store.search(
            vector=query_embedding,
            top_k=top_k
        )

        if not results:
            return None

        top_result = results[0]
        similarity = top_result['score']

        if similarity >= self.threshold:
            return (top_result['response'], similarity)

        return None

    def store(
        self, 
        query: str, 
        response: str,
        metadata: Optional[Dict] = None
    ) -> None:
        """캐시에 저장"""
        embedding = self._get_embedding(query)

        self.vector_store.upsert(
            id=hashlib.md5(query.encode()).hexdigest(),
            vector=embedding,
            metadata={
                "query": query,
                "response": response,
                **(metadata or {})
            }
        )


# 사용 예시
from sentence_transformers import SentenceTransformer

embedding_model = SentenceTransformer('all-MiniLM-L6-v2')
# vector_store = PineconeIndex(...) 또는 RedisVector(...)

cache = SemanticCache(
    embedding_model=embedding_model,
    vector_store=vector_store,
    similarity_threshold=0.90
)

# 조회
result = cache.search("Python 리스트 정렬 방법")
if result:
    response, similarity = result
    print(f"Cache hit! (similarity: {similarity:.2f})")
else:
    response = llm_client.generate(query)
    cache.store(query, response)

구현 고려사항

항목 권장값 비고
유사도 임계값 0.90-0.95 낮으면 오탐, 높으면 히트율 감소
임베딩 모델 text-embedding-3-small 속도/품질 균형
벡터 DB Redis Vector, Pinecone 저지연 필수
인덱스 타입 HNSW 근사 검색, 빠름

Semantic Cache 주의사항

한계점과 해결책:

문제 예시 해결책
시간 민감 쿼리 "오늘 날씨" ≈ "어제 날씨" 시간 표현 정규화
맥락 의존 쿼리 "그거 다시 설명해줘" 이전 N턴 포함 해싱
개인화 쿼리 "내 주문 상태" 사용자별 캐시 분리
class ContextAwareSemanticCache(SemanticCache):
    """
    컨텍스트를 고려한 Semantic Cache
    """

    def normalize_query(self, query: str, context: Dict) -> str:
        """
        쿼리 정규화

        - 시간 표현 상대화
        - 대명사 해소
        """
        import re
        from datetime import datetime

        normalized = query

        # "오늘" → 날짜로 변환
        if "오늘" in query:
            today = context.get('date', datetime.now().strftime('%Y-%m-%d'))
            normalized = query.replace("오늘", f"({today})")

        # 이전 대화에서 참조 해소
        if "그것" in query or "그거" in query:
            last_topic = context.get('last_topic', '')
            if last_topic:
                normalized = re.sub(r'그것|그거', last_topic, normalized)

        return normalized

    def get_cache_key(
        self, 
        query: str, 
        user_id: str,
        context: Dict
    ) -> str:
        """사용자별, 컨텍스트별 캐시 키"""
        normalized = self.normalize_query(query, context)
        return f"{user_id}:{normalized}"

3. KV Cache (Prefix Caching)

원리

llm-caching diagram

vLLM에서의 활용

from vllm import LLM, SamplingParams

# Prefix caching 활성화
llm = LLM(
    model="meta-llama/Llama-2-7b-hf",
    enable_prefix_caching=True,
    max_num_batched_tokens=8192,
    gpu_memory_utilization=0.9
)

# 공통 시스템 프롬프트
SYSTEM_PROMPT = """당신은 전문적인 고객 상담 AI입니다.

다음 규칙을 반드시 따르세요:
1. 항상 공손하고 친절하게 응대합니다.
2. 정확한 정보만 제공합니다.
3. 불확실한 경우 확인 요청을 합니다.
4. 개인정보 보호에 유의합니다.

응답 형식:
- 먼저 고객의 문의를 요약합니다.
- 해결책이나 정보를 제공합니다.
- 추가 도움이 필요한지 확인합니다.
"""

# 여러 사용자 쿼리 - SYSTEM_PROMPT의 KV 캐시 공유
queries = [
    f"{SYSTEM_PROMPT}\n\nUser: 환불 절차가 어떻게 되나요?\nAssistant:",
    f"{SYSTEM_PROMPT}\n\nUser: 배송은 보통 얼마나 걸리나요?\nAssistant:",
    f"{SYSTEM_PROMPT}\n\nUser: 상품 교환이 가능한가요?\nAssistant:",
]

# 배치 처리
sampling_params = SamplingParams(
    temperature=0.7,
    max_tokens=500
)

outputs = llm.generate(queries, sampling_params)

for output in outputs:
    print(output.outputs[0].text)

KV Cache 효과

시스템 프롬프트 길이 KV 캐시 절감 TTFT 개선
500 토큰 15-20% 100-200ms
2000 토큰 40-50% 400-800ms
8000 토큰 60-70% 1-2초

4. 캐시 무효화 전략

시간 기반

from enum import Enum
from typing import Dict

class ContentType(Enum):
    FACTUAL = "factual"           # 상식, 불변 지식
    DOCUMENTATION = "documentation"  # 제품/서비스 문서
    NEWS = "news"                 # 뉴스, 시사
    REALTIME = "realtime"         # 실시간 데이터

# 콘텐츠 유형별 TTL 정책
CACHE_POLICIES: Dict[ContentType, int] = {
    ContentType.FACTUAL: 86400 * 30,     # 30일
    ContentType.DOCUMENTATION: 86400 * 7,  # 7일
    ContentType.NEWS: 3600,               # 1시간
    ContentType.REALTIME: 0,              # 캐시 안함
}

def get_ttl_for_query(query: str, content_classifier) -> int:
    """쿼리 내용에 따른 TTL 결정"""
    content_type = content_classifier.classify(query)
    return CACHE_POLICIES.get(content_type, 3600)

이벤트 기반

llm-caching diagram

from typing import Set, Optional
from datetime import datetime
import redis

class CacheInvalidator:
    """
    캐시 무효화 관리자
    """

    def __init__(self, redis_client: redis.Redis):
        self.redis = redis_client

    def invalidate_by_tag(self, tag: str) -> int:
        """
        태그 기반 무효화

        예: 특정 문서 관련 캐시 모두 삭제
        """
        pattern = f"llm_cache:*:tag:{tag}"
        keys = self.redis.keys(pattern)
        if keys:
            return self.redis.delete(*keys)
        return 0

    def invalidate_by_prefix(self, prefix: str) -> int:
        """
        프리픽스 기반 무효화

        예: 특정 프롬프트 버전 관련 캐시 삭제
        """
        pattern = f"llm_cache:{prefix}:*"
        keys = self.redis.keys(pattern)
        if keys:
            return self.redis.delete(*keys)
        return 0

    def invalidate_by_timestamp(
        self, 
        before: datetime
    ) -> int:
        """
        시간 기반 무효화

        특정 시점 이전 캐시 삭제
        """
        # ZRANGEBYSCORE로 타임스탬프 인덱스 조회
        cutoff = before.timestamp()
        old_keys = self.redis.zrangebyscore(
            "llm_cache:timestamps",
            "-inf",
            cutoff
        )

        if old_keys:
            # 캐시 항목 삭제
            self.redis.delete(*old_keys)
            # 인덱스에서도 제거
            self.redis.zremrangebyscore(
                "llm_cache:timestamps",
                "-inf",
                cutoff
            )
            return len(old_keys)
        return 0

    def flush_all(self) -> None:
        """전체 LLM 캐시 삭제"""
        keys = self.redis.keys("llm_cache:*")
        if keys:
            self.redis.delete(*keys)


# Webhook 핸들러 예시
from flask import Flask, request

app = Flask(__name__)
invalidator = CacheInvalidator(redis_client)

@app.route("/webhook/document-updated", methods=["POST"])
def handle_document_update():
    """문서 업데이트 시 캐시 무효화"""
    doc_id = request.json.get("document_id")
    count = invalidator.invalidate_by_tag(f"doc:{doc_id}")
    return {"invalidated": count}

@app.route("/webhook/model-changed", methods=["POST"])
def handle_model_change():
    """모델 변경 시 전체 캐시 무효화"""
    invalidator.flush_all()
    return {"status": "flushed"}

5. 모니터링 메트릭

메트릭 계산 목표값 알림 조건
Hit Rate 히트 / 전체 요청 > 30% < 10%
Cost Savings (1 - 실제비용/캐시없음비용) × 100 > 50% < 20%
Latency P50 캐시 히트 지연 < 100ms > 500ms
Staleness Rate 오래된 캐시 반환 비율 < 5% > 10%
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional
import time

@dataclass
class CacheMetrics:
    """캐시 메트릭"""
    hits: int = 0
    misses: int = 0
    latency_sum_ms: float = 0
    latency_count: int = 0
    cost_saved: float = 0
    stale_hits: int = 0

class CacheMonitor:
    """
    캐시 모니터링
    """

    def __init__(self, redis_client):
        self.redis = redis_client
        self.metrics = CacheMetrics()

    def record_hit(self, latency_ms: float, is_stale: bool = False):
        """캐시 히트 기록"""
        self.metrics.hits += 1
        self.metrics.latency_sum_ms += latency_ms
        self.metrics.latency_count += 1
        if is_stale:
            self.metrics.stale_hits += 1

        # Redis에도 기록 (분산 환경용)
        self.redis.incr("llm_cache:metrics:hits")

    def record_miss(self, llm_cost: float):
        """캐시 미스 기록"""
        self.metrics.misses += 1
        self.redis.incr("llm_cache:metrics:misses")

    def record_cost_saved(self, amount: float):
        """절감 비용 기록"""
        self.metrics.cost_saved += amount
        self.redis.incrbyfloat("llm_cache:metrics:cost_saved", amount)

    @property
    def hit_rate(self) -> float:
        """히트율"""
        total = self.metrics.hits + self.metrics.misses
        return self.metrics.hits / total if total > 0 else 0

    @property
    def avg_latency_ms(self) -> float:
        """평균 지연시간"""
        if self.metrics.latency_count == 0:
            return 0
        return self.metrics.latency_sum_ms / self.metrics.latency_count

    @property
    def staleness_rate(self) -> float:
        """Stale 히트 비율"""
        if self.metrics.hits == 0:
            return 0
        return self.metrics.stale_hits / self.metrics.hits

    def get_dashboard_data(self) -> dict:
        """대시보드용 데이터"""
        return {
            "hit_rate": f"{self.hit_rate:.1%}",
            "avg_latency_ms": f"{self.avg_latency_ms:.1f}ms",
            "cost_saved": f"${self.metrics.cost_saved:.2f}",
            "staleness_rate": f"{self.staleness_rate:.1%}",
            "total_requests": self.metrics.hits + self.metrics.misses
        }


# 사용 예시
monitor = CacheMonitor(redis_client)

# 캐시 조회 시
start = time.time()
result = cache.get(query, model, params)
latency = (time.time() - start) * 1000

if result:
    monitor.record_hit(latency)
    monitor.record_cost_saved(0.003)  # 예상 LLM 호출 비용
else:
    response = llm_client.generate(query)
    monitor.record_miss(0.003)
    cache.set(query, model, params, response)

# 메트릭 확인
print(monitor.get_dashboard_data())

구현 체크리스트

Exact Match Cache

[ ] Redis 또는 인메모리 캐시 설정
[ ] 해시 키 생성 로직 (모델 + 파라미터 포함)
[ ] TTL 정책 정의
[ ] 히트/미스 메트릭 수집

Semantic Cache

[ ] 임베딩 모델 선택 (속도 vs 품질)
[ ] 벡터 DB 설정 (Redis Vector, Pinecone 등)
[ ] 유사도 임계값 튜닝
[ ] 예외 쿼리 필터링 (시간, 맥락, 개인화)
[ ] 정기적 벤치마크 및 임계값 조정

KV Cache

[ ] vLLM prefix_caching 활성화
[ ] 시스템 프롬프트 표준화
[ ] 배치 처리 최적화
[ ] GPU 메모리 모니터링

모니터링

[ ] 히트율 대시보드
[ ] 비용 절감 추적
[ ] 캐시 품질 검증 (샘플링)
[ ] 알림 설정 (히트율 급락 등)

트러블슈팅

흔한 문제와 해결책

문제 원인 해결책
낮은 히트율 쿼리 다양성 높음 Semantic cache 도입
잘못된 캐시 반환 임계값 너무 낮음 유사도 임계값 상향 (0.92→0.95)
캐시 오염 할루시네이션 캐싱 응답 품질 검증 후 캐싱
메모리 부족 캐시 크기 증가 TTL 단축, LRU 정책
지연시간 증가 벡터 검색 느림 인덱스 최적화, 더 빠른 DB

참고 자료

자료 링크 설명
vLLM Docs docs.vllm.ai Prefix caching 공식 문서
GPTCache github.com/zilliztech/GPTCache 오픈소스 LLM 캐싱
Redis Vector redis.io/docs/stack/search Redis 벡터 검색
Pinecone pinecone.io 관리형 벡터 DB