
AI Agent Memory: Short-Term, Long-Term, and Persistent Memory Explained
Three genuinely different engineering problems hide behind one word. An agent that confuses them either forgets things mid-task or loses them on restart.
Two failures look identical from the outside and come from opposite causes. In the first, an agent is told early in a long conversation to always use PowerShell instead of bash, and forty turns later suggests a bash script anyway, not because it disagreed, but because that instruction scrolled out of what it could actually attend to. In the second, an agent correctly remembers a user's timezone across an entire session, gets redeployed for an unrelated bug fix, and asks for the timezone again the next morning, because the "memory" holding it was never anything more durable than a Python dictionary in a process that no longer exists.
Both are memory bugs. They require completely different fixes, because "memory" in an agent system isn't one thing, it's at least three architecturally distinct mechanisms with different lifetimes, different storage, and different failure modes, and conflating them is exactly how both bugs above get built by accident.
The one sentence to remember
Short-term memory is a scope question (does this survive past the current run?), long-term memory is a content question (what's actually worth carrying forward?), and persistent memory is an infrastructure question (does the storage holding it survive a restart, a crash, or a redeploy?). Treating any two of these as the same problem is where agent memory systems actually break.
Three Layers, Three Different Problems
| Layer | Scope | Typical storage | What breaks when it's wrong |
|---|---|---|---|
| Short-term | One agent run, discarded after | The context window itself | Instructions or facts fall out of context and get silently ignored |
| Long-term | Across sessions, for one user or task | A structured store, keyed and searchable | Everything gets re-explained every session, or irrelevant history piles up forever |
| Persistent | Survives process restarts, crashes, redeploys | Durable storage: a database or disk-backed store | Long-term memory quietly resets to empty the moment the process holding it dies |
Short-Term Memory: The Context Window Itself
This is the least architecturally interesting layer and the one every agent has by default, because it's just the running list of messages sent to the model on every call. It requires no design decision to exist. It requires a real one to not silently fail as a conversation grows:
class ShortTermMemory:
def __init__(self, max_messages: int = 40):
self.messages: list[dict] = []
self.max_messages = max_messages
def add(self, role: str, content: str):
self.messages.append({"role": role, "content": content})
if len(self.messages) > self.max_messages:
self._consolidate()
def _consolidate(self):
# Summarize the oldest half instead of silently dropping it.
oldest = self.messages[: self.max_messages // 2]
summary = summarize(oldest)
remainder = self.messages[self.max_messages // 2 :]
self.messages = [{"role": "system", "content": f"Earlier context: {summary}"}] + remainderNaive truncation and 'lost in the middle' are two different failure modes, and both are real
The obvious fix, just drop the oldest messages once a limit is hit, silently deletes whatever instruction or constraint was established early in the conversation, which is exactly the bash-instead-of-PowerShell failure from the opening. The less obvious problem is that even within a context window that technically still contains everything, models attend less reliably to information buried in the middle of a very long context than to what's near the beginning or the most recent turns, a well-documented effect generally referred to as "lost in the middle." Summarizing aggressively as context grows, rather than trusting the model to reliably use everything still technically present, addresses both problems at once.
Short-term memory never survives past the run it belongs to, and that's correct, not a limitation to work around. It exists to give the current reasoning step everything it needs right now. What happens to anything worth keeping after the run ends is a different layer's job entirely.
Long-Term Memory: Deciding What Actually Survives
Long-term memory holds structured, retrievable facts that should carry into future sessions, a user's stated preference, a correction the agent got wrong once and shouldn't repeat, a project's current state. The two real design questions here are how something gets in, and how it gets found again.
import time
class LongTermMemory:
def __init__(self, store: "PersistentStore"):
self.store = store
def remember(self, user_id: str, key: str, value: str):
self.store.upsert(user_id, key, value, timestamp=time.time())
def recall(self, user_id: str, query: str, top_k: int = 5) -> list[dict]:
# Semantic retrieval over this user's own remembered facts,
# the same cosine-similarity mechanism covered in
# RAG From Scratch, applied to self-generated memory instead
# of externally authored documents.
return self.store.search(user_id, query, top_k=top_k)Two genuinely different ways something ends up in long-term memory, and most real systems need both:
Explicit: the agent calls a remember tool on purpose
Given a tool like remember(key, value), the model can decide mid-conversation that something is worth keeping, "the user prefers metric units," and store it deliberately. This is precise but depends entirely on the model reliably recognizing what's worth remembering in the moment, which it won't always do.
Automatic: consolidation at the end of a session
A separate summarization pass reviews the full short-term transcript after a session ends and extracts what's durable from what was just working context, the same consolidation step shown in ShortTermMemory._consolidate() above, run once more at a coarser grain. This catches what the model didn't think to flag explicitly, at the cost of running an extra pass that itself might misjudge what mattered.
Persistent Memory: Making Long-Term Memory Actually Survive
Here's the distinction that the opening timezone bug depends on entirely: "long-term" describes how long something should conceptually matter. It says nothing about whether the thing holding it will still exist after a crash. LongTermMemory above just delegates to a PersistentStore, and that's deliberate, because durability is a separate concern with its own separate failure mode:
import sqlite3
import json
class PersistentStore:
def __init__(self, db_path: str = "agent_memory.db"):
self.conn = sqlite3.connect(db_path)
self.conn.execute("""
CREATE TABLE IF NOT EXISTS memories (
user_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
embedding TEXT NOT NULL,
timestamp REAL NOT NULL,
PRIMARY KEY (user_id, key)
)
""")
self.conn.commit()
def upsert(self, user_id: str, key: str, value: str, timestamp: float):
vector = embed_query(value) # reuses the embedding step from RAG From Scratch
self.conn.execute(
"INSERT OR REPLACE INTO memories VALUES (?, ?, ?, ?, ?)",
(user_id, key, value, json.dumps(vector), timestamp),
)
self.conn.commit()
def search(self, user_id: str, query: str, top_k: int = 5) -> list[dict]:
rows = self.conn.execute(
"SELECT key, value, embedding FROM memories WHERE user_id = ?", (user_id,)
).fetchall()
query_vector = embed_query(query)
scored = [
{"key": k, "value": v, "score": cosine_similarity(json.loads(e), query_vector)}
for k, v, e in rows
]
return sorted(scored, key=lambda r: r["score"], reverse=True)[:top_k]An in-memory dict is not long-term memory, no matter how long the process happens to stay running
The timezone bug from the opening comes from exactly this substitution: a dict keyed by user ID, holding facts the agent genuinely intended to keep long-term, living entirely in one process's RAM. It works perfectly in every test, every demo, and every session until that process restarts for any reason, a deploy, a crash, an autoscaler cycling instances, at which point every "long-term" memory it held vanishes with no error, no warning, and no indication to the user that anything was lost. SQLite, or any real database, survives exactly the event that a dict doesn't, which is the entire reason this layer needs to be named and designed separately instead of assumed to come for free.
The Concurrency Problem Persistence Alone Doesn't Solve
Moving to SQLite fixes the restart problem. It doesn't automatically fix a second one: if the agent runs as multiple concurrent instances behind a load balancer, each instance's own local SQLite file diverges from the others the moment two instances handle the same user. A fact remembered by instance A is invisible to instance B until both are reading from the same shared database, not one file per instance. At real production scale, this typically means moving from local SQLite to a shared, networked store, Postgres with pgvector for the same similarity search shown above, or a managed vector database, but the underlying requirement doesn't change: every instance that might serve a given user needs to be reading and writing the exact same persistent store, not a copy of it.
How This Differs From RAG
RAG From Scratch builds nearly the same retrieval mechanism, chunk, embed, store, search by cosine similarity, and it's worth being precise about what's actually different here, because the code above deliberately reuses it. RAG retrieves from a corpus of documents someone else authored: runbooks, policies, product docs. Long-term memory retrieves from state the agent itself generated about its own interactions: what this specific user said, what the agent decided, what turned out to be true. The retrieval math is the same. What's being retrieved, and who authored it, is not, and that distinction matters most exactly where it seems least important: when deciding how much to trust what comes back. A document from an approved runbook and a memory the agent wrote about itself six weeks ago deserve different default levels of confidence, one is verified organizational knowledge, the other is the system's own possibly-outdated belief about the world.
Memory Isolation Is a Security Boundary
Cross-user memory leakage is a real, specific failure mode, not a hypothetical one
Every function above takes user_id as an explicit parameter for a reason that goes beyond data organization: a recall() call that isn't scoped correctly can surface one user's remembered facts, preferences, or conversation history to a different user entirely, especially in a system where memory is retrieved by semantic similarity rather than an exact, enforced key match. This is the same identity and boundary discipline covered in AI Agent Identity, applied specifically to memory: the retrieval query has to be constrained to the requesting user's own partition before similarity search ever runs, not filtered afterward, because a similarity search that scans across every user's memories and filters late has already put the wrong data within one bug of being returned.
Forgetting Is a Feature, Not a Bug
Long-term memory that only ever grows eventually becomes its own failure mode: retrieval gets slower, results get noisier as old, no-longer-true facts compete with current ones, and, for anything involving real personal data, indefinite retention becomes a genuine privacy liability rather than just an engineering inconvenience. Three practical answers, usually combined:
Time-to-live expiration
Attach a timestamp, as PersistentStore.upsert() does above, and treat memories past a defined age as expired for retrieval purposes even if they're not physically deleted yet, cheap to implement and effective for facts that naturally go stale, like "the current on-call engineer" or "today's incident status."
Explicit overwrite
A new remember() call with the same key should replace the old value outright, which the INSERT OR REPLACE in the schema above already does, rather than accumulating a growing list of possibly-contradictory facts about the same thing.
Deliberate deletion
A real forget(user_id, key) path, reachable by an explicit user request, isn't optional if any of what's stored is personal data. "The agent remembers things about me" is a feature. "The agent remembers things about me and there's no way to make it stop" is a liability, and the gap between those two is usually a single missing function.
One Full Trace, Across All Three Layers
An agent handling an incident, restarted mid-week for an unrelated deploy, illustrates every layer at once:
| Stage | What happens |
|---|---|
| Session 1, short-term | The engineer says "I'm the on-call for payments this week, ping me directly, not the whole channel" |
| Consolidation | At session end, that instruction is extracted as a durable fact, not just left to expire with the transcript |
| Long-term write | remember(user_id, "notification_preference", "direct ping, not channel") |
| Persistent write | The fact is written to the shared database, not a local process dict |
| Process restart | An unrelated deploy restarts the agent process entirely |
| Session 2, days later | A new incident starts. Short-term memory is empty, this is a brand new run |
| Long-term recall | recall(user_id, "how should I notify this person") retrieves the stored preference from the persistent store, unaffected by the restart in between |
| Result | The agent pings directly, correctly, with zero re-explanation needed, because the fact never depended on the process that first heard it still being alive |
The Bottom Line
"Give the agent memory" sounds like one feature and is actually three separate engineering decisions: how much of the current run to keep in context without drowning the model in irrelevant history, what's actually worth carrying forward once the run ends, and whether the thing storing that is durable enough to survive the process restarting without anyone noticing it happened. Most agent memory bugs trace back to exactly one of those three being treated as if it were one of the other two, an instruction that should have been consolidated but wasn't, or a fact that was faithfully remembered by code that was never actually built to survive a restart.
The audit worth running on your own agent's memory
For anything your system currently "remembers," ask which of the three layers it actually lives in. If the honest answer is "a variable in the running process," that's long-term memory wearing persistent memory's job, and it will fail exactly the way the timezone example did, quietly, on the next restart, with nobody finding out until a user notices.
Written by
Chetan Yamger
Cloud Engineer · AI Automation Architect · Modern Workplace Consultant
Cloud Engineer, AI Automation Architect, and Modern Workplace Consultant based in Amsterdam, Netherlands. Specializing in scalable, secure enterprise solutions with Microsoft Azure, Intune, PowerShell, and AI-driven automation using ChatGPT, Gemini, and modern LLM technologies.
Stay in the loop.
New articles, straight to you.
Deep-dive technical articles on Intune, PowerShell, and AI — no noise, no spam.
Discussion
Share your thoughts — your email stays private
Leave a comment