Engineering the Agentic Stack · Part 2

AI Agent Memory Architecture: Checkpoints and Vector Stores

Article update

Originally published on 14 February 2026. Reviewed and updated on 6 September 2026. The update covers context compaction, memory benchmarks, and storage APIs, and clarifies the distinctions between working state, checkpoints, and long-term memory.

A reasoning loop only survives one request unless its state is stored outside the worker. Without agent memory, the agent cannot resume a paused plan, recover after a crash, or recall a preference from an earlier session. Part 1 covered the control flow. This post identifies which state each later turn needs and where that state should live.

I’ll use the Market Analyst Agent — a small LangGraph agent that fetches market data and writes an analyst report — to anchor the hot-checkpoint discussion. The cold-vector and raw-Markdown sections are independent illustrative designs that show extensions the current project does not yet implement. Then I’ll cover when PostgreSQL, Redis, Qdrant, key-value stores, and plain Markdown files each make sense.

Every store below is read by the harness, the code that drives the loop around the model. The harness decides which of their contents reach the context window; the stores do not. This article is about where that state lives before the harness reaches for it. Part 3 and Part 4 cover what the harness does with the prompt next.


What is AI agent memory?

AI agent memory is the state layer that lets an agent preserve task progress, retrieve prior knowledge, and update what it knows across runs. A design may use checkpoints, semantic or structured stores, and human-readable documents. Choose only the stores needed for the product’s recall and recovery requirements.

NeedBest defaultWhy
Pause and resume one runPostgreSQL checkpoint storeDurable, queryable, and easy to operate with app data
Low-latency transient stateRedis checkpoint storeFast resume and short-lived state, with persistence trade-offs
Cross-thread semantic recallQdrant or pgvectorRetrieves memories by meaning, not only exact keys
Structured user factsPostgreSQL or key-value storeDeterministic updates beat fuzzy retrieval for preferences and IDs
Project conventions and learned proceduresMarkdown or JSON filesHuman-readable, diffable, and easy for agents to update
Multi-entity relationship memoryKnowledge graphUseful when relationships matter more than individual facts

Do not start with memory because it sounds intelligent. Start with the user-visible failure: losing progress, forgetting a preference, repeating research, or failing to reuse a project convention.

The failures that require memory

A stateless agent can answer an isolated question, but it forgets the request as soon as the call ends. That design fails when the product needs any of the following behaviors:

  • Pause and resume: a user starts a research task, closes their laptop, and comes back tomorrow. Without checkpointed state, the agent restarts from scratch.
  • Multi-turn coherence: over a long conversation the agent has to remember what tools it called, what data it gathered, and what plan steps it finished.
  • Personalization: a returning user expects the agent to know their risk tolerance, preferred analysis depth, and past interactions.
  • Human-in-the-loop (HITL): the agent gathers its evidence and waits for a human to approve the next step. The “waiting” state has to survive process restarts.

In the Market Analyst Agent from Part 1, the request “Analyze NVDA” produces a plan, five tool calls, gathered data, and a draft report. When the user replies “looks good, but add competitor analysis,” a checkpoint restores the plan and research from the last completed step. Adding the competitor step would require follow-up interpretation and replanning; the companion does not implement that behavior. The checkpoint supplies prior state, while the application must decide how the new request changes the plan.

Long-term memory handles a different case. If the user returns a week later and asks, “Update my NVDA analysis,” the agent may need to recall a preference for conservative risk assessments and an interest in semiconductor stocks. A vector-backed memory store can retrieve those facts across sessions without asking for them again.

The implementation examples below use LangGraph, LangChain’s open-source library for building agents as explicit state graphs; the storage boundaries it draws generalize to any framework. Think of a user’s ongoing “Analyze NVDA” conversation as one thread. Each time the graph runs to answer or continue it is one run within that thread. While a run is active, the model’s context and the program’s local variables are its working memory; they disappear when that work stops. LangGraph calls state saved for that one thread short-term memory and facts available to other threads long-term memory. Below, “thread” and “conversation” mean the same thing. Part 5 uses “session” for the durable log of one run, so this article avoids that term for the conversation.

Six agent memory types and the three storage tiers they collapse intoSix agent memory types and the three storage tiers they collapse into


A taxonomy of AI agent memory

Before getting into implementation, it helps to classify what agents need to remember. The CoALA framework — Cognitive Architectures for Language Agents (Sumers, Yao et al., 2023) — is a widely cited taxonomy that draws on cognitive science. I introduced memory scoping in my context engineering post; here I expand it into six categories:

Memory TypeScopeLifetimeExampleStorage Pattern
WorkingCurrent stepMillisecondsTool call arguments, current LLM responseIn-process (Python dict)
Short-termCurrent threadMinutes–hoursConversation history, plan progress, gathered dataCheckpoint store
EpisodicCross-threadDays–months”Last week the user asked about NVDA earnings”Vector store / KV store
SemanticCross-threadMonths–permanent”User prefers conservative investments”Vector store / KV store
DocumentCross-threadDays–permanentProject notes, research summaries, learned patternsFile store (Markdown/JSON)
ProceduralSystem-widePermanent”When analyzing stocks, always check SEC filings”Config / system prompt

Working memory holds the current observations, retrieved facts, and intermediate results used by the active run. Some live in application variables; the selected messages and tool results form the model’s input. That input must fit the model’s context window, while application state can be larger and persist across multiple steps. Process memory is lost on a crash unless explicitly saved. The other tiers supply information to this working state.

Short-term memory is the checkpoint LangGraph writes after each unit of graph execution — a super-step, defined in the next section. Episodic and semantic memories persist across threads. Document memory stores project notes, research summaries, and learned conventions in files that people and agents can inspect. Procedural memory includes system instructions, tool definitions, and reusable procedures that may be retrieved for a task. The lifetimes in the table are illustrative; retention follows the application’s policy, and working state can last for a whole active run.

For implementation, five of those six collapse into three storage tiers. Short-term memory becomes hot memory, the checkpoint for the current thread. Episodic and semantic become cold memory, recall across threads. Document memory keeps accumulated project knowledge readable and directly editable. Working memory is grouped with the hot tier because checkpoints can preserve the state needed to reconstruct an active run. A checkpoint is not the model’s complete internal computation. Procedures may ship with the agent or be stored and retrieved from files or another store. These tiers describe this article’s implementation choices, not mutually exclusive memory types.

CoALA classifies working, episodic, semantic, and procedural memory. The Memory in the Age of AI Agents survey instead organizes memory by form, function, and dynamics, including documents, codebases, and reusable workflows. Files can implement several of those categories. This article names document memory separately to make its storage and maintenance responsibilities visible.

The same storage pattern appears in other domains. A Minecraft agent (Voyager) stores reusable game skills as code libraries, and web agents induce reusable browsing workflows from successful runs. I come back to both later. Inspectable files and indexed retrieval can coexist: Voyager retrieves programs using embeddings of their descriptions.

Agent-managed memory also differs from a fixed RAG pipeline in who performs the write. The agent or its harness selects what to store, update, and delete, then later chooses when to retrieve it.

