← Back to Payloads
2026-07-08

Conversation Memory in Redis: A Drop-In Session Store for LLM Apps

Your chat endpoint forgets every turn, your session table is choking at scale, and you don't need a vector DB — you need Redis. Forty lines of Python, one hash per session, a sliding message window and a 24h TTL gives you production-grade conversation memory in an afternoon.
Quick Access
Install command
$ mrt install tutorial
Browse related skills
Conversation Memory in Redis: A Drop-In Session Store for LLM Apps

Conversation Memory in Redis: A Drop-In Session Store for LLM Apps

Hey guys, Mr. Technology here.

The fastest way to ship a chat feature that remembers what the user said ten minutes ago is Redis. Not Postgres. Not Mongo. Not a vector DB. A few hundred lines of Python, an HSET, and a sane eviction policy. Here is the build.

What we are solving

Your chat endpoint has no memory between turns. Or it has memory but the session store OOMs at ten thousand users. Or you are storing full conversation histories in Postgres and your SELECT count(*) is climbing on every query. Redis with a per-session key, a sliding window of recent messages, and a 24-hour TTL is the boring, correct answer.

Why Redis over the alternatives

Postgres works fine for hundreds of users and breaks at thousands. Vector DBs are overkill — you do not need semantic retrieval to remember the last twenty messages. Dedicated conversation stores (LangChain memory backends, Zep, etc.) lock you in to a specific framework. Redis is fast, ubiquitous, has hash storage that maps cleanly to message lists, and you already have it for caching. One dependency, one mental model, one thing to debug at 2am.

Step 1 — The schema

One Redis key per session. Use a hash: llm:session:{session_id} with fields user_id, created_at, last_active, and a JSON-serialized message list.

python
import redis
import json
from time import time
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
def session_key(sid: str) -> str:
    return f"llm:session:{sid}"
def create_session(sid: str, user_id: str) -> None:
    r.hset(session_key(sid), mapping={
        "user_id": user_id,
        "created_at": int(time()),
        "last_active": int(time()),
        "messages": json.dumps([]),
    })
    r.expire(session_key(sid), 86400)  # 24h TTL on creation

Run it locally with docker run -p 6379:6379 redis:7-alpine and you are coding in under a minute.

Step 2 — Append and load

You want append on write and full load on read. Keep it boring.

python
def append_message(sid: str, role: str, content: str, max_messages: int = 20) -> None:
    key = session_key(sid)
    raw = r.hget(key, "messages") or "[]"
    msgs = json.loads(raw)
    msgs.append({"role": role, "content": content, "ts": int(time())})
    msgs = msgs[-max_messages:]  # sliding window
    r.hset(key, mapping={
        "messages": json.dumps(msgs),
        "last_active": int(time()),
    })
    r.expire(key, 86400)  # refresh TTL on activity
def load_messages(sid: str) -> list[dict]:
    raw = r.hget(session_key(sid), "messages")
    if not raw:
        return []
    return json.loads(raw)

The max_messages cap is your context-window insurance. For Claude Sonnet at 200k tokens you can go higher; for gpt-4o-mini at 128k, twenty messages covers most product flows. Set it on the model, not the store.

Step 3 — Wire it into your LLM call

python
from openai import OpenAI
client = OpenAI()
def chat(sid: str, user_message: str) -> str:
    history = load_messages(sid)
    history.append({"role": "user", "content": user_message})
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=history,
    )
    reply = resp.choices[0].message.content
    append_message(sid, "user", user_message)
    append_message(sid, "assistant", reply)
    return reply

That is a complete session layer. About forty lines of code and one Redis call per turn.

Gotcha 1 — Sliding TTL quietly doubles your bill

expire(key, 86400) on every activity means long sessions renew forever. An abandoned tab from a week ago is still alive in Redis, eating RAM. Set a hard max-age checked on read (created_at + 7d is a common cap), or back the cache with ZSET-based LRU eviction. Otherwise your INFO memory will tell you a sad story at the end of the month when the bill arrives.

Gotcha 2 — Do not store raw tool outputs in the same list

Function-calling agents return raw tool payloads — five-megabyte JSON blobs, base64 images, the entire contents of whatever file the agent just read. They will eat your context window and your HSET bandwidth on the next turn. Strip them to a one-line summary before persisting:

python
def tool_summary(tool_call_result: dict) -> str:
    return f"[{tool_call_result['name']}] {str(tool_call_result['output'])[:500]}"

Persist only what the next turn actually needs. This is the lesson every team learns after their fourth OpenAI bill shock.

When not to use this

Skip Redis when you need semantic recall — "the user mentioned their cat three conversations ago" is a vector DB job. Skip it when you have strict compliance requirements and need every message audit-logged with WORM semantics — that is Postgres plus an append-only ledger. Skip it when your sessions are measured in months, not days — Redis lives in RAM and RAM is money past a certain scale.

For 90% of chat products shipping this year, Redis is the boring right answer. Build it once, forget about it, and ship the feature.

Mr. Technology


*Packages: redis-py 5.x. Hash storage key: llm:session:{session_id}. decode_responses=True returns str instead of bytes and saves a thousand .decode() calls. Sliding window default: 20 messages (covers about 5 turns of typical product chat). For multi-tenant, prefix the key with a tenant id: llm:session:{tenant}:{session_id}. Production tip: set maxmemory-policy allkeys-lru in redis.conf so unused sessions evict gracefully if you ever blow past the budget.*

Related Dispatches