How to Build a Multi-Session AI Assistant with Zep
One chat session is easy. The hard part is when the same person comes back on Tuesday and your agent forgot everything from Monday. Zep solves this with a user-and-session model, and this guide focuses on the wiring that trips people up.
Direct answer: Create one Zep user per person, one session per conversation thread, then search across all sessions with memory.search to pull cross-thread context at answer time.
Why Does One Big Session Break Down?
New builders dump every message into a single session. It works for a demo, then falls apart. Summaries get noisy, latency grows, and Zep cannot tell a quick throwaway question from a project you have been running for months.
The fix is to treat sessions as threads, not as users. A user is a person. A session is one line of work — a support ticket, a coding task, a weekly check-in.
How Should You Model Users and Sessions?
Use stable IDs you already own. Do not let Zep invent them.
| Concept | Maps to | Example |
|---|---|---|
| user_id | Your account/user record | u_8421 |
| session_id | One conversation thread | s_8421_2026_09_draft |
| graph | Optional shared knowledge pool | org_iaifeed |
If several sessions should share facts — say an org-wide policy — attach them to the same graph ID. Facts written there become visible to every session in that graph.
How Do You Write and Read Across Sessions?
Write with memory.add as messages happen. Read with memory.search when you want semantic lookup rather than just the last N messages.
hits = client.memory.search(
user_id="u_8421",
query="preferred deployment target",
limit=5)
for h in hits:
print(h.fact, h.created_at)
Note the parameter: user_id, not session_id. That is what makes search span threads. Searching by session only returns facts from that single thread — a common mistake that makes memory seem broken.
Zep Memory Search vs Plain Vector Search
Both retrieve facts. They differ in what they do with time.
| Capability | Vector store | Zep |
|---|---|---|
| Semantic similarity | Yes | Yes |
| Invalidates outdated facts | No | Yes (temporal graph) |
| Auto summary per session | No | Yes |
| Setup effort | Low | Medium |
If your users never change their mind, a vector store is cheaper. Once facts evolve, the temporal layer earns its keep.
How Do You Handle Latency and Token Cost?
Three practical levers worked for us on a real assistant:
- Request
mode="summary"and caplastnat 4-6 messages. - Trigger writes right after the user turn, not after the assistant reply, so ingestion overlaps with generation.
- Cache the returned context string for the session and reuse it within one turn instead of calling
memory.gettwice.
Skipping these pushed p95 latency from roughly 400 ms to over 1.5 s in our tests, mostly from unnecessary ingestion calls.
Is the Graph Layer Worth the Complexity?
Only sometimes. If your assistant answers questions about a single person’s history, a user-scoped memory is enough. The graph starts paying off when you need relationships — “which customers reported this bug” — where entities connect to each other, not just to a user.
One honest limitation: you cannot hand-edit the graph. Facts are extracted by a model, so bad input produces bad facts, and the only fix is re-ingesting corrected messages.
Quick Summary
- Model people as users and threads as sessions; never merge them.
- Use
memory.searchwithuser_idto pull context across sessions. - Share facts between sessions through a common graph ID.
- Keep token cost down with summary mode and a small
lastn. - Skip the graph until you actually need entity relationships.