The Generative Agents paper (Park et al., 2023) showed how far this can go: simulated agents stored, reflected on, and retrieved their own memories. Its memory stream ranked candidates by recency, importance, and relevance, a design that still provides a useful reference point for agent-memory retrieval.


Compaction keeps a conversation usable

A larger context window does not remove the need to choose what survives. Current APIs can summarize an older conversation before it fills the window. Claude’s server-side compaction, still a beta feature on 2026-09-06, returns a compaction block that subsequent requests use in place of earlier content. It can reduce client-side summarization work, but the summary can omit a fact needed later.

Keep authoritative task state outside that summary: completed effects, approvals, source references, and exact user constraints. A checkpoint restores execution; compaction shortens model context; long-term memory selects knowledge for another conversation. Test those three behaviors separately. Force compaction halfway through a test and check whether the next action still respects an earlier constraint. Do not use a compacted transcript as the only record of what was approved.

Short-term agent memory: the checkpoint store

LangGraph checkpoints graph state at super-step boundaries — one node, or a batch of nodes that ran in parallel. With the default durability="async", the next step can run while that write finishes; durability="sync" waits for persistence before proceeding, adding write latency. Crash recovery uses the latest persisted checkpoint, not necessarily the most recently completed step. That’s the foundation for pause/resume, time-travel debugging, and HITL workflows.

Hot memory: a checkpoint written at every super-step, and the recovery path that reloads itHot memory: a checkpoint written at every super-step, and the recovery path that reloads it

A checkpoint contains the graph state needed to resume: the AgentState from Part 1 — messages, identity, user profile, plan steps, research data, execution mode. After a HITL interrupt or process restart, LangGraph restores the latest saved state and uses its scheduling metadata to choose the next node. It resumes at a completed node boundary, not an arbitrary Python line. The stored details include a checkpoint ID and timestamp, a version for each channel (LangGraph’s name for a state key), and the channel versions each node has already seen. The step number is metadata for that checkpoint. A checkpoint is also different from an append-only event log or trace; Part 5 separates those runtime observability surfaces explicitly.

How LangGraph checkpointing works

LangGraph’s BaseCheckpointSaver is a simple interface: put() writes a checkpoint, get_tuple() reads the latest one for a thread, list() returns the history. Every checkpoint is keyed by (thread_id, checkpoint_ns, checkpoint_id), where thread_id identifies the conversation, checkpoint_ns handles subgraph namespacing, and checkpoint_id is a unique version.

The decision that matters is which backend to put behind it. PostgreSQL and Redis are two common production choices.

PostgreSQL vs Redis

Redis and PostgreSQL as checkpoint backends, compared on latency, durability, and query modelRedis and PostgreSQL as checkpoint backends, compared on latency, durability, and query model

DimensionPostgreSQL (langgraph-checkpoint-postgres)Redis (langgraph-checkpoint-redis)
Durability modelACID transactions, WAL, and replicationConfigurable persistence: an append-only command log (AOF) or periodic snapshots (RDB)
Checkpoint historyDurable history for resume and debuggingRetention depends on saver and eviction settings
Primary constraintDatabase write latency and table growthRAM use, eviction, and persistence configuration
Operational fitTeams already operating relational databasesTeams already operating Redis at high throughput
Best default forDurable resume and reproducible debuggingLatency-sensitive, recoverable session state

Generic database benchmarks do not predict checkpoint performance. Measure the serialized state size, write frequency, persistence settings, and concurrency of your own graph.

PostgreSQL: the durable default

PostgreSQL is the safer default for most teams. Checkpoints survive crashes, you get full transaction semantics, and the checkpoint history makes time-travel debugging straightforward.

A simplified version of the checkpoint setup in memory/hot.py. If an attacker could write checkpoints, set LANGGRAPH_STRICT_MSGPACK=true or configure allowed_msgpack_modules. That limits deserialization to safe or declared types; the permissive default warns about unregistered types but still allows them.

import asyncio
from contextlib import asynccontextmanager

from langchain_core.messages import HumanMessage
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver

@asynccontextmanager
async def postgres_checkpointer(connection_string: str):
    """Yield a PostgreSQL-backed checkpoint store.

    PostgreSQL gives us ACID guarantees — if a checkpoint write succeeds,
    the state is durable even if the process crashes immediately after.
    `from_conn_string` is itself an async context manager: it owns the
    connection and closes it on exit, so the graph has to run inside it.
    """
    async with AsyncPostgresSaver.from_conn_string(connection_string) as checkpointer:
        # Create the checkpoint tables if they don't exist.
        # This is idempotent — safe to call on every startup.
        await checkpointer.setup()
        yield checkpointer

async def main(authenticated_user_id: str) -> None:
    # The graph lives inside the context manager's scope.
    async with postgres_checkpointer(
        "postgresql://user:pass@localhost:5432/agent_memory"
    ) as checkpointer:
        graph = create_graph(checkpointer=checkpointer)

        # Every invoke/stream call now persists state automatically.
        config = {"configurable": {"thread_id": "user-123-session-1"}}
        result = await graph.ainvoke(
            {"user_id": authenticated_user_id,
             "messages": [HumanMessage(content="Analyze NVDA")]}, config
        )

        # After the server authenticates the approver and validates approval
        # of this exact draft, update the companion's approval field.
        await graph.aupdate_state(config, {"report_approved": True})
        # Continue the static interrupt_before pause; new input starts a new run.
        result = await graph.ainvoke(None, config)

# Local fixture identity. A server supplies this only after authentication.
asyncio.run(main(authenticated_user_id="user-123"))

The user_id in graph input comes from the authenticated server context; thread_id only locates checkpoints and does not establish identity or authorize access to a thread. The AsyncPostgresSaver uses the langgraph-checkpoint-postgres package, which creates four tables: checkpoints (the serialized state), checkpoint_blobs (large binary data), checkpoint_writes (pending writes for crash recovery), and checkpoint_migrations (schema version). Concurrent writers are separated by the primary key (thread_id, checkpoint_ns, checkpoint_id) and upserts rather than by locking — two workers on the same thread will not corrupt each other, but they will not coordinate either.

Redis: when latency is the bottleneck

When checkpoint latency is the bottleneck, Redis is an option for recoverable state. Measure serialized state size, persistence settings, and concurrency before choosing it over PostgreSQL.

A simplified version of the checkpoint setup in memory/hot.py:

import asyncio
from contextlib import asynccontextmanager

from langgraph.checkpoint.redis.aio import AsyncRedisSaver

@asynccontextmanager
async def redis_checkpointer(redis_url: str):
    """Yield a Redis-backed checkpoint store.

    Redis keeps checkpoints in memory for low-latency access.
    Durability depends on RDB snapshots, AOF fsync policy, and replication.
    AOF with appendfsync everysec can still lose about one second of writes
    after a crash; enabling AOF alone is not a no-loss guarantee.
    """
    async with AsyncRedisSaver.from_conn_string(redis_url) as checkpointer:
        # Initialize Redis data structures
        await checkpointer.asetup()
        yield checkpointer

