Artykuł opublikowany po angielsku.
Thread Memory for a LangGraph Support Agent With SQLite Checkpoints
How a LangGraph support agent keeps multi-turn context using SqliteSaver and thread ids, and why short-term thread memory differs from long-term memory.
A support agent that can search the LEPA documentation is useful, but it still falls over on the second message of a chat. Picture a user typing a single word and the agent asking which area they mean.
Turn 1: "help"
Agent: Which area — login, grades, reports…?
The user answers with the real problem, and unless something stored the first exchange, the agent begins from scratch.
Turn 2: "Why aren't grades showing?"
Agent: …starts from zero unless something remembers Turn 1
Support work is a dialogue, so the agent needs a way to remember it. What follows covers the memory that was actually built for the LEPA support agent, plus the kind that was left out on purpose.
Why a stateless endpoint breaks clarification
Each HTTP request arrives with no history. To the server, every call looks like a new visitor.
User → Agent → answer → forget
User → Agent → answer → forget
User → Agent → answer → forget
That wrecks any flow where the agent asks a question first. A reply to "which LEPA area?" only makes sense if the system still knows both messages belong to one conversation.
Two kinds of memory, only one built
Earlier in the project the split was drawn like this:
- Short-term memory covers one conversation, or one thread.
- Long-term memory covers facts that outlive a session.
Version one of the agent has the first kind. The second kind doesn't exist yet. The distinction matters enough that it gets repeated below wherever confusion could creep in.
State during a run versus state across runs
LangGraph state already acts as a small memory while a run is in progress. The support state object carries four things.
messages
user_role
issue_category
clarification_needed
retrieved_documents
final_answer
…
The messages field is wired to the add_messages reducer, so each new turn gets appended and nothing gets overwritten. Within one invoke call, that shared state is how the nodes pass context to each other.
An invoke call does finish, though. Once the next HTTP request comes in, everything from the last one is gone unless the state has been saved somewhere. Saving it is exactly what checkpointing means.
How a checkpointer works
In plain terms, a checkpointer stores the graph state after work completes and files it under a conversation identifier. Think of closing a ticket tab in a help desk and reopening it later: the ticket number is the same and so is the history.
The project uses SqliteSaver from the langgraph-checkpoint-sqlite package. The flow starts with an identifier and passes it in the run configuration.
thread_id = "abc-123"
↓
invoke graph with config { configurable: { thread_id } }
↓
state is saved to SQLite
↓
next invoke with same thread_id
↓
prior messages / fields load again
On the API side, the handler takes the thread id from the request body, or generates a UUID when none was supplied, and wraps it in the configuration dictionary.
thread_id = body.thread_id or str(uuid.uuid4())
config = {"configurable": {"thread_id": thread_id}}
That configuration is handed to the graph together with the new human message.
result = graph.invoke(
{"messages": [HumanMessage(content=body.message)]},
config=config,
)
One thread id means one lane of memory.
What gets saved
For a given thread, the checkpointer persists the whole graph state. That includes the growing message list and fields such as the user's role and the issue category, as set in earlier steps.
In practice, the first turn looks like this: the user says something vague, the agent asks a clarifying question, and the state is stored.
Turn 1: "help"
→ clarification question
→ state saved
The second turn then proceeds with the earlier messages still available, so the classifier reads the latest user text with context and the knowledge and support steps can respond properly.
Turn 2: "Why aren't grades showing?"
→ prior messages still there
→ classify sees the latest user text
→ knowledge + support can answer
Tests confirm the behavior. They send a vague opening message, follow up with the grades question under the same thread id, and check that the second result carries a longer message history than the first.
What "short-term memory" covers here
When the agent is described as having short-term memory, the claim is limited to this stack.
SQLite checkpoint
+ thread_id
+ SupportState.messages (append)
It's memory for a single support thread. It supports:
- a clarification followed by an answer
- recall of earlier turns inside the same chat
- a chat endpoint that behaves like a session instead of a stream of unrelated questions
It does not automatically give:
- a permanent profile of every teacher
- school-wide knowledge beyond the Markdown documents
- a statement like "the agent knows this person prefers dark mode in every app"
Those belong to long-term memory designs.
What long-term memory would be
Long-term memory is information deliberately kept beyond one thread and fetched later. For LEPA, candidates might be that a user is always a teacher, that a school calls its terms "Semester A", or that a grade-publishing problem was solved last week.
A rough analogy helps, and it's only an analogy.
Short-term ≈ RAM for this conversation
Long-term ≈ saved notes you can look up next month
A stored transcript for thread abc-123 is still thread memory. Conversation history is not long-term memory by default; that would need a separate store and a retrieval policy. The state object even has a conversation summary field set aside for later. No current node fills it, so it's a marker for future work rather than a feature.
The chat response and the thread id contract
The chat endpoint returns a JSON body with the answer, the category and a memory flag, among other fields.
{
"answer": "...",
"category": "grades",
"memory_updated": true,
"thread_id": "...",
"clarification_needed": false
}
Clients are expected to send the thread id back on the following turn. If they leave it out, the API mints a new UUID, which starts a fresh conversation. That single rule makes memory explicit instead of magical.
One caveat about the response: the memory flag is always true in the current API. It doesn't indicate that a long-term summary got written. It only says the request passed through the checkpointed graph.
A small operational trap
SqliteSaver.from_conn_string is a context manager, so it closes the database as soon as execution leaves the with block. That works for a script and fails for a long-running FastAPI process. For that reason a helper named get_sqlite_checkpointer opens a connection, calls setup, and holds the connection for the lifetime of the app.
It's a tiny detail and easy to overlook, but it becomes important once memory has to live outside a notebook.
The memory stack in one line
The agent's memory is run-time state plus SQLite checkpoints between runs.
State during a run
+
SQLite checkpoints across runs
+
thread_id to find the right conversation
That suffices for multi-turn support conversations. It falls short of a real long-term memory system, and saying so plainly is part of the lesson. The next step zooms out from a single tool plus memory to the reason the workflow is divided into intake, knowledge and support stages.