Phase 5: Production & Deployment

Prompt caching & result caching strategies

Intermediate ~18 min read
Think of it this way A friendly analogy. Read this if the technical version feels dense. Show Hide

Imagine you're running a super popular burger stand. You have an amazing chef who can cook anything you ask for, but every time he cooks a burger from scratch, it costs you money for ingredients and his valuable time. Now, what if 500 different people all order the 'Classic Cheeseburger' in one day? If your chef cooks each one from scratch, that's 500 times you're paying for ingredients and his time! That's a lot of wasted money and waiting around for burgers when everyone wants the same thing.

Instead, you could have a smart system. For the really popular items, like that 'Classic Cheeseburger,' you could have a special shelf where you keep a few ready-made ones. So, when someone walks up and says, 'I'd like the Classic Cheeseburger, please,' you don't send the order to the chef. You just grab one from the shelf! This is like 'prompt caching.' It only works if the order is exactly the same—word for word. If someone says, 'Can I have your Famous Cheeseburger?' and you don't have a ready shelf for that exact name, the chef still has to cook it from scratch. It's super fast and always correct, but it only catches identical requests.

But what if someone asks for 'your best beef and cheese burger' or 'that yummy cheesy classic you make'? They might mean the exact same 'Classic Cheeseburger,' but they're using different words. Sending these to the chef every time would still be a waste! This is where you get even smarter. You could have a super clever manager who understands that 'best beef and cheese burger' and 'yummy cheesy classic' both mean the 'Classic Cheeseburger.' So, if you've already cooked a 'Classic Cheeseburger' recently, or even one that answered a similar question like 'What's a great simple burger?', the manager can just give them that known answer or burger. This 'result caching' is like understanding the idea behind the order, not just the exact words. It means you can serve many more people quickly, even if they phrase things differently.

And sometimes, even the chef himself has a shortcut. If many burgers start with the same basic ingredients, like a special bun and sauce, he might prepare a big batch of those first to save time. This is like how the super-smart computer programs you'll use also have their own built-in ways to speed things up on their end. By combining all these strategies—having ready-made exact orders, a smart manager who understands different ways of asking, and even the chef’s internal shortcuts—you save the most money and serve customers much faster. So, when you build computer programs that answer lots of questions, you'll want to use these tricks. This means your programs can help more people, much faster, without spending too much money asking the super-smart chef to cook the same thing again and again.

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

python
# 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 result

How 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.

python
# 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_exc

How 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.

python
# 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 hit

Quick check

  1. You normalize and hash a prompt before storing it. What is the PRIMARY reason to normalize before hashing?

  2. 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?

  3. Provider-side prefix caching (e.g., Anthropic's) reduces costs most when you do what?

Self-check: Describe the three distinct caching layers covered in this lesson, explain what traffic pattern makes each one worth the implementation cost, and identify one scenario where semantic caching would be dangerous to use and why.