async def main() -> None:
    # Same graph API, different backend.
    async with redis_checkpointer("redis://localhost:6379") as checkpointer:
        graph = create_graph(checkpointer=checkpointer)

asyncio.run(main())

The AsyncRedisSaver from langgraph-checkpoint-redis stores each checkpoint as its own RedisJSON document, under the same (thread_id, checkpoint_ns, checkpoint_id) key as the Postgres saver. The v0.1.0 redesign inlined checkpoint values and replaced per-channel retrieval with a JSON.GET path. That change concerns value retrieval, not every persistence operation; the vendor’s latency measurements depend on its workload. Redis 8.0+ includes RedisJSON and RediSearch by default — no extra modules to install.

Choose the Redis persistence and fsync policy from the tolerated loss window. RDB can lose writes since the last snapshot; the usual AOF appendfsync everysec policy can lose about one second. always trades write latency for stronger persistence, while no leaves flushing to the OS. Test recovery with the actual disk and replication settings.

For memory-constrained deployments, ShallowRedisSaver stores only the latest checkpoint per thread — no history, but minimal RAM usage. Use this when you need pause/resume but don’t need time-travel debugging.

When to use which

Use PostgreSQL when:

  • You need full checkpoint history for time-travel debugging or reproducible resume
  • Durability is non-negotiable (financial services, healthcare)
  • You already run PostgreSQL in your stack
  • Your agent runs long tasks where losing state means hours of recomputation
  • You want a unified data store — PostgreSQL with pgvector can be a single backend for checkpoints, long-term memory, and vector search

Use Redis when:

  • Checkpoint latency is your bottleneck (real-time chat, streaming UX)
  • You’re building voice bots or streaming experiences where checkpoint access is on a measured latency-critical path
  • You need horizontal scaling across many independent threads. If several agents change shared state, give that state an owner and coordinate outside the checkpoint saver.
  • Short-lived sessions where losing a checkpoint is recoverable
  • You want semantic caching to reduce redundant LLM calls (Redis LangCache caches semantically similar queries to avoid repeated LLM calls)

Other options: langgraph-checkpoint-sqlite works for local development and single-process deployments. For AWS-native stacks, langgraph-checkpoint-aws provides a DynamoDBSaver with automatic payload offloading — the documented saver offloads above its 350 KB threshold when an S3 bucket is configured. That threshold is implementation policy, not DynamoDB’s 400 KB item limit. Serverless pricing and no infrastructure to manage make it attractive for variable-load deployments.


Long-term memory: remembering across sessions

Hot memory handles the current conversation. Long-term memory covers the user who comes back next week: it stores facts, preferences, and interaction history that persist across threads.

LangGraph provides a Store interface for cross-thread memory via its BaseStore class. Each memory item is a (namespace, key) pair with a JSON value and optional vector embedding. The namespace typically encodes the user or organization: ("user", "user-123", "preferences").

The cold-memory retrieval path: embed the query, search Qdrant filtered by user, rescore, injectThe cold-memory retrieval path: embed the query, search Qdrant filtered by user, rescore, inject

Vector storage: semantic recall with Qdrant

When the agent needs to recall unstructured facts (“What did the user say about their investment timeline?”), vector search provides semantic recall. Instead of exact key lookups, the agent queries by meaning.

Qdrant is a purpose-built vector database written in Rust that handles embedding storage, indexing (Hierarchical Navigable Small World, or HNSW), and filtered search. I covered HNSW and its trade-offs in detail in my search ranking post. Qdrant also offers an MCP server that acts as a semantic memory layer — useful if your agent framework supports the Model Context Protocol.

The following is an independent illustrative Qdrant design. It is not a simplified version of the current memory/long.py. The current project stores user profiles with exact user_id filtering and a zero-vector placeholder. Real embedding integration remains future work. The request handler must authenticate the request and construct principal from its verified identity; the client never supplies it. The Qdrant filter is retrieval scope, not authorization.

from qdrant_client import QdrantClient
from qdrant_client.models import (
    PointStruct, Distance, VectorParams, Filter, FieldCondition, MatchValue,
)
import hashlib
import json
from dataclasses import dataclass

@dataclass(frozen=True)
class AuthenticatedPrincipal:
    """Created by the server after authentication, never from request JSON."""
    user_id: str

class UserMemoryStore:
    """Long-term memory backed by Qdrant vector search.

    Stores user facts as embedded vectors for semantic retrieval.
    Each fact is a short natural-language statement about the user.
    """

    def __init__(self, qdrant_url: str, collection_name: str = "user_memory"):
        self.client = QdrantClient(url=qdrant_url)
        self.collection_name = collection_name
        self._ensure_collection()

    def _ensure_collection(self):
        """Create the collection if it doesn't exist."""
        collections = [c.name for c in self.client.get_collections().collections]
        if self.collection_name not in collections:
            self.client.create_collection(
                collection_name=self.collection_name,
                vectors_config=VectorParams(
                    size=1536,  # text-embedding-3-small dimensions
                    distance=Distance.COSINE,
                ),
            )

    def store_fact(
        self, principal: AuthenticatedPrincipal, fact: str, embedding: list[float]
    ):
        """Store a user fact with its embedding."""
        identity = json.dumps([principal.user_id, fact], ensure_ascii=False).encode()
        point_id = hashlib.sha256(identity).hexdigest()[:32]
        self.client.upsert(
            collection_name=self.collection_name,
            points=[PointStruct(
                id=point_id,
                vector=embedding,
                payload={"user_id": principal.user_id, "fact": fact},
            )],
        )

    def recall(
        self,
        principal: AuthenticatedPrincipal,
        query_embedding: list[float],
        top_k: int = 5,
    ):
        """Retrieve the most relevant facts for a user given a query."""
        results = self.client.query_points(
            collection_name=self.collection_name,
            query=query_embedding,
            query_filter=Filter(
                must=[FieldCondition(
                    key="user_id", match=MatchValue(value=principal.user_id)
                )]
            ),
            limit=top_k,
        )
        return [hit.payload["fact"] for hit in results.points]

The point ID hashes a JSON array of user ID and fact so delimiters inside either value cannot merge two identities. For example, user a:b with fact c must differ from user a with fact b:c. The 32 hexadecimal characters fit Qdrant’s UUID point-ID representation.

The flow has three steps. In this illustrative design, an LLM extracts key facts from the interaction (“user has high risk tolerance”, “user is interested in semiconductor stocks”). Those facts are embedded and stored in Qdrant. At the start of the next conversation, the server supplies the authenticated principal and the agent queries Qdrant with the user’s new message to recall relevant context. The current Market Analyst Agent does not yet implement this semantic extraction and embedding flow.

Retrieval scoring: beyond cosine similarity

Raw cosine similarity is a starting point, but production memory systems need richer retrieval. The Generative Agents paper (Park et al., 2023) introduced a scoring function that combines three signals:

  • Recency: Rule-based decay so recent memories score higher. An exponential decay function makes a fact from yesterday outrank an equivalent fact from six months ago.
  • Importance: LLM-rated significance on a 1-10 scale. “User’s portfolio is down 40%” scores higher than “user said hello.”
  • Relevance: Embedding cosine similarity between the query and the stored fact.

