Lab 04 · Remembering customers
Give the support agent two kinds of memory: SQLite-backed conversation threads and a shared customer profile in a store.
What you will build
A support agent with two memories that do different jobs:
- A SQLite checkpointer stores each conversation (
thread_id) in a file, so it survives a restart. - A store keeps a customer profile under
(org_id, customer_id, "preferences"). The agent reads and writes it through tools, so a preference stated on Monday is still known in a brand-new conversation on Thursday.
The test for the whole lab fits in one sentence: two different threads, one shared customer profile, nothing leaking between companies.
What you will learn
- The difference between short-term memory (a thread) and long-term memory (a store)
- How
SqliteSavermakes threads durable across restarts - How tools read and write the store through
ToolRuntime - How namespaces with the tenant first keep customers of different companies apart
- Why keying a memory on its kind turns a restated preference into an update
Check your prerequisites
You need three things on your machine and three values from your instructor.
- Python 3.11, 3.12 or 3.13. Check with the command below. On Windows, install it from python.org and tick Add python.exe to PATH.
- A code editor. VS Code with the Python extension works well.
- A terminal. PowerShell on Windows, Terminal on macOS or Linux.
python --versionpython3 --versionFrom your instructor you get an Azure OpenAI endpoint, an API key and a deployment name. Keep the key private: never paste it into code or share it in chat.
Create the project folder and virtual environment
Create a new folder for this lab, open it in a terminal, and create a virtual environment. A virtual environment is a private copy of Python for this project, so the libraries you install here never clash with other projects.
mkdir C:\labs\lab-04-remembering-customers
cd C:\labs\lab-04-remembering-customers
python -m venv .venv
.venv\Scripts\Activate.ps1mkdir -p ~/labs/lab-04-remembering-customers
cd ~/labs/lab-04-remembering-customers
python3 -m venv .venv
source .venv/bin/activateKeep lab folders on a short path such as C:\labs. Deep folders, for example inside OneDrive or Desktop, can exceed the Windows path limit. Some libraries then fail to load with The filename or extension is too long. OneDrive syncing can also lock files the labs write.
When activation works, your prompt starts with (.venv). Activate it again every time you open a new terminal for this lab. Open the folder in VS Code with code . if you like.
PowerShell says running scripts is disabled on this system? Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned, answer Y, and activate again.
This is what your folder will look like at the end of the lab:
Files marked as generated are created when you run the code. You do not create them yourself.
Install the libraries
Create a file named requirements.txt in your lab folder with the content below. The versions are pinned to the ones this lab was tested with.
langchain==1.4.2
langchain-core==1.6.4
langchain-openai==1.6.2
python-dotenv==1.2.3
langgraph==1.2.11
langgraph-checkpoint==4.2.0
langgraph-checkpoint-sqlite==3.1.1langgraph-checkpoint-sqlite is a separate package that provides SqliteSaver. The store (InMemoryStore) and the base checkpointer classes come with langgraph.
Then install everything into your virtual environment. It takes a minute or two.
python -m pip install --upgrade pip
pip install -r requirements.txtpython -m pip install --upgrade pip
pip install -r requirements.txtAdd your Azure OpenAI settings
Create a file named .env (the name starts with a dot) in your lab folder. Paste the template and replace the three placeholders with the values from your instructor.
# Azure OpenAI settings. Your instructor gives you these three values.
AZURE_OPENAI_ENDPOINT=https://<your-resource>.openai.azure.com/openai/v1
AZURE_OPENAI_API_KEY=<paste-your-key-here>
AZURE_OPENAI_DEPLOYMENT=<your-chat-deployment-name>
# Only used when your endpoint does NOT end in /openai/v1.
OPENAI_API_VERSION=2025-04-01-preview- AZURE_OPENAI_ENDPOINT is your resource URL. If it ends in
/openai/v1, keep it that way. If it is justhttps://<resource>.openai.azure.com/, paste that; the helper in the next step handles both. - AZURE_OPENAI_API_KEY is your secret key.
- AZURE_OPENAI_DEPLOYMENT is the deployment name, for example
gpt-5-mini. On Azure you call a deployment, not a model name.
Never commit .env to Git or share it. If you use Git, add a .gitignore file containing .env and .venv/.
Create the Azure OpenAI helper
Every lab talks to Azure OpenAI through one small helper file. Create model_setup.py and add the blocks below one after another. You write it once per lab folder; the lab code then only needs from model_setup import get_model.
Block 1Imports, .env loading and a console fix
load_dotenv() reads your .env file and puts every line into environment variables, so your key never appears in code.
The loop below it switches the console to UTF-8. Windows terminals default to an older encoding and crash when a model prints curly quotes or dashes.
"""Azure OpenAI model setup shared by every lab.
Import it in lab code as: from model_setup import get_model, get_embeddings
"""
import asyncio
import os
import sys
from dotenv import load_dotenv
from langchain_core.embeddings import Embeddings
from langchain_core.language_models import BaseChatModel
# Read AZURE_OPENAI_* values from the .env file next to your lab code.
load_dotenv()
# Windows consoles default to cp1252 and crash on the curly quotes models love to print.
for _stream in (sys.stdout, sys.stderr):
if hasattr(_stream, "reconfigure"):
_stream.reconfigure(encoding="utf-8", errors="replace")Block 2Read settings safely
_setting() fetches a value from the environment and stops with a friendly message if it is missing, instead of a long stack trace.
_uses_v1_api() checks the shape of your endpoint. Azure offers two:
https://<resource>.openai.azure.com/openai/v1is the newer OpenAI-compatible API.https://<resource>.openai.azure.com/is the classic API that needs anapi_version.
def _setting(name: str) -> str:
"""Return a required setting from .env, or stop with a readable message."""
value = os.getenv(name, "").strip()
if not value:
sys.exit(f"Missing {name} in your .env file. Open .env and fill it in.")
return value
def _uses_v1_api(endpoint: str) -> bool:
"""True for the OpenAI-compatible Azure endpoint that ends in /openai/v1."""
return endpoint.rstrip("/").endswith("/openai/v1")Block 3Build the chat model
get_model() returns a LangChain chat model connected to your deployment.
- With a
/openai/v1endpoint it usesChatOpenAIwithbase_url. On Azure the deployment name goes inmodel. - With a classic endpoint it uses
AzureChatOpenAIwithazure_deploymentandapi_version.
Either way the lab code just calls get_model() and never cares which one it got.
def get_model(deployment: str | None = None, **kwargs) -> BaseChatModel:
"""Return a chat model for your Azure OpenAI deployment."""
endpoint = _setting("AZURE_OPENAI_ENDPOINT")
api_key = _setting("AZURE_OPENAI_API_KEY")
name = deployment or _setting("AZURE_OPENAI_DEPLOYMENT")
if _uses_v1_api(endpoint):
from langchain_openai import ChatOpenAI
return ChatOpenAI(model=name, base_url=endpoint, api_key=api_key, **kwargs)
from langchain_openai import AzureChatOpenAI
return AzureChatOpenAI(
azure_deployment=name,
azure_endpoint=endpoint,
api_key=api_key,
api_version=os.getenv("OPENAI_API_VERSION", "2025-04-01-preview"),
**kwargs,
)Block 4Build the embeddings model
get_embeddings() does the same for embeddings, which turn text into vectors for search. Only the retrieval labs use it, but keeping one helper for every lab is simpler.
def get_embeddings(deployment: str | None = None) -> Embeddings:
"""Return an embeddings model for your Azure OpenAI embeddings deployment."""
endpoint = _setting("AZURE_OPENAI_ENDPOINT")
api_key = _setting("AZURE_OPENAI_API_KEY")
name = deployment or _setting("AZURE_OPENAI_EMBEDDING_DEPLOYMENT")
if _uses_v1_api(endpoint):
from langchain_openai import OpenAIEmbeddings
return OpenAIEmbeddings(model=name, base_url=endpoint, api_key=api_key)
from langchain_openai import AzureOpenAIEmbeddings
return AzureOpenAIEmbeddings(
azure_deployment=name,
azure_endpoint=endpoint,
api_key=api_key,
api_version=os.getenv("OPENAI_API_VERSION", "2025-04-01-preview"),
)Block 5A Windows fix for async code
use_stable_event_loop() matters only for the async MCP lab. The default Windows event loop can crash on exit after async network sessions close. On macOS and Linux it does nothing.
def use_stable_event_loop() -> None:
"""On Windows, use the Selector event loop so async MCP code exits cleanly."""
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())Check that the helper can reach your deployment with a one-line test:
python -c "from model_setup import get_model; print(get_model().invoke('Say hello in 5 words').text)"python -c "from model_setup import get_model; print(get_model().invoke('Say hello in 5 words').text)"You should see a short greeting. If you see an error instead, check the troubleshooting step at the end.
Write the agent with memory
An agent can remember at two levels:
- Short-term memory is the message history of one conversation. The checkpointer saves it after every step, keyed by
thread_id. A new thread starts empty. - Long-term memory lives in a store, organised by namespace. Any thread can read it, so it is the right place for facts about the customer.
You will run two threads for the same customer and see that the second one knows what was said in the first, only through the store.
Create an empty file named remembering_customers.py in your lab folder. Then copy each block below into it, in order, one after another.
Block 1Imports
sqlite3opens the database file for the checkpointer.SqliteSaversaves threads to SQLite.InMemoryStoreis the long-term memory. It lives in RAM, which is enough for a lab.ToolRuntimegives tools access to the store and the context.
"""Lab 04: remembering customers with SQLite threads and a store-backed profile.
Two different thread_ids, one shared long-term memory.
Run: python remembering_customers.py
"""
import sqlite3
from dataclasses import dataclass
from typing import Any
from langchain.agents import create_agent
from langchain.tools import ToolRuntime, tool
from langchain_core.language_models import BaseChatModel
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.store.base import BaseStore
from langgraph.store.memory import InMemoryStore
from model_setup import get_modelBlock 2Orders and the run context
SupportContext says who the agent serves: which company (org_id) and which customer. You pass it with context= on every call. It is not saved in the thread, which makes it the right place for the tenant id: a thread can never carry one customer's identity into another run.
ORDERS = {"A-100": "shipped via DHL, ETA 2026-09-15", "A-101": "processing, ETA 2026-09-18"}
@dataclass
class SupportContext:
"""Who this run serves. Per-run, and deliberately never persisted in the thread."""
org_id: str
customer_id: strBlock 3The memory namespace
A store organises items in namespaces, tuples that work like folder paths. Putting the tenant first, (org_id, customer_id, "preferences"), means a search for ("acme-corp",) can only ever reach Acme's customers, never another company's.
def preference_namespace(org_id: str, customer_id: str) -> tuple[str, str, str]:
"""(org, user, kind): tenant first, so a prefix search can never cross organisations."""
return (org_id, customer_id, "preferences")Block 4Tool 1: order status
A plain tool, so the Thursday conversation has something to do besides memory.
@tool
def get_order_status(order_id: str) -> str:
"""Look up the shipping status of an order by id, such as A-100."""
return f"Order {order_id.upper()}: {ORDERS.get(order_id.upper(), 'no such order')}."Block 5Tool 2: save a preference
runtime.store is the store you pass to create_agent, and runtime.context is the SupportContext of this run. The model only fills kind and value.
store.put(namespace, key, value)writes one item. The key iskind, such ascontact_channel, so stating the preference again overwrites it instead of adding a second, conflicting memory.- The docstring says Never store secrets. The docstring is the policy the model reads.
@tool
def save_preference(kind: str, value: str, runtime: ToolRuntime[SupportContext]) -> str:
"""Store a lasting preference so future conversations can use it. `kind` is a short slug
such as "contact_channel"; restating a preference updates it. Never store secrets."""
namespace = preference_namespace(runtime.context.org_id, runtime.context.customer_id)
runtime.store.put(namespace, kind, {"text": f"{kind}: {value}", "value": value})
return f"Saved preference {kind}: {value}"Block 6Tool 3: recall preferences
store.search(namespace, query=..., limit=5) returns the items in the customer's namespace. InMemoryStore without an embeddings index does not rank by meaning; it simply returns the items, which is fine for a handful of preferences.
The docstring tells the model to call this at the start of every conversation.
@tool
def recall_preferences(query: str, runtime: ToolRuntime[SupportContext]) -> str:
"""Look up what we already know about this customer. Call this at the start of every
conversation, before asking them to repeat themselves."""
namespace = preference_namespace(runtime.context.org_id, runtime.context.customer_id)
hits = runtime.store.search(namespace, query=query, limit=5)
return "\n".join(f"- {hit.value['text']}" for hit in hits) or "No stored preferences yet."Block 7The system prompt
The prompt says when to use each tool: recall first, save when a lasting preference is stated, and look up orders for shipping questions.
SYSTEM_PROMPT = (
"You are the Northwind support assistant. Call `recall_preferences` at the start of a "
"conversation, `save_preference` when the customer states a lasting preference, and "
"`get_order_status` for shipping. Never store secrets. Reply in one sentence."
)Block 8Assemble the agent
checkpointer=enables threads (short-term memory).store=enablesruntime.storein tools (long-term memory). Without it,runtime.storeisNoneand the tools fail.context_schema=declares the type of the per-run context.
def build_agent(
model: BaseChatModel | None = None,
*,
checkpointer: BaseCheckpointSaver | None = None,
store: BaseStore | None = None,
) -> Any:
"""Build the agent with short-term (checkpointer) and long-term (store) memory."""
return create_agent(
model=model or get_model(),
tools=[get_order_status, save_preference, recall_preferences],
system_prompt=SYSTEM_PROMPT,
context_schema=SupportContext,
checkpointer=checkpointer,
store=store,
)Block 9Two threads, one profile
sqlite3.connect("lab04_threads.db", check_same_thread=False)opens (or creates) the database file.check_same_thread=Falseis needed because the agent may use the connection from worker threads.- Thread
mon-1: Ada states a preference. Expectsave_preference. - Thread
thu-1: a new conversation that asks how Ada will be updated. It has none of Monday's messages, butrecall_preferencesfinds the saved preference in the store. beforecounts the messages already in the thread, so thetoolsline only shows the tools called in this run, even when you rerun the script.agent.get_state(...)shows that each thread has its own message history.- The last two lines search the store directly: Acme's Ada has a profile, and Globex's Ada (a different company) sees nothing.
CONVERSATIONS = [
("mon-1", "Hi, I'm Ada. Always email me, never phone."),
("thu-1", "Hi again - where is order A-100, and how will you update me?"),
]
if __name__ == "__main__":
connection = sqlite3.connect("lab04_threads.db", check_same_thread=False)
store = InMemoryStore()
agent = build_agent(checkpointer=SqliteSaver(connection), store=store)
ada = SupportContext(org_id="acme-corp", customer_id="ada")
for thread_id, text in CONVERSATIONS:
config = {"configurable": {"thread_id": thread_id}}
before = len(agent.get_state(config).values.get("messages", []))
result = agent.invoke({"messages": [{"role": "user", "content": text}]},
config=config, context=ada)
new_messages = result["messages"][before:]
tools = [c["name"] for m in new_messages for c in getattr(m, "tool_calls", [])]
print(f"[{thread_id}] you : {text}")
print(f"[{thread_id}] tools : {', '.join(tools) or 'none'}")
print(f"[{thread_id}] agent : {result['messages'][-1].text}\n")
# Short-term memory: each thread has its own message history in SQLite.
for thread_id, _ in CONVERSATIONS:
state = agent.get_state({"configurable": {"thread_id": thread_id}})
print(f"thread {thread_id}: {len(state.values.get('messages', []))} messages saved")
# Long-term memory: one profile per (org, customer), shared by every thread.
print("acme-corp/ada profile:",
[i.value["text"] for i in store.search(preference_namespace("acme-corp", "ada"))])
print("globex/ada profile :",
[i.value["text"] for i in store.search(preference_namespace("globex", "ada"))])
connection.close()Run the two conversations
python remembering_customers.pypython3 remembering_customers.pyYour output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.
[mon-1] you : Hi, I'm Ada. Always email me, never phone.
[mon-1] tools : recall_preferences, save_preference
[mon-1] agent : Nice to meet you, Ada — I’ll email you and won’t call.
[thu-1] you : Hi again - where is order A-100, and how will you update me?
[thu-1] tools : recall_preferences, get_order_status
[thu-1] agent : Order A-100 has shipped via DHL with an ETA of 2026-09-15, and I will send updates to your preferred contact channel (email).
thread mon-1: 6 messages saved
thread thu-1: 5 messages saved
acme-corp/ada profile: ['contact_channel: email']
globex/ada profile : []- On Monday the model called
save_preference. On Thursday, in a thread with no Monday messages, it calledrecall_preferences, found the email preference and says it will update Ada by email. - The two threads have separate message counts: short-term memory is per thread.
acme-corp/adahas one profile entry,globex/adahas none: long-term memory is per tenant and customer.
A new file, lab04_threads.db, now holds both threads. Run the script again: the message counts grow, because the threads continue where they stopped. The store starts empty again, because InMemoryStore lives in RAM. In production you would use a database-backed store such as PostgresStore.
Check your work
You are done when
- Thread
mon-1callssave_preference, threadthu-1callsrecall_preferences. - The Thursday answer mentions email, although that thread never saw Monday's messages.
- The two threads report different message counts.
acme-corp/ada profilelists the email preference andglobex/ada profileis empty.- The file
lab04_threads.dbexists in your lab folder.
Troubleshooting
ModuleNotFoundError: No module named 'langgraph.checkpoint.sqlite'
Install the separate package: pip install langgraph-checkpoint-sqlite==3.1.1 (it is in requirements.txt).
AttributeError: 'NoneType' object has no attribute 'put' (or 'search')
runtime.store is None because create_agent was not given store=.
The message counts are higher than in the expected output
You ran the script before. The threads in lab04_threads.db continue. Delete the file to start over.
database is locked
Another Python process still has lab04_threads.db open, or OneDrive is syncing the folder. Close the other process and keep the lab on a short local path such as C:\labs.
401 Unauthorized or 'Access denied due to invalid subscription key'
The key in .env is wrong or has spaces around it. Copy it again from your instructor, with no quotes.
404 'DeploymentNotFound' or 'The API deployment for this resource does not exist'
AZURE_OPENAI_DEPLOYMENT must be the deployment name exactly as your instructor gave it, and the endpoint must be the same resource.
429 Too Many Requests
The shared deployment is busy. Wait 30 seconds and run again.
ModuleNotFoundError: No module named 'model_setup' (or another package)
Run the command from inside your lab folder, with the virtual environment activated. Reinstall with pip install -r requirements.txt if a library is missing.
'python' is not recognized, or the wrong Python runs
Activate the virtual environment first. On Windows you can also try py -3.11 instead of python when creating the venv.
Go further
- Add a third thread where Ada says Actually, phone is fine now. Check the store still has one
contact_channelitem. - Add a
forget_preference(kind)tool that callsruntime.store.delete(...). Together withcheckpointer.delete_thread(thread_id)that is a data-erasure request. - Swap
InMemoryStoreforPostgresStore(packagelanggraph-checkpoint-postgres) so the profile survives a restart too. - Add
SummarizationMiddlewareso long threads stay inside the model's context window.
Stuck?
Download the finished project and compare it with yours. It contains every file from this guide except .env and .venv, so you still add your own settings.