Exact-match caching works by reducing an LLM request to a deterministic cache key. You serialize the parts of the request that actually affect the output (model name, system prompt, user prompt, temperature, max_tokens), hash them with SHA-256 or similar, and do a key-value lookup before touching the API. If it hits, you return the stored string and pay zero tokens. If it misses, you call the API, store the response under that key, and set a TTL that reflects how long you trust that answer. The normalization step matters more than most developers realize: "What is the capital of France?" and "what is the capital of france ?" are functionally the same question. Stripping extra whitespace, lowercasing, and trimming punctuation before hashing can double your hit rate on exact-match caches for typical chatbot traffic.
Semantic caching goes a step further. You embed the incoming prompt using a small, fast embedding model (text-embedding-3-small at roughly $0.00002/1K tokens as of writing, check current pricing) and run an ANN search against a vector store of previously cached prompt embeddings. If the nearest neighbor has a cosine similarity above your threshold (typically 0.92-0.95), you return the cached answer for that neighbor. The key insight is that you are trading a cheap embedding call plus a fast vector search for an expensive LLM call. Even if the embedding call and vector lookup cost $0.0001 combined, you save the full LLM cost on a hit. At scale, this math becomes very favorable. Tools like GPTCache, Redis with the RediSearch module, or Qdrant with a dedicated cache collection all support this pattern out of the box.
Provider-side prefix caching is a third, often overlooked layer. Anthropic's API and OpenAI's API both discount input tokens for context that has already been processed in a prior request within a short window. Anthropic charges roughly 10% of normal input token cost for cached prefix tokens (verify current pricing). The trick is structural: put your long, stable system prompt and any boilerplate context at the beginning of every request, and let the variable user turn come last. This way the provider caches the expensive prefix across many requests automatically. If you have a 4,000-token system prompt with instructions and examples, and it gets hit 10,000 times a day, prefix caching alone can cut your input token costs by 50% or more for that traffic.
A real production scenario: a B2B SaaS company uses an LLM to generate meeting summaries from transcripts. Their system prompt is 3,000 tokens of instructions plus formatting rules. Every summary request sends those same 3,000 tokens. Before any caching: 3,000 input tokens times 10,000 requests per day is 30M tokens of system prompt alone. With Anthropic prefix caching, those 3,000 tokens are cached server-side after the first call per cache window, cutting that to roughly 3M effective billable tokens for the prefix. On top of that, they add exact-match caching for re-summarization requests (users often re-run the same transcript after editing). Their cache hit rate on exact-match runs at about 18%, which is modest but meaningful. A senior engineer on that team would also add semantic caching for the FAQ-style questions their users ask about the summaries, where hit rates run closer to 40-60%.
Tradeoffs between exact and semantic caching are real. Exact-match has no correctness risk: if the key matches, the stored answer was correct for that exact input. Semantic caching introduces the possibility of a false positive: two prompts that embed similarly but actually need different answers. The threshold is your main control. At 0.98 you almost never return a wrong answer but your hit rate is little better than exact-match. At 0.85 your hit rate climbs dramatically but you will occasionally serve a subtly wrong answer. Most production systems settle between 0.92 and 0.95 and add a bypass mechanism for high-stakes queries. One pattern is to tag certain query types (financial advice, medical information, anything with a date) as cache-ineligible and always hit the LLM directly.
At scale, cache architecture changes. At 10 users, an in-process dict with an LRU eviction policy works fine. At 10,000 users, you need a shared cache store (Redis is the standard choice) because multiple server instances need to share state. At 10 million users, you need cache sharding, careful key eviction policies (LRU vs LFU depending on your traffic distribution), and cache warming strategies for predictable peak traffic. You also start caring about cache stampede: when a key expires and 100 concurrent requests all miss the cache simultaneously and all fire LLM calls at once. The standard fix is probabilistic early expiration or a distributed lock that lets one request refresh the cache while others wait. Your cache hit rate, miss cost, and infrastructure cost all need to go into a spreadsheet to find the right balance.
Key Takeaways
- Normalize prompts before hashing to maximize exact-match cache hits on trivially different inputs.
- Use semantic caching with a similarity threshold above 0.92 to avoid returning wrong cached answers.
- Provider-side prefix caching (Anthropic, OpenAI) discounts repeated system-prompt tokens automatically -- structure prompts to exploit it.
- Cache invalidation strategy is not optional; stale cached answers erode user trust faster than slow responses.
Pro tips
- Put your longest, most stable content first in every prompt. Anthropic and OpenAI both apply prefix caching to leading tokens in a context window. A 3,000-token system prompt that repeats across requests is essentially free after the first call within the cache window if you structure it correctly.
- Semantic cache hit rate is highly sensitive to your embedding model. If you switch from text-embedding-3-small to a different model mid-deployment, all your stored cache embeddings are now in a different vector space and every lookup will miss. Version your cache keys by embedding model name to avoid silent regressions.
- Cache stampede is a real production incident waiting to happen. When a popular cache key expires, dozens of concurrent requests all miss and simultaneously fire LLM calls. Use a distributed lock (Redis SETNX with a short TTL) or probabilistic early expiration to let only one request refresh the cache while others wait or serve a slightly stale response.
- Track your cache hit rate, miss cost, and the cost of the cache infrastructure separately in your observability stack. It is surprisingly common for teams to build a semantic cache using a hosted vector DB that costs more per month than the LLM calls it saves. Run the math before deploying.
Common pitfalls
- Mistake: Hashing the raw user string without normalization. Fix: Strip whitespace, lowercase, and collapse repeated spaces before hashing so 'Hello ' and 'hello' map to the same key.
- Mistake: Setting semantic similarity threshold too low (e.g., 0.80). Fix: Stay between 0.92 and 0.95 for general-purpose text; run offline eval on a sample of your real query pairs to calibrate before shipping.
- Mistake: Caching responses that include current dates, prices, or user-specific data. Fix: Tag those request types as cache-ineligible and route them directly to the LLM, or use very short TTLs (under 60 seconds).
- Mistake: No cache invalidation strategy -- cached answers silently become wrong after a model upgrade or data change. Fix: Version cache keys by model name and deploy a flush script that clears affected key namespaces when you roll a new model or major prompt change.
Which caching layer to use
| Option | Use when | Avoid when |
|---|---|---|
| Exact-match cache (Redis key-value) | Prompts are templated and mostly deterministic: FAQ bots, code generation from fixed schemas, batch document classification. | Prompts are free-form natural language where even identical intent produces varied phrasing. |
| Semantic cache (embedding + vector search) | Users ask the same logical question in many different wordings; hit rate on exact-match is below 10%. | Query volume is low (vector search overhead not worth it), or incorrect cache hits carry high risk (medical, legal, financial). |
| Provider-side prefix caching (Anthropic / OpenAI) | Your system prompt or few-shot examples exceed ~1,000 tokens and you send them on every request. | Your system prompt is short or highly variable per request -- prefix caching only fires on repeated leading tokens. |
| No caching | Every request is genuinely unique (creative writing, personalized long-form), or responses must always be fresh (live data queries). | You are serving any kind of FAQ, support bot, or batch pipeline -- almost always worth adding at least exact-match caching. |
Code Example
# openai>=1.0.0, redis>=5.0.0
import hashlib, json, os
import redis
from openai import OpenAI
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
CACHE_TTL = 3600 # 1 hour
def normalize(prompt: str) -> str:
return " ".join(prompt.strip().lower().split())
def cached_completion(user_prompt: str, system_prompt: str) -> str:
key = hashlib.sha256(
json.dumps({"system": system_prompt, "user": normalize(user_prompt)}).encode()
).hexdigest()
cached = r.get(key)
if cached:
return cached # cache hit -- no API call
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
)
result = response.choices[0].message.content
r.setex(key, CACHE_TTL, result)
return resultHow this code works
This code demonstrates "prompt caching," a strategy to reduce costs by reusing previous responses from AI models. Instead of asking an AI model the same question repeatedly, which incurs API charges each time, this solution stores answers in a local cache (Redis) and retrieves them when the exact same prompt is given again. The initial lines import necessary libraries like openai and redis, then connect to a Redis server and define a CACHE_TTL for how long responses should be remembered.
The core logic resides in the cached_completion function. When a prompt is given, it first calls normalize on the user_prompt to clean up inconsistencies like extra spaces or differing capitalization. This normalized prompt, along with the system_prompt, is then used to create a unique key via hashlib.sha256. The code checks if this key already exists in Redis using r.get. If a cached response is found, it's returned immediately, avoiding an expensive API call. Otherwise, it makes a new request to client.chat.completions.create, gets the result, and then stores it in Redis with r.setex (which handles the CACHE_TTL) before returning it. The normalize function is a subtle but crucial detail; without it, slightly different phrasing of the same logical prompt could bypass the cache, leading to unnecessary API calls.
Production-grade example
Two-layer cache (exact then semantic) with retries, token logging, env-var config, and structured logs.
# openai>=1.0.0, redis>=5.0.0, numpy>=1.26.0
import hashlib, json, os, time, logging, struct
from contextlib import contextmanager
from typing import Optional
import numpy as np
import redis
from openai import OpenAI, RateLimitError, APITimeoutError, APIStatusError
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"], timeout=30.0)
r = redis.Redis(
host=os.environ.get("REDIS_HOST", "localhost"),
port=int(os.environ.get("REDIS_PORT", 6379)),
decode_responses=False, # bytes for embedding storage
)
EXACT_TTL = int(os.environ.get("EXACT_CACHE_TTL", 3600))
SEMAN_TTL = int(os.environ.get("SEMAN_CACHE_TTL", 7200))
SIM_THRESHOLD = float(os.environ.get("SIM_THRESHOLD", 0.93))
COMPLETION_MODEL = os.environ.get("COMPLETION_MODEL", "gpt-4o-mini")
EMBED_MODEL = os.environ.get("EMBED_MODEL", "text-embedding-3-small")
def _normalize(text: str) -> str:
return " ".join(text.strip().lower().split())
def _exact_key(system: str, user: str, model: str) -> str:
payload = json.dumps({"model": model, "system": system, "user": _normalize(user)}, sort_keys=True)
return f"llm:exact:{hashlib.sha256(payload.encode()).hexdigest()}"
def _get_embedding(text: str) -> np.ndarray:
"""Embed text, with one retry on rate limit."""
for attempt in range(2):
try:
resp = client.embeddings.create(model=EMBED_MODEL, input=text)
return np.array(resp.data[0].embedding, dtype=np.float32)
except RateLimitError:
if attempt == 0:
time.sleep(2)
else:
raise
def _cosine(a: np.ndarray, b: np.ndarray) -> float:
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9))
def _semantic_lookup(user: str) -> Optional[str]:
"""Scan semantic cache keys and return best match above threshold."""
query_vec = _get_embedding(user)
best_score, best_val = 0.0, None
for key in r.scan_iter("llm:seman:*"):
raw = r.hgetall(key)
if not raw:
continue
stored_vec = np.frombuffer(raw[b"vec"], dtype=np.float32)
score = _cosine(query_vec, stored_vec)
if score > best_score:
best_score, best_val = score, raw[b"answer"].decode()
if best_score >= SIM_THRESHOLD:
log.info("semantic_hit score=%.4f", best_score)
return best_val
return None
def _store_semantic(user: str, answer: str) -> None:
vec = _get_embedding(user)
key = f"llm:seman:{hashlib.sha256(user.encode()).hexdigest()}"
pipe = r.pipeline()
pipe.hset(key, mapping={"vec": vec.tobytes(), "answer": answer.encode()})
pipe.expire(key, SEMAN_TTL)
pipe.execute()
def completion_with_cache(
user_prompt: str,
system_prompt: str,
use_semantic: bool = True,
) -> dict:
start = time.monotonic()
exact_key = _exact_key(system_prompt, user_prompt, COMPLETION_MODEL)
# Layer 1: exact-match cache
cached = r.get(exact_key)
if cached:
log.info("cache=exact latency_ms=%.0f", (time.monotonic() - start) * 1000)
return {"answer": cached.decode(), "cache": "exact", "tokens": 0}
# Layer 2: semantic cache
if use_semantic:
sem_hit = _semantic_lookup(user_prompt)
if sem_hit:
log.info("cache=semantic latency_ms=%.0f", (time.monotonic() - start) * 1000)
return {"answer": sem_hit, "cache": "semantic", "tokens": 0}
# Layer 3: live LLM call with retries
last_exc = None
for attempt in range(3):
try:
resp = client.chat.completions.create(
model=COMPLETION_MODEL,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
timeout=25.0,
)
answer = resp.choices[0].message.content
total_tokens = resp.usage.total_tokens
log.info(
"cache=miss tokens=%d latency_ms=%.0f attempt=%d",
total_tokens, (time.monotonic() - start) * 1000, attempt + 1,
)
# Store in both cache layers
r.setex(exact_key, EXACT_TTL, answer.encode())
if use_semantic:
_store_semantic(user_prompt, answer)
return {"answer": answer, "cache": "miss", "tokens": total_tokens}
except RateLimitError as e:
last_exc = e
wait = 2 ** attempt
log.warning("rate_limit attempt=%d wait=%ds", attempt + 1, wait)
time.sleep(wait)
except APITimeoutError as e:
last_exc = e
log.warning("timeout attempt=%d", attempt + 1)
time.sleep(1)
except APIStatusError as e:
log.error("api_error status=%d", e.status_code)
raise
log.error("all_retries_exhausted")
raise last_excHow this code works
This code demonstrates a robust, multi-layered caching strategy for Large Language Model (LLM) calls, designed to significantly reduce API costs and improve response latency. It achieves this by attempting to return a previously computed answer from a cache before making an expensive and potentially slow request to a live LLM API.
The core logic resides in completion_with_cache. First, it tries an exact-match lookup by generating a unique _exact_key from the prompt and model, checking if r.get retrieves an answer. If no exact match, and use_semantic is true, it moves to the semantic cache. Here, _semantic_lookup converts the user prompt into a numerical _get_embedding, then iterates through existing semantic entries in Redis via r.scan_iter. It calculates _cosine similarity to find the best semantically similar cached question, returning its answer if above SIM_THRESHOLD. Only if both caches miss, a live client.chat.completions.create call is made, with RateLimitError retries. A subtle aspect for beginners is that _semantic_lookup iterates over all semantic keys to find a match, which might become a performance bottleneck for very large caches without a dedicated vector search index.
Practice & master
Try the exercise, check your understanding, then mark this lesson mastered to track your path to pro.
Exercise
Build a two-layer cache function in Python. Layer 1 is exact-match using a plain dict. Layer 2 is semantic: embed the user prompt with OpenAI's text-embedding-3-small, compare cosine similarity to previously cached embeddings, and return the cached answer if similarity exceeds 0.93. Log whether each call was an exact hit, semantic hit, or miss.
# openai>=1.0.0, numpy>=1.26.0
import os
import numpy as np
from openai import OpenAI
client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
exact_cache: dict[str, str] = {}
semantic_cache: list[dict] = [] # [{"vec": np.ndarray, "answer": str}]
SIM_THRESHOLD = 0.93
def normalize(text: str) -> str:
# TODO: strip whitespace, lowercase, collapse spaces
pass
def get_embedding(text: str) -> np.ndarray:
# TODO: call client.embeddings.create with text-embedding-3-small
pass
def cosine(a: np.ndarray, b: np.ndarray) -> float:
# TODO: implement cosine similarity
pass
def cached_llm_call(user_prompt: str, system_prompt: str) -> str:
# TODO: check exact_cache first
# TODO: check semantic_cache second
# TODO: on miss, call the LLM, store in both caches, return answer
pass
if __name__ == "__main__":
system = "You are a helpful assistant. Answer concisely."
print(cached_llm_call("What is the capital of France?", system)) # miss
print(cached_llm_call("What is the capital of France?", system)) # exact hit
print(cached_llm_call("Tell me the capital city of France", system)) # semantic hitQuick check
You normalize and hash a prompt before storing it. What is the PRIMARY reason to normalize before hashing?
Your semantic cache similarity threshold is set to 0.80 and you start seeing users complain about wrong answers. What is the most likely cause?
Provider-side prefix caching (e.g., Anthropic's) reduces costs most when you do what?