The paper normalizes all three signals to comparable scales before combining them. Do the same before tuning weights; a raw 1–10 importance score would otherwise dominate a 0–1 signal. The final retrieval score is a weighted sum: score = alpha * recency + beta * importance + gamma * relevance. That keeps fresh, important facts from being buried under stale but semantically similar ones. For a financial-analysis prototype, I would start at alpha = 0.3 for recency, beta = 0.2 for importance, and gamma = 0.5 for relevance, because the current query usually determines which otherwise valid fact belongs in context. The Generative Agents paper used equal weighting; these values are a proposed starting point, not a measured improvement. Tune them against held-out recall and task-quality checks before relying on them.

Vector search is powerful but not always the right tool. Here’s when to use alternatives:

ApproachBest forMain operational cost
Vector search (Qdrant)Semantic recall of unstructured factsEmbedding and index lifecycle
Key-value store (Redis)Structured user profiles and preferencesMemory use and persistence policy
Document store (files)Project knowledge and agent-managed notesConcurrency, permissions, and search
Full-text search (PostgreSQL GIN index)Keyword recall over conversation historyIndex growth and query tuning
Knowledge graph (Neo4j)Entity relationships and multi-hop queriesGraph modeling and another data system
Hybrid (vector + keyword)Recall when query intent variesTwo scoring paths to tune and evaluate

Key-value stores work well for structured data. If your long-term memory is a user profile — risk tolerance, investment horizon, preferred sectors — a Redis hash or PostgreSQL JSONB column is simpler and faster than embedding and querying vectors. Use vector search when the memory is unstructured and the retrieval query varies in phrasing.

LangGraph’s built-in Store provides a namespace-based key-value interface with optional vector search. The BaseStore API is simple: put(), get(), search(), and delete() with hierarchical namespace scoping. Three implementations are available:

  • InMemoryStore — for development and testing (data lost on process exit)
  • PostgresStore — production persistent store with full SQL querying
  • AsyncRedisStore — cross-thread memory with vector search, TTL support, and metadata filtering

The index configuration enables vector search over stored items using a configurable embedding model. For many use cases, this built-in store is sufficient without reaching for a dedicated vector database.

import asyncio
from langgraph.store.memory import InMemoryStore

# Create a store with vector search enabled
store = InMemoryStore(
    index={
        "dims": 1536,
        "embed": my_embedding_function,  # e.g., OpenAI text-embedding-3-small
    }
)

async def main() -> None:
    # Store a user preference (namespace scopes to user).
    await store.aput(
        namespace=("user", "user-123", "preferences"),
        key="risk-profile",
        value={"risk_tolerance": "high", "horizon": "long-term"},
    )

    # Semantic search across the user's memories.
    # The namespace prefix is positional here — `search`/`asearch` declare it
    # as positional-only `namespace_prefix`, unlike `aput`.
    results = await store.asearch(
        ("user", "user-123"),
        query="What is their investment style?",
        limit=5,
    )

asyncio.run(main())

Choosing a long-term memory strategy

Start with key-value if your memory is structured and well-defined (user profiles, settings, named entities). Add vector search when you need semantic retrieval over unstructured facts or when the query phrasing varies unpredictably.

Knowledge graphs earn their keep when relationships between entities matter, e.g. “Which companies did the user ask about that are competitors of NVDA?” The most interesting recent project here is Graphiti (by Zep), which builds a temporally-aware knowledge graph that tracks when facts were true, not just what was true. Its temporal relationships can preserve validity intervals and superseded values; extraction and update logic still determine whether a fact is current. The Zep paper reports 94.8% DMR accuracy for the evaluated Zep system powered by Graphiti with GPT-4 Turbo, versus 94.4% for full context. DMR uses 60-message conversations and a limited fact-retrieval task. That small gap does not establish a general advantage for temporal graphs.

The catch is operational. Running a graph database is non-trivial, and for most agent applications vector search with metadata filtering covers the same ground with less infrastructure.

Managed memory frameworks like Mem0 and Letta (formerly MemGPT) handle the extraction-consolidation-retrieval pipeline for you. Mem0’s approach is notable: an LLM extracts candidate memories, a decision engine compares each new fact against existing entries in the vector store, and a resolver decides to add, update, delete, or do nothing. That keeps the memory store coherent and non-redundant. Letta takes an operating-systems angle: agents manage their own context window using memory management tools, autonomously moving data between “core memory” (in-context) and “archival memory” (out-of-context). Both are worth evaluating if you want faster time-to-production and don’t need full control over the memory pipeline.


Document memory: the agent’s filing cabinet

Vector stores and key-value backends handle semantic recall and structured lookups well. Accumulated project context—conventions, research notes, and decisions carried across sessions—often belongs in files that people can read, review, and version.

This is document memory: the agent reads and writes structured files (Markdown, JSON, YAML) to a known directory. No embeddings, no database, no infrastructure. Just files on disk that both the agent and the developer can cat, grep, git diff, and edit by hand.

In one vendor-run evaluation, Letta reported 74.0% accuracy on LoCoMo—a long-conversation question-answering benchmark—for a GPT-4o mini agent using attached files, automatic embeddings, semantic search_files, and required search-tool rules. Mem0’s best graph variant scored 68.5%. This is one vendor, model, benchmark, and harness. It shows that a file-facing interface can work well in that setup; it does not show that raw Markdown or keyword search is enough. The operational advantage is separate: developers can read, edit, and diff the stored knowledge directly.

Longer context windows also make whole-file reads practical for some project documents. Chunked retrieval still fits large corpora, but a short conventions or handoff file can often be loaded directly. The choice depends on document size, retrieval precision, context budget, and how often people need to review or edit the memory.

Why files?

For a long-lived agent project, use a directory of well-organized notes when people need a reviewable record. Consider a coding agent that works on one project for weeks:

  • It learns that the project uses Pydantic v2, not v1
  • It discovers that tests must run with pytest -x --tb=short
  • It accumulates knowledge about the codebase architecture
  • It learns the developer’s preferences (“always use pathlib, never os.path”)

These facts could live in a vector or key-value system. Files are the better default here because the developer needs to read, edit, review, and version connected notes. Add keyword or semantic search only when the document corpus and query pattern need it. If the agent learns something wrong, open the file and fix it.

Claude Code, Cursor, and Devin Desktop use versions of this pattern. The examples below show how each stores and loads its files.

Implementing a file memory store

The implementation is deliberately simple. The agent gets four operations: write a document, read a document, list available documents, and search across documents by keyword.

The following is an independent illustrative raw-Markdown file store. It is not a simplified version of the current memory/document.py. The current project uses DocumentMemory, which requires a namespace and key and writes a JSON envelope containing content, metadata, and created_at. This sketch defines a different design to show the trade-off of human-readable Markdown files:

