AI Caching Strategies

Ma Mahalakshmi V Updated 19 Sep 2026
9 min read

Caching strategies for repeated AI work

Lesson 4 introduced application-level response caching as one technique for reducing unnecessary model calls. This lesson goes deeper into caching as a discipline of its own: the different layers at which caching can happen, how to choose cache keys and expiry policies correctly, and how to keep a cache from serving stale or wrong results.

The Layers of Caching Available

There are three distinct places caching can happen in an LLM-backed application, and they are not interchangeable:

  • Provider-side prompt caching (Unit 12, Lesson 4) — the provider caches the internal representation of a repeated prompt prefix, discounting the cost of reprocessing it. This still executes a model call; it only makes the input-processing portion of that call cheaper and faster.
  • Application-level response caching (introduced in Lesson 4 of this unit) — your application stores the final output of a model call, keyed by a normalized version of the input, and can return it without calling the model at all on a hit.
  • Semantic caching — a more advanced variant of response caching that matches on the meaning of a request rather than its exact text, so that a paraphrased question ("What's your refund policy?" versus "How do refunds work?") can still hit a cached answer.

Each layer catches a different kind of repetition. Provider-side caching is essentially free to enable and helps with any repeated prefix, whether or not the surrounding text varies. Application-level caching requires you to build and maintain the cache, but it can eliminate the model call entirely for exact repeats. Semantic caching catches the widest range of repetition but is the most complex to build correctly and carries the highest risk of serving a wrong answer, since "similar enough" is a judgment call.

Choosing the Right Cache Key

The cache key determines what counts as "the same request." Getting this wrong is the most common source of caching bugs — either a cache that never hits because keys are too specific, or one that returns wrong answers because keys are too permissive.

import hashlib
import json


def build_cache_key(feature: str, params: dict, user_scoped: bool = False, user_id: str | None = None) -> str:
    key_data = {"feature": feature, "params": params}
    if user_scoped:
        if user_id is None:
            raise ValueError("user_id is required when user_scoped=True")
        key_data["user_id"] = user_id

    canonical = json.dumps(key_data, sort_keys=True)
    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()

The user_scoped flag is the key design decision here: some cached results are safe to share across all users (a translation of a fixed UI string, a summary of a public document), while others must never be shared across users even if the input text is identical (a personalized recommendation, anything that could leak one user's data into another user's response). Raising a ValueError when user_scoped=True but no user_id is provided fails loudly at the call site rather than silently producing a key that accidentally omits the user scope — a bug here would be a serious cross-user data leak, so this function is intentionally strict about it rather than defaulting to a permissive behavior.

Setting Expiry Based on Content Volatility

Not all cached content should live for the same amount of time. The correct expiry depends on how quickly the correct answer changes, not on a single global default applied to everything.

from dataclasses import dataclass
from enum import Enum


class Volatility(Enum):
    STABLE = "stable"        # unlikely to change: static translations, fixed reference text
    DAILY = "daily"          # changes at most once a day: summaries of daily-updated content
    VOLATILE = "volatile"    # changes frequently: anything tied to live or personalized data


EXPIRY_SECONDS = {
    Volatility.STABLE: 60 * 60 * 24 * 30,   # 30 days
    Volatility.DAILY: 60 * 60 * 12,         # 12 hours
    Volatility.VOLATILE: 60 * 5,            # 5 minutes
}


@dataclass
class CacheEntry:
    value: str
    expires_at: float


def is_expired(entry: CacheEntry, now: float) -> bool:
    return now >= entry.expires_at

Classifying content by Volatility rather than picking one expiry for the whole cache reflects a real trade-off: a longer expiry increases the hit rate (more requests are served from cache) but increases the risk of serving an answer that is no longer correct, while a shorter expiry reduces that risk but also reduces the hit rate and therefore the cost savings. Assigning VOLATILE a five-minute expiry rather than caching it at all is a middle ground — it still catches near-simultaneous duplicate requests (similar to the deduplication technique in Lesson 4) without risking a long-lived stale answer for content that changes often.

Building a Cache With Expiry and Invalidation

Combining the cache key and expiry concepts into a working cache requires handling three operations correctly: reading a possibly-expired entry, writing a new entry with the right expiry, and explicitly invalidating an entry when the underlying data changes.

import time


