LangGraph Persistent Memory: Beyond MemorySaver

LangGraph's MemorySaver stores state between runs — but it can't search it semantically, decay stale facts, or share memory across graphs. Here's how to fix that.

The Problem with LangGraph's Built-In Memory

LangGraph ships with checkpoint savers — InMemorySaver, SqliteSaver, PostgresSaver — that persist a graph's full state between invocations. They solve checkpointing (resuming a graph after a crash) and short-term continuity within a thread. But they weren't designed as memory systems.

When you ask a LangGraph agent "what did we work on last week?", a checkpoint saver has no way to answer: it stores structured JSON snapshots, not retrievable episodic memory. You can't search across threads. You can't weight recent interactions more heavily. You can't extract entities from conversation history and build a knowledge graph. And stale state from months ago is just as present as what happened yesterday.

The result is a ceiling. Agents built entirely on LangGraph's built-in persistence tend to degrade over time: the context window fills with irrelevant state, older decisions are forgotten, and multi-agent graphs have no shared memory layer.

What Checkpointing Misses

Checkpoint savers answer: "what was the exact state of this graph thread at this point in time?" That's the right answer for graph resumption. It's the wrong answer for agent memory.

Agent memory needs to answer: "what is relevant to know right now, given this query?" The two requirements diverge quickly:

CapabilityLangGraph MemorySaverDakera
Persist graph state between runs✓ Core feature✓ via store node
Semantic search across memories✓ Hybrid BM25 + HNSW
Cross-thread memory✗ Thread-scoped only✓ Agent-scoped, namespace-isolated
Memory decay (stale-fact aging)✓ Configurable decay rates
Knowledge graph (entity linking)✓ Auto-extracted entities + traversal
Multi-agent shared memory✓ Cross-agent recall with namespaces
Graph resume / checkpointing✓ Core featureNot designed for this
Temporal graph replay

The right architecture uses both: LangGraph's checkpoint saver for graph lifecycle management, and Dakera for semantic long-term memory. They operate at different layers and don't overlap.

Architecture: Two Memory Layers

Think of it as two separate concerns running in parallel:

┌─────────────────────────────────────────────────┐
│                 LangGraph StateGraph             │
│                                                  │
│  ┌──────────┐    ┌──────────┐    ┌──────────┐  │
│  │  recall  │───▶│  agent   │───▶│  store   │  │
│  │   node   │    │   node   │    │   node   │  │
│  └──────────┘    └──────────┘    └──────────┘  │
│       │                                │         │
│       └──────── Dakera MCP/SDK ────────┘         │
│                       │                          │
│   SqliteSaver / PostgresSaver (checkpoints)       │
└───────────────────────┼──────────────────────────┘
                        │
              ┌─────────▼─────────┐
              │   Dakera Server   │
              │  (self-hosted)    │
              │                  │
              │  • HNSW index    │
              │  • BM25 index    │
              │  • Knowledge     │
              │    graph         │
              │  • Decay engine  │
              └──────────────────┘

The graph's recall node queries Dakera at the start of each invocation, injecting relevant long-term context into the LangGraph state. The store node writes key outcomes back to Dakera at the end. The checkpoint saver still handles state snapshots for thread continuity — it just doesn't need to store everything.

Setup: Dakera + LangGraph

Start the Dakera server via Docker:

docker run -d --name dakera \
  -p 3300:3300 \
  -e DAKERA_API_KEY=dk-your-key \
  -v dakera-data:/data \
  ghcr.io/dakera-ai/dakera:latest

Install the Python packages:

pip install dakera langgraph langchain-anthropic

Create a shared Dakera client:

import os
from dakera import DakeraClient

dakera = DakeraClient(
    base_url=os.environ.get("DAKERA_URL", "http://localhost:3300"),
    api_key=os.environ.get("DAKERA_API_KEY", ""),
)

Basic Integration — Memory Nodes in StateGraph

Here's the core pattern: a TypedDict state that includes a memories field, populated by a recall node before the agent runs, and written back by a store node after.

from typing import TypedDict, Annotated, List
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.sqlite import SqliteSaver
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage

class AgentState(TypedDict):
    messages: Annotated[List, "conversation messages"]
    memories: Annotated[List[str], "recalled long-term memories"]
    agent_id: str

llm = ChatAnthropic(model="claude-sonnet-5")

def recall_node(state: AgentState) -> dict:
    """Query Dakera for memories relevant to the latest user message."""
    messages = state["messages"]
    if not messages:
        return {"memories": []}

    # Use the last user message as the recall query
    last_human = next(
        (m.content for m in reversed(messages) if isinstance(m, HumanMessage)),
        None
    )
    if not last_human:
        return {"memories": []}

    results = dakera.search_memories(
        agent_id=state["agent_id"],
        query=last_human,
        top_k=5,
        min_score=0.3,
    )
    memories = [r["content"] for r in results]
    return {"memories": memories}

