LangGraph Explained: Agents as State Machines
How LangGraph turns the agent while-loop into a graph — nodes, edges, shared state, per-key reducers, the tools loop, and checkpoint time travel.
This article has an interactive companion
Don't just read it — step through it interactively, one state change at a time.
Every agent you have ever written is a while loop. Call the model, check whether it asked for a tool, run the tool, feed the result back, repeat until it stops asking. The logic is simple; the trouble is that all the interesting control flow — the if, the break, the "wait, retry that step" — is buried inside the loop body where you cannot see it, pause it, or rewind it.
LangGraph's whole pitch is to take that hidden control flow and make it a graph you can hold in your hand. Nodes do the work. Edges decide what runs next. A shared state object is the whiteboard every node reads from and writes to. Seven lines of wiring is the entire API surface, and once the loop is a graph, durability, human-in-the-loop pauses, and time travel come almost for free.
This is a guide to the mechanisms behind YeetCode's interactive LangGraph deep dive: the reframe, state and reducers, the agent ⇄ tools loop, and checkpoints. Every trace below comes from a real deterministic graph engine, not a hand-waved diagram.
Your while-loop is already a graph
Here is the agent loop most people write by hand:
while True:
ai = model.invoke(messages)
messages.append(ai)
if not ai.tool_calls: # the hidden if/break
break
for call in ai.tool_calls:
messages.append(run_tool(call))The if not ai.tool_calls: break is a routing decision, but it is trapped inside procedural code. You cannot inspect it, you cannot snapshot the state right before it fires, and you cannot swap it out without editing the loop.
LangGraph draws the same program as a state machine:
graph = StateGraph(MessagesState)
graph.add_node("agent", call_model)
graph.add_node("tools", ToolNode(tools))
graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", tools_condition)
graph.add_edge("tools", "agent")
app = graph.compile(checkpointer=saver)Two nodes, three edges. START → agent is the entry. agent → tools_condition is the old if — now a first-class conditional edge that reads the state and returns the name of the next node. tools → agent is the loop-back that closes the cycle. The break becomes a route to END. Nothing about the behavior changed; the difference is that the decisions are now data on the graph instead of statements in a function.
State and reducers: how nodes write to the whiteboard
A node never returns the whole state. It returns a partial update — a dict with only the keys it wants to touch — and LangGraph merges that update into the running state. The rule for how each key merges is called its reducer, and you attach one per key:
from typing import Annotated, TypedDict
from operator import add
from langgraph.graph import add_messages
class State(TypedDict):
messages: Annotated[list, add_messages] # append-or-update-by-id
counter: Annotated[int, add] # sum the deltasReducers are the part people skip and then get burned by. The same node return folds completely differently depending on which reducer sits on the key:
| Reducer | Merge rule | A return with an existing id |
|---|---|---|
| (none) / overwrite | the returned value replaces the channel | wipes everything, keeps only the new value |
add | concatenates the returned list | duplicates — add has no notion of identity |
add_messages | appends new items | updates that message in place |
Trace a three-item queue through each to feel the difference. A node returns id=1 (a user message), then id=2 ("Let me check…"), then id=2 again ("It's 24°C and sunny."):
overwrite → 1 message (each return wipes the list; only the last survives)
add → 3 messages (the repeated id=2 is concatenated; now two id=2 exist)
add_messages → 2 messages (the second id=2 UPDATES the first in place)add_messages is why a streamed or edited assistant message replaces its earlier draft instead of piling up duplicates: matching ids overwrite, new ids append. And because reducers are per-key, a second channel folds independently in lockstep — a counter on the add reducer sums 0 + 1 + 2 = 3 no matter what the messages channel is doing. One node return, two channels, two merge rules.
The agent ⇄ tools loop as a conditional edge
The router is the smallest, most important function in the graph. tools_condition looks at exactly one thing — whether the most recent AIMessage carries any tool_calls — and returns where to go next:
def tools_condition(state):
last = state["messages"][-1]
return "tools" if last.tool_calls else ENDNon-empty tool_calls routes to the tools node; empty routes to END. That single check is the entire difference between "the agent keeps working" and "the agent is done." When control reaches tools, ToolNode runs every tool call in that last message — a model can request several at once — and appends one ToolMessage per call, each tagged with the matching tool_call_id so the model can line results up with requests. Then the tools → agent edge feeds everything back and the model decides again.
The honesty test: remove a tool the question needs and the same question ends differently. The model finds no bound tool for a sub-task, emits no tool_calls, and the router sends it straight to END with a truthful "I can't do that" — the loop length is computed from what the question needs, never scripted.
Walkthrough: "12.5% of 840 then add 30"
Give the agent that question with calculator bound and step through the graph. The model here is rule-based so the trace is fully deterministic, but the routing, tool execution, and loop length are the real thing. LangGraph writes one checkpoint per super-step, so each row below is a genuine snapshot you could rewind to.
| # | checkpoint | node | msgs | What happens |
|---|---|---|---|---|
| 0 | ckpt-0 | START | 1 | HumanMessage "12.5% of 840 then add 30" enters the messages channel |
| 1 | ckpt-1 | agent | 2 | call_model emits an AIMessage with 1 tool_call: calculator("12.5% * 840") |
| 2 | ckpt-2 | router | 2 | tools_condition sees 1 tool_call → route to tools |
| 3 | ckpt-3 | tools | 3 | ToolNode runs calculator → 105, appends a ToolMessage |
| 4 | ckpt-4 | agent | 4 | prior result resolves the chained step → 1 tool_call: calculator("105 + 30") |
| 5 | ckpt-5 | router | 4 | 1 tool_call → route to tools |
| 6 | ckpt-6 | tools | 5 | ToolNode runs calculator → 135, appends a ToolMessage |
| 7 | ckpt-7 | agent | 6 | everything solved → AIMessage with empty tool_calls + the final answer |
| 8 | ckpt-8 | router | 6 | 0 tool_calls → route to END |
| 9 | ckpt-9 | END | 6 | Answer: 12.5% of 840 → 105; add 30 to the previous result → 135. |
Two details reward a second look. At super-step 1 the model wanted to add 30 as well, but the "add 30" step depends on a result that does not exist yet, so it defers — it only requests the calculator once, waits for 105 to land in the scratch state, then requests 105 + 30 on the next tick. That is the loop earning its keep: multi-step reasoning falls out of re-entering agent with more solved state each time. And notice the loop ran exactly twice because the question had exactly two arithmetic steps. Ask something with three tool-worthy parts and the trace grows by itself.
Checkpointers: durable state, pause, and time travel
Everything above is stateless in memory. The moment you compile with a checkpointer — graph.compile(checkpointer=saver) — the graph starts persisting one snapshot per super-step, each keyed by a thread_id and carrying a checkpoint_id, a parent_checkpoint_id, and the full channel_values. That one change buys four capabilities at once:
- Durable state. Crash mid-run and resume from the last checkpoint on the same
thread_id— the messages, scratch results, and attempt history are all there. - Pause and resume. A long or human-gated step can stop and pick up later without holding a process open.
- Human-in-the-loop interrupts. Halt before a risky node, let a person inspect or approve the state, then continue — because the state is a saved object, not a stack frame.
- Time travel. Walk
get_state_history()(newest → oldest), rewind to any past checkpoint, and fork a new timeline from it.
The fork is the sharpest tool. Take the question "is 91 prime" run without the is_prime tool bound: the model finds no tool for the primality sub-task and honestly gives up at END. Now rewind to ckpt-0, call update_state to bind is_prime, and re-run forward. update_state folds your edit through the same add_messages reducer (so the correction appends) and writes a child checkpoint whose parent is ckpt-0. The re-run diverges into a second branch that this time computes 91 → false (91 = 7 × 13). Same starting point, two timelines — the original giving-up tail is preserved as the rewound past, the fork lives alongside it. That is debugging an agent by editing its history, not by re-running from scratch.
What to remember
- An agent is a state machine: nodes do work, edges route, shared state is the whiteboard. The graph makes the hidden
if/breakvisible. - Nodes return partial updates; the reducer on each key decides the merge — overwrite replaces,
addconcatenates,add_messagesappends-or-updates-by-id. - The agent ⇄ tools loop is one conditional edge:
tool_calls?→tools, elseEND. Loop length is computed from the question, not scripted. - Compile with a checkpointer and you get durable state, pause/resume, human-in-the-loop interrupts, and rewind-and-fork time travel — one snapshot per super-step, keyed by
thread_id.
Keep going
Agents rarely work alone — most real ones sit on top of retrieval. Build that foundation with What Is RAG? Retrieval-Augmented Generation from the Ground Up, then sharpen it with Advanced RAG: Hybrid Search, Reranking & Query Transformation. For a different take on managing an agent's working memory, read Recursive Language Models: Context as a Variable. And when you want the primary sources behind all of this, How to Read AI Research Papers Without Drowning will get you through them faster.
Then open the interactive deep dive to step the real graph tick by tick — toggle tools off and watch the same question end differently, switch reducers to see a channel wipe or duplicate, and fork a failed run to fix it in a new timeline.
FAQ
What is the difference between a node and an edge in LangGraph?
A node is a function that does work: it reads the current state and returns a partial update — call_model invokes the model, ToolNode runs the requested tools. An edge decides which node runs next. A normal edge is unconditional (tools → agent always loops back), while a conditional edge like tools_condition reads the state and returns the name of the next node or END. Splitting "do the work" (nodes) from "decide what's next" (edges) is exactly what turns a buried if/break into visible, inspectable control flow.
What does the add_messages reducer actually do?
add_messages merges a node's returned messages into the existing list with append-or-update-by-id semantics. A message with a brand-new id is appended to the end; a message whose id already exists replaces the earlier one in place. That is different from a plain add reducer, which blindly concatenates and would leave two messages sharing an id, and from no reducer at all, which overwrites the whole channel and discards history. add_messages is what lets a streamed or corrected message update its earlier version without duplicating the conversation.
Why does removing a tool change where the agent ends?
Because routing is a pure function of state. The rule-based (or real) model only emits tool_calls for sub-tasks it has a bound tool to serve. Take away a needed tool and that sub-task produces no tool call, so the last AIMessage has empty tool_calls, and tools_condition routes straight to END with an honest "I can't complete that." The loop never runs a step it cannot serve — its length and its outcome are both computed from the question plus the tools you bound, which is why the same prompt genuinely ends in a different place.
What is a checkpointer and why does it enable time travel?
A checkpointer is the persistence layer you pass to graph.compile(checkpointer=saver). It saves one snapshot per super-step, each with a checkpoint_id, a parent_checkpoint_id, and the full channel values, all keyed by thread_id. Because every past state is a durable, addressable object, you can list history with get_state_history(), rewind to any checkpoint, call update_state to edit it (the edit folds through the channel reducers and writes a child checkpoint), and re-run forward to produce a divergent branch. Time travel is just "resume from an old checkpoint" — the checkpointer is what makes those old states exist.
Make it stick: run this one yourself
Don't just read it — step through it interactively, one state change at a time.