LangChain (YT style)LangChain 1.4 · Python 3.12+
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
46 small wins to finish your path

Saving threads with a SqliteSaver checkpointer

SqliteSaver is a checkpointer that keeps every thread in a file. A conversation is still there after the program exits and starts again.

Last updated: 27 Sep, 2026 · LangChain 1.4

Restart the program and every conversation at the desk is gone, because InMemorySaver lives in memory. A real shop restarts on every deploy. Let's say Ravi asks about A17, the server restarts, and he writes again: the desk should still know what he said. SqliteSaver keeps each thread in a file. It comes from langgraph-checkpoint-sqlite, installed in the integrations lesson, and takes an open connection. desk.py is still on DeskModel here, as in the Chroma lesson, so the output below and the five desk tests stay exact and only the saver changes.

The SqliteSaver connection

python
import sqlite3

from langgraph.checkpoint.sqlite import SqliteSaver

saver = SqliteSaver(sqlite3.connect("desk.db", check_same_thread=False))   # a file, not memory
Project files used on this pageThis lesson builds on a project from earlier lessons. The code below imports these files. Click a file to see its code, or follow the link to the lesson that wrote it. To run the code yourself, keep them in the same folder.
View the code here
shop_model.py
import re

from langchain.chat_models import BaseChatModel
from langchain.messages import AIMessage, ToolMessage
from langchain_core.outputs import ChatGeneration, ChatResult


class ShopModel(BaseChatModel):
    tools: list = []

    @property
    def _llm_type(self):
        return "shop"

    def bind_tools(self, tools, **kwargs):
        return self.model_copy(update={"tools": tools})   # a copy holding the tools

    def _generate(self, messages, stop=None, run_manager=None, **kwargs):
        message = self.decide(messages)                   # the reply comes from decide
        return ChatResult(generations=[ChatGeneration(message=message)])

    def decide(self, messages):
        results = []                                # the tool results at the end
        for m in reversed(messages):
            if not isinstance(m, ToolMessage):
                break
            results.insert(0, m.text)
        if results:                                 # results are back: answer with them
            return AIMessage(" ".join(results))
        text = messages[-1].text
        orders = re.findall(r"\b[A-Z]\d+\b", text)
        tool = "refund_order" if "refund" in text.lower() else "lookup_order"
        if orders and tool in [t.name for t in self.tools]:   # one call per order id
            calls = [{"name": tool, "args": {"order_id": o}, "id": f"call_{o}"}
                     for o in orders]
            return AIMessage("", tool_calls=calls)
        if orders:                                  # that tool is not bound
            return AIMessage(f"I have no way to look up {orders[0]} yet.")
        return AIMessage("Hello. Which order is this about?")
desk_tools.py
from dataclasses import dataclass

from langchain.tools import ToolRuntime, tool

ORDERS = {"A17": ("ravi", "shipped on 3 March"), "C40": ("mei", "waiting for stock")}


@dataclass
class Customer:
    name: str


@tool
def lookup_order(order_id: str, runtime: ToolRuntime[Customer]) -> str:
    """Look up one of the customer's orders by its id, such as A17."""
    owner, status = ORDERS.get(order_id, (None, None))
    if owner != runtime.context.name:
        return f"{order_id} is not one of your orders."
    return f"{order_id} {status}."


@tool
def refund_order(order_id: str, runtime: ToolRuntime[Customer]) -> str:
    """Refund one of the customer's orders in full. This cannot be undone."""
    owner, _ = ORDERS.get(order_id, (None, None))
    if owner != runtime.context.name:
        return f"{order_id} is not one of your orders, so it cannot be refunded."
    return f"Refunded {order_id}."
policies.py
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter

POLICIES = {
    "refunds.md": "Refunds go back to the card you paid with. They take up to 5 working days to arrive."
                  "\n\nYou can ask for a refund within 30 days of delivery. Opened items can be refunded if they are faulty.",
    "shipping.md": "Standard shipping takes 3 to 5 working days. Shipping is free on orders over 50 euros."
                   "\n\nExpress shipping arrives the next working day and costs 9 euros.",
    "accounts.md": "To reset your password, use the reset link on the sign-in page. Support staff never ask for your password.",
}

docs = [Document(page_content=text, metadata={"source": name}) for name, text in POLICIES.items()]
splitter = RecursiveCharacterTextSplitter(chunk_size=120, chunk_overlap=0, add_start_index=True)
chunks = splitter.split_documents(docs)
word_embeddings.py
import re
import zlib

from langchain_core.embeddings import Embeddings

COMMON = {"a", "an", "and", "are", "can", "do", "does", "for", "how", "i",
          "if", "is", "it", "my", "of", "on", "the", "to", "what", "with", "you", "your"}