from pathlib import Path
import json

class FileMemory:
    """Document memory backed by the local filesystem.

    Stores agent knowledge as human-readable files organized by topic.
    No embeddings, no database — just files that both the agent and
    the developer can read, edit, and version-control.
    """

    def __init__(self, base_dir: str | Path):
        self.base_dir = Path(base_dir).resolve()
        self.base_dir.mkdir(parents=True, exist_ok=True)

    def _resolve_path(self, path: str) -> Path:
        """Return a path inside base_dir, rejecting escapes and symlinks."""
        requested = Path(path)
        if requested.is_absolute() or ".." in requested.parts:
            raise ValueError("path must be relative to base_dir without traversal")
        resolved = (self.base_dir / requested).resolve()
        try:
            resolved.relative_to(self.base_dir)
        except ValueError as error:
            raise ValueError("path must stay inside base_dir") from error
        return resolved

    def write_doc(self, path: str, content: str, metadata: dict | None = None):
        """Write or overwrite a document at the given path.

        Paths are relative to base_dir. Directories are created automatically.
        Metadata (if provided) is stored as a JSON sidecar file.
        """
        full_path = self._resolve_path(path)
        full_path.parent.mkdir(parents=True, exist_ok=True)
        full_path.write_text(content, encoding="utf-8")

        if metadata:
            meta_path = self._resolve_path(
                str(full_path.relative_to(self.base_dir).with_suffix(full_path.suffix + ".meta"))
            )
            meta_path.write_text(json.dumps(metadata, indent=2), encoding="utf-8")

    def read_doc(self, path: str) -> str | None:
        """Read a document by path. Returns None if not found."""
        full_path = self._resolve_path(path)
        if full_path.exists():
            return full_path.read_text(encoding="utf-8")
        return None

    def list_docs(self, pattern: str = "**/*") -> list[str]:
        """List documents matching a glob pattern."""
        self._resolve_path(pattern)
        return [
            str(self._resolve_path(str(p.relative_to(self.base_dir))).relative_to(self.base_dir))
            for p in self.base_dir.glob(pattern)
            if self._resolve_path(str(p.relative_to(self.base_dir))).is_file()
            and not p.name.endswith(".meta")
        ]

    def search_docs(self, query: str, pattern: str = "**/*.md") -> list[dict]:
        """Search documents by keyword. Returns matching files with context.

        This is intentionally simple — grep-style keyword search.
        For semantic search, use a vector store instead.

        # ponytail: linear scan of file bytes; add an index when measured
        # latency, concurrency, or retrieval quality requires it.
        """
        self._resolve_path(pattern)
        results = []
        for path in self.base_dir.glob(pattern):
            path = self._resolve_path(str(path.relative_to(self.base_dir)))
            if not path.is_file() or path.name.endswith(".meta"):
                continue
            content = path.read_text(encoding="utf-8")
            if query.lower() in content.lower():
                # Return the paragraph containing the match for context
                for paragraph in content.split("\n\n"):
                    if query.lower() in paragraph.lower():
                        results.append({
                            "path": str(path.relative_to(self.base_dir)),
                            "match": paragraph.strip()[:500],
                        })
        return results

The path helper is deliberately shared by reads, writes, and glob results: relative paths can still leave a directory through .. or an existing symlink. This illustrative class is for a trusted single-user or controlled filesystem. It checks a resolved path before use; at a hostile multi-tenant boundary, use descriptor-relative no-follow operations so a filesystem mutation cannot race that check. Run this small regression check after copying the class:

from tempfile import TemporaryDirectory

with TemporaryDirectory() as root:
    memory = FileMemory(root)
    memory.write_doc("notes/ok.md", "safe memory")
    assert memory.read_doc("notes/ok.md") == "safe memory"
    assert memory.list_docs() == ["notes/ok.md"]
    assert memory.search_docs("safe")[0]["path"] == "notes/ok.md"

    (Path(root) / "escape").symlink_to(Path(root).parent, target_is_directory=True)
    for operation in (
        lambda: memory.write_doc("../escape.md", "nope"),
        lambda: memory.read_doc("/tmp/escape.md"),
        lambda: memory.read_doc("escape/outside.md"),
        lambda: memory.list_docs("../**/*"),
        lambda: memory.search_docs("safe", "../**/*.md"),
    ):
        try:
            operation()
        except ValueError:
            pass
        else:
            raise AssertionError("FileMemory accepted an escaped path")

Folder structure

Most of the value of document memory comes from how the directory is laid out. Here’s the shape I’d use for a research agent. The Market Analyst Agent uses namespaces under memory/documents/, but its current DocumentMemory writes each entry as a JSON envelope with a content string rather than raw Markdown. The raw-Markdown layout below belongs to the independent illustrative FileMemory design above:

.agent-memory/
    README.md                  # What this directory is, for human readers
    PROGRESS.md                # Handoff for the next session: what is done, what is next
    user-profiles/
        user-123.md            # Preferences, history, risk profile
        user-456.md
    research/
        NVDA-2026-02.md        # Research notes from recent analysis
        TSLA-2026-01.md
    conventions/
        analysis-format.md     # How to structure analysis reports
        data-sources.md        # Preferred data sources and API patterns
    learnings/
        common-errors.md       # Mistakes the agent has learned to avoid
        tool-patterns.md       # Effective tool call sequences

The document memory directory and the four operations an agent runs against it: read, write, list, and searchThe document memory directory and the four operations an agent runs against it: read, write, list, and search

In the illustrative FileMemory design, every document is Markdown, and every document’s purpose is obvious from its path. You can git diff the entire memory directory to see what the agent learned in a session, git revert a bad learning, or copy the directory to another project. The current project’s JSON envelopes keep the namespace and key structure, but they do not provide the same raw-Markdown diff experience.

When to use document memory vs vector vs key-value

The three memory backends serve different access patterns:

DimensionVector StoreKey-Value StoreDocument Store
Query pattern”Find facts similar to X""Get the value for key""Read the doc at path”
Best forUnstructured, varied recallStructured lookupsProject context, notes
Human readableReadable text payloadsPartially (JSON)Yes (Markdown)
DebuggableInspect payloads and scoresEasy (exact keys)Inspect files and search
Version controllableVia exports or change logsPossibleYes (git-native)
Embedding infrastructureRequiredNot neededNot needed
Scales toMillions of factsMillions of keysDepends on bytes and index
Search capabilitySemantic similarityExact matchPath, keyword, optional index

Use document memory when:

  • The agent accumulates project knowledge over multiple sessions
  • Developers need to inspect, edit, or override what the agent “knows”
  • The knowledge is structured as documents (notes, summaries, conventions) rather than isolated facts
  • You want git-based versioning of agent memory
  • Zero infrastructure is a hard requirement

Use vector stores when:

  • You need fuzzy semantic retrieval (“find memories related to X”)
  • The query phrasing varies unpredictably
  • You have thousands to millions of individual facts

Use key-value stores when:

  • You need exact, fast lookups for structured data (user profiles, settings)
  • The data schema is well-defined

