--- name: llm-caching description: Implement multi-layer LLM caching with exact match, semantic similarity, and provider-side prompt caching. Reduce API costs by 30–70%, cut latency, and improve throughput using Redis, GPTCache, and provider caching APIs. license: MIT metadata: author: devops-skills version: "1.0" --- # LLM Caching Cut LLM costs and latency with exact match, semantic, and provider-side caching layers. ## When to Use This Skill Use this skill when: - The same or similar queries are asked repeatedly (FAQ bots, support tools) - LLM API costs are growing and you need immediate savings - Serving high request volumes where repeated queries cause bottlenecks - Implementing prompt caching for long system prompts (Anthropic/OpenAI) - Building offline-capable AI features that need response persistence ## Caching Layers ``` Request → Exact Cache → Semantic Cache → Provider Cache → LLM API ↓ hit ↓ hit ↓ hit instant ~5ms 50-80% cheaper ``` ## Layer 1: Exact Match Cache (Redis) ```python import hashlib import json import redis from openai import OpenAI r = redis.Redis(host="localhost", port=6379, decode_responses=True) client = OpenAI() def build_cache_key(model: str, messages: list, temperature: float) -> str: """Deterministic key from request parameters.""" payload = json.dumps({ "model": model, "messages": messages, "temperature": temperature, }, sort_keys=True) return f"llm:exact:{hashlib.sha256(payload.encode()).hexdigest()}" def cached_completion(model: str, messages: list, temperature: float = 0.0, ttl: int = 3600) -> dict: key = build_cache_key(model, messages, temperature) # Check cache if cached := r.get(key): return json.loads(cached) # Call API response = client.chat.completions.create( model=model, messages=messages, temperature=temperature ) result = response.model_dump() # Cache result (only cache deterministic responses) if temperature == 0.0: r.setex(key, ttl, json.dumps(result)) return result ``` ## Layer 2: Semantic Cache (GPTCache) ```python from gptcache import cache, Config from gptcache.adapter import openai from gptcache.embedding import Onnx from gptcache.manager import CacheBase, VectorBase, get_data_manager from gptcache.similarity_evaluation.distance import SearchDistanceEvaluation # Configure GPTCache with Qdrant backend def init_gptcache(cache_obj, llm: str): onnx = Onnx() # local embedding model data_manager = get_data_manager( CacheBase("redis"), # metadata store VectorBase("qdrant", host="localhost", port=6333, collection_name=f"llm-cache-{llm}", dimension=onnx.dimension), ) cache_obj.init( embedding_func=onnx.to_embeddings, data_manager=data_manager, similarity_evaluation=SearchDistanceEvaluation(), config=Config(similarity_threshold=0.80), # 80% similarity = cache hit ) cache.set_openai_key() init_gptcache(cache, "gpt-4o-mini") # Now openai calls are automatically cached response = openai.ChatCompletion.create( model="gpt-4o-mini", messages=[{"role": "user", "content": "What is machine learning?"}], ) # Second call with similar question ("Explain machine learning") → cache hit ``` ## Custom Semantic Cache (Production-Grade) ```python from sentence_transformers import SentenceTransformer from qdrant_client import QdrantClient from qdrant_client.models import Distance, VectorParams, PointStruct, Filter, FieldCondition, Range import numpy as np import uuid import time embed_model = SentenceTransformer("BAAI/bge-small-en-v1.5") # fast, 33M params qdrant = QdrantClient("http://localhost:6333") CACHE_COLLECTION = "semantic-cache" SIMILARITY_THRESHOLD = 0.88 CACHE_TTL_SECONDS = 86400 # 24h # Create collection once qdrant.create_collection( collection_name=CACHE_COLLECTION, vectors_config=VectorParams(size=384, distance=Distance.COSINE), on_disk_payload=True, ) def semantic_cache_lookup(query: str, model: str) -> str | None: embedding = embed_model.encode(query).tolist() results = qdrant.query_points( collection_name=CACHE_COLLECTION, query=embedding, query_filter=Filter(must=[ FieldCondition(key="model", match={"value": model}), FieldCondition(key="expires_at", range=Range(gte=time.time())), ]), limit=1, score_threshold=SIMILARITY_THRESHOLD, ) if results.points: return results.points[0].payload["response"] return None def semantic_cache_store(query: str, response: str, model: str): embedding = embed_model.encode(query).tolist() qdrant.upsert( collection_name=CACHE_COLLECTION, points=[PointStruct( id=str(uuid.uuid4()), vector=embedding, payload={ "query": query, "response": response, "model": model, "created_at": time.time(), "expires_at": time.time() + CACHE_TTL_SECONDS, }, )], ) def smart_llm_call(query: str, model: str = "gpt-4o-mini") -> dict: # 1. Semantic lookup if cached_response := semantic_cache_lookup(query, model): return {"response": cached_response, "source": "semantic_cache", "cost": 0} # 2. LLM call response = client.chat.completions.create( model=model, messages=[{"role": "user", "content": query}], ) text = response.choices[0].message.content cost = litellm.completion_cost(response) # 3. Store in cache semantic_cache_store(query, text, model) return {"response": text, "source": "llm_api", "cost": cost} ``` ## Layer 3: Provider-Side Prompt Caching ```python # Anthropic — cache long system prompts (saves 90% on cached input tokens) import anthropic client = anthropic.Anthropic() # Long system prompt — mark for caching SYSTEM_PROMPT = open("knowledge-base.txt").read() # e.g., 50k tokens def call_with_prompt_cache(user_question: str) -> str: response = client.messages.create( model="claude-sonnet-4-6", max_tokens=1024, system=[ {"type": "text", "text": "You are a helpful assistant."}, { "type": "text", "text": SYSTEM_PROMPT, "cache_control": {"type": "ephemeral"}, # cache this block } ], messages=[{"role": "user", "content": user_question}], ) # Log cache efficiency usage = response.usage cache_savings = usage.cache_read_input_tokens * 0.9 # 90% discount on cached print(f"Cache hits: {usage.cache_read_input_tokens} tokens " f"(saved ~${cache_savings * 3.0 / 1_000_000:.4f})") return response.content[0].text # OpenAI — automatic for repeated prefixes (≥1,024 tokens) # No code change needed; cached tokens appear in usage.prompt_tokens_details response = client.chat.completions.create( model="gpt-4o-mini", messages=[ {"role": "system", "content": LONG_SYSTEM_PROMPT}, # auto-cached {"role": "user", "content": user_question}, ] ) cached = response.usage.prompt_tokens_details.cached_tokens print(f"OpenAI cached {cached} tokens") ``` ## Cache Warming ```python async def warm_cache(common_queries: list[str], model: str): """Pre-populate cache with known frequent queries.""" import asyncio from openai import AsyncOpenAI aclient = AsyncOpenAI() async def warm_single(query: str): if not semantic_cache_lookup(query, model): response = await aclient.chat.completions.create( model=model, messages=[{"role": "user", "content": query}], ) text = response.choices[0].message.content semantic_cache_store(query, text, model) print(f"Warmed: {query[:50]}...") await asyncio.gather(*[warm_single(q) for q in common_queries]) # Warm on startup import asyncio asyncio.run(warm_cache(FREQUENT_QUERIES, "gpt-4o-mini")) ``` ## Cache Metrics ```python from prometheus_client import Counter, Histogram cache_hits = Counter("llm_cache_hits_total", "Cache hits", ["cache_layer", "model"]) cache_misses = Counter("llm_cache_misses_total", "Cache misses", ["model"]) cache_savings_usd = Counter("llm_cache_savings_usd_total", "USD saved by cache", ["model"]) # Use in your smart_llm_call function if source == "semantic_cache": cache_hits.labels(cache_layer="semantic", model=model).inc() cache_savings_usd.labels(model=model).inc(estimated_cost) else: cache_misses.labels(model=model).inc() ``` ## Redis Configuration for LLM Caching ```bash # redis.conf tuning for LLM cache workload maxmemory 8gb maxmemory-policy allkeys-lru # evict least-recently-used when full save "" # disable persistence (cache is ephemeral) appendonly no tcp-keepalive 60 ``` ## Common Issues | Issue | Cause | Fix | |-------|-------|-----| | Low cache hit rate | Threshold too strict | Lower `SIMILARITY_THRESHOLD` to 0.82–0.85 | | Stale cached responses | Long TTL | Use topic-specific TTLs; invalidate on data updates | | Cache serving wrong answers | Threshold too loose | Raise threshold or add model-name filtering | | Redis OOM | No eviction policy | Set `maxmemory` + `allkeys-lru` | | Slow semantic lookup | Large cache collection | Add payload index on `model` + `expires_at` | ## Best Practices - Start with exact cache — zero cost, instant wins for identical queries. - Semantic threshold of 0.88–0.92 balances hit rate vs. accuracy; tune with your data. - Set per-model TTLs: longer for stable knowledge (1 week), shorter for news/events (1 hour). - Always filter by model name in semantic cache — different models give different answers. - Log cache hit rate as a KPI; target 30%+ for FAQ-style applications. ## Related Skills - [llm-cost-optimization](../llm-cost-optimization/) - Full cost strategy - [llm-gateway](../../infrastructure/networking/llm-gateway/) - Gateway-level caching - [vector-database-ops](../../infrastructure/databases/vector-database-ops/) - Qdrant setup - [agent-observability](../agent-observability/) - Cache metrics dashboards