Long-term memory¶
Checkpoints persist a single run; long-term memory persists knowledge across runs. This guide explains the difference and the teff memory design.
Checkpoints are not memory¶
Checkpoints snapshot the whole workflow state of one run
(state, next_node_id, iteration), scoped by an owner id. They exist
so a run can be resumed, rolled back, or inspected.
They do not make a good memory layer:
- No semantics — a checkpoint is an opaque dump; you cannot ask "what does this user prefer?", only "replay this session".
- Session-bound — the snapshot belongs to one
checkpoint_id; it cannot be reused by a different conversation. - Unbounded — the conversation history in
stategrows linearly and every LLM call pays for it. - No lifecycle — no aggregation, deduplication, or forgetting.
Long-term memory is a separate concern: small, relevant, cross-session facts that are looked up semantically and evolve over time.
The three kinds of memory¶
| Kind | Scope | Example | Persisted in |
|---|---|---|---|
| Short-term (working) | one run | current messages, scratch state | state + checkpoints |
| Long-term (facts) | cross-session, per user | "user prefers email over Slack" | memory store |
| Episodic / procedural | cross-session | past successful plans; self-tuned instructions | memory store |
teff's memory layer targets the bottom two rows. The top row is already
handled by state reducers and the checkpointer.
Comparison: LangGraph store¶
For reference, LangGraph solves the same problem with a separate
BaseStore:
- Namespaces — hierarchical tuple paths (e.g.
("users", "123", "memories")). The common mistake is rooting the namespace onthread_id, which recreates per-session silos; the recommended root is the user id. - KV API —
put(namespace, key, value),get,search,delete,list_namespaces.valueis a dict; thetextfield is what gets embedded. - Optional semantic index — stores can be built with an
indexconfig (dims+ embed model +fields); thensearch(query=...)ranks by similarity. Without the index,searchreturns nothing for aquery. - TTL — optional time-to-live per item.
- LangMem — a higher-level layer adding
create_manage_memory_tool/create_search_memory_tool, plus three memory types (semantic, episodic, procedural where the agent rewrites its own system prompt).
teff already has the two hard parts LangGraph had to build: a pluggable
VectorStore with semantic search + filter DSL, and an Embedder. What
is missing is the namespace/put/search layer and the agent-facing
tools on top.
teff design¶
MemoryStore — a namespace store over VectorStore¶
from teff.memory import MemoryStore
from teff.rag import Embedder, InMemoryVectorStore
memory = MemoryStore(
store=InMemoryVectorStore(dim=768),
embedder=Embedder(provider="ollama", model="nomic-embed-text"),
)
Namespace → metadata mapping:
- Every item stores its namespace components in metadata
(
_ns_0,_ns_1, …) and its text undertext. - A
search/listcall passes a filter over the prefix components, so("users", "123")finds items stored under("users", "123")and("users", "123", "memories")— the namespace is a subtree. - Item ids are stable:
"::".join(namespace) + "::" + key, soputis an upsert.
API:
await memory.put(
("users", "u1"), "prefs", {"text": "prefers email over Slack", "channel": "email"}
)
item = await memory.get(("users", "u1"), "prefs")
hits = await memory.search(("users", "u1"), query="how to reach them?", k=5)
await memory.delete(("users", "u1"), "prefs")
keys = await memory.list(("users", "u1"))
search(query=...)embeds the query and returns top-k by similarity, filtered to the namespace subtree.search(query=None)returns the most recent items in the namespace (metadata carriesupdated_at).- Optional per-store
ttl: expired items are skipped on read.
MemoryTool — the agent-facing tool¶
tools:
- type: memory
config:
store: {type: sqlite, path: ./memory.db, dim: 768}
embedder: {provider: ollama, model: nomic-embed-text}
namespace: ["users", "${USER_ID}"]
default_k: 5
Actions exposed to the agent:
remember— upsert one fact; the tool deduplicates: if a semantically close item already exists in the namespace, the new text overwrites it (resolving conflicts by meaning, not just by key).recall— top-k semantic results formatted for the prompt.forget— delete by key.list— enumerate keys (no query → recency order).
Writing facts¶
Facts are written one of two ways:
- Explicitly — the agent calls
rememberwhen it decides something is worth keeping. - Extracted —
MemoryExtractorruns a lightweight LLM pass that turns a conversation into candidate facts, then upserts them. This is the LangMem "manage memory" pattern and can be a normalLLMnode in a graph:
from teff.memory import MemoryExtractor
extractor = MemoryExtractor(model="gpt-4o")
await extractor.save(memory, conversation, ("users", user_id))
Each fact is stored under a stable key derived from its text, so re-extracting the same fact updates it in place instead of duplicating it.
Reading facts¶
Reading is explicit (via recall) or injected:
- Auto-injection — a
react_agentorllmnode accepts amemoryconfig: on every turn it recalls the top-k memories for the last user message and inserts them as asystemmessage before the LLM call, so relevant cross-session facts are visible without the agent having to call the tool:
react_agent:
model: gpt-4o
memory:
store: {type: sqlite, path: ./memory.db, dim: 768}
namespace: ["users", "${USER_ID}"]
k: 5
The raw building blocks are memory_context(store, query, namespace=...)
(a ready-to-append block of recalled memories, or "") and
memory_context_from_config(cfg, state=, ctx=), the shared helper both
nodes use.
In Python, pass a typed :class:~teff.memory.context.MemoryConfig — the
same shape a config dict or YAML entry carries:
from teff import Flow, MemoryConfig
flow.react(
model="gpt-4o",
memory=MemoryConfig(store=memory, namespace=("users", "ana"), k=5),
)
Per-user memory behind a shared graph¶
A single compiled graph can serve many end-users: graph.run(owner=...)
scopes checkpoints per tenant, and the node's run context carries
owner / session_id / checkpoint_id. Namespace segments may reference
those, so memory becomes per-user without building a graph per request:
flow.react(
model="gpt-4o",
memory=MemoryConfig(
store=memory,
namespace=["users", "${owner}"], # resolved from the run context
k=5,
),
)
memory_context_from_config resolves ${owner}, ${session_id} and
${checkpoint_id} from the node's context at recall time (falling back to
the process environment, then leaving the reference untouched). Point every
tenant at the same store and keep the root segment — "users" — constant
so tenants are just separate sub-namespaces. Facts written for one owner
are never recalled for another; a fact can be shared by putting it under
the common root instead. This is the pattern for chat apps like
examples/applications/repair-ai-chat, whose Assistant.run already
threads owner into graph.run.
Lifecycle¶
- Deduplication — semantic overwrite (see above).
- TTL / forgetting — per-item
expires_at; acleanupmethod mirrorsCheckpointer.cleanup(max_age,keep_lastper owner/namespace). - Ownership — the namespace root is the user/session id; never the checkpoint id, or cross-session memory silently becomes per-session.
Why this, not more¶
This is deliberately thin: it reuses VectorStore + Embedder instead of
building a second storage engine, and it keeps the exact same mental model
as LangGraph's BaseStore so patterns learned there transfer. The RAG
layer stays for documents; memory is for facts about the world and the
user.
Roadmap¶
- [x] Design (this document)
- [x]
MemoryStore(put/get/search/delete/list + subtree namespaces + TTL) - [x]
MemoryTool(remember/recall/forget/list) - [x] Fact-extraction layer (
MemoryExtractor— LLM summarisation intosave) - [x] Context injection (
memory_context+react_agentmemoryconfig) - [x] Example:
examples/memory_assistant/