Caching Prompts and Responses

AILLMArchitectureBackend
Share on LinkedIn

The second customer asking "What are your business hours?" shouldn't cost another $0.002 in tokens. Exact-match caching is the lowest-complexity cost optimization in LLM apps — hash the input, return the stored output, skip the API call. Teams that skip it pay double for every FAQ, every repeated eval run, and every integration test in CI.

Response cache architecture

import hashlib
import json

def cache_key(request: CompletionRequest) -> str:
    payload = {
        "model": request.model,
        "messages": request.messages,
        "temperature": request.temperature,
        "tools": request.tools,
        "response_format": request.response_format,
        "tenant_id": request.tenant_id,
    }
    return hashlib.sha256(
        json.dumps(payload, sort_keys=True).encode()
    ).hexdigest()

async def cached_complete(request: CompletionRequest) -> str:
    key = cache_key(request)
    if cached := await redis.get(f"llm:{key}"):
        metrics.cache_hit(request.feature)
        return cached
    response = await provider.complete(request)
    ttl = ttl_for_feature(request.feature)
    await redis.setex(f"llm:{key}", ttl, response)
    return response

Store in Redis for sub-millisecond lookups. For large responses, store in S3 with Redis holding the pointer.

What to cache (and what not to)

Good candidates:

Bad candidates:

When in doubt, cache with short TTL and stamp entries with cached_at so downstream can warn users.

Provider prompt caching

OpenAI and Anthropic cache identical prompt prefixes automatically:

┌─────────────────────────────┐
│ System prompt (10K tokens)  │ ← cached prefix (discounted)
├─────────────────────────────┤
│ RAG context (varies)        │
├─────────────────────────────┤
│ User message                │
└─────────────────────────────┘

Structure prompts so static content comes first:

messages = [
    {"role": "system", "content": STATIC_SYSTEM_PROMPT},      # cached
    {"role": "system", "content": f"Context:\n{rag_chunks}"}, # varies
    {"role": "user", "content": user_message},
]

A 4K-token system prompt cached at 50% discount on 10K daily requests saves real money. Monitor cached_tokens in API responses.

Invalidation

Cache invalidation events:

Event Action
Prompt version deploy Flush feature-scoped keys or bump key prefix
Document reindex Flush RAG-dependent keys for affected tenant
Model upgrade Global flush or version in cache key
Manual content fix Targeted delete by content hash

Use key prefixes for cheap bulk invalidation:

llm:v3:support:{hash}   ← bump v3 → v4 on prompt deploy

Hit rate optimization

Low hit rate usually means:

def normalize_user_text(text: str) -> str:
    return text.strip().lower().rstrip("?.!")

Only normalize when semantically safe. Don't lowercase proper nouns in technical support.

Target 30–60% hit rate on FAQ-heavy features. Below 10%, caching isn't worth the complexity.

Observability

Track per feature:

Alert if hit rate drops suddenly — often means a prompt change invalidated keys or a new traffic pattern emerged.

Security

Semantic caching

Exact-match caching misses paraphrased queries. Semantic cache matches by embedding similarity:

async def semantic_cache_lookup(query: str, threshold: float = 0.95) -> str | None:
    query_emb = embed_model.encode(query)
    # Search cache index for similar queries
    results = cache_index.search(query_emb, k=1)
    if results and results[0].score > threshold:
        return cache_store.get(results[0].id)
    return None

async def cached_generate(query: str) -> str:
    cached = await semantic_cache_lookup(query)
    if cached:
        metrics.increment("cache.semantic_hit")
        return cached
    response = await llm.generate(query)
    await cache_store.set(embed_model.encode(query), response)
    return response

Semantic cache hit rate 2–3× higher than exact match on FAQ-style queries. Threshold 0.95 prevents serving wrong answers for similar-but-different queries.

Cache invalidation strategies

Strategy Use when Tradeoff
TTL expiry General content Stale data until TTL
Version key in cache key Prompt/model changes Manual version bump
Event-driven purge Knowledge base update Requires event infrastructure
LRU eviction Memory-bounded cache Oldest entries evicted
def cache_key(prompt_version: str, tenant_id: str, query: str) -> str:
    normalized = normalize_user_text(query)
    content_hash = hashlib.sha256(normalized.encode()).hexdigest()[:16]
    return f"{tenant_id}:{prompt_version}:{content_hash}"

Include prompt version in key — prompt change automatically invalidates old cache entries without manual purge.

Provider-native prompt caching

OpenAI and Anthropic cache repeated prompt prefixes automatically:

# OpenAI: system prompt cached after first request
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": LONG_SYSTEM_PROMPT},  # cached
        {"role": "user", "content": user_query},             # not cached
    ],
)
# First request: full cost. Subsequent: system prompt at 50% discount.

Place static content (system prompt, RAG context, few-shot examples) at the start of the prompt. Provider caches prefix automatically — no application cache needed for the static portion.

Failure modes

Production checklist

Resources

Frequently asked questions

What is the difference between prompt caching and response caching?

Prompt caching (provider-side) reuses computed KV activations for identical prompt prefixes — you pay reduced rates on cached input tokens. Response caching (your side) stores complete model outputs keyed by the full input hash and skips the API call entirely. Use both: provider caching for shared system prompts, response caching for repeated user queries.

How do I build a cache key for LLM requests?

Hash the model name, temperature (if non-zero), full message list (roles + content), tool definitions, and response format. Include tenant_id for isolation. Exclude request metadata that doesn't affect output. Normalize whitespace in user input if near-duplicates are common.

What TTL should LLM cache entries use?

Match your data freshness requirements. FAQ answers: 1–24 hours. User-specific data: don't cache or use minutes. System prompts with provider-side caching: hours to days. Always invalidate on prompt version change, document reindex, or model version bump.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →