Long-term Memory
In shortHow to build agents that learn and remember across conversations using semantic memory storage and retrieval.
- 10 min read
- 12 sections
- Updated
- v0.10.0
- Markdown
Your agent needs to remember facts across conversations: a user’s preferences, resolved questions, learned skills. The memory store makes this possible by providing a semantic, searchable database of memories that persists independently of the thread. It works alongside the checkpointer to create a two-level recall system: fast thread-specific state and cross-thread semantic knowledge.
Why long-term memory matters
As an agent serves multiple users and conversations, it accumulates context that is too specific or expensive to carry in every message:
- A user mentioned their timezone once; future conversations should know it.
- You discovered an error-handling workaround; later conversations should learn from it.
- You extracted a fact from a document; it should be available without re-uploading.
The checkpointer solves this within a thread (conversation continuity), but it cannot share knowledge across threads. The memory store adds that cross-thread layer, enabling agents to learn from experience and personalize behavior over time.
How it works: memory store vs checkpointer
The checkpointer saves your full agent state (messages, context, custom fields) to resume a thread. The memory store saves individual semantic memories you want to recall across threads. They solve different problems:
| Aspect | Checkpointer | Memory store |
|---|---|---|
| Scope | One thread | Cross-thread, cross-user |
| Content | Full AgentState snapshot |
Individual semantic records |
| Lifetime | Until thread is deleted | Until explicitly deleted |
| Access | By thread_id |
By semantic similarity |
| Use case | Conversation continuity | Facts, preferences, knowledge |
For example, if a user says “I’m in UTC-5”, the checkpointer remembers it for that thread. The memory store lets you save it (to find later) and retrieve it in any future thread when the agent asks “What timezone is the user in?”
How the agent uses memory
The LLM doesn’t passively receive memories (except in preload mode); it calls a tool to work with them. There are two ways to get these tools, and they differ:
| Setup | Tools the model sees |
|---|---|
Agent(memory=MemoryConfig(...)) in postload mode |
user_memory_tool (action="search" or "remember", with text) and, if agent memory is enabled, the read-only agent_memory_tool (query) |
MemoryIntegration(...).tools |
A single memory_tool with action="store", "search", "update" or "delete" |
With user_memory_tool the model never supplies memory ids; the memory layer manages identity and deduplication.
The memory_tool flow looks like this:
LLM thinks: "I should remember this timezone"
↓
Calls: memory_tool(action="store", content="User is in UTC-5", memory_key="user_timezone")
↓
Result: {"status": "scheduled", "action": "store"}
↓
Future thread: LLM calls memory_tool(action="search", query="user timezone")
↓
Result: JSON list of matches, retrieved by semantic similarity, not keyword lookupDeduplication (memory_tool): if you call store with the same memory_key, the old record is updated rather than duplicated. When no key is given, a similarity check (score of 0.95 or higher) skips near-identical content.
Non-blocking writes: memory writes run in the background via BackgroundTaskManager, so they never delay the agent’s response. A search first waits for pending writes to finish.
Retrieval modes: when do memories reach the LLM?
The framework supports three strategies (no_retrieval, preload, postload) for when the LLM sees memories. MemoryConfig defaults to postload; MemoryIntegration defaults to no_retrieval. Choose based on how much memory context you want injected, and whether you prefer the agent to drive retrieval:
No retrieval (default)
The LLM cannot read past memories. With MemoryIntegration, it can still write them through memory_tool. With Agent(memory=MemoryConfig(retrieval_mode="no_retrieval")), no memory tools are exposed.
When to use: Your agent is mostly stateless, or memories are only for logging/analytics.
| Pros | Cons |
|---|---|
| Fast; no store queries | No cross-conversation learning |
| Simple; minimal overhead | Agent can’t retrieve past context |
Preload
Relevant memories are retrieved automatically and injected as a system message before the LLM runs.
How it works:
- The user’s latest message is used as the search query (override it with
query_builderonMemoryIntegration). - Memories matching that query (by semantic similarity) are fetched.
- They are injected as a system message that starts with
[Long-term Memory Context]. - The LLM then sees the conversation with these memories in context.
With Agent(memory=...) this happens inside the Agent before each model call. With MemoryIntegration, memory.wire(...) adds a separate preload node.
When to use: Your agent should proactively use past knowledge. Ideal for retrieval-augmented generation (RAG), customer support, or personalization where the agent should always have relevant context available.
| Pros | Cons |
|---|---|
| Agent sees memories by default | Extra latency (preload query) |
| Good for RAG patterns | More tokens injected per message |
| Transparent to agent | Noisy if memories are not well-curated |
Postload
The LLM decides when to retrieve memories by calling the memory tool with action="search" itself.
How it works: The memory tool is offered alongside your other tools. When the agent wants to check past context, it calls the tool with a query, receives results, and decides what to do with them.
When to use: Your agent should selectively retrieve memories only when needed. Ideal for conversational agents that can ask “Do I know about this user?” or agents that are memory-efficient and don’t need context injected automatically.
| Pros | Cons |
|---|---|
| Agent controls memory use | More tool calls (latency) |
| Avoids injecting irrelevant context | Requires the agent to know to retrieve |
| Efficient | Agent might forget to retrieve |
Trade-off table
| Aspect | No retrieval | Preload | Postload |
|---|---|---|---|
| Memory injection | Never | Automatic (every message) | On-demand (agent calls tool) |
| Latency | Lowest | Higher (one search per message) | Variable (depends on agent) |
| Best for | Stateless agents | RAG, personalization | Selective retrieval |
| Token cost | Lowest | Highest (if many memories match) | Variable |
Memory scoping: user vs agent memory
You can partition memories into scopes so different agents or users see different memories:
| Scope | Who can access | Use case |
|---|---|---|
User memory (UserMemoryConfig, enabled by default) |
The LLM can search and write | Facts about a specific user (timezone, preferences, history) |
Agent memory (AgentMemoryConfig, disabled by default) |
The LLM can only search | Shared knowledge scoped by agent_id or app_id (domain facts, workarounds) |
Example: A customer support agent remembers the customer’s billing cycle (user memory). Your own code seeds agent memory with known workarounds, and the model reads them through agent_memory_tool. The model has no tool to write agent memory.
Both scopes can share one backing store or each take their own store, and they are queried separately.
Choosing: memory tools (postload) vs preload
The most important decision is how the agent accesses memory. This determines your latency, token usage, and agent behavior.
Use postload (memory tools) if:
- The agent should decide when to recall, not every message.
- You want to minimize token overhead and latency.
- Your agent is sophisticated enough to know when to ask “Do I have context on this?”
- Example: A conversational assistant that occasionally checks past notes, but doesn’t need proactive context.
Use preload if:
- Memories should always be available without the agent asking.
- You’re building RAG and expect relevant memories to match most queries.
- You want the simplest behavior: the agent always sees relevant context.
- Token cost is secondary to ensuring the agent has context.
- Example: A customer support bot that needs a user’s history for every message.
Use no_retrieval if:
- You don’t need cross-conversation memory at all, only in-thread state.
- Memories are for analytics or audit, not agent behavior.
- You want the fastest, simplest setup.
Available store backends
The two main options are Qdrant (vector database you host) and Mem0 (managed service):
| Store | Hosting | Embeddings | Good for |
|---|---|---|---|
QdrantStore |
Local or cloud Qdrant | Your choice (OpenAI, Google, custom) | Full control, self-hosted |
Mem0Store |
Mem0 managed service | Mem0-provided | Low ops, Mem0 handles scaling |
Both use semantic similarity search, so searches match meaning, not keywords. A query like “What’s my timezone?” will find a memory “I’m UTC-5” even though the words are different.
Setting up Qdrant locally
pip install "10xgraph[qdrant,openai]"from tenxgraph.storage.store import (
create_local_qdrant_store,
OpenAIEmbedding,
)
store = create_local_qdrant_store(
collection="agent-memories",
path="./memory_data",
embedding=OpenAIEmbedding(), # requires OPENAI_API_KEY
)This creates a persistent database on disk. For production, use create_cloud_qdrant_store(url=..., api_key=..., embedding=...) for Qdrant Cloud, or create_remote_qdrant_store(host=..., port=..., embedding=...) for a server you run. GoogleEmbedding is also available. For Mem0, install the mem0 extra and use create_mem0_store.
Implementation patterns
Pattern 1: With the high-level Agent class
This is the simplest approach. Pass memory to Agent and the framework registers the memory tools and prompt. You still build the graph and pass the store to compile:
pip install "10xgraph[qdrant,openai,google-genai]"from tenxgraph.core.graph import Agent, StateGraph, ToolNode
from tenxgraph.core.state import AgentState, Message
from tenxgraph.storage.store import (
MemoryConfig,
OpenAIEmbedding,
create_local_qdrant_store,
)
from tenxgraph.utils import END, tool
store = create_local_qdrant_store(
collection="user-memories",
path="./memory_data",
embedding=OpenAIEmbedding(), # requires OPENAI_API_KEY
)
@tool
def search_web(query: str) -> str:
"""Search the web for information."""
return f"No results for {query}"
tool_node = ToolNode([search_web])
agent = Agent(
model="google/gemini-2.5-flash",
tool_node=tool_node,
memory=MemoryConfig(
store=store,
retrieval_mode="postload", # the model calls user_memory_tool when needed
limit=5, # max memories per search
),
)
def route(state: AgentState) -> str:
last = state.context[-1] if state.context else None
if last and last.role == "assistant" and last.tools_calls:
return "TOOL"
return END
graph = StateGraph()
graph.add_node("MAIN", agent)
graph.add_node("TOOL", tool_node)
graph.add_conditional_edges("MAIN", route, {"TOOL": "TOOL", END: END})
graph.add_edge("TOOL", "MAIN")
graph.set_entry_point("MAIN")
app = graph.compile(store=store)
result = app.invoke(
{"messages": [Message.text_message("What's my timezone?")]},
config={"thread_id": "t1", "user_id": "user-42"},
)The Agent class automatically:
- Adds memory instructions to the system prompt (unless
inject_system_prompt=False). - Registers
user_memory_tool(andagent_memory_toolif enabled) on theToolNode, inpostloadmode only. - Scopes memories by
user_idfrom the config.
In postload mode you must give the Agent a ToolNode (or the name of a ToolNode graph node); otherwise the framework raises RuntimeError.
Pattern 2: With StateGraph and MemoryIntegration
For more control (custom routing, multiple nodes), use MemoryIntegration with StateGraph:
from tenxgraph.core.graph import Agent, StateGraph, ToolNode
from tenxgraph.core.state import AgentState
from tenxgraph.storage.store import (
MemoryIntegration,
OpenAIEmbedding,
create_local_qdrant_store,
)
from tenxgraph.utils import END, tool
store = create_local_qdrant_store(
collection="agent-memories",
path="./memory_data",
embedding=OpenAIEmbedding(),
)
memory = MemoryIntegration(
store=store,
retrieval_mode="preload", # inject memories before the LLM runs
limit=5,
)
@tool
def search_web(query: str) -> str:
"""Search the web for information."""
return f"No results for {query}"
# memory.tools is [memory_tool], so the model can still write memories
tool_node = ToolNode([search_web, *memory.tools])
agent = Agent(
model="google/gemini-2.5-flash",
system_prompt=[{"role": "system", "content": memory.system_prompt}],
tool_node="TOOLS",
)
def route(state: AgentState) -> str:
last = state.context[-1] if state.context else None
if last and last.role == "assistant" and last.tools_calls:
return "TOOLS"
return END
graph = StateGraph()
graph.add_node("AGENT", agent)
graph.add_node("TOOLS", tool_node)
graph.add_edge("TOOLS", "AGENT")
graph.add_conditional_edges("AGENT", route, {"TOOLS": "TOOLS", END: END})
# Adds the preload node and sets the entry point
memory.wire(graph, entry_to="AGENT")
app = graph.compile(store=store)Invoke it as in Pattern 1. Key differences:
- You build the graph yourself, so you have full control over nodes and edges.
memory.toolsgives you the singlememory_toolto add alongside your own tools.memory.wire(graph, entry_to="AGENT")sets the entry point and, in preload mode, inserts the preload node.memory.system_promptis a string with the LLM instructions for the configured mode.
MemoryConfig options
For Agent(memory=...), customize memory behavior:
from tenxgraph.storage.store import (
AgentMemoryConfig,
MemoryConfig,
UserMemoryConfig,
)
config = MemoryConfig(
store=store, # The vector store
retrieval_mode="postload", # "no_retrieval" | "preload" | "postload" (default "postload")
limit=5, # Max memories per search (default 5)
score_threshold=0.0, # Min similarity (default 0.0)
max_tokens=None, # Optional: limit tokens in injected memories
inject_system_prompt=True, # Auto-add memory instructions
user_memory=UserMemoryConfig(
enabled=True, # User-scoped memories the LLM can search and write
memory_type="episodic",
category="general",
limit=5,
),
agent_memory=AgentMemoryConfig(
enabled=True, # Read-only for the LLM (default False)
memory_type="semantic",
agent_id="my-agent",
),
)How memories are written and retrieved
With MemoryIntegration, the LLM writes memories by calling memory_tool:
memory_tool(
action="store",
content="User is in UTC-5 timezone",
memory_key="user_timezone", # Key for deduplication
memory_type="episodic",
category="preferences",
)
# Returns {"status": "scheduled", "action": "store"}. If "user_timezone"
# already existed, it is updated.It searches (in postload mode) by calling:
memory_tool(action="search", query="What timezone is the user in?")
# Returns JSON: a list of matches with content and scoreWith Agent(memory=...) the calls are user_memory_tool(action="remember", text="...") and user_memory_tool(action="search", text="...").
In preload mode, the framework does the search automatically and injects the results as a system message starting with [Long-term Memory Context].
Accessing memory in the REST API
When a store is configured in 10xgraph.json, the server exposes memory endpoints:
POST /v1/store/memories # Store a new memory
POST /v1/store/memories/list # List memories for a user
POST /v1/store/search # Search memories by query
POST /v1/store/memories/{id} # Get a memory
PUT /v1/store/memories/{id} # Update a memory
DELETE /v1/store/memories/{id} # Delete a memory
POST /v1/store/memories/forget # Forget memories matching filtersSee REST API: Memory store for request/response schemas.
Memories are scoped by the authenticated user’s user_id (anonymous when there is none), so users do not see each other’s memories.
Configuring memory in 10xgraph.json
Point the API server to a memory store:
{
"agent": "graph:app",
"store": "graph.dependencies:my_memory_store"
}The store key expects a module:attribute path pointing to a BaseStore instance. The server uses it for the /v1/store endpoints.
Next steps
- To set up memory: See the task guide Use memory store.
- For REST API details: See REST API: Memory store.
- To learn about thread memory: See Checkpointing and threads (in-thread state is different from long-term memory).
- To understand scoping: See Authorization scopes (user isolation is handled by auth).