class WordEmbeddings(Embeddings):
    def embed_query(self, text):
        vector = [0.0] * 256
        for word in re.findall(r"[a-z]+", text.lower()):
            if word not in COMMON:
                vector[zlib.crc32(word.rstrip("s").encode()) % 256] += 1.0
        return vector

    def embed_documents(self, texts):
        return [self.embed_query(text) for text in texts]
search.py
from langchain.tools import tool
from langchain_chroma import Chroma
from policies import chunks
from word_embeddings import WordEmbeddings

store = Chroma(collection_name="policies", embedding_function=WordEmbeddings(),
               persist_directory="./policy_store",
               collection_metadata={"hnsw:space": "cosine"})
if not store.get()["ids"]:
    store.add_documents(chunks)


@tool
def search_policies(query: str) -> str:
    """Search the shop's policies on refunds, shipping and accounts.
    Pass the customer's question, word for word, as the query."""
    found = [doc for doc, score in store.similarity_search_with_relevance_scores(query, k=2)
             if score >= 0.3]
    if not found:
        return "No policy covers this."
    return "\n".join(f"[{doc.metadata['source']}] {doc.page_content}" for doc in found)
desk_model.py
import re

from langchain.messages import AIMessage
from shop_model import ShopModel


class DeskModel(ShopModel):
    def decide(self, messages):
        last = messages[-1]
        if last.type == "tool" and last.text == "No policy covers this.":
            return AIMessage("Our policies do not cover that. A person will reply.")
        if last.type == "tool" or re.findall(r"\b[A-Z]\d+\b", last.text):
            return super().decide(messages)
        query = {"name": "search_policies", "args": {"query": last.text}, "id": "call_p"}
        return AIMessage("", tool_calls=[query])
password_check.py
from langchain.agents.middleware import before_agent
from langchain.messages import AIMessage


@before_agent(can_jump_to=["end"])
def no_passwords(state, runtime):
    if "password" in state["messages"][-1].text.lower():
        answer = AIMessage("I cannot help with passwords. Please use the reset link.")
        return {"messages": [answer], "jump_to": "end"}
chat.py
from langgraph.types import Command
from desk import Customer, agent


def say(who, text, thread):
    config = {"configurable": {"thread_id": thread}}
    result = agent.invoke({"messages": [{"role": "user", "content": text}]}, config,
                          context=Customer(who), version="v2")
    if result.interrupts:
        print(f"{who}: {text}\n  paused for approval: {result.interrupts[0].value['action_requests'][0]['args']}")
        result = agent.invoke(Command(resume={"decisions": [{"type": "approve"}]}), config,
                              context=Customer(who), version="v2")
        text = "(approved)"
    print(f"{who}: {text}\n  desk: {result.value['messages'][-1].text}")
test_desk.py
from uuid import uuid4

from desk import agent
from desk_tools import Customer


def ask(who, text):
    config = {"configurable": {"thread_id": str(uuid4())}}   # a new thread for every call
    result = agent.invoke({"messages": [{"role": "user", "content": text}]}, config,
                          context=Customer(who), version="v2")
    return result.value["messages"][-1].text


def test_an_order_is_answered_to_its_owner():
    assert "shipped" in ask("ravi", "Where is A17?")


def test_another_customers_order_is_refused():
    assert "not one of your orders" in ask("mei", "Where is A17?")


def test_a_policy_question_is_answered_from_the_documents():
    assert "5 working days" in ask("ravi", "How long does a refund take?")


def test_an_uncovered_question_is_refused():
    assert "A person will reply" in ask("ravi", "Do you sell gift cards?")


def test_passwords_never_reach_the_model():
    assert "reset link" in ask("ravi", "What is my password?")

The saver in desk.py

This replaces desk.py. Two lines change, marked with was: the saver's import, and the checkpointer argument, which now opens desk.db. check_same_thread=False lets the agent use the connection from whichever thread it runs on.

python
import sqlite3

from langchain.agents import create_agent
from langchain.agents.middleware import HumanInTheLoopMiddleware, ModelCallLimitMiddleware, PIIMiddleware
from langgraph.checkpoint.sqlite import SqliteSaver                 # was InMemorySaver
from desk_model import DeskModel
from password_check import no_passwords
from search import search_policies
from desk_tools import Customer, lookup_order, refund_order

