← Back to DevBytes

How to Implement Model Caching in Redis for AI Apps

How to Implement Model Caching in Redis for AI Apps

AI applications are powerful, but they are also expensive and slow. Every time a user asks a question to a large language model or runs an inference on a vision model, you pay in latency, compute cost, and API fees. Many of those requests are redundant — users frequently ask the same or very similar questions. Model caching is the practice of storing the results of previous model calls so that subsequent identical or similar requests can be served instantly from a fast in-memory store instead of re-running the model. Redis, with its sub-millisecond read performance and rich data structures, is an ideal backend for this pattern.

What Is Model Caching?

Model caching is a specialized form of response caching where the "computation" being cached is an AI model inference. When a request comes in — say, a prompt sent to an LLM — the application first checks the cache for a stored response keyed by the prompt (and relevant parameters). If a hit is found, the cached response is returned immediately. If not, the model is invoked, and the result is written back to the cache for future use.

This differs from generic HTTP caching in a few important ways. First, cache keys often need to incorporate model-specific parameters like temperature, model version, and system prompts. Second, cached values can be large (full text completions, embeddings, or JSON payloads). Third, semantic similarity matters — you may want to return a cached answer for a prompt that is not identical but close enough. Redis handles all of these concerns well.

Why Model Caching Matters

Setting Up Redis

You can run Redis locally with Docker or use a managed service like Redis Cloud. For local development, the fastest path is:

docker run -d --name redis-cache -p 6379:6379 redis:latest redis-server --save "" --appendonly no

The --save "" and --appendonly no flags disable persistence, which is fine for a pure cache use case where data loss on restart is acceptable. In production, you may want persistence or replication depending on your tolerance for cache misses.

Install the Python client:

pip install redis openai

Basic Exact-Match Caching

The simplest implementation caches exact prompt strings. You hash the prompt and parameters into a stable key, check Redis, and either return the cached value or call the model and store the result.

import redis
import json
import hashlib
from openai import OpenAI

r = redis.Redis(host="localhost", port=6379, decode_responses=True)
client = OpenAI()

def cache_key(prompt, model="gpt-4o", temperature=0):
    raw = f"{model}:{temperature}:{prompt}"
    return "llm:" + hashlib.sha256(raw.encode()).hexdigest()

def cached_completion(prompt, model="gpt-4o", temperature=0, ttl=86400):
    key = cache_key(prompt, model, temperature)
    cached = r.get(key)
    if cached:
        return json.loads(cached)["response"]

    response = client.chat.completions.create(
        model=model,
        temperature=temperature,
        messages=[{"role": "user", "content": prompt}],
    )
    text = response.choices[0].message.content
    r.setex(key, ttl, json.dumps({"response": text, "model": model}))
    return text

print(cached_completion("What is the capital of France?"))

The setex command sets a value with an expiration time in seconds. Setting a TTL is critical — model outputs can become stale, and you do not want your cache to grow unbounded. A 24-hour TTL is a reasonable default for many applications.

Caching Embeddings

Embeddings are even better candidates for caching than text completions because they are deterministic for a given model and input, and they are often recomputed many times for the same documents.

def embedding_key(text, model="text-embedding-3-small"):
    raw = f"{model}:{text}"
    return "emb:" + hashlib.sha256(raw.encode()).hexdigest()

def cached_embedding(text, model="text-embedding-3-small", ttl=604800):
    key = embedding_key(text, model)
    cached = r.get(key)
    if cached:
        return json.loads(cached)

    response = client.embeddings.create(model=model, input=text)
    vec = response.data[0].embedding
    r.setex(key, ttl, json.dumps(vec))
    return vec

Since embeddings rarely change for the same input, a longer TTL of a week or more is appropriate. For document corpora, you might even skip the TTL entirely and invalidate manually when documents are updated.

Using Redis Hashes for Structured Caching

When you want to store metadata alongside the cached response — such as token counts, timestamps, or the original prompt — a Redis hash is cleaner than a JSON blob.

def cached_completion_hash(prompt, model="gpt-4o", temperature=0, ttl=86400):
    key = cache_key(prompt, model, temperature)

    if r.exists(key):
        return r.hget(key, "response")

    response = client.chat.completions.create(
        model=model,
        temperature=temperature,
        messages=[{"role": "user", "content": prompt}],
    )
    text = response.choices[0].message.content
    r.hset(key, mapping={
        "response": text,
        "model": model,
        "prompt": prompt,
        "tokens": response.usage.total_tokens,
    })
    r.expire(key, ttl)
    return text

