Multi-level caching strategies for LLM applications - semantic caching (Redis), prompt caching (Claude/OpenAI native), cache hierarchies, cost optimization, and Langfuse cost tracking with...
Modern LLM applications can reduce costs by 70-95% through intelligent multi-level caching. This skill covers the multi-tier caching architecture (2025 best practice): combining in-memory LRU, Redis semantic caching, and provider-native prompt caching for maximum efficiency.
Real-World Use Cases:
When to use this skill:
Expected Impact:
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
ā CACHE HIERARCHY (2025 BEST PRACTICE) ā
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Request ā L1 (Exact Hash) ā L2 (Semantic) ā L3 (Prompt) ā L4 (Full LLM)
ā Hit: ~1ms ā Hit: ~10ms ā Cached ā Full Cost
100% savings 100% savings 90% savings $$$
L1: In-Memory LRU Cache
āāāāāāāāāāāāāāāāāāāāāāāā
⢠Exact content hash matching
⢠1,000-10,000 entry size
⢠TTL: 5-10 minutes
⢠Use Case: Duplicate requests within session
⢠Implementation: Python functools.lru_cache or cachetools
L2: Redis Semantic Cache
āāāāāāāāāāāāāāāāāāāāāāāāā
⢠Vector similarity search (cosine distance < 0.08)
⢠Configurable similarity threshold (0.85-0.95)
⢠TTL: 1-24 hours
⢠Use Case: Similar but not identical queries
⢠Implementation: RedisVL SemanticCache + RediSearch
L3: Prompt Caching (Provider Native)
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
⢠Cache identical prompt PREFIXES (system prompts, examples)
⢠Claude: cache_control ephemeral markers
⢠GPT: Cached prefix automatically detected
⢠TTL: 5 minutes (auto-refresh on use)
⢠Use Case: Same prompts, different user content
⢠March 2025: Cache reads don't count against rate limits!
L4: Full LLM Call
āāāāāāāāāāāāāāāāā
⢠No cache hit - full generation required
⢠Store response in L2 and L1 for future hits
⢠Full token cost
async def get_llm_response(query: str, agent_type: str) -> dict:
"""Multi-level cache lookup."""
# L1: Exact match (in-memory)
cache_key = hash_content(query)
if cache_key in lru_cache:
return lru_cache[cache_key] # ~1ms, 100% savings
# L2: Semantic similarity (Redis)
embedding = await embed_text(query)
similar = await redis_cache.find_similar(
embedding=embedding,
agent_type=agent_type,
threshold=0.92 # Configurable
)
if similar and similar.distance < 0.08:
lru_cache[cache_key] = similar.response # Promote to L1
return similar.response # ~10ms, 100% savings
# L3 + L4: Prompt caching + LLM call
# Prompt cache breakpoints reduce L4 cost by 90%
response = await llm.generate(
messages=build_cached_messages(
system_prompt=AGENT_PROMPT, # ā Cached
examples=few_shot_examples, # ā Cached
user_content=query # ā NOT cached
)
)
# Store in L2 and L1
await redis_cache.set(embedding, response, agent_type)
lru_cache[cache_key] = response
return response # L3: ~2s, 90% savings | L4: ~3s, full cost
Problem: How similar is "similar enough" to return a cached response?
Threshold Guidelines (cosine similarity):
Recommended Starting Point: 0.92 (distance < 0.08)
Tuning Process:
Pre-populate cache from golden dataset for instant hit rates:
async def warm_cache_from_golden_dataset(
cache: SemanticCache,
min_quality: float = 0.8
) -> int:
"""Warm cache with high-quality historical responses."""
# Load golden dataset analyses
analyses = await db.query(
"SELECT * FROM analyses WHERE confidence_score >= ?",
(min_quality,)
)
warmed = 0
for analysis in analyses:
# Extract agent findings
for finding in analysis.findings:
await cache.set(
content=analysis.content,
response=finding.output,
agent_type=finding.agent_type,
quality_score=finding.confidence_score
)
warmed += 1
return warmed
# RedisVL Index Schema
CACHE_INDEX_SCHEMA = {
"index": {
"name": "llm_semantic_cache",
"prefix": "cache:",
},
"fields": [
{"name": "agent_type", "type": "tag"},
{"name": "content_type", "type": "tag"},
{"name": "input_hash", "type": "tag"},
{
"name": "embedding",
"type": "vector",
"attrs": {
"dims": 1536, # OpenAI text-embedding-3-small
"distance_metric": "cosine",
"algorithm": "hnsw", # Fast approximate search
}
},
{"name": "response", "type": "text"},
{"name": "created_at", "type": "numeric"},
{"name": "hit_count", "type": "numeric"},
{"name": "quality_score", "type": "numeric"},
]
}
from redisvl.index import SearchIndex
from redisvl.query import VectorQuery
from redis import Redis
class SemanticCacheService:
"""Redis semantic cache for LLM responses."""
def __init__(self, redis_url: str, similarity_threshold: float = 0.92):
self.client = Redis.from_url(redis_url)
self.threshold = similarity_threshold
self.embedding_service = EmbeddingService()
# Initialize RedisVL index
schema = IndexSchema.from_dict(CACHE_INDEX_SCHEMA)
self.index = SearchIndex(schema, self.client)
self.index.create(overwrite=False)
async def get(
self,
content: str,
agent_type: str,
content_type: str | None = None
) -> CacheEntry | None:
"""Look up cached response by semantic similarity."""
# Generate embedding
embedding = await self.embedding_service.embed_text(content[:2000])
# Build query with filters
filter_expr = f"@agent_type:{{{agent_type}}}"
if content_type:
filter_expr += f" @content_type:{{{content_type}}}"
query = VectorQuery(
vector=embedding,
vector_field_name="embedding",
return_fields=["response", "quality_score", "hit_count"],
num_results=1,
filter_expression=filter_expr
)
results = self.index.query(query)
if results and len(results) > 0:
result = results[0]
distance = float(result.get("vector_distance", 1.0))
# Check similarity threshold
if distance <= (1 - self.threshold):
# Increment hit count
self.client.hincrby(result["id"], "hit_count", 1)
return CacheEntry(
response=json.loads(result["response"]),
quality_score=float(result["quality_score"]),
hit_count=int(result["hit_count"]),
distance=distance
)
return None
async def set(
self,
content: str,
response: dict,
agent_type: str,
content_type: str | None = None,
quality_score: float = 1.0
) -> None:
"""Store response in cache."""
content_preview = content[:2000]
embedding = await self.embedding_service.embed_text(content_preview)
key = f"cache:{agent_type}:{hash_content(content_preview)}"
data = {
"agent_type": agent_type,
"content_type": content_type or "",
"input_hash": hash_content(content_preview),
"embedding": embedding,
"response": json.dumps(response),
"created_at": time.time(),
"hit_count": 0,
"quality_score": quality_score,
}
self.client.hset(key, mapping=data)
self.client.expire(key, ttl=86400) # 24 hours
class PromptCacheManager:
"""Manage Claude prompt caching with cache breakpoints."""
def build_cached_messages(
self,
system_prompt: str,
few_shot_examples: str | None = None,
schema_prompt: str | None = None,
dynamic_content: str = ""
) -> list[dict]:
"""Build messages with cache breakpoints.
Cache structure:
1. System prompt (always cached)
2. Few-shot examples (cached per content type)
3. Schema documentation (always cached)
āāāāāāāāāāāāāāāā CACHE BREAKPOINT āāāāāāāāāāāāāāāā
4. Dynamic content (NEVER cached)
"""
content_parts = []
# Breakpoint 1: System prompt
content_parts.append({
"type": "text",
"text": system_prompt,
"cache_control": {"type": "ephemeral"}
})
# Breakpoint 2: Few-shot examples (if provided)
if few_shot_examples:
content_parts.append({
"type": "text",
"text": few_shot_examples,
"cache_control": {"type": "ephemeral"}
})
# Breakpoint 3: Schema documentation (if provided)
if schema_prompt:
content_parts.append({
"type": "text",
"text": schema_prompt,
"cache_control": {"type": "ephemeral"}
})
# Dynamic content (NOT cached)
content_parts.append({
"type": "text",
"text": dynamic_content
})
return [{"role": "user", "content": content_parts}]
Without Prompt Caching:
āāāāāāāāāāāāāāāāāāāāāāāāā
System prompt: 2,000 tokens @ $3/MTok = $0.006
Few-shot examples: 5,000 tokens @ $3/MTok = $0.015
Schema docs: 1,000 tokens @ $3/MTok = $0.003
User content: 10,000 tokens @ $3/MTok = $0.030
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Total input: 18,000 tokens = $0.054 per request
With Prompt Caching (90% hit rate):
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Cached prefix: 8,000 tokens @ $0.30/MTok = $0.0024 (cache read)
User content: 10,000 tokens @ $3/MTok = $0.0300
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Total: 18,000 tokens = $0.0324 per request
Savings: 40% per request
With Semantic Cache (35% hit rate) + Prompt Cache:
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
35% requests: $0.00 (semantic cache hit)
65% requests: $0.0324 (prompt cache benefit)
Average: $0.021 per request
Total Savings: 61% vs no caching
For higher precision, rerank top-k semantic cache candidates:
async def get_with_reranking(
query: str,
agent_type: str,
top_k: int = 3
) -> CacheEntry | None:
"""Retrieve with LLM reranking for better precision."""
# Get top-k candidates
candidates = await semantic_cache.get_topk(query, agent_type, k=top_k)
if not candidates:
return None
# Use lightweight model to rerank
rerank_prompt = f"""
Query: {query}
Rank these cached responses by relevance (1 = most relevant):
{format_candidates(candidates)}
"""
ranking = await lightweight_llm.rank(rerank_prompt)
best_candidate = candidates[ranking[0]]
if best_candidate.score > 0.8: # Rerank threshold
return best_candidate
return None
Filter before vector search to improve precision:
# Good: Filter by agent_type + content_type
query = VectorQuery(
vector=embedding,
filter_expression="@agent_type:{security_auditor} @content_type:{article}"
)
# Better: Add difficulty level
query = VectorQuery(
vector=embedding,
filter_expression="""
@agent_type:{security_auditor}
@content_type:{article}
@difficulty_level:{advanced}
"""
)
Prioritize keeping high-quality responses:
async def evict_low_quality_entries(cache: SemanticCache, max_size: int):
"""Evict low-quality entries when cache is full."""
# Get all entries sorted by quality score
entries = await cache.get_all_sorted_by_quality()
if len(entries) > max_size:
# Keep top N by quality, evict rest
to_evict = entries[max_size:]
for entry in to_evict:
await cache.delete(entry.key)
Adjust similarity threshold based on cache hit rate:
class AdaptiveThresholdManager:
"""Dynamically adjust threshold based on metrics."""
def __init__(self, target_hit_rate: float = 0.35):
self.target = target_hit_rate
self.threshold = 0.92
async def adjust(self, actual_hit_rate: float):
"""Adjust threshold to reach target hit rate."""
if actual_hit_rate < self.target - 0.05:
# Too many misses, lower threshold (more permissive)
self.threshold = max(0.85, self.threshold - 0.01)
elif actual_hit_rate > self.target + 0.05:
# Too many hits (possibly false positives), raise threshold
self.threshold = min(0.98, self.threshold + 0.01)
logger.info(f"Adjusted threshold to {self.threshold}")
@dataclass
class CacheMetrics:
"""Track cache performance."""
# Hit rates
l1_hit_rate: float
l2_hit_rate: float
l3_hit_rate: float
combined_hit_rate: float
# Latency
l1_avg_latency_ms: float
l2_avg_latency_ms: float
l3_avg_latency_ms: float
l4_avg_latency_ms: float
# Cost
estimated_cost_saved_usd: float
total_requests: int
# Quality
false_positive_rate: float # Wrong cached responses
false_negative_rate: float # Missed valid cache hits
Langfuse automatically tracks token usage and costs for all LLM calls. This eliminates manual cost calculation and provides accurate cost attribution.
from langfuse.decorators import observe, langfuse_context
from uuid import UUID
@observe(as_type="generation")
async def call_llm_with_cache(
prompt: str,
agent_type: str,
analysis_id: UUID | None = None
) -> str:
"""LLM call with automatic cost tracking via Langfuse.
CRITICAL: Always link to parent trace for cost attribution!
"""
# Link to parent analysis trace (for cost rollup)
if analysis_id:
langfuse_context.update_current_trace(
name=f"{agent_type}_generation",
session_id=str(analysis_id), # Group by analysis
tags=[agent_type, "cached"],
metadata={"analysis_id": str(analysis_id)}
)
# Langfuse decorator automatically:
# 1. Captures input/output tokens
# 2. Calculates costs using model pricing
# 3. Tags with agent_type for cost attribution
# 4. Records cache hit/miss status
# L1: Check exact cache
cache_key = hash_content(prompt)
if cache_key in lru_cache:
# Mark as cache hit (zero cost)
langfuse_context.update_current_observation(
metadata={"cache_layer": "L1", "cache_hit": True}
)
return lru_cache[cache_key]
# L2: Check semantic cache
embedding = await embed_text(prompt)
similar = await redis_cache.find_similar(embedding, agent_type)
if similar:
langfuse_context.update_current_observation(
metadata={"cache_layer": "L2", "cache_hit": True, "distance": similar.distance}
)
return similar.response
# L3/L4: LLM call with prompt caching
# Langfuse automatically tracks token usage and cost
response = await llm.generate(
messages=build_cached_messages(prompt),
model="claude-3-5-sonnet-20241022"
)
# Langfuse records:
# - input_tokens (total)
# - output_tokens
# - cache_creation_input_tokens (prompt cache breakpoints)
# - cache_read_input_tokens (cached prefix tokens)
# - total_cost (calculated from model pricing)
langfuse_context.update_current_observation(
metadata={
"cache_layer": "L3/L4",
"cache_hit": False,
"prompt_cache_hit": response.usage.cache_read_input_tokens > 0
}
)
# Store in L2 and L1 for future hits
await redis_cache.set(embedding, response.content, agent_type)
lru_cache[cache_key] = response.content
return response.content
from langfuse import Langfuse
from langfuse.decorators import observe, langfuse_context
from uuid import uuid4, UUID
class CodeReviewWorkflow:
"""Multi-agent code review with hierarchical cost tracking."""
@observe(as_type="trace")
async def run_code_review(self, pr_id: int, diff: str, review_id: UUID) -> dict:
"""Parent trace - aggregates all child agent costs.
Trace Hierarchy:
run_code_review (trace)
āāā security_scan_generation (generation)
āāā performance_analysis_generation (generation)
āāā style_check_generation (generation)
āāā test_coverage_generation (generation)
āāā synthesis_generation (generation)
Langfuse automatically rolls up costs to parent trace.
"""
# Set trace metadata for filtering/grouping
langfuse_context.update_current_trace(
name="code_review",
session_id=str(review_id),
user_id=f"pr_{pr_id}", # Group by PR for analysis
tags=["multi-agent", "production", "code-review"],
metadata={
"review_id": str(review_id),
"pr_id": pr_id,
"agent_count": 5,
"diff_size": len(diff)
}
)
# Each review agent creates a child generation
findings = {}
for agent in self.review_agents:
# Child generation auto-linked to parent trace
result = await self.run_review_agent(
agent=agent,
code_diff=diff,
review_id=review_id # Links to parent
)
findings[agent.name] = result
# Synthesis also tracked as child generation
synthesis = await self.synthesize_review(
findings=findings,
review_id=review_id
)
# Langfuse dashboard shows:
# - Total cost for this review (sum of all child generations)
# - Token breakdown by review agent type
# - Cache hit rate per agent
# - Latency per agent
# - Quality score trends
return {"findings": findings, "synthesis": synthesis, "approved": synthesis.approved}
@observe(as_type="generation")
async def run_agent(
self,
agent: Agent,
content: str,
analysis_id: UUID
) -> dict:
"""Child generation - costs roll up to parent trace."""
langfuse_context.update_current_observation(
name=f"{agent.name}_generation",
metadata={
"agent_type": agent.name,
"content_length": len(content)
}
)
# LLM call automatically tracked
response = await agent.analyze(content)
return response
from langfuse import Langfuse
from datetime import datetime, timedelta
async def get_analysis_costs(analysis_id: UUID) -> dict:
"""Get total cost for an analysis (parent trace + all child generations)."""
langfuse = Langfuse()
# Fetch parent trace by session_id
traces = langfuse.get_traces(
session_id=str(analysis_id),
limit=1
)
if not traces.data:
return {"error": "Trace not found"}
trace = traces.data[0]
# Langfuse automatically aggregates child costs
return {
"trace_id": trace.id,
"total_cost": trace.total_cost, # Sum of all child generations
"input_tokens": trace.usage.input_tokens,
"output_tokens": trace.usage.output_tokens,
"cache_read_tokens": trace.usage.cache_read_input_tokens,
"observations_count": trace.observation_count, # Number of child LLM calls
"latency_ms": trace.latency,
"created_at": trace.timestamp
}
async def get_daily_costs_by_agent() -> list[dict]:
"""Get cost breakdown by agent type for last 30 days."""
langfuse = Langfuse()
# Fetch all generations from last 30 days
from_date = datetime.now() - timedelta(days=30)
generations = langfuse.get_generations(
from_timestamp=from_date,
limit=10000
)
# Group by agent type (from metadata)
costs_by_agent = {}
for gen in generations.data:
agent_type = gen.metadata.get("agent_type", "unknown")
cost = gen.calculated_total_cost or 0.0
if agent_type not in costs_by_agent:
costs_by_agent[agent_type] = {
"agent_type": agent_type,
"total_cost": 0.0,
"call_count": 0,
"total_input_tokens": 0,
"total_output_tokens": 0,
"cache_hits": 0
}
costs_by_agent[agent_type]["total_cost"] += cost
costs_by_agent[agent_type]["call_count"] += 1
costs_by_agent[agent_type]["total_input_tokens"] += gen.usage.input or 0
costs_by_agent[agent_type]["total_output_tokens"] += gen.usage.output or 0
if gen.metadata.get("cache_hit"):
costs_by_agent[agent_type]["cache_hits"] += 1
# Calculate averages
results = []
for stats in costs_by_agent.values():
stats["avg_cost_per_call"] = stats["total_cost"] / stats["call_count"]
stats["cache_hit_rate"] = stats["cache_hits"] / stats["call_count"]
results.append(stats)
# Sort by total cost descending
results.sort(key=lambda x: x["total_cost"], reverse=True)
return results
# Langfuse dashboard query:
# GROUP BY metadata.agent_type
# SUM(total_cost) AS cost_per_agent
#
# Results show:
# - security_auditor: $12.45 (35% cache hit rate)
# - implementation_planner: $8.23 (42% cache hit rate)
# - tech_comparator: $5.67 (58% cache hit rate)
from langfuse import Langfuse
langfuse = Langfuse()
# Query all generations with cache metadata
generations = langfuse.get_generations(
limit=1000,
from_timestamp=datetime.now() - timedelta(days=7)
)
cache_hits = 0
cache_misses = 0
total_cost = 0.0
cost_saved = 0.0
for gen in generations:
metadata = gen.metadata or {}
is_cache_hit = metadata.get("cache_hit", False)
if is_cache_hit:
cache_hits += 1
# Estimate saved cost (cost of equivalent full LLM call)
cost_saved += gen.calculated_total_cost or 0 # Would be higher without cache
else:
cache_misses += 1
total_cost += gen.calculated_total_cost or 0
hit_rate = cache_hits / (cache_hits + cache_misses)
print(f"Cache Hit Rate: {hit_rate:.1%}")
print(f"Cost Saved: ${cost_saved:.2f}")
print(f"Total Cost: ${total_cost:.2f}")
print(f"Savings Rate: {(cost_saved / (cost_saved + total_cost)):.1%}")
from dataclasses import dataclass
@dataclass
class ModelInfo:
"""Model configuration with pricing."""
model_id: str
display_name: str
max_tokens: int
input_cost_per_1m: float # USD per 1M input tokens
output_cost_per_1m: float # USD per 1M output tokens
def calculate_cost(self, input_tokens: int, output_tokens: int) -> float:
"""Calculate total cost for token usage."""
input_cost = (input_tokens / 1_000_000) * self.input_cost_per_1m
output_cost = (output_tokens / 1_000_000) * self.output_cost_per_1m
return input_cost + output_cost
# Claude 3.5 Sonnet (Updated March 2025)
MODEL_REGISTRY = {
"claude-3-5-sonnet-20241022": ModelInfo(
model_id="claude-3-5-sonnet-20241022",
display_name="Claude 3.5 Sonnet (New)",
max_tokens=8192,
input_cost_per_1m=3.00, # $3 per 1M tokens
output_cost_per_1m=15.00, # $15 per 1M tokens
),
"gpt-4-turbo-2024-04-09": ModelInfo(
model_id="gpt-4-turbo-2024-04-09",
display_name="GPT-4 Turbo",
max_tokens=4096,
input_cost_per_1m=10.00,
output_cost_per_1m=30.00,
),
}
Access cost insights at http://localhost:3000:
Cost Dashboard:
Cache Effectiveness:
Agent Performance:
Access Redis cache visualization at http://localhost:8001:
When using local models via Ollama, the caching calculus changes:
Cost Impact:
| Provider | Caching Value | Reason |
|---|---|---|
| Cloud APIs | Critical | $3-15 per MTok |
| Ollama Local | Optional | FREE per token |
When to still cache with Ollama:
Simplified Cache Strategy for Local:
# With Ollama, L1 (LRU) cache is usually sufficient
# Skip L2 (Redis semantic) unless latency-critical
async def get_local_llm_response(query: str) -> str:
# L1: Exact match only (sufficient for local)
cache_key = hash_content(query)
if cache_key in lru_cache:
return lru_cache[cache_key] # ~1ms
# Direct local inference (FREE, fast enough)
response = await ollama_provider.ainvoke(query) # ~50-200ms
# Store in L1 only
lru_cache[cache_key] = response.content
return response.content
Best Practice: Use factory pattern to apply full caching hierarchy only for cloud APIs:
if settings.OLLAMA_ENABLED:
# Minimal caching for local models
return LocalCacheStrategy(l1_only=True)
else:
# Full L1/L2/L3 caching for cloud APIs
return CloudCacheStrategy(l1=True, l2=True, l3=True)
See ai-native-development skill section "10. Local LLM Inference with Ollama" for provider setup.
See:
references/redis-setup.md - Docker Compose + RedisVL setupreferences/cache-hierarchy.md - Multi-level cache implementationreferences/cost-optimization.md - ROI calculations and benchmarkstemplates/semantic-cache-service.py - Production-ready servicetemplates/prompt-cache-wrapper.py - Claude caching wrapperexamples/project-integration.md - this project specific patternsSkill Version: 1.3.0 Last Updated: 2025-12-28 Maintained by: this project AI Agent Hub
session_id linking pattern for cost attribution to parent analysis@observe decoratorcalculate_cost() method