agent = create_agent(
    DeskModel(),
    system_prompt="You are the support assistant for a small online shop. Answer in one or two short sentences, using only what the tools returned. If a tool says an order is not the customer's, say exactly that. Add nothing the tools did not say.",
    tools=[lookup_order, refund_order, search_policies],
    context_schema=Customer,
    middleware=[
        no_passwords,
        PIIMiddleware("credit_card", strategy="mask"),
        ModelCallLimitMiddleware(run_limit=6),
        HumanInTheLoopMiddleware(interrupt_on={"refund_order": True}),
    ],
    checkpointer=SqliteSaver(sqlite3.connect("desk.db", check_same_thread=False)),   # was InMemorySaver()
)

An import, a connection, and the argument that was already there. Everything the desk does with threads was written against the checkpointer, so the rest of the file is untouched.

Ask once, then run it again

The program below asks one question and counts what the thread holds. Running it twice is two separate processes, which is what a restart is.

python
from chat import say
from desk import agent

thread = {"configurable": {"thread_id": "ravi-1"}}
say("ravi", "Where is A17?", "ravi-1")
print(len(agent.get_state(thread).values["messages"]), "messages saved")
Example
python threads.py
python threads.py

Four messages after the first run, eight after the second: the second process found Ravi's thread already there and carried on. With InMemorySaver both runs would have printed four.

Why the second run found the thread

  • The first run saved four messages. The second run was a new process, but it found Ravi's thread already in desk.db and carried on, so it reached eight.
  • Running the file twice is what a restart is, and the file is what carries the thread across.
  • With InMemorySaver both runs would print four, because it holds threads in memory only and each run starts empty.

The tests, against the real pieces

The five tests from the project were written against the desk, not against the stand-ins. The store is Chroma now and the saver writes to a file, and nothing in the test file changes. They still pass because desk.py keeps DeskModel. On the Groq desk, test_an_uncovered_question_is_refused would be the first to need loosening: it looks for the exact words "A person will reply", which only DeskModel says, while a real model words its refusal its own way.

Example
pytest -q -p no:warnings test_desk.py

That is what those tests were for. Two pieces of the desk were replaced by packages it had never run against, and the owner check, the policy answer, the refusal and the guardrail all still hold.

The finished desk, and what each piece becomes
DeskModelyou wrote itBaseChatModelthe interfaceinit_chat_modelyou install itWordEmbeddingscounts wordsEmbeddingsthe interfaceOpenAIEmbeddingslangchain-openaiInMemoryVectorStorea list in memoryVectorStorethe interfaceChromalangchain-chroma, on diskInMemorySavera dict in memoryBaseCheckpointSaverthe interfaceSqliteSaverdesk.db, or Postgres
Hover or tap a piece to see what it is and which lesson built it.
Swap by swap

Pick one to watch it run, step by step.

The desk as it now stands, and what each piece it was built on becomes. The middle column is the only reason the swaps are one line each: everything around them was written against the interface, never against your class.

When threads must survive a restart

  • Any desk that must remember a customer between visits, or across a deploy.
  • A service on more than one machine, where PostgresSaver takes a connection string instead of a file.
Watch out. A file-backed saver needs check_same_thread=False on the connection, or the agent raises when it reads the thread from a different thread than the one that opened the file.

LangChain is larger than one desk. These are the areas left out above, and what each is for.

TopicWhat it is for
Event streamingstream_events with version="v3": typed events for tokens, tool calls and middleware, recommended for new apps.
HandoffsAgents that pass the whole conversation to another agent, which then talks to the user directly.
SkillsLoading instructions and tools into an agent only when a task needs them.
Custom workflowsMixing fixed steps and agents in one LangGraph graph.
Multi-agent tutorialsA personal assistant with subagents, customer support with handoffs, a knowledge base with a parallel router, and a SQL assistant with skills.
SQL agentAn agent that writes and runs SQL queries against a database.
LangSmith observabilityTracing every run to LangSmith by setting two environment variables.
Integration tests and evalsTesting against real providers, and scoring an agent's answers on a larger dataset than the three goldens in The desk on a hosted model.
Context engineering and memory conceptsGuides to what goes into each model call, and to kinds of memory. The trimming and summarization lessons use two of their techniques.
Component architectureDiagrams of how models, tools, retrievers, vector stores and agents fit together.
Runtimes, frameworks and harnessesHow LangChain, LangGraph and Deep Agents relate.
Deep AgentsYou saw its subagents in Subagents as tools; it also adds planning and a virtual filesystem.
Frontend and Agent Chat UIReact components and a chat interface for LangChain agents.
Deployment and StudioRunning agents on LangSmith's servers and inspecting them in a browser.
Voice agentsSpeech in and out around an agent.
Provider integrationsHundreds of chat models, embedding models, vector stores and loaders, each its own package.
Try it yourself
  • Delete desk.db and run threads.py twice again.
  • Ask as a second customer on another thread id and look at the file's size.
  • Read agent.get_state_history(thread) after three questions.

Every expert started right here.