n4nAI

Building a multi-turn support bot in LangGraph with memory

A hands-on tutorial for building a multi-turn LangGraph customer support bot with durable conversation memory via checkpointers, including runnable Python code and expected output.

n4n Team3 min read574 words

Audio narration

Coming soon — every post will get a voice note here.

A langgraph support bot memory implementation needs more than a prompt with chat history—it needs durable state across turns. This tutorial builds a multi-turn customer support agent in LangGraph that persists conversation state per thread, so the bot recalls prior messages without you manually re-sending the transcript on every call.

Prerequisites

  • Python 3.10 or newer
  • langgraph and langchain-openai installed
  • An OpenAI-compatible API key (or any endpoint you can call)
pip install langgraph langchain-openai

Export your credential:

export OPENAI_API_KEY="sk-..."

If you later point at a gateway, you’ll swap the base URL and key, not the graph code.

The core idea behind langgraph support bot memory

LangGraph separates execution from state. The graph node runs the model; a checkpointer stores the message list keyed by thread_id. On each invocation you pass the same thread_id, and LangGraph loads prior messages automatically. You never concatenate history by hand or stuff it into the system prompt.

This matters for support bots because users ask follow-ups (“How long do I have?”) that are incoherent without the prior turn. Checkpointers make that context a deployment detail, not a prompt-engineering hack.

Define the state and node

LangGraph ships MessagesState, a typed dict with a messages field. We’ll inject a system prompt at call time so it isn’t duplicated in the stored transcript.

from langgraph.graph import StateGraph, MessagesState, START, END
from langchain_openai import ChatOpenAI

SYSTEM = "You are a concise customer support agent for Acme Corp. Use prior context in the thread."

model = ChatOpenAI(model="gpt-4o-mini", temperature=0)

def chatbot(state: MessagesState):
    msgs = state["messages"]
    if not msgs or msgs[0].type != "system":
        msgs = [("system", SYSTEM)] + list(msgs)
    response = model.invoke(msgs)
    return {"messages": [response]}

The node returns only the new AI message; LangGraph appends it to the existing list.

Compile with a checkpointer

Memory requires a checkpointer. MemorySaver keeps state in process memory; swap it for SqliteSaver or PostgresSaver in production.

from langgraph.checkpoint.memory import MemorySaver

builder = StateGraph(MessagesState)
builder.add_node("chatbot", chatbot)
builder.add_edge(START, "chatbot")
builder.add_edge("chatbot", END)

memory = MemorySaver()
app = builder.compile(checkpointer=memory)

Run a multi-turn conversation

Create a thread config. Each user message is invoked with the same config.

config = {"configurable": {"thread_id": "user-42"}}

turn1 = app.invoke(
    {"messages": [("user", "What is your return policy?")]},
    config
)
print(turn1["messages"][-1].content)

Expected output (abridged):

Our return policy allows returns within 30 days of purchase with a receipt.

Now ask a follow-up that relies on context:

turn2 = app.invoke(
    {"messages": [("user", "Do I need the original packaging?")]},
    config
)
print(turn2["messages"][-1].content)

Because the checkpointer loaded the prior user question and the bot’s answer, the model responds coherently:

If the item is unopened, original packaging helps but is not required within 30 days.

Inspect stored memory

Debugging a langgraph support bot memory issue starts with dumping the state.

state = app.get_state(config)
for m in state.values["messages"]:
    print(m.type, ":", m.content[:60])

Output:

system : You are a concise customer support agent for Acme Corp...
user : What is your return policy?
ai : Our return policy allows returns within 30 days...
user : Do I need the original packaging?
ai : If the item is unopened, original packaging helps...

Adding a retrieval tool without losing memory

Support bots need facts. Wrap a function as a tool; LangGraph stores tool calls and observations in the same message list, so memory stays consistent.

from langchain_core.tools import tool
from langgraph.prebuilt import ToolNode, tools_condition

@tool
def lookup_policy(query: str) -> str:
    """Lookup Acme policy docs."""
    return "Returns accepted within 30 days, original packaging preferred."

tool_node = ToolNode([lookup_policy])

def chatbot(state: MessagesState):
    msgs = state["messages"]
    if not msgs or msgs[0].type != "system":
        msgs = [("system", SYSTEM)] + list(msgs)
    model_with_tools = model.bind_tools([lookup_policy])
    return {"messages": [model_with_tools.invoke(msgs)]}

builder = StateGraph(MessagesState)
builder.add_node("chatbot", chatbot)
builder.add_node("tools", tool_node)
builder.add_edge(START, "chatbot")
builder.add_conditional_edges("chatbot", tools_condition)
builder.add_edge("tools", "chatbot")
builder.add_edge("chatbot", END)

app = builder.compile(checkpointer=memory)

Run with the same config. The tool result is appended to the thread state, visible in get_state. This proves the langgraph support bot memory approach scales to agentic loops.

Expected interaction when asking “What’s the return window?”:

AI: I'll check the policy. (tool_call)
Tools: Returns accepted within 30 days, original packaging preferred.
AI: You have 30 days to return items.

Clearing or forking threads

To reset a conversation, change thread_id or delete the checkpoint. With MemorySaver you can simply use a new ID. For SQLite:

from langgraph.checkpoint.sqlite import SqliteSaver
saver = SqliteSaver.from_conn_string("support.db")
config = {"configurable": {"thread_id": "user-43"}}

Forking lets you branch a conversation: copy state to a new thread and continue without mutating the original.

Pointing at an inference gateway

If you don’t want to juggle provider keys, point ChatOpenAI at an OpenAI-compatible gateway. n4n.ai exposes one endpoint that fronts 240+ models and automatically falls back when a provider is rate-limited, while honoring your routing directives per call.

model = ChatOpenAI(
    base_url="https://api.n4n.ai/v1",
    api_key="your-n4n-key",
    model="anthropic/claude-3.5-sonnet"
)

The rest of the graph code stays identical. Per-token metering shows up in your gateway dashboard instead of scattered provider bills.

Production considerations

  • Truncation: MemorySaver grows unbounded. Use a custom reducer or prune messages older than N turns before they hit the model.
  • Concurrency: SqliteSaver is fine for single-instance; use PostgresSaver with row locking for multi-worker deployments.
  • Secrets: System prompts and API keys belong in env vars, not in code.
  • Streaming: app.stream() yields token chunks; wire it to your chat UI with the same config object.

The langgraph support bot memory pattern is fundamentally about letting the framework own state persistence. Once that clicks, multi-turn support stops being a prompt-engineering chore and becomes a configuration choice.

Tagslanggraphcustomer-supportmemorychatbot

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All framework tutorials: customer support bots posts →