def agent_node(state: AgentState) -> dict:
    """Run the LLM with long-term memories injected into context."""
    system_parts = ["You are a helpful assistant with persistent memory."]
    if state["memories"]:
        memory_block = "\n".join(f"- {m}" for m in state["memories"])
        system_parts.append(f"\nRelevant memories from previous sessions:\n{memory_block}")

    response = llm.invoke([
        SystemMessage(content="\n".join(system_parts)),
        *state["messages"],
    ])
    return {"messages": state["messages"] + [response]}

def store_node(state: AgentState) -> dict:
    """Store the AI response and any key facts into Dakera."""
    messages = state["messages"]
    # Find the last AI message to persist
    last_ai = next(
        (m for m in reversed(messages) if isinstance(m, AIMessage)),
        None
    )
    if last_ai:
        last_human = next(
            (m.content for m in reversed(messages[:-1]) if isinstance(m, HumanMessage)),
            ""
        )
        dakera.store_memory(
            agent_id=state["agent_id"],
            content=f"Q: {last_human[:200]} A: {str(last_ai.content)[:400]}",
            importance=0.7,
        )
    return {}  # No state change — side-effect only

# Build the graph
builder = StateGraph(AgentState)
builder.add_node("recall", recall_node)
builder.add_node("agent", agent_node)
builder.add_node("store", store_node)

builder.set_entry_point("recall")
builder.add_edge("recall", "agent")
builder.add_edge("agent", "store")
builder.add_edge("store", END)

# Use SqliteSaver for thread checkpointing (short-term state)
memory = SqliteSaver.from_conn_string(":memory:")
graph = builder.compile(checkpointer=memory)

Invoke it with a thread ID and agent ID:

config = {"configurable": {"thread_id": "user-alice-session-1"}}

result = graph.invoke(
    {
        "messages": [HumanMessage(content="I prefer Python over TypeScript for backend work")],
        "memories": [],
        "agent_id": "assistant-alice",
    },
    config=config,
)

# Second invocation — Dakera will recall the preference
result2 = graph.invoke(
    {
        "messages": [HumanMessage(content="What language should I use for this new API?")],
        "memories": [],
        "agent_id": "assistant-alice",
    },
    config={"configurable": {"thread_id": "user-alice-session-2"}},  # New thread
)
print(result2["messages"][-1].content)
# "Based on your preference for Python for backend work, I'd recommend Python..."

Notice the second invocation uses a different thread ID — the SqliteSaver has no memory of session 1, but Dakera does. This is the gap checkpointing can't bridge.

Recall Node: Semantic Search at Graph Entry

The recall node is the most important piece. A naive implementation searches only on the latest message, but richer queries produce better results:

def recall_node_advanced(state: AgentState) -> dict:
    """Multi-strategy recall: semantic + recent + session context."""
    messages = state["messages"]
    agent_id = state["agent_id"]
    all_memories = []
    seen_ids = set()

    # 1. Semantic search on latest query
    if messages:
        last_human = next(
            (m.content for m in reversed(messages) if isinstance(m, HumanMessage)),
            None
        )
        if last_human:
            results = dakera.search_memories(
                agent_id=agent_id,
                query=last_human,
                top_k=5,
                min_score=0.35,
            )
            for r in results:
                if r["id"] not in seen_ids:
                    all_memories.append(r["content"])
                    seen_ids.add(r["id"])

    # 2. Pull the 3 most recent memories (continuity signal)
    recent = dakera.list_memories(
        agent_id=agent_id,
        limit=3,
        sort="created_at",
        sort_dir="desc",
    )
    for r in recent:
        if r["id"] not in seen_ids:
            all_memories.append(r["content"])
            seen_ids.add(r["id"])

    return {"memories": all_memories[:8]}

Cap the total to avoid filling the context window. Eight to twelve memories is typically the right balance for a 200K-token context model.

Store Node: Persisting Conversation Outcomes

Not every exchange is worth storing. A good store node filters for signal:

import re

# Phrases that signal storable facts
STORE_SIGNALS = [
    r"\bI prefer\b", r"\bmy \w+ is\b", r"\bI always\b",
    r"\bwe decided\b", r"\bthe architecture\b", r"\bthe deadline\b",
    r"\bremember that\b", r"\bdon't forget\b",
]