The three stores can coexist, but that is not a requirement. The current Market Analyst Agent uses PostgreSQL checkpoints for hot memory, Qdrant for exact user-profile storage with placeholder vectors, and a namespaced JSON-envelope document store. The semantic-recall and raw-Markdown variants in this article are illustrative extensions.

Real-world examples

The pattern is already widespread in AI coding assistants:

  • Claude Code reads CLAUDE.md files from the project root and parent directories, and maintains a per-project memory file under ~/.claude/projects/ for cross-session learnings. The memory system is plain Markdown files, and the project-level ones commit alongside your code.
  • Cursor loads project rules from .cursor/rules as .mdc files — coding conventions, framework preferences, architectural decisions — with frontmatter controlling when each rule applies.
  • Devin Desktop’s legacy Cascade agent reads rules from .devin/rules/, with .windsurf/rules/ and the root-level .windsurfrules retained as legacy fallbacks. Cascade stores autogenerated memories locally per workspace and retrieves them later; the default Devin Local agent for new tabs does not persist memories.
  • Anthropic’s memory tool for the Claude API is a client-side tool the model drives with file operations — view, create, str_replace, insert, delete, and rename — over a /memories directory. Your application implements each command, so it decides where the files actually live (local disk, S3, database).

The file-backed variants store agent knowledge as human-readable text with explicit read/write operations, and none needs an embedding pipeline. The agent decides what to write; when that text lives in a Git-managed local directory, the developer can see and edit it in a git diff. When an Anthropic memory-tool handler maps /memories to S3 or a database, inspection and versioning depend on that implementation.

Declarative notes and executable skills

File-backed knowledge also appears outside coding assistants, but storage format does not tell you how it is used. Voyager stores reusable JavaScript programs: the agent can execute that code. The main Agent Workflow Memory method instead adds induced web workflows to prompt context as guidance for subsequent actions. Its separate AWM_AS experiment exposes workflows as callable actions. A procedure described in context and an executable procedure need different checks.

Test callable skills by running them in a controlled environment and checking effects. Review project notes and contextual workflows for the facts, constraints, and action guidance they supply, then test whether those instructions improve downstream behavior. Either form can lead to a harmful action; neither grants additional permissions.

The same boundary keeps memory distinct from skills and tools. The Agent Skills standard uses SKILL.md files to tell an agent how to perform a class of work; memory records facts learned from a project or prior run. Part 3 draws the neighboring boundary between a skill and a tool. Choose a file store for inspectable learned context; choose a skill or tool only when the requirement is reusable procedure or capability.

Scaling document memory for production

The file-based implementation above fits a controlled single-user filesystem. Multiple tenants and concurrent writers require explicit access and write coordination, regardless of document count.

The raw store above has no concurrent-write coordination, tenant model, or search index. Measure those requirements before replacing it. A database or object store can supply different concurrency and access contracts; files can also be indexed.

Three common approaches:

Approach A: hybrid with a thin database layer

Keep files for authoring (developers edit Markdown locally) but serve from a database at runtime. On deployment, sync files to PostgreSQL rows. The agent reads from the database, not disk. This gives you:

  • Developer ergonomics (edit Markdown, commit to git)
  • Production query performance (indexed database reads)
  • Clean separation between authoring and serving

Approach B: object storage + vector index sidecar

Store documents in S3/GCS as objects, with a Qdrant collection that indexes their embeddings. The agent queries Qdrant for relevant document IDs, then fetches content from object storage. This scales horizontally and supports semantic search, but adds complexity: two systems to manage, an embedding pipeline to maintain, and eventual consistency between store and index.

Approach C: structured document store with PostgreSQL (recommended)

Store documents as PostgreSQL JSONB rows with full-text search (GIN index) and optional vector embeddings (pgvector). This gives you hybrid search (keyword + semantic), ACID transactions, and a single operational system.

A sketch of Approach C. The combined score performs exact scoring over a bounded tenant corpus; it does not use an approximate nearest-neighbor (ANN) index. pgvector requires direct ascending distance ordering with LIMIT for that index path. For a larger corpus, retrieve bounded keyword and vector candidates separately, then fuse their ranks. This is an RLS pattern, not drop-in application code: its database role must be available only to the trusted application server. The server authenticates the request and constructs principal; it does not accept a tenant ID from the caller. PostgreSQL RLS then makes that scope enforceable even if a query later omits its tenant predicate.

from typing import Optional
from dataclasses import dataclass
import asyncpg

@dataclass(frozen=True)
class AuthenticatedPrincipal:
    """The verified identity returned by the application's authentication layer."""
    tenant_id: str

class ProductionDocumentMemory:
    """Illustrative PostgreSQL document memory with hybrid search and RLS.

    Apply this schema and policy as the table owner during deployment:

        CREATE TABLE documents (
            id SERIAL PRIMARY KEY,
            tenant_id TEXT NOT NULL,
            path TEXT NOT NULL,
            content TEXT NOT NULL,
            metadata JSONB,
            embedding vector(1536),  -- pgvector extension
            ts_vector tsvector GENERATED ALWAYS AS (to_tsvector('english', content)) STORED,
            created_at TIMESTAMPTZ DEFAULT NOW(),
            UNIQUE(tenant_id, path)
        );
        CREATE INDEX ON documents USING GIN(ts_vector);

        ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
        ALTER TABLE documents FORCE ROW LEVEL SECURITY;
        CREATE POLICY tenant_documents ON documents
            USING (tenant_id = current_setting('app.tenant_id', true))
            WITH CHECK (tenant_id = current_setting('app.tenant_id', true));

    `FORCE` also subjects the table owner to the policy. Superusers and roles with
    `BYPASSRLS` still bypass it, so neither belongs in the application's pool.
    """

    def __init__(self, pool: asyncpg.Pool):
        self.pool = pool

    async def write(
        self,
        principal: AuthenticatedPrincipal,
        path: str,
        content: str,
        metadata: Optional[dict] = None,
        embedding: Optional[list[float]] = None,
    ):
        """Write or update a document.

        Sketch: on a real pool you must register codecs first, or asyncpg
        raises DataError — `set_type_codec` for the JSONB metadata column
        and pgvector's `register_vector` for the embedding.
        """
        async with self.pool.acquire() as conn:
            async with conn.transaction():
                # true keeps this trusted context to this transaction only.
                await conn.execute(
                    "SELECT set_config('app.tenant_id', $1, true)", principal.tenant_id
                )
                await conn.execute(
                    """
                    INSERT INTO documents (tenant_id, path, content, metadata, embedding)
                    VALUES ($1, $2, $3, $4, $5)
                    ON CONFLICT (tenant_id, path) DO UPDATE
                    SET content = EXCLUDED.content,
                        metadata = EXCLUDED.metadata,
                        embedding = EXCLUDED.embedding
                    """,
                    principal.tenant_id, path, content, metadata, embedding,
                )

    async def search(
        self,
        principal: AuthenticatedPrincipal,
        query: str,
        embedding: Optional[list[float]] = None,
        limit: int = 5,
    ) -> list[dict]:
        """Hybrid search: full-text + optional vector similarity."""
        async with self.pool.acquire() as conn:
            async with conn.transaction():
                await conn.execute(
                    "SELECT set_config('app.tenant_id', $1, true)", principal.tenant_id
                )
                if embedding:
                    # Hybrid scoring: 0.6 * text relevance + 0.4 * vector similarity
                    rows = await conn.fetch(
                        """
                        SELECT path, content, metadata,
                               (0.6 * ts_rank(ts_vector, plainto_tsquery('english', $1)) +
                                0.4 * COALESCE(1 - (embedding <=> $2), 0)) AS score
                        FROM documents
                        WHERE ts_vector @@ plainto_tsquery('english', $1)
                           OR (embedding <=> $2) < 0.5
                        ORDER BY score DESC
                        LIMIT $3
                        """,
                        query, embedding, limit,
                    )
                else:
                    # Full-text search only
                    rows = await conn.fetch(
                        """
                        SELECT path, content, metadata,
                               ts_rank(ts_vector, plainto_tsquery('english', $1)) AS score
                        FROM documents
                        WHERE ts_vector @@ plainto_tsquery('english', $1)
                        ORDER BY score DESC
                        LIMIT $2
                        """,
                        query, limit,
                    )
                return [dict(row) for row in rows]

