Agentic AI Engineering with LangChain / Lab 04
Module 04 · Memory

Lab 04 · Remembering customers

Give the support agent two kinds of memory: SQLite-backed conversation threads and a shared customer profile in a store.

45 minIntermediateAzure OpenAIcheckpointerSqliteSaverthread_idInMemoryStorenamespaceToolRuntimecontext

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 SqliteSaver makes 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
1

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.
PowerShell
python --version
i

From 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.

2

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.

PowerShell
mkdir C:\labs\lab-04-remembering-customers
cd C:\labs\lab-04-remembering-customers
python -m venv .venv
.venv\Scripts\Activate.ps1
✓

Keep 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:

lab-04-remembering-customers/ ├── .env ├── lab04_threads.db ├── model_setup.py ├── remembering_customers.py └── requirements.txt

Files marked as generated are created when you run the code. You do not create them yourself.

3

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.

requirements.txt
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.1

langgraph-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.

PowerShell
python -m pip install --upgrade pip
pip install -r requirements.txt
4

Add 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.

.env
# 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 just https://<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/.

5

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.

model_setup.py (add to the end of the file)
"""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/v1 is the newer OpenAI-compatible API.
  • https://<resource>.openai.azure.com/ is the classic API that needs an api_version.
model_setup.py (add to the end of the file)
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/v1 endpoint it uses ChatOpenAI with base_url. On Azure the deployment name goes in model.
  • With a classic endpoint it uses AzureChatOpenAI with azure_deployment and api_version.

Either way the lab code just calls get_model() and never cares which one it got.

model_setup.py (add to the end of the file)
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.

model_setup.py (add to the end of the file)
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.

model_setup.py (add to the end of the file)
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:

PowerShell
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.

6

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

  • sqlite3 opens the database file for the checkpointer.
  • SqliteSaver saves threads to SQLite.
  • InMemoryStore is the long-term memory. It lives in RAM, which is enough for a lab.
  • ToolRuntime gives tools access to the store and the context.
remembering_customers.py (add to the end of the file)
"""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_model

Block 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.

remembering_customers.py (add to the end of the file)
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: str

Block 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.

remembering_customers.py (add to the end of the file)
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.

remembering_customers.py (add to the end of the file)
@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 is kind, such as contact_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.
remembering_customers.py (add to the end of the file)
@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.

remembering_customers.py (add to the end of the file)
@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.

remembering_customers.py (add to the end of the file)
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= enables runtime.store in tools (long-term memory). Without it, runtime.store is None and the tools fail.
  • context_schema= declares the type of the per-run context.
remembering_customers.py (add to the end of the file)
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=False is needed because the agent may use the connection from worker threads.
  • Thread mon-1: Ada states a preference. Expect save_preference.
  • Thread thu-1: a new conversation that asks how Ada will be updated. It has none of Monday's messages, but recall_preferences finds the saved preference in the store.
  • before counts the messages already in the thread, so the tools line 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.
remembering_customers.py (add to the end of the file)
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()
7

Run the two conversations

PowerShell
python remembering_customers.py

Your output should look similar to this. Model answers vary a little from run to run, so the wording will not match exactly.

Expected output
[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 called recall_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/ada has one profile entry, globex/ada has 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.

8

Check your work

You are done when

  • Thread mon-1 calls save_preference, thread thu-1 calls recall_preferences.
  • The Thursday answer mentions email, although that thread never saw Monday's messages.
  • The two threads report different message counts.
  • acme-corp/ada profile lists the email preference and globex/ada profile is empty.
  • The file lab04_threads.db exists 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_channel item.
  • Add a forget_preference(kind) tool that calls runtime.store.delete(...). Together with checkpointer.delete_thread(thread_id) that is a data-erasure request.
  • Swap InMemoryStore for PostgresStore (package langgraph-checkpoint-postgres) so the profile survives a restart too.
  • Add SummarizationMiddleware so 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.