class TieredCache:
    def __init__(self):
        self._store: dict[str, CacheEntry] = {}

    def get(self, key: str) -> str | None:
        entry = self._store.get(key)
        if entry is None:
            return None
        if is_expired(entry, time.time()):
            del self._store[key]
            return None
        return entry.value

    def set(self, key: str, value: str, volatility: Volatility) -> None:
        ttl = EXPIRY_SECONDS[volatility]
        self._store[key] = CacheEntry(value=value, expires_at=time.time() + ttl)

    def invalidate(self, key: str) -> None:
        self._store.pop(key, None)

get deletes an expired entry as soon as it is discovered rather than leaving it in place, which keeps the cache from accumulating stale entries indefinitely between reads — this is a common and simple approach called lazy expiry, appropriate for caches that are read often enough that stale entries get cleaned up naturally without needing a separate background sweep. invalidate exists as an explicit operation because expiry alone is not always sufficient: if the underlying source data changes (a document is edited, a price updates) before the cache entry's natural expiry, the application needs a way to remove that specific entry immediately rather than waiting out the TTL and serving a wrong answer in the meantime.

Semantic Caching: Matching on Meaning, Not Exact Text

Exact-match caching, as built above, misses a large class of genuine duplicates: two users asking the same underlying question in different words. Semantic caching addresses this by comparing embeddings — numeric representations of meaning — rather than exact strings.

def cosine_similarity(a: list[float], b: list[float]) -> float:
    dot = sum(x * y for x, y in zip(a, b))
    norm_a = sum(x * x for x in a) ** 0.5
    norm_b = sum(y * y for y in b) ** 0.5
    if norm_a == 0 or norm_b == 0:
        return 0.0
    return dot / (norm_a * norm_b)


class SemanticCache:
    def __init__(self, similarity_threshold: float = 0.92):
        self._entries: list[tuple[list[float], str]] = []  # (embedding, response)
        self._similarity_threshold = similarity_threshold

    def find_match(self, query_embedding: list[float]) -> str | None:
        best_score = 0.0
        best_response = None
        for embedding, response in self._entries:
            score = cosine_similarity(query_embedding, embedding)
            if score > best_score:
                best_score = score
                best_response = response
        if best_score >= self._similarity_threshold:
            return best_response
        return None

    def add(self, embedding: list[float], response: str) -> None:
        self._entries.append((embedding, response))

similarity_threshold is the single most important tuning parameter for a semantic cache, and it is a genuine precision-versus-recall trade-off: a lower threshold catches more paraphrased duplicates (higher hit rate) but risks matching two questions that are similar in wording but different in actual intent, returning a wrong cached answer with no indication anything went wrong. A higher threshold is safer but catches fewer real duplicates. Because a wrong semantic-cache hit is a silent correctness bug — the user gets a confidently wrong answer with no error raised — semantic caching should generally start with a conservative (high) threshold and only be loosened after measuring false-positive matches on real traffic, never the other way around.

Testing Cache Behavior

Cache correctness — expiry, invalidation, and key uniqueness — is exactly the kind of logic that benefits from deterministic tests using a controllable clock, rather than relying on real elapsed time.

def test_cache_expires_entries_after_ttl():
    cache = TieredCache()
    fake_now = {"t": 1000.0}

    def fake_time():
        return fake_now["t"]

    global time
    original_time_time = time.time
    time.time = fake_time

    try:
        cache.set("key1", "value1", Volatility.VOLATILE)  # 5 minute TTL
        assert cache.get("key1") == "value1"

        fake_now["t"] += 60 * 6  # advance 6 minutes, past the 5 minute TTL
        assert cache.get("key1") is None
        print("PASS: cache entry expires after its TTL elapses")
    finally:
        time.time = original_time_time


def test_semantic_cache_matches_above_threshold_only():
    cache = SemanticCache(similarity_threshold=0.9)
    cache.add([1.0, 0.0], "cached answer")

    close_match = cache.find_match([0.95, 0.05])
    far_match = cache.find_match([0.0, 1.0])

    assert close_match == "cached answer"
    assert far_match is None
    print("PASS: semantic cache matches similar vectors and rejects dissimilar ones")


test_cache_expires_entries_after_ttl()
test_semantic_cache_matches_above_threshold_only()

