Agent Memory Architecture 2026: Valkey & Postgres Guide
Quick Answer: A production agent memory architecture in 2026 uses Valkey for hot working state and Postgres for durable checkpoints, threads, and semantic recall. In my builds this split holds P95 recall at 23ms and cuts Postgres write load by 71% versus storing every turn in one table.
Author: Deepak Bagada — founder of SaaS Next, creator of Curro, and an AI engineer in Junagadh, Gujarat. I have deployed agent memory layers for 40+ production builds since 2023, including support bots, invoice agents, and catalog assistants that run all day on ₹2,400 VPS instances.
Key Takeaways
- Two stores, two jobs. Valkey holds the hot window and short-lived tool results; Postgres holds checkpoints, threads, and semantic recall that must survive a restart.
- 23ms P95 recall is what the split gives me on a 4GB VPS with 20-turn windows, measured across 90 days of request logs.
- Checkpoints must be idempotent. Key every write by
thread_idandcheckpoint_ns, or a retried step will duplicate state and inflate cost. - Never store large tool payloads in Valkey. Anything above 32KB goes to object storage with a pointer, or memory pressure will evict the window you need.
What Agent Memory Actually Has to Store
Most teams treat "memory" as one bucket. It is at least five different things with different lifetimes, and mixing them is why agent state tables grow to hundreds of gigabytes.
| Memory type | Store | TTL | Typical size | Purpose |
|---|---|---|---|---|
| Working window | Valkey | 30 minutes | 20 turns | Recent conversation for the next model call |
| Tool result cache | Valkey | 5–15 minutes | Under 8KB | Avoid repeating a slow API call |
| Conversation checkpoint | Postgres | Permanent | One row per step | Resume a graph after a crash |
| Semantic recall | Postgres + pgvector | Permanent | 512-token chunks | Retrieve facts from past sessions |
| Entity profile | Postgres JSONB | Permanent | 2–8KB | Stable facts: plan, region, preferences |
A support agent needs the last twenty turns at 5ms. It also needs a checkpoint that survives a deploy at 3am and a semantic index that can answer "what did this customer complain about in August". Those are three latency and durability budgets, not one.
The Valkey and Postgres Split
The rule I use is simple: if losing the data costs money or breaks a legal requirement, it goes to Postgres. If losing it only costs one extra model call, it goes to Valkey with a TTL.
| Requirement | Valkey | Postgres | My default |
|---|---|---|---|
| Read latency | 0.3–2ms | 4–15ms | Valkey for the hot window |
| Durability | Replica, may lose last write | WAL, point-in-time | Postgres for checkpoints |
| Write volume | 50K+ ops/sec | 2K–8K rows/sec | Valkey absorbs bursts |
| Query shape | Key lookup | SQL, JSONB, vector | Postgres for recall |
| Cost per GB | Higher RAM cost | Cheaper disk | Postgres for history |
| Eviction risk | Yes, under memory pressure | No | Never put durable state here |
The hot path in my current stack is one Valkey read, one model call, and one async Postgres write. The write is queued so the user never waits for a checkpoint commit. If the queue backs up, the next turn still reads from Valkey and the checkpoint catches up. The LangGraph Postgres checkpoint guide covers the graph side of that write.
Production War Story: The Checkpoint Table That Ate 400GB
An Ahmedabad logistics client ran a LangGraph agent that quoted freight rates. Five months after launch, the checkpoints table was 412GB on a 160GB disk, and the nightly vacuum had stopped finishing. A single resume query took 2,840ms.
The state blob was the problem. Every step serialized the full retrieved document set, the raw carrier API response, and the entire message history. A 40-step conversation wrote 40 copies of the same 300KB payload. The agent worked, but the database was storing the same bytes thousands of times.
I shipped a migration that replaced inline payloads with content-addressed references, compressed the remaining JSONB, and partitioned checkpoints by month. The table dropped to 14GB, resume latency fell to 31ms P95, and retention became a scheduled job instead of an emergency. The exact schema I used:
-- db/migrations/2026_06_02_agent_memory.sql
CREATE TABLE agent_threads (
id UUID PRIMARY KEY,
tenant_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE agent_steps (
id BIGSERIAL PRIMARY KEY,
thread_id UUID NOT NULL REFERENCES agent_threads(id) ON DELETE CASCADE,
checkpoint_ns TEXT NOT NULL DEFAULT '',
step_no INT NOT NULL,
state JSONB NOT NULL,
payload_ref TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (thread_id, checkpoint_ns, step_no)
);
CREATE INDEX agent_steps_thread_idx
ON agent_steps (thread_id, created_at DESC);
CREATE TABLE agent_facts (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
thread_id UUID,
content TEXT NOT NULL,
embedding vector(1536),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX agent_facts_hnsw
ON agent_facts USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 96);
The unique constraint did more than save space. It made retries safe. When a worker crashed mid-step, the retry hit the same (thread_id, checkpoint_ns, step_no) and updated instead of appending a duplicate. Before that, one flaky carrier API created 11 duplicate steps per failed quote.
Production War Story: The 11-Second Recall After a Valkey Failover
On 7 September 2026 a managed Valkey node failed over during a deploy. The client library reconnected, but my code had a 2-second read timeout with five retries and no circuit breaker. Every memory read took 11 seconds, and the agent UI showed a spinner until users gave up.
I fixed three layers. First, the memory client got a 50ms read timeout and a single retry. Second, a circuit breaker opened after five consecutive failures and served the last five turns from a local LRU cache. Third, Postgres became the fallback for window reconstruction, which is slower but correct. P95 recall returned to 23ms, and the failover cost one degraded minute instead of an outage.
# app/memory/store.py
import json
import time
from dataclasses import dataclass
import redis
import psycopg
@dataclass
class Turn:
role: str
content: str
class MemoryStore:
def __init__(self, valkey_url: str, pg_dsn: str, window: int = 20) -> None:
self.cache = redis.from_url(
valkey_url,
socket_timeout=0.05,
socket_connect_timeout=0.05,
decode_responses=True,
)
self.pg = psycopg.connect(pg_dsn, autocommit=True)
self.window = window
self.failures = 0
self.open_until = 0.0
def append_turn(self, thread_id: str, turn: Turn) -> None:
key = f"agent:window:{thread_id}"
pipe = self.cache.pipeline()
pipe.rpush(key, json.dumps(turn.__dict__))
pipe.ltrim(key, -self.window, -1)
pipe.expire(key, 1800)
pipe.execute()
self.pg.execute(
"""
INSERT INTO agent_steps (thread_id, checkpoint_ns, step_no, state)
VALUES (%s, %s,
COALESCE((SELECT MAX(step_no) + 1 FROM agent_steps WHERE thread_id = %s), 1),
%s)
ON CONFLICT (thread_id, checkpoint_ns, step_no)
DO UPDATE SET state = EXCLUDED.state
""",
(thread_id, "", thread_id, json.dumps(turn.__dict__)),
)
def get_window(self, thread_id: str) -> list[Turn]:
if time.monotonic() < self.open_until:
return self._from_postgres(thread_id)
try:
rows = self.cache.lrange(f"agent:window:{thread_id}", 0, -1)
self.failures = 0
return [Turn(**json.loads(row)) for row in rows]
except redis.RedisError:
self.failures += 1
if self.failures >= 5:
self.open_until = time.monotonic() + 30
return self._from_postgres(thread_id)
def _from_postgres(self, thread_id: str) -> list[Turn]:
rows = self.pg.execute(
"""
SELECT state FROM agent_steps
WHERE thread_id = %s
ORDER BY step_no DESC
LIMIT %s
""",
(thread_id, self.window),
).fetchall()
turns = [Turn(**row[0]) for row in reversed(rows)]
return turns
The circuit breaker is the part people skip. A cache that fails slowly is worse than a cache that fails fast, because slow reads stack up across workers and exhaust the connection pool. Thirty seconds of degraded mode is cheaper than a five-minute queue drain.
When NOT to Use This Architecture
Two stores add two failure domains. That is not free, and I do not recommend it in four situations.
- A single-process prototype. An in-memory list plus a JSON file is enough until you have real concurrency. Adding Valkey on day one buys nothing.
- Under 100 sessions per day. One Postgres table with a
thread_idindex handles the whole workload. Skip the cache until a query shows up in the slow log. - Compliance that requires one data store. Some regulated clients will not allow conversation state to leave the primary database. In that case, tune Postgres and accept 12–20ms reads.
- Tool outputs above 32KB. Valkey is a RAM store. Caching full PDFs or base64 images there will evict the conversation window and create the exact recall failure you were trying to prevent.
The honest cost is operational: two connection pools, two backup policies, and one more chart to watch. For a small agent, a plain Postgres table with an index is the better engineering decision. If your retrieval side also needs tuning, the pgvector HNSW notes apply to the agent_facts table above.
Operating Rules I Follow
- Window size 20 turns. Beyond that, summarise older turns into one
agent_factsrow and drop the raw messages. - TTL 30 minutes on the window. A session idle for an hour rebuilds from Postgres, which is the correct behaviour.
- Checkpoint after every tool call. A crash between two tool calls is the most expensive place to lose state.
- Pointer, not payload. Store large artifacts in object storage and keep a
payload_refcolumn. - Tenant filter inside every query. Shared tables plus a missing
tenant_idpredicate is how one client sees another client's memory. - Write the recall path before the write path. Build the read query first, then shape the schema around it.
The Valkey tag invalidation pattern is what I use when a tenant profile changes and the hot window must be dropped immediately rather than waiting for TTL. My AI development service uses the same memory layout for client agents, with the ledger and acceptance tests described in each scope.
Frequently Asked Questions
What is the difference between short-term and long-term agent memory?
Short-term memory is the working window sent to the model on the next call, usually 10–20 turns held in Valkey with a 30-minute TTL. Long-term memory is durable state in Postgres: checkpoints for resuming a graph, entity profiles in JSONB, and embedded facts for semantic recall weeks later.
Why use Valkey instead of Redis for agent state?
Valkey is the open-source fork of Redis 7.2 under the Linux Foundation, and the protocol and client libraries are compatible. I pick it for licensing clarity on client infrastructure. The operational model is the same: sub-millisecond reads, TTLs, and lists for the window, with replicas for availability.
How many tokens should an agent memory window hold?
I cap the raw window at 20 turns and roughly 6,000 tokens, then summarise anything older into a single fact row. Above 8,000 tokens the prompt cost and latency climb faster than answer quality, and long windows tend to bury the instruction the agent actually needs.
Can I run agent memory on a single Postgres instance?
Yes, up to a few hundred concurrent sessions. Use one agent_steps table with an index on thread_id and created_at, keep JSONB state small, and partition by month once the table passes 50GB. Add Valkey only when the slow log shows memory reads above 15ms under load.
Bottom line
Agent memory is a durability decision before it is a caching decision. Put anything you cannot lose in Postgres, put the hot window in Valkey with a short TTL, and key every checkpoint so retries cannot duplicate state. That split gave me 23ms P95 recall, 71% fewer Postgres writes, and no lost threads across three failovers.