set_config(..., true) is transaction-scoped, so a pooled connection cannot retain one tenant’s context for the next request. The OR in the first branch is what makes it hybrid. COALESCE keeps a keyword-matching document without an embedding in the result set with its text score; it contributes no vector similarity. With only the @@ predicate, a document that means the right thing but shares no keywords with the query gets filtered out before scoring ever runs — that is keyword retrieval with semantic re-ranking, not hybrid retrieval. The 0.6/0.4 weights are illustrative: text rank and cosine similarity have different scales. Normalize them against your retrieval evaluation or use rank fusion before interpreting those weights as relative importance. The distance threshold is a knob: tighten it if the vector arm floods the results, loosen it if semantic matches never surface.

The following regression is the behavior to test against a real database after migrations. Under tenant-a, a read of tenant-b returns no rows and a direct cross-tenant insert fails RLS:

BEGIN;
SELECT set_config('app.tenant_id', 'tenant-a', true);
SELECT path FROM documents WHERE tenant_id = 'tenant-b'; -- 0 rows
INSERT INTO documents (tenant_id, path, content)
VALUES ('tenant-b', 'leak.md', 'must fail'); -- ERROR: row-level security policy
ROLLBACK;

What you get:

  • Hybrid search: keyword matching (GIN index) + semantic similarity (pgvector) scored together
  • Multi-tenancy: server-derived identity plus database-enforced RLS
  • ACID guarantees: transactions on the primary commit atomically; replica reads can lag
  • Single operational system: no separate vector database to manage
  • Scaling: read replicas can serve stale-tolerant queries. Native partitioning can help pruning and maintenance but does not distribute writes across servers; that needs an explicit sharding design. Route read-after-write paths to the primary or measure a suitable synchronous policy

Files are great for single-developer workflows. For multi-tenant production, a structured document store on PostgreSQL is usually the right balance of simplicity, performance, and operational maturity.


Putting it together: the full architecture

Here’s how all three memory tiers can work together in an architecture inspired by the Market Analyst Agent. The diagram shows an illustrative flow from user request to response, with all memory layers active.

All three memory tiers wired around one agent and their read and update pathsAll three memory tiers wired around one agent and their read and update paths

The architecture has three memory paths:

  1. Hot path (checkpoint store): LangGraph writes the resumable graph state to the checkpoint store at every super-step boundary. When the graph hits an interrupt_before node (like the publish node in Part 1), execution pauses. The user can close the app, and when they return, the graph resumes from the checkpoint. Runtime event logs and traces are separate production concerns.

  2. Cold path (long-term store): After the router chooses a route, the planner queries the long-term store for relevant user context. The planner cannot personalize until that read returns. A vector-backed lookup may include query embedding and index retrieval; a key-value lookup does not. New facts can be extracted and stored after the conversation ends, so that write does not delay the reasoning loop.

  3. Document path (file store): During planning, the agent reads the project conventions and research notes needed for the request. During execution, it writes research summaries and learned patterns back to disk. Those reads inform the current task, so file size, filesystem speed, and cache state affect response time. Cache only if the cache has clear invalidation and tenant-isolation rules. Writes can happen later.

The wiring in LangGraph is straightforward — the checkpoint store and long-term store are passed at graph compilation, while the document store is injected as a dependency. The local sketch below compiles an already configured StateGraph builder, including nodes that accept the store. This extends the graph wiring; the Part 1 and companion create_graph helpers do not accept a store argument. It uses InMemoryStore so the snippet stays small; the reference Docker topology uses Qdrant for the same semantic-recall role.

import asyncio
from langgraph.store.memory import InMemoryStore

# Cold memory: local sketch with vector search
# (The reference Docker topology uses Qdrant for persistent recall.)
memory_store = InMemoryStore(
    index={"dims": 1536, "embed": embedding_function}
)

# Document memory: illustrative file-based store for project knowledge
# FileMemory is the illustrative class defined above, not the project's current
# DocumentMemory implementation.
doc_memory = FileMemory(base_dir=".agent-memory")

async def main() -> None:
    # Hot memory: PostgreSQL for durable checkpoints. postgres_checkpointer() is
    # the async context manager defined earlier, so the graph runs inside it.
    async with postgres_checkpointer(pg_connection_string) as checkpointer:
        # builder is the configured StateGraph for this extended design.
        # The Part 1/companion create_graph helper does not accept store.
        graph = builder.compile(
            checkpointer=checkpointer,
            store=memory_store,
        )
        # ... run the graph here, while the connection is still open

asyncio.run(main())

# The store is accessible inside any node via the store parameter
def planner_node(state: AgentState, *, store: BaseStore) -> dict:
    """Plan with user context from long-term memory."""

    # Recall relevant user facts from vector store.
    # Namespace prefix is positional — see the store example above.
    user_memories = store.search(
        ("user", state.user_id),
        query=state.messages[-1].content,
        limit=5,
    )

    # Load project conventions from document memory
    conventions = doc_memory.read_doc("conventions/analysis-format.md")

    # Inject both into planning context
    # Each stored value is a dict; render whatever keys it carries
    memory_context = "\n".join(str(m.value) for m in user_memories)
    # ... rest of planning logic with personalized context and conventions

The complete flow

