AI Caching Strategies

Ma Mahalakshmi V Updated 19 Sep 2026
9 min read ·Lesson 180 of 224

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.output_text 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 Memorizing Names 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 Reducing Unsupported Claims with Grounded Generation Testing Freshness-Sensitive AI Answers Production Considerations for Web-Grounded Applications Understanding File Search and Retrieval-Augmented Generation Creating and Organizing Vector Stores Uploading Documents for Retrieval Connecting Vector Stores to Responses API Requests Designing Document Metadata and Filtering Strategies Building a PDF Question-Answering Application Improving Retrieval Quality With Better Document Preparation Handling Missing Evidence and Retrieval Failures Combining File Search With Web Search Building a Production Knowledge-Base Assistant What the Code Interpreter Tool Is Designed For Running Python-Based Analysis Through the OpenAI SDK Uploading Datasets for Analysis Analyzing CSV and Spreadsheet Data Generating Charts and Data Summaries Handling Generated Files and Downloadable Artifacts Building a Data-Analysis Assistant Combining Code Execution with Structured Outputs Validating Generated Calculations and Results Security and Sandbox Considerations for Code Execution Understanding Multimodal Input with the OpenAI SDK Sending Images to a Model Image Analysis from URLs and Uploaded Files Extracting Text and Information from Screenshots Building an Image-Question-Answering Application Combining Image Input with Structured Output Analyzing Multiple Images in One Request Handling Image Quality and Input Limitations Designing Multimodal Prompts for Reliable Results Building a Practical Vision-Powered Python Application Understanding Speech-to-Text and Text-to-Speech Workflows Transcribing Audio with the OpenAI SDK Working with Uploaded Audio Files Handling Timestamps and Transcription Metadata Building a Meeting Transcription Workflow Generating Spoken Responses from Text Handling Long Audio and Processing Failures Combining Audio with Text and Tool Calling Building an End-to-End Python Voice Application What Embeddings Are and When to Use Them Generating Embeddings With the OpenAI API Preparing Text for Embedding Comparing Vectors With Cosine Similarity Building a Simple Semantic Search Engine in Python Storing Embeddings in a Database Metadata Filtering for Semantic Search Chunking Strategies for Better Retrieval Evaluating Semantic Search Quality Building a Document Similarity Application When Batch Processing Makes Sense Designing Large-Volume AI Processing Pipelines Using Asynchronous Python with the OpenAI SDK Running Concurrent Requests Safely Controlling Concurrency and Avoiding Rate Limits Tracking Batch Job Progress Handling Partial Failures in Bulk Workloads Retrying Failed Items Without Duplicating Successful Work Designing Resumable AI Processing Jobs Building a Production Batch-Processing Pipeline Batch Processing Makes Sense Large-Scale AI Processing Pipelines Async Python with OpenAI SDK Safe Concurrent Requests Concurrency & Rate Limits Batch Progress Tracking Partial Failure Handling Safe Retry Handling Resumable AI Jobs Production Batch Pipeline System–User Data Separation Reusable App Instructions Prompt Templates & Variables Extraction & Classification Prompts Summarization & Transformation Prompts Explicit Output Requirements Prompt Version Management Prompt Testing & Evaluation Reusable Python Prompt Library API Key Security Secure API Key Storage Secure Secret Management Prompt Injection Prevention Trusted vs. Untrusted Content Tool Argument Validation Sensitive Data Handling Secure Logging AI Action Authorization Production AI Security Checklist Why AI Applications Need Evaluation Beyond Unit Tests Unit Testing OpenAI SDK Integration Code Mocking API Responses in Python Tests Testing Structured Outputs Against Schemas Testing Tool-Calling Workflows Building a Small Evaluation Dataset Measuring Accuracy, Consistency, and Failure Rates Regression Testing Prompts and Model Changes Human Evaluation Versus Automated Evaluation Creating a Repeatable Evaluation Pipeline AI Request Monitoring Token Cost Management Usage Metrics Design Reducing Model Calls Prompt & Context Optimization Model Selection & Optimization AI Caching Strategies Interactive Latency Optimization Usage Dashboards & Budget Alerts Performance & Cost Checklist Every API Call Starts Fresh Fixing API Statelessness Server-Side Conversation Memory Limits of Response Chaining What We're Building Conversation Memory Challenges Preparing an OpenAI SDK Application for Deployment Environment-Specific Configuration for Development and Production Deploying a Python AI Service with Docker Container Health Checks and Startup Configuration Managing Secrets in Cloud Deployments Background Workers for Long-Running AI Tasks Queues and Asynchronous Job Architectures Scaling AI Workloads Horizontally Monitoring Production Incidents and Failures Production Deployment Checklist for OpenAI SDK Applications Reusable OpenAI Service Classes AI Client Dependency Injection Typed AI Responses Python Configuration Management AI Request Decorators Centralized AI Error Handling Clean SDK Abstractions Reusable OpenAI Utilities Internal AI Python Libraries SDK Integration Maintenance Production AI Chatbot Document Q&A System Web Research Assistant Customer Support Agent AI Data Analysis Assistant Image Analysis App Meeting Transcription & Summary Semantic Document Search Multi-Tool AI Agent Production OpenAI SDK App Why "It Looked Fine When I Tested It" Isn't Enough Timing Note Status Note Pre-Decision Status Note Current Availability Note
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.