This approach makes it easy to inspect the cache during debugging with redis-cli HGETALL <key> and to selectively read individual fields without deserializing the whole payload.

Semantic Caching with Redis Vector Search

Exact-match caching misses opportunities when users phrase the same question differently. "What is the capital of France?" and "What's France's capital?" should return the same cached answer. Redis Stack includes vector search capabilities that enable semantic caching: you store the embedding of each prompt alongside its response, and on a new request you search for the nearest cached prompt. If it is within a similarity threshold, you return that cached response.

import numpy as np

# Create a vector index once
def ensure_index():
    try:
        r.ft("semantic_cache").create_index([
            r.ft("semantic_cache").TextField("response"),
            r.ft("semantic_cache").VectorField(
                "embedding",
                "FLAT",
                {"TYPE": "FLOAT32", "DIM": 1536, "DISTANCE_METRIC": "COSINE"},
            ),
        ])
    except Exception:
        pass  # Index already exists

def semantic_completion(prompt, model="gpt-4o", threshold=0.15):
    ensure_index()
    query_vec = np.array(cached_embedding(prompt), dtype=np.float32).tobytes()

    results = r.ft("semantic_cache").search(
        f"*=>[KNN 1 @embedding $vec AS score]",
        query_params={"vec": query_vec},
    )

    if results.docs:
        score = float(results.docs[0].score)
        if score < threshold:
            return results.docs[0].response

    response = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
    )
    text = response.choices[0].message.content

    r.hset(f"sem:{hashlib.sha256(prompt.encode()).hexdigest()}",
           mapping={"response": text, "embedding": query_vec})
    return text

The threshold controls how aggressive the cache is. A lower threshold requires closer matches and reduces false positives but increases cache misses. A value around 0.1 to 0.2 cosine distance works well for short prompts, but you should tune it based on your specific use case and evaluate the quality of returned answers.

Cache Invalidation Strategies

Caching is easy; invalidation is famously hard. For AI applications, consider these strategies:

def flush_namespace(prefix):
    cursor = 0
    while True:
        cursor, keys = r.scan(cursor, match=f"{prefix}*", count=500)
        if keys:
            r.delete(*keys)
        if cursor == 0:
            break

flush_namespace("llm:")

Best Practices

Adding Resilience with Fallbacks

Production systems should never let a cache outage take down the application. Wrap your cache access in try/except blocks and degrade gracefully.

def safe_cached_completion(prompt, model="gpt-4o", temperature=0, ttl=86400):
    key = cache_key(prompt, model, temperature)
    try:
        cached = r.get(key)
        if cached:
            return json.loads(cached)["response"]
    except redis.RedisError:
        pass  # Cache unavailable, proceed to model call

    response = client.chat.completions.create(
        model=model,
        temperature=temperature,
        messages=[{"role": "user", "content": prompt}],
    )
    text = response.choices[0].message.content

    try:
        r.setex(key, ttl, json.dumps({"response": text}))
    except redis.RedisError:
        pass  # Cache write failed, non-fatal

    return text

Measuring Cache Effectiveness

To justify and tune your caching layer, instrument it with metrics. At minimum, track hit count, miss count, and average response time for cached versus uncached requests.

from collections import Counter

metrics = Counter()

def instrumented_completion(prompt, model="gpt-4o", temperature=0, ttl=86400):
    key = cache_key(prompt, model, temperature)
    cached = r.get(key)
    if cached:
        metrics["hits"] += 1
        return json.loads(cached)["response"]

    metrics["misses"] += 1
    response = client.chat.completions.create(
        model=model,
        temperature=temperature,
        messages=[{"role": "user", "content": prompt}],
    )
    text = response.choices[0].message.content
    r.setex(key, ttl, json.dumps({"response": text}))
    return text

def hit_rate():
    total = metrics["hits"] + metrics["misses"]
    return metrics["hits"] / total if total else 0

In a real deployment, export these counters to Prometheus or your preferred monitoring system and visualize hit rate over time. A healthy cache for an AI assistant typically achieves a 30% to 70% hit rate depending on user behavior patterns.

Conclusion

Model caching in Redis is one of the highest-leverage optimizations you can apply to an AI application. It reduces latency from seconds to milliseconds, slashes API costs, and improves throughput — all with relatively little code. Start with exact-match caching for deterministic outputs like embeddings and completions, then graduate to semantic caching with Redis vector search once you understand your traffic patterns. Pair your cache with sensible TTLs, robust fallbacks, and ongoing metrics, and you will have a fast, cost-efficient AI backend that scales gracefully as your user base grows.

— Ad —

Google AdSense will appear here after approval

← Back to all articles