def should_store(text: str) -> bool:
    return any(re.search(p, text, re.IGNORECASE) for p in STORE_SIGNALS)

def store_node_smart(state: AgentState) -> dict:
    messages = state["messages"]
    agent_id = state["agent_id"]

    # Store user messages containing explicit facts or preferences
    for msg in messages:
        if isinstance(msg, HumanMessage) and should_store(msg.content):
            dakera.store_memory(
                agent_id=agent_id,
                content=msg.content,
                importance=0.85,
                metadata={"source": "user_explicit"},
            )

    # Store any AI message that contains a decision or recommendation
    last_ai = next(
        (m for m in reversed(messages) if isinstance(m, AIMessage)),
        None
    )
    if last_ai and should_store(str(last_ai.content)):
        dakera.store_memory(
            agent_id=agent_id,
            content=f"AI recommendation: {str(last_ai.content)[:500]}",
            importance=0.6,
            metadata={"source": "ai_response"},
        )

    return {}

Higher importance scores surface more readily in future recalls and decay more slowly. User-explicit facts should score 0.8–1.0; AI-generated summaries are typically 0.5–0.7.

Cross-Session Context in Practice

The value of this architecture becomes clear across a realistic multi-session workflow. Here's a coding assistant scenario spanning three days:

# Session 1 — User establishes context
invoke_graph("assistant-dev", "We're building a Rust CLI with clap 4 and tokio 1.38")
invoke_graph("assistant-dev", "We use anyhow for errors, not thiserror — team preference")
invoke_graph("assistant-dev", "The deploy target is a musl binary for Alpine Linux")

# Session 2 (next day) — New thread, Dakera recalls everything
result = invoke_graph(
    "assistant-dev",
    "I need to add a subcommand for exporting data as CSV"
)
# AI response will reference clap 4 syntax, anyhow, musl constraints
# without the user re-stating any of it

# Session 3 (two days later) — Querying decisions
result = invoke_graph(
    "assistant-dev",
    "What error handling approach are we using?"
)
# Recalls: "We use anyhow for errors, not thiserror — team preference"

Without Dakera, session 2 and session 3 start cold. The user re-explains the stack every time. With Dakera, the semantic recall surfaces the three stored facts in under 10ms.

Knowledge Graph: Entity Linking Across Runs

Dakera automatically extracts entities from stored memories and builds a traversable knowledge graph. For a coding assistant, this means entities like clap, tokio, anyhow, and relationships like uses, prefers, targets.

# After a few sessions, query the knowledge graph
entities = dakera.knowledge_graph(
    agent_id="assistant-dev",
    query="project dependencies",
    depth=2,
)

for node in entities["nodes"]:
    print(f"{node['name']} ({node['type']})")
# clap (library) — relationship: uses
# tokio (library) — relationship: uses
# anyhow (library) — relationship: prefers
# Alpine Linux (platform) — relationship: targets

# Graph traversal: what do we know about 'anyhow'?
related = dakera.graph_traverse(
    agent_id="assistant-dev",
    entity="anyhow",
    max_depth=2,
)
# Returns: team preference, error handling context, connected entities

Inject the knowledge graph into the LangGraph state alongside raw memories for richer context:

class AgentStateWithGraph(TypedDict):
    messages: Annotated[List, "messages"]
    memories: Annotated[List[str], "recalled memories"]
    entities: Annotated[List[str], "entity context from knowledge graph"]
    agent_id: str

def recall_node_with_graph(state: AgentStateWithGraph) -> dict:
    last_human = next(
        (m.content for m in reversed(state["messages"]) if isinstance(m, HumanMessage)),
        ""
    )
    memories_result = dakera.search_memories(
        agent_id=state["agent_id"],
        query=last_human,
        top_k=5,
    )
    graph_result = dakera.knowledge_graph(
        agent_id=state["agent_id"],
        query=last_human,
        depth=1,
    )
    entity_summaries = [
        f"{n['name']} ({n['type']}): {n.get('summary', '')}"
        for n in graph_result.get("nodes", [])[:6]
    ]
    return {
        "memories": [r["content"] for r in memories_result],
        "entities": entity_summaries,
    }

Memory Decay: Keeping Context Fresh

Without decay, memory stores accumulate stale facts indefinitely. A user's technology preferences from two years ago shouldn't have the same weight as last week's decision. Dakera's decay engine scores each memory based on recency and access frequency.

# Configure decay policy for this agent
dakera.set_memory_policy(
    agent_id="assistant-dev",
    policy={
        "decay_enabled": True,
        "half_life_days": 30,          # Score halves every 30 days
        "min_importance_to_decay": 0.3, # Don't decay high-value memories quickly
        "access_boost": 0.1,            # Each recall boosts score by 10%
    }
)