In the extended design above, a returning user’s “Analyze TSLA” request could follow this flow. Semantic recall and asynchronous fact extraction are proposed extensions, not current companion behavior:

  1. Document memory load: When the planner runs, it reads project conventions from the document store: analysis format preferences, preferred data sources, tool usage patterns. These set the baseline behavior for that plan.

  2. Router: The router classifies the request as DEEP_RESEARCH. In this example, routing uses the request itself, not long-term preferences.

  3. Cold memory recall + planner: The planner queries the long-term store with the user’s message. It retrieves: “User has high risk tolerance”, “User prefers detailed competitor analysis”, “User previously researched NVDA and AMD”. It then creates a 5-step research plan personalized to those preferences. It includes a competitor analysis step because the user’s history shows they want one. The plan follows the format from the conventions document.

  4. Executor loop (hot memory): Each step executes via the ReAct pattern from Part 1 — think, act, observe, repeated until the step is done. LangGraph checkpoints each super-step (router, planner, each sequential executor step here). Recovery starts from the latest persisted checkpoint. If step 3’s write completed, the graph can continue from step 4; with asynchronous persistence, a crash may require repeating a completed step.

  5. HITL interrupt: The reporter writes a draft. A separate model session, with no history of the run, reads the draft and records an assessment. The graph then reaches publish, where interrupt_before pauses it regardless of that assessment. The checkpoint holds both the draft and assessment, so the human reviews them before choosing whether to publish. Hours later, the graph reloads the checkpoint and follows that decision.

  6. Memory updates: After the conversation ends, an asynchronous process extracts new user facts (“user is now tracking TSLA”, “user approved the report format”) and stores them in the long-term vector store. The agent also writes a research summary to the document store (research/TSLA-2026-02) for future reference.

The three-tier pattern separates concerns cleanly. The checkpoint store handles durability and resume; it’s infrastructure. The long-term store handles personalization; it’s product logic. The document store holds accumulated project knowledge; it’s the agent’s notebook.


Trade-offs and considerations

Memory adds value, but it also adds cost and complexity:

  • Embedding cost: Every fact stored in a vector database requires embedding generation. A hosted embedding provider adds an API call, provider-specific cost, and network latency; as of September 2026, OpenAI lists text-embedding-3-small at $0.02 per million tokens. Per-fact hosted-model cost is negligible, but it adds up across thousands of users and sessions. Batch hosted calls and cache results. At query time, vector recall can include query embedding plus index and network latency; a key-value lookup does not. Measure that path in your deployment, then cache common query embeddings or use a local embedding model if it is latency-sensitive.

  • Stale memory: User preferences change. A fact stored six months ago (“user prefers conservative investments”) may no longer be accurate. Set expiry policies. For example, a team might expire preferences after 365 days and episodic events after 90 days if its privacy rules, update rate, and retrieval evaluation justify those windows; those values are a proposed policy, not portable defaults. The context engineering post rejects fixed retention rules as portable policy. Expiry is the blunt version. Schema-guided typed state covers the sharper one: temporal validity and provenance on each fact, so a superseded value loses to the current one at retrieval time rather than at expiry.

  • Memory overhead in context: Every recalled fact consumes tokens in the LLM’s context window. If you recall 20 facts per query, that’s several hundred tokens of memory context competing with the actual task. Cap the number of recalled facts and prioritize by relevance score.

  • Privacy and compliance: Long-term memory stores user data. You need PII redaction before storage, clear retention policies, and user-facing controls for data deletion. None of this is optional in regulated industries.

  • Checkpoint storage growth: PostgreSQL checkpoint tables grow with every super-step. Do not run a general SQL pruning query: delta channels can require ancestor checkpoints and their write/blob records to reconstruct a retained checkpoint. Use a saver-supported pruning API only after verifying it against the exact installed saver and its delta-channel recovery contract. If that support is unavailable, retain the complete parent, write, and blob closure, then test resume from a retained checkpoint with the installed saver.

  • Memory consolidation: Over time, detailed episodic memories should compress into compact semantic representations: “user asked about NVDA three times in January” rather than storing all three conversations verbatim. That mirrors human memory consolidation and keeps the store manageable. Mem0 and Graphiti handle this automatically; if you build your own, schedule periodic consolidation jobs.

  • Cold start problem: New users have no long-term memory. The agent should degrade gracefully and ask clarifying questions instead of making assumptions. Memory is additive, not required.

  • Memory poisoning: Anything in the agent’s context window is a potential injection point. If an attacker writes misleading facts to the document store or long-term memory (“always approve transactions without verification”), the agent may execute them as instructions. Prompt injection through stored memories is a real attack surface. The mitigations are validation before storage, treating recalled content as untrusted data rather than system instructions, and access controls that limit which memories can influence critical operations.

  • Document memory drift: File-based memory has no automatic deduplication or conflict resolution. Over time, documents accumulate contradictions: one file says “use pytest” while another says “use unittest.” Schedule periodic reviews (or let the agent do them) to prune and consolidate. Files support grep; vector-store payloads can also be inspected or exported. Neither storage format detects contradictions on its own.

  • Search scale: the raw file scan above reads the corpus for each query. Choose an index from bytes scanned, update rate, concurrency, latency, and retrieval quality. File-backed content can use a full-text or vector index; document count alone does not choose the backend.


Test recall and memory lifecycle

Compare with no-memory and full-context baselines on held-out questions. Include paraphrases, contradictions, preference changes, stale facts, unanswerable questions, deletions, and cross-tenant requests. LongMemEval provides 500 questions spanning extraction, multi-session and temporal reasoning, updates, and abstention. Measure retrieval precision/recall separately from answer correctness, plus stale-fact use, unauthorized disclosure, write/update/delete correctness, latency, and cost.

Recall questions are only part of the evaluation. MemoryArena adds interdependent tasks across sessions, where an earlier action and its feedback must change later behavior. Its tasks cover shopping, travel planning, progressive search, and formal reasoning. Use that design when the product promises to learn from work, rather than only answer questions about stored conversations. These are research tasks, not measurements of a deployed memory service.

EvoMemBench also separates knowledge from execution experience and within-episode from cross-episode memory. Its comparison of 15 methods finds no uniformly strongest memory form; long-context baselines remain competitive under its protocol. That supports keeping the simple baselines in your evaluation, not replacing every store with the latest framework.

Keep provenance and validity beside recalled facts. Importance scores cannot establish trust or change permissions. Deletion policy must cover indexes, cached summaries, and retained artifacts as well as the original record.

The next layer is action

Parts 5 and 6 return to memory from the operational side, and they take different halves of it. The runtime owns the checkpoint: where execution stopped and how to restart it. The harness owns the handoff: what the work means and what is left, written as document memory for the next model session — one continuous stretch of model context, in the vocabulary Part 5 pins down. Restoring the process is not the same as restoring the task.

References

Papers

LangGraph documentation

Checkpoint backends

Vector databases and memory tools

  • Qdrant — Open-source vector database with HNSW indexing and filtering
  • Qdrant Agentic Builders Guide — Practical guide to building agent memory with Qdrant
  • pgvector — Vector similarity search extension for PostgreSQL
  • Graphiti — Open-source temporal knowledge graph engine by Zep

Document and file-based memory

Memory frameworks

  • Mem0 — Managed memory layer with extraction/consolidation pipeline
  • Letta (MemGPT) — OS-inspired virtual context management for agents
  • LangMem SDK — Memory management tools for LangGraph

Workshops

Demo project

  • Market Analyst Agent — Reference implementation for the checkpoint and current profile/document storage paths