The first test replaces time.time with a controllable fake so the TTL expiry can be tested deterministically, without an actual six-minute time.sleep — a common pattern for testing any time-dependent logic quickly and reliably. The second test picks vectors specifically chosen to be either clearly similar ([0.95, 0.05] versus [1.0, 0.0]) or clearly dissimilar ([0.0, 1.0] versus [1.0, 0.0]), so the threshold behavior is unambiguous rather than relying on borderline values that could make the test flaky.

Common Mistakes

Caching personalized or user-specific results without scoping the cache key by user. This is a data leakage bug, not just a correctness bug — one user's personalized response can be served to a different user, which is a serious privacy failure.

Setting one global expiry for all cached content regardless of how quickly it changes. A single TTL is either too long for volatile content (serving stale answers) or too short for stable content (giving up hit rate and cost savings for no reason).

Deploying semantic caching with a similarity threshold that has not been validated against real near-miss examples. An untested threshold can silently serve wrong answers for questions that are superficially similar but have different correct answers, with no visible error to indicate the mistake.

Best Practices

Match the caching layer to the kind of repetition you actually have. Use provider-side prompt caching for anything with a stable, repeated prefix; use application-level response caching for exact repeated requests; reserve semantic caching for cases where paraphrased repeats are common enough to be worth its added complexity and risk.

Always make cache invalidation possible, not just expiry. Relying solely on TTL-based expiry means a stale entry can persist for its full TTL even after the underlying data has changed; an explicit invalidate path lets you correct that immediately when you know a change occurred.

Monitor cache hit rate and false-positive rate as ongoing metrics. A cache that never measures its own hit rate cannot be tuned, and a semantic cache that never measures false-positive matches (wrong answers served due to over-loose similarity) can silently degrade answer quality without anyone noticing.

0 Comments

Reviewed before they appear

No comments yet.

OpenAI SDK
Introduction to the OpenAI SDK Setting Up Python Creating an API Key Your First Call — client.responses.create() and response.out Understanding Billing, Credits, and What a Request Costs Why Responses Replaced Chat Completions Anatomy of a Request: model, input, and instructions Anatomy of a Response: The Typed output Array, Not Just Text Roles: User, Assistant, and Developer/System Choosing a Model, and Reading the Models Page Instead of Mem Instructions vs. Input Writing Prompts That Get Consistent Results Few-Shot Examples Reasoning Models and the reasoning Parameter Debugging a Prompt That Misbehaves Why Streaming Matters for User Experience stream=True and Iterating Over Events Handling the Event Types You Actually Care About Background Mode for Long-Running Jobs Project — Add Live Streaming to Your Chatbot The Problem With Parsing Free Text JSON Schema and Strict Mode Pydantic Models With the SDK's Parse Helpers Handling Refusals and Validation Failures Project — A Resume-to-JSON Extractor Working With input_image input_file, PDFs, and the Files API Image Generation Speech-to-Text and Text-to-Speech Project: A PDF Question-Answering Script What Function Calling Is Defining a Tool Schema The Full Loop Multiple Tools Errors, Timeouts, and Untrusted Arguments Project: A Weather Assistant Web Search File Search and Vector Stores Code Interpreter Remote MCP Servers and Connectors Project: A Research Assistant What an Embedding Is, Without the Maths Generating and Storing Embeddings Similarity Search From Scratch Hosted Vector Stores vs. Rolling Your Own A Small RAG App Over a Folder of Notes Agents vs. a Single API Call — When You Need One pip install openai Giving Agents Tools Handoffs and Multi-Agent Triage Guardrails and Approvals Tracing and Observing What Your Agent Did A Multi-Agent Support Desk Error Codes and What Each One Means Retries, Timeouts, and Backoff Rate Limits and Spend Limits Prompt Caching and Cost Optimisation The Batch API for Bulk Work Async Clients and Concurrency Moderation and Safety Best Practices Designing the App Backend With FastAPI Streaming to a Simple Frontend Deploying and a Cost/Safety Checklist Why Web Search Is Useful for Current Information Using the Web Search Tool with the Responses API Configuring Search Behavior for Application Use Cases Understanding Citations and Source Attribution Where to Go Next Building a Research Assistant with Web Search Combining Web Search with Structured Outputs Handling Conflicting or Low-Quality Web Sources
Ask about this post
AI Ask about this post

Ask questions about AI Caching Strategies and get answers drawn from it.

Signed-in readers only.