This means frequently-recalled facts (the user's core preferences, key architecture decisions) stay surface-level while old one-off exchanges decay gracefully. The semantic search automatically returns higher-scored memories first.

To exclude decayed memories from recall:

results = dakera.search_memories(
    agent_id="assistant-dev",
    query="error handling",
    top_k=5,
    min_score=0.25,  # Filter out very stale memories (score < 0.25)
)

Multi-Agent Memory Sharing

LangGraph multi-agent graphs — where a supervisor dispatches to specialized subgraphs — benefit from shared memory with per-agent namespaces:

# Supervisor graph and two specialized agents share a common namespace
SHARED_NS = "team-project-shared"
PLANNER_NS = "agent-planner"
REVIEWER_NS = "agent-reviewer"

def planner_recall(state):
    # Search both planner-private and shared namespaces
    private = dakera.search_memories(agent_id=PLANNER_NS, query=state["task"], top_k=3)
    shared = dakera.search_memories(agent_id=SHARED_NS, query=state["task"], top_k=3)
    return {"memories": [r["content"] for r in private + shared]}

def reviewer_recall(state):
    # Reviewer sees its own memory + shared context
    private = dakera.search_memories(agent_id=REVIEWER_NS, query=state["task"], top_k=3)
    shared = dakera.search_memories(agent_id=SHARED_NS, query=state["task"], top_k=3)
    return {"memories": [r["content"] for r in private + shared]}

def planner_store(state):
    # Key planning decisions go to shared namespace for reviewer to see
    last_ai = next((m for m in reversed(state["messages"]) if isinstance(m, AIMessage)), None)
    if last_ai:
        dakera.store_memory(
            agent_id=SHARED_NS,
            content=f"Planner decision: {str(last_ai.content)[:400]}",
            importance=0.8,
            metadata={"source_agent": "planner"},
        )
    return {}

This is the pattern that enables true multi-agent collaboration: the reviewer can see what the planner decided without being in the same graph invocation or thread.

Production Deployment

For production LangGraph + Dakera deployments:

Asynchronous Store Node

The store node is a side-effect and doesn't affect the response. Run it asynchronously to avoid adding latency to the graph's critical path:

import asyncio
from concurrent.futures import ThreadPoolExecutor

executor = ThreadPoolExecutor(max_workers=4)

def store_node_async(state: AgentState) -> dict:
    """Fire-and-forget store — doesn't block graph completion."""
    messages = state["messages"]
    agent_id = state["agent_id"]

    def _store():
        last_ai = next((m for m in reversed(messages) if isinstance(m, AIMessage)), None)
        if last_ai and should_store(str(last_ai.content)):
            dakera.store_memory(
                agent_id=agent_id,
                content=str(last_ai.content)[:500],
                importance=0.6,
            )

    executor.submit(_store)
    return {}

Using the MCP Interface

If you're deploying LangGraph with a Claude model via Claude Code or Cursor, you can skip the Python SDK entirely and use Dakera's MCP server. The agent calls dakera_recall and dakera_store as MCP tools directly — no recall/store nodes needed. See the MCP Server docs for configuration.

Connection Pooling

# Initialize once at module level, reuse across all graph invocations
dakera = DakeraClient(
    base_url=os.environ["DAKERA_URL"],
    api_key=os.environ["DAKERA_API_KEY"],
    timeout=5.0,      # Fast timeout — don't stall the graph on memory failure
    max_retries=1,    # One retry; don't queue up on a slow memory server
)

Graceful Degradation

def recall_node_safe(state: AgentState) -> dict:
    """Return empty memories on Dakera failure — graph continues."""
    try:
        results = dakera.search_memories(
            agent_id=state["agent_id"],
            query=state["messages"][-1].content if state["messages"] else "",
            top_k=5,
        )
        return {"memories": [r["content"] for r in results]}
    except Exception:
        return {"memories": []}  # Degrade gracefully, don't fail the graph

When to Use What

Use LangGraph's checkpoint savers for:

Use Dakera for:

They're complementary. A production LangGraph application should typically use both: a checkpoint saver for graph lifecycle, and Dakera for the semantic memory layer that makes agents feel genuinely knowledgeable across time.

Related: LangChain integration · Multi-Agent Memory Patterns · Dakera Python SDK · Temporal Memory & Decay · Quick Start

Build with Dakera

Give your AI agents persistent memory — self-hosted, production-ready, zero dependencies.

Stay sharp on agent memory
Benchmark releases, engineering deep-dives, and product updates. Once a week max, no fluff.
✓ Subscribed. Thanks!