LangGraph Tutorial: Building Stateful Multi-Step Agents
Most agent bugs are not model bugs. They are architecture bugs — a chain that cannot loop, a workflow that forgets what happened two steps ago, or a "multi-step agent" that is secretly just three prompts stapled together with string formatting. If you have tried to build an agent that calls a tool, checks the result, and decides whether to call another tool or stop, you have probably hit the wall that plain LLM chains cannot climb: chains only go forward. This LangGraph tutorial exists because agents need to go in circles.
LangGraph is a graph-based orchestration layer built by the LangChain team specifically to solve this. Instead of chaining calls in a straight line, you model your agent as a graph of nodes and edges, with an explicit, typed state object flowing between them. That one shift — from linear chain to graph with cycles — is what makes reasoning loops, tool-calling loops, and human-in-the-loop checkpoints actually tractable. This is a practitioner-level walkthrough: the mental model, a working state schema, a working graph, and the debugging habits that will save you hours once your graph has more than three nodes.
Why chains break down for agents
A chain (in the classic LangChain sense) is a fixed pipeline: step A feeds step B feeds step C. That works fine for "summarize this document" or "translate this text." It falls apart the moment your task has a variable number of steps, because chains have no way to ask "do I need to run this again?"
Consider a research agent that needs to search the web, read results, and decide if it has enough information to answer. Sometimes one search is enough. Sometimes it needs four. A chain forces you to either hardcode the number of iterations (brittle, wastes calls when one search would do, fails when four are not enough) or bail out into raw Python with manual while loops and ad hoc state passing (unmaintainable past a few hundred lines).
LangGraph's answer is to make the loop a first-class citizen of the orchestration layer. You describe the graph once — including the edge that says "go back to reasoning if not done" — and the runtime handles execution, state propagation, and termination. You stop writing bookkeeping code and start writing decision logic.
The core mental model: state, nodes, edges, cycles
Everything in LangGraph reduces to four concepts. Get these right and the rest of the framework is just API surface.
State is a shared object that flows through the entire graph. Think of it as the single source of truth for "everything that has happened so far" — the conversation history, the tool call results, a counter, a flag, whatever your agent needs to remember. It is typically defined as a TypedDict or a Pydantic model, and every node receives the current state and returns an update to it.
Nodes are plain Python functions (or callables) that do one job: read some state, do some work — call an LLM, call a tool, transform data — and return a partial update to that state. A node does not need to know about the rest of the graph. It just needs to know its slice of the state.
Edges decide what runs next. A normal edge is unconditional: node A always leads to node B. A conditional edge is a function that inspects the current state and returns the name of the next node to run. This is where branching logic lives — "if the LLM asked for a tool call, go to the tool node; otherwise, go to the end."
Cycles are what you get for free once edges can point backward. A conditional edge from your tool node back to your reasoning node turns a one-shot chain into a loop that runs until some stopping condition is met. This is the single capability that separates "agent" from "chain," and it is the reason LangGraph exists as a distinct project rather than just being another chain type.
If you have used state machines or workflow engines before, this will feel familiar — LangGraph is, deliberately, a state machine with LLM-shaped nodes. What makes it more than "a state machine with an LLM bolted on" is that the state object is typed and versioned through the whole run, so you always know exactly what data was available to a node at the moment it executed. That property is what makes debugging tractable later, and it is worth internalizing now, before you write a single node.
It also helps to be explicit about what LangGraph is not. It is not a prompt-templating library, and it is not a replacement for whatever LLM SDK you are using to actually call a model — that work still happens inside a node, using whatever client you already know. LangGraph's entire job is orchestration: deciding what runs, in what order, with what data, and when to stop. Treat it as the scheduler sitting above your LLM calls, not a rewrite of them.
Nodes versus edges: a common point of confusion
New LangGraph users often try to cram routing logic into a node, or side effects into an edge. Keeping the two cleanly separated is worth stating explicitly.
A node is a worker. It does one job — call a model, call an API, transform some data — and its only contract with the rest of the graph is the dict of state updates it returns. A node should not need to know which node runs after it.
An edge is a decision. A conditional edge in particular should be cheap and side-effect-free: read a few fields off state, return a string. If your conditional edge function is making network calls or mutating anything, that logic belongs in a node instead, with the edge simply reading the result the node already computed. This separation keeps your graph's control flow legible — you can look at add_conditional_edges calls alone and understand every possible path through the agent, without reading node internals.
Setting up
Install the core package. You will also want a model provider package and, for tool-calling examples, whatever LLM SDK you are using.
pip install langgraph langchain-core langchain-openaiLangGraph is intentionally provider-agnostic — it does not care whether the reasoning node calls OpenAI, Anthropic, or a local model. The graph only cares about the shape of the state going in and out of each node.
Defining the state schema
The state schema is the contract every node in your graph agrees to honor. Get this right first, because retrofitting new fields into a graph that already has ten nodes is more painful than designing it up front.
For a multi-step agent that reasons and calls tools, you typically need at minimum: the running message history, a flag or counter for whether more tool calls are pending, and space for the final answer.
from typing import Annotated, TypedDict
from langgraph.graph.message import add_messages
class AgentState(TypedDict):
# add_messages is a reducer: it appends new messages
# instead of overwriting the whole list on every update
messages: Annotated[list, add_messages]
tool_calls_remaining: bool
step_count: int
final_answer: str | NoneTwo details matter here. First, Annotated[list, add_messages] attaches a reducer to the messages field. Without a reducer, returning {"messages": [new_message]} from a node would replace the entire message list with a single-item list — obviously wrong. The add_messages reducer tells LangGraph "append, don't overwrite," which is exactly what you want for a running conversation.
Second, step_count exists purely as a safety valve. Reasoning loops that depend on an LLM's judgment to terminate can, in practice, fail to terminate — the model keeps deciding it needs "just one more" tool call. A hard step ceiling checked in your conditional edge is cheap insurance against runaway loops and runaway API bills.
Building the reasoning node
The reasoning node is where the LLM looks at the conversation so far and decides what to do next: answer directly, or request a tool call. In practice this is a single LLM invocation bound to your available tools.
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
@tool
def search_web(query: str) -> str:
"""Search the web and return a short summary of results."""
# real implementation would call a search API
return f"Top result for '{query}': ..."
llm = ChatOpenAI(model="gpt-4o").bind_tools([search_web])
def reasoning_node(state: AgentState) -> dict:
response = llm.invoke(state["messages"])
has_tool_calls = bool(response.tool_calls)
return {
"messages": [response],
"tool_calls_remaining": has_tool_calls,
"step_count": state["step_count"] + 1,
}Notice the shape: read from state, do exactly one unit of work, return a dict of updates. The node does not call itself, does not loop, does not know what happens after it runs. All of that is the graph's job, not the node's.
Building the tool-calling node
The tool node's job is narrower: take whatever tool calls the LLM just requested, execute them, and feed the results back into the message history as tool result messages.
from langchain_core.messages import ToolMessage
tools_by_name = {"search_web": search_web}
def tool_node(state: AgentState) -> dict:
last_message = state["messages"][-1]
results = []
for call in last_message.tool_calls:
tool_fn = tools_by_name[call["name"]]
output = tool_fn.invoke(call["args"])
results.append(
ToolMessage(content=str(output), tool_call_id=call["id"])
)
return {"messages": results}This pattern generalizes cleanly to multiple tools. tools_by_name is just a dispatch table, and the loop handles the case where a single reasoning step requests several tool calls at once — common with modern tool-calling models that can batch requests.
Adding the conditional edge
This is the piece that turns two nodes into an actual agent. After the reasoning node runs, you need to branch: if it asked for tools, go run them; if it did not, you are done.
def route_after_reasoning(state: AgentState) -> str:
if state["step_count"] >= 6:
return "end"
if state["tool_calls_remaining"]:
return "tools"
return "end"This function is a conditional edge — it does not do any work itself, it just returns a label. LangGraph uses that label to decide which node runs next. Note the step ceiling check runs first: even if the model wants more tool calls, six steps in you cut it off. This is the kind of guardrail that looks unnecessary in a demo and saves you in production.
Assembling the graph
With both nodes and the router defined, wiring the graph together is a small amount of code — and reading it top to bottom should read almost like a flowchart.
from langgraph.graph import StateGraph, START, END
graph_builder = StateGraph(AgentState)
graph_builder.add_node("reasoning", reasoning_node)
graph_builder.add_node("tools", tool_node)
graph_builder.add_edge(START, "reasoning")
graph_builder.add_conditional_edges(
"reasoning",
route_after_reasoning,
{"tools": "tools", "end": END},
)
graph_builder.add_edge("tools", "reasoning") # the loop
graph = graph_builder.compile()Walk through the flow: execution starts at START, which always goes to reasoning. From reasoning, the conditional edge either sends control to tools or to END. Critically, tools has a plain edge straight back to reasoning — that is the cycle. Every tool call result gets re-evaluated by the reasoning node, which can decide to call another tool, or stop.
This is the entire mental model made concrete: two nodes, one conditional branch, one loop-back edge. Everything more elaborate you build in LangGraph — multi-agent supervisors, human-approval checkpoints, parallel tool fan-out — is this same skeleton with more nodes and more edges.
Running the graph and inspecting state
Invoke the compiled graph like any other Runnable, passing in the initial state.
result = graph.invoke({
"messages": [{"role": "user", "content": "What's the latest LangGraph release?"}],
"tool_calls_remaining": False,
"step_count": 0,
"final_answer": None,
})
print(result["messages"][-1].content)For anything beyond a toy example, do not just call invoke and hope. Use .stream() instead of .invoke() during development — it yields the state after each node executes, which turns your graph into something you can watch run step by step rather than a black box that returns an answer three seconds later.
for step in graph.stream(initial_state, stream_mode="values"):
last_msg = step["messages"][-1]
print(f"[step_count={step['step_count']}] {type(last_msg).__name__}: {last_msg.content[:80]}")This one habit — streaming state instead of invoking blind — is the fastest way to catch the two most common bugs in a new graph: a reducer that is silently overwriting instead of appending, and a conditional edge that routes to the wrong node because a flag was never reset.
Debugging: visualizing the graph and stepping through state
Two debugging techniques cover the vast majority of real issues, and both are things beginners skip until something breaks badly enough to force the issue.
Visualize the graph structure before you run it. LangGraph can render the compiled graph as a diagram, which catches structural mistakes — a missing edge, a conditional edge with an unreachable branch, an accidental disconnected node — before you burn API calls debugging what looks like a logic bug but is actually a wiring bug.
graph.get_graph().draw_mermaid_png(output_file_path="agent_graph.png")Open that PNG whenever a graph "isn't doing what you expect." Half the time the problem is visible immediately: an edge you thought you added is missing, or a conditional edge's dictionary of labels does not match what the router function actually returns (a classic typo bug — "Tools" vs "tools" fails silently and just routes to a KeyError or falls through).
Inspect state at each step, not just at the end. The stream_mode="values" pattern above gives you the full state snapshot after every node. When a bug shows up in the final answer, do not start by reading the final answer — start by reading the state trace and finding the first step where something looks wrong. Common things you will find this way: step_count not incrementing (you forgot to pass it through in a node's return dict), messages duplicating (a reducer misconfiguration), or the loop never exiting (the conditional edge's termination branch is checking a field that a node never actually sets).
For deeper inspection, LangGraph also supports checkpointers — pluggable persistence that snapshots state after every node, so you can pause a run, inspect it, rewind to an earlier step, and resume. This is not just a debugging nicety; it is the same mechanism that enables human-in-the-loop approval flows in production, where a graph pauses before a sensitive action and waits for a human to approve before the cycle continues.
from langgraph.checkpoint.memory import MemorySaver
checkpointer = MemorySaver()
graph = graph_builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "debug-session-1"}}
result = graph.invoke(initial_state, config=config)
# inspect the full history of states for this thread
for snapshot in graph.get_state_history(config):
print(snapshot.values["step_count"], snapshot.next)snapshot.next tells you which node LangGraph is about to run next for every recorded checkpoint — which means you can reconstruct the exact path your agent took through the graph, node by node, after the fact. That is worth far more than print statements scattered through node functions.
Common mistakes when you start building
A few failure patterns show up constantly in graphs written by people new to LangGraph, and all of them trace back to not fully internalizing the state-node-edge model.
- Forgetting reducers on list fields. If a field accumulates values across steps — messages, intermediate results, a scratchpad — it needs a reducer like
add_messagesor a custom one. Without it, each node's return silently overwrites the field instead of appending to it. - Mutating state in place instead of returning updates. Nodes should return a dict describing what changed, not mutate the incoming
stateobject directly. In-place mutation works by accident in some setups and breaks in others (particularly with checkpointing), so do not rely on it. - No termination guarantee. Any loop driven by an LLM's judgment needs a hard backstop — a step counter, a timeout, or both. "The model will eventually say it's done" is not a termination guarantee, it is a hope.
- Conditional edge labels that do not match the routing dictionary. The string a router function returns must exactly match a key in the dict passed to
add_conditional_edges. Typos here fail at graph-compile or graph-run time in ways that are easy to misdiagnose as model behavior rather than wiring. - Overloading a single node with multiple responsibilities. If a node is calling an LLM, calling a tool, and updating three unrelated state fields, split it. Small, single-purpose nodes are what make the graph visualization — and the debugging story — actually useful.
- Ignoring parallel tool calls. Modern tool-calling models frequently return multiple tool calls from a single reasoning step. If your tool node only handles
tool_calls[0], it will silently drop every additional request. The loop pattern shown earlier iterates over all of them for exactly this reason. - Skipping the visualization step because the graph "feels simple." Three-node graphs are exactly where people skip
draw_mermaid_pngand exactly where a stray missing edge goes unnoticed for an afternoon. It takes one line of code; run it every time you change the graph's shape, not just when something is already broken.
A note on state size and cost
One thing that catches people off guard once a loop runs for more than two or three iterations: the messages field keeps growing, and every reasoning node invocation re-sends the entire accumulated history to the model. A six-step loop with verbose tool outputs can quietly balloon into a very large, very expensive prompt by the final step — and past a certain length, added context does not just cost money, it can measurably degrade the model's reasoning quality.
Two practical mitigations are worth building in from the start rather than retrofitting later. First, summarize or truncate tool outputs before they go into messages — a tool node can return a condensed version of a search result instead of the raw payload. Second, consider a dedicated "trim" step, either as its own node or as logic inside the reasoning node, that keeps only the last N exchanges plus a running summary of anything older. Neither of these is exotic; they are the same context-management discipline you would apply to any long-running conversation, just now formalized as part of your graph's state contract instead of handled ad hoc.
Where this goes from here
The graph you built in this LangGraph tutorial — reasoning node, tool node, conditional edge, loop-back edge — is the full skeleton underneath nearly every production agent pattern: ReAct-style tool use, multi-agent supervisor architectures where a router node dispatches to specialist sub-graphs, and human-in-the-loop workflows that pause mid-cycle for approval. None of those add a new concept. They add more nodes, more conditional edges, and occasionally a nested sub-graph — the mental model of state flowing through nodes connected by edges, some of which loop, does not change.
The gap between "I can build a two-node demo" and "I can debug a fifteen-node production agent at 2am" is mostly about the habits covered here: schema discipline up front, streaming state instead of invoking blind, visualizing the graph before assuming the bug is in your prompt, and always having a hard termination guarantee. Those habits, plus real practice building graphs that branch, loop, and recover from failure, are exactly what we drill into in Advanced AI Agents — the course where Pramod Dutta and Ira Menon take you from this kind of tutorial-sized graph to production-grade multi-agent systems.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.