LangGraph — Stateful Workflows & Routing
A chain is a straight line; an agent is a black-box loop. LangGraph is the middle: you model your agent as an explicit graph — a shared state, nodes that update it, and edges (some conditional) that decide what runs next. This chapter builds one from scratch so the prebuilt agent stops being magic.
- Claude: an Anthropic API key (
ANTHROPIC_API_KEY) from console.anthropic.com +pip install langchain-anthropic - OpenAI: an OpenAI API key (
OPENAI_API_KEY) from platform.openai.com +pip install langchain-openai
invoke are identical.Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.Learning objectives
- Explain LangGraph's model: State, Nodes, Edges, and the compiled graph.
- Define a typed state with reducers (why
messagesappends instead of overwrites). - Build a graph with nodes and normal edges.
- Add conditional edges for routing — the branch a chain can't express.
- Rebuild the ReAct loop as an explicit model↔tools graph.
30-second recap
- Agent architectures: Agents differ in who controls the flow: fixed workflows, a single tool-using loop, or graphs of steps with explicit state.
- Agent & tool foundations: The model asks to call a tool, your code runs it and returns the result, and the loop repeats until the model answers — with step caps so it can't run forever.
create_react_agent is a compiled LangGraph graph you didn't have to write. You drop to raw LangGraph when you need control it hides: custom routing, extra state fields, a reflection step, multiple agents, or a human-approval pause (L5). Learn the primitives here; wield them in L5 and the multi-agent module.The mental model: State + Nodes + Edges advanced
You've hit this wall already, even if you didn't have a name for it. A LangChain chain (L2) is a straight pipe: A feeds B feeds C, no branches, no loops, no going back. It's perfect until the day your agent needs to decide — "is this a billing question or a bug?" — or to repeat — "call the tool, look at the result, then think again." A chain can't turn and a chain can't loop, so people reach for the fully prebuilt agent instead, which can do both but hides how behind a single function call. LangGraph is the missing middle: the branching and looping you need, but written out in the open where you can see and change every step.
Here's the one analogy to hold onto: LangGraph is a flowchart made real. You've drawn flowcharts on whiteboards your whole career — boxes for the steps, arrows for "what happens next," diamonds for decisions. LangGraph is that exact picture turned into runnable code, with one addition that makes it powerful: a shared clipboard that every box writes on as work flows through. Get those two ideas — boxes-and-arrows plus one shared clipboard — and the rest of this chapter is just naming the parts.
There are exactly three parts, and they map cleanly onto the flowchart. A LangGraph app is a graph over a single state object — that's the shared clipboard, a plain typed dictionary that holds everything the app knows so far. Each node is one box: a function that reads the state and returns an update to it (never the whole thing — just the fields it touched). Edges are the arrows: they decide which node runs next. Most edges are plain ("always go A→B"), but a conditional edge is the decision diamond — it runs a little function on the current state and picks the next node at runtime. You wire it all up, call compile() to freeze it into a runnable app, then invoke it — and execution flows from START to END, threading the state through every node it passes.
The mistake almost everyone makes first is thinking a node "calls" the next node, the way one function calls another. It doesn't. Nodes never call each other and never reach out to grab the next step — they just read the clipboard, write their update back, and return. The edges are what move execution along; the graph runtime is the thing driving the whole flow. That separation is exactly why LangGraph is testable: a node is a nearly pure function of State → partial State, so you can call it with a fake state in a unit test and check what it returns, with no graph running at all. Keep the flowchart in your head as you read the diagram below — boxes do work, arrows decide flow, and one clipboard ties them together.
This is the whole idea of LangGraph in one picture. A LangGraph app is a graph: little boxes (called nodes) joined by arrows (called edges). There is one shared State object — a bag of data — and every node reads it and writes back into it as execution moves left to right.
- The circles START and END are the entrance and exit. Execution always begins at
STARTand finishes atEND— you never call the nodes yourself. - node A is an ordinary node: a function that looks at the State and does some work. The plain arrow into it is a normal edge — it always goes to the same next node.
- After node A the two amber arrows are a conditional edge (labelled conditional): a small function looks at the State and picks whether to go to node B or node C. Only one of them runs. This runtime choice is the thing a straight-line chain cannot do.
- Both branches funnel into
END. The caption line at the bottom is the key rule: every node reads & updates the same shared State object — the State is how nodes pass information to each other.
In short: Think of nodes as workers and edges as arrows on a flowchart. LangGraph just makes that flowchart real code, with one shared clipboard (the State) that every worker writes on.
Lab L4.1 · Typed state & reducers advanced
Here's a bug that has cost people whole afternoons. They build a chat graph, run it, and the assistant has amnesia — every turn it acts as if the conversation just started. The nodes look correct. The model is fine. The culprit is one missing annotation on the state: without it, each node's new message overwrote the entire history instead of adding to it. This section is about the single mechanism that fixes that — and it's the mechanism people most often skip because it looks like a fussy type hint.
Start with the shape of state. State in LangGraph is a typed dict — a dictionary with a fixed set of named, typed fields, so the graph (and your editor) knows exactly what data flows through. Simple enough. The subtlety is what happens when a node returns a value for one of those fields. By default the rule is dead simple and probably what you'd guess: last write wins. A node returns {"category": "billing"} and the graph drops that value into the category slot, replacing whatever was there. Overwrite. That's perfect for a field like "the current category" — you want the newest value to win.
But now think about the message list. Every turn, a node produces one new message. If "last write wins" applied here, each node would slam its single message into the messages slot and erase every message before it — that's the amnesia bug. What you actually want is append: add the new message to the running list, keep the old ones. That "how do I combine the new value with the old value" decision is called a reducer, and it's the whole idea of this section. A reducer is a rule attached to a specific field that says how a node's update merges into the existing state, instead of just overwriting it.
The analogy that makes it click: think of a bank account. Most fields are like your address — when it changes, the new one simply replaces the old (overwrite). But your balance is different — a $50 deposit doesn't become your balance, it gets added to it (a reducer). LangGraph lets you pick, per field, which kind you want. For the messages list the built-in reducer is add_messages, and attaching it is what turns a pile of stateless node calls into a graph with memory: because messages append, each node can return just its own new message and trust the graph to accumulate the full conversation. The common mistake — worth burning into memory — is forgetting that annotation and then wondering why history keeps vanishing. If a field should grow, give it a reducer; if it should reset each time, leave it plain. The lab below declares both kinds side by side so the contrast is concrete.
add_messages reducer makes a node's [msg #3] append to the list (memory grows); a plain field like step_count is simply overwritten by whatever the node returns.
Read each side top-to-bottom: the old state at the top, the node's returned update in the middle, and the merged result at the bottom. The dashed line splits the two fields.
- The left column is the
messagesfield, which carries theadd_messagesreducer. It starts with two messages; a node returns just[msg #3]. - The blue arrows are the node handing its small update back to the graph — never the whole state, just the field it changed.
- Because
messageshas a reducer, the merge appends: the greenmsg #3joins the old two, so the list grows (that is the conversation "memory"). - The right column is
step_count, which has no reducer. The node returns3and the merge simply overwrites the old2— last-write-wins.
In short: A reducer decides how an update combines with the old value — append for messages, plain overwrite for everything else.
state.pyfrom typing import Annotated, TypedDict
from langgraph.graph import add_messages # the append reducer for messages
class State(TypedDict):
messages: Annotated[list, add_messages] # nodes APPEND to this
step_count: int # no reducer → nodes OVERWRITE it
Before you build a graph you must declare its State — the shape of the shared data every node reads and writes. Here State is a TypedDict (a dictionary with named, typed fields). The clever part is the reducer: a rule for how a node's update gets merged into the existing State.
TypedDictjust means "a dictionary with a fixed set of named keys". Here the State has two keys:messagesandstep_count.messages: Annotated[list, add_messages]attaches the reduceradd_messagesto themessagesfield.Annotated[type, extra]is Python's way of tagging a type with extra info — LangGraph reads that tag and uses it.- Because of that reducer, when a node returns a new message the graph APPENDS it to the list instead of replacing the list. That is how the conversation grows turn by turn.
step_counthas no reducer, so it is last-write-wins: whatever a node returns for it simply OVERWRITES the old value. Choosing a reducer per field is how you decide what accumulates versus what resets.
What the output means: Nothing runs yet — this file only declares the State's shape. But it decides the behaviour of the whole graph: messages will grow; step_count will be replaced each time.
Try this: Picture two nodes each returning one message. With add_messages you end up with both messages in the list; without it, the second node's message would erase the first. That single difference is what LangGraph calls "memory".
add_messages is why every node can return just its new message and the graph accumulates the full conversation — the resend-the-history pattern (C2) turned into a state rule. A field with no reducer is last-write-wins. Choosing the reducer per field is how you control what accumulates versus what resets.Lab L4.2 · Build a two-node graph advanced
Enough theory — let's build the smallest graph that does something real. The mental model and the reducers only truly land once you've wired a graph with your own hands and watched data flow through it, so this lab keeps the graph deliberately tiny: a support message comes in, one node classifies it (bug / billing / other), a second node drafts a reply, done. Two nodes, one edge between them, plus the START and END bookends. If you can build this, you can build any graph — everything larger is just more of the same three moves.
Those three moves are worth naming before you read the code, because they're the same every single time. First you declare the state — the typed dict of fields the nodes will read and write. Second you write the nodes as plain functions, each one taking the state and returning a small dict of only the fields it changed. Third you wire the graph: create a StateGraph, register each function with add_node, lay the arrows with add_edge, and call compile() to freeze it into something you can invoke. Build → compile → invoke. That rhythm never changes, whether your graph has two nodes or twenty.
The one habit to form right now — and the thing beginners most often get wrong — is what a node returns. Your instinct from ordinary Python might be to grab the whole state, mutate it, and hand the whole thing back. Don't. A LangGraph node returns only the keys it touched: classify returns {"category": ...} and nothing else, even though the state also has text and reply. The graph merges that little update into the shared state for you (using reducers where you defined them). Returning partial updates is what keeps nodes composable and independently testable — each one is a self-contained "read these fields, produce those fields" unit that doesn't need to know the rest of the graph exists. Notice too that the second node reads state['category'], a value the first node wrote: that's the shared clipboard doing its job, carrying data from one node to the next without either function calling the other. Watch for that hand-off as you read — it's the moment the graph stops being a diagram and starts being a program.
START → classify → draft → END; each node returns only the field it changed (classify→category, draft→reply), and the graph merges those partial updates into the one shared State.
Read the top row left-to-right as execution flow, then look down: each node drops its small update into the shared State band at the bottom.
- The circles START and END are the entrance and exit; the runtime drives the flow, you never call the nodes yourself.
- The two blue boxes are the nodes —
classifythendraft— joined by a plain edge that always goes in that order. - The dashed arrows going down are what each node returns:
classifyreturns only{category},draftonly{reply}— never the whole state. - The amber band is the one shared State (a typed dict of
text,category,reply). The graph merges each partial update in, sodraftcan read thecategorythatclassifywrote — the hand-off without either function calling the other.
In short: Nodes return partial updates; the graph threads one shared State from START to END, carrying data node-to-node.
A two-node graph — in Claude or OpenAI
The graph, nodes, edges, and model.invoke calls are identical for both vendors — only the model object changes: ChatAnthropic (from langchain_anthropic) or ChatOpenAI (from langchain_openai). Toggle the tabs.
graph.pyfrom langgraph.graph import StateGraph, START, END
from langchain_anthropic import ChatAnthropic
from typing import TypedDict
model = ChatAnthropic(model="claude-opus-4-8", max_tokens=512)
class State(TypedDict):
text: str
category: str
reply: str
def classify(state: State) -> dict:
r = model.invoke(f"One word — bug, billing, or other:\n{state['text']}")
return {"category": r.content.strip().lower()} # update to state
def draft(state: State) -> dict:
r = model.invoke(f"Write a short reply to a {state['category']} ticket:\n{state['text']}")
return {"reply": r.content}
g = StateGraph(State)
g.add_node("classify", classify)
g.add_node("draft", draft)
g.add_edge(START, "classify")
g.add_edge("classify", "draft")
g.add_edge("draft", END)
app = g.compile()
out = app.invoke({"text": "I was double charged."})
print(out["category"], "|", out["reply"])
graph.pyfrom langgraph.graph import StateGraph, START, END
from langchain_openai import ChatOpenAI
from typing import TypedDict
model = ChatOpenAI(model="gpt-5.5", max_tokens=512)
class State(TypedDict):
text: str
category: str
reply: str
def classify(state: State) -> dict:
r = model.invoke(f"One word — bug, billing, or other:\n{state['text']}")
return {"category": r.content.strip().lower()} # update to state
def draft(state: State) -> dict:
r = model.invoke(f"Write a short reply to a {state['category']} ticket:\n{state['text']}")
return {"reply": r.content}
g = StateGraph(State)
g.add_node("classify", classify)
g.add_node("draft", draft)
g.add_edge(START, "classify")
g.add_edge("classify", "draft")
g.add_edge("draft", END)
app = g.compile()
out = app.invoke({"text": "I was double charged."})
print(out["category"], "|", out["reply"])
This is your first real graph: a tiny pipeline that classifies a support message and then drafts a reply. Read it in three parts — the State, the two node functions, then the wiring that turns them into a runnable graph.
- The State (
class State(TypedDict)) has three fields:text(the incoming message),category, andreply. The nodes will fill these in as data flows through. - node
classifyasks the model for one word (bug / billing / other) and returns{"category": ...}— just the one key it changed. It does not return the whole State; LangGraph merges that small update in for you. - node
draftreadsstate['category'](set by the previous node) and writes areply. This is nodes passing data through the shared State. - The wiring:
StateGraph(State)creates the graph;add_noderegisters each function;add_edgelays the arrowsSTART → classify → draft → END.g.compile()freezes it into a runnableapp, andapp.invoke({...})runs it once with a starting State.
What the output means: Prints the model's guess and its drafted reply, e.g. billing | We're sorry about the double charge…. Note the two nodes ran in order because a plain edge always goes classify → draft.
Try this: Add a print(state) as the first line of draft and re-run: you'll see category is already filled in, proving the classify node's update landed in the shared State before draft ran.
State → partial State.Lab L4.3 · Conditional routing expert
This is the section that justifies the whole chapter. Everything so far — state, reducers, nodes, edges — a chain could arguably fake if you squinted. But a chain runs the same steps in the same order every time; it cannot look at what just happened and change course. The moment your app needs to send a billing question one way and a bug report another, or escalate the hard cases to a human, you've left chain territory for good. Conditional routing is how a graph makes that decision at runtime, and it is the single most important primitive in LangGraph.
The idea is smaller than it sounds. A normal edge is hard-wired: "after classify, always go to draft." A conditional edge replaces that fixed arrow with a routing function — a plain Python function that reads the current state and returns the name of the node to go to next. No LLM, no framework magic: just if statements returning strings. If the category is "billing", return "billing_reply"; if it's "bug", return "tech_reply"; otherwise return "escalate_human". The graph runs your function, takes the string it hands back, and jumps to the matching node. One node, many possible exits — but only one is taken per run.
Think of it as a railway switch, or the classic fork-in-the-road. The train arrives at one point; a switch reads the signal and throws it toward exactly one of several tracks. The other tracks exist, but this train won't touch them. That's precisely what happens here: after classify, only the chosen reply node runs — the others are skipped entirely. This shape has names you'll hear constantly: it's the router or supervisor pattern from L1, and it's the skeleton of every triage, dispatch, and "which specialist handles this?" agent you'll ever build. The router function is literally where L1's architecture diagrams turn into executable code.
Two traps to avoid, because both bite in practice. First, always give your router a catch-all: a final return that handles anything you didn't explicitly match — here, escalate to a human. A model that answers with an unexpected category shouldn't crash your graph; it should fall through to a safe default. Second — and this is the classic LangGraph bug — a conditional edge is wired with a mapping dict that translates each string your router can return into a real target node. If your router can return "refund_reply" but that string isn't in the dict (or the node doesn't exist), the graph fails with a "maps to a missing node" error. Every value the router can produce must map to a real node or to END. Keep the router and the dict in sync and conditional routing becomes the reliable workhorse it's meant to be.
This diagram shows the payoff a straight chain can't give you: one node with many exits. After classify figures out the category, a conditional edge sends the work to exactly one of three different reply nodes.
- On the left, the classify node produces a category. The three arrows leaving it are a single conditional edge — labelled route by category — not three normal edges.
- A little routing function looks at the State's
categoryand returns a name. That name decides which one arrow is actually followed this run. - billing_reply, tech_reply, and escalate_human are the three possible destinations. Only the chosen one runs; the other two are skipped entirely.
- The colours are just hints:
escalate_human(red) is the "send to a person" path — routing lets you peel off the hard cases automatically.
In short: This is the router / supervisor pattern: a decision step in the middle of a graph that picks the branch. It is the skeleton of every triage or dispatch agent.
Setup to run this snippet
class _Any:
'''stands in for any undefined demo value; supports call/attr/index/
iteration and basic arithmetic (as 0.7) so demo snippets run.'''
def __call__(self, *a, **k): return _Any()
def __getattr__(self, k): return _Any()
def __getitem__(self, k): return _Any()
def __iter__(self): return iter([])
def __len__(self): return 0
def __contains__(self, o): return True
def __enter__(self, *a): return _Any()
def __exit__(self, *a): return False
def __float__(self): return 0.7
def __int__(self): return 1
def __lt__(self, o): return True
def __gt__(self, o): return False
def __le__(self, o): return True
def __ge__(self, o): return False
def __add__(self, o): return o
def __radd__(self, o): return o
def __bool__(self): return True
def __repr__(self): return 'demo'
def __str__(self): return 'demo'
State = _Any()
class _g_t:
add_conditional_edges = 'demo'
def add_conditional_edges(self, *a, **k): return 'demo'
def __getattr__(self, k): return 'demo'
g = _g_t()routing.pydef route(state: State) -> str:
"""Return the name of the next node based on the category."""
if state["category"] == "billing": return "billing_reply"
if state["category"] == "bug": return "tech_reply"
return "escalate_human"
g.add_conditional_edges("classify", route, {
"billing_reply": "billing_reply",
"tech_reply": "tech_reply",
"escalate_human": "escalate_human",
}) # the dict maps route()'s return value → target node
Here is the code behind that routing diagram. Two pieces work together: a plain routing function that decides, and add_conditional_edges that wires its decision to real nodes.
route(state)is an ordinary function that readsstate["category"]and returns a string — the name of the branch to take. No LLM, no magic; justifstatements returning labels like"billing_reply".- The final
return "escalate_human"is the default: anything that isn't billing or bug falls through to the human-escalation path. Always give a router a catch-all. g.add_conditional_edges("classify", route, { ... })attaches the router to theclassifynode. The dictionary is a lookup table: it maps each stringroute()can return to the actual target node to jump to.- So the flow is: run
classify→ callroute(state)→ look its return value up in the dict → go to that node. That indirection is what lets one node have several possible next steps.
What the output means: No output on its own (this snippet only wires the edges). At run time it silently steers each ticket to one reply node — the branch you'd otherwise need a human to choose.
Try this: Add a new category like "refund": return "refund_reply" from route and add both a matching node and a "refund_reply": "refund_reply" entry to the dict. Forgetting the dict entry is the classic "maps to a missing node" bug.
The ReAct loop as a graph expert
In L3 you called create_react_agent and a working tool-using agent popped out — magic. This is the section where the magic dissolves. Because here's the reveal that surprises people: that prebuilt agent, the one that felt like a black box, is itself just a compiled LangGraph graph. And a small one. Once you've seen it drawn out, you'll never look at a "tool-calling agent" as anything mystical again — it's two nodes and one conditional edge that loops back. That's the entire machine.
Recall the ReAct rhythm from the agents module: think, act, observe, repeat. The model thinks and either answers or asks to use a tool; if it used a tool, you run the tool, feed the result back, and let the model think again with that new information; you keep going until the model is satisfied and answers directly. Now map that onto the two graph primitives you already know. "Think" is a model node that calls the LLM. "Act" is a tools node that runs whatever tools the model requested. And the "did it ask for a tool?" question — the decision that either loops or finishes — is a conditional edge, exactly the routing you just learned, wired to a tiny function that peeks at the last message: if it contains tool calls, go to tools; otherwise go to END.
There's one genuinely new thing here, and it's the star of L5: the graph loops back. After the tools node runs, its edge doesn't go forward — it goes back to the model node. That back-edge is a cycle, and it's the one structure a chain fundamentally cannot have (a chain is a straight line; a line has no loops). The cycle is what lets the agent take as many think-act-observe turns as a problem needs — one tool call or ten — without you hard-coding the number. The model itself decides when to stop by simply not asking for another tool, at which point the conditional edge routes to END.
Watching this makes concrete why you'd ever drop below the convenient prebuilt function. When the agent is just these primitives laid bare, you can reach in and change them: insert a reflection node that critiques the draft before END, add a step_count field to the state to cap runaway loops, or slot in a router to hand off to sub-agents. The prebuilt agent trades that control for convenience; raw LangGraph hands it back. One honest caution as you read the code below — an unbounded cycle can loop forever if the model keeps asking for tools, so real graphs set a recursion limit (that's an L5 topic). For now, focus on recognizing each piece of the diagram in the code: the model node, the ToolNode, the should_continue branch, and the loop-back edge that closes the ring.
create_react_agent, unpacked. A model node and a tools node, with a conditional edge: if the model asked for tools, go run them and loop back; otherwise go to END. That back-edge — a cycle — is exactly what L5 explores next.
This is L3's prebuilt agent with the lid off. An agent that can use tools is just two nodes and one conditional edge that loops back — and this diagram is that whole machine.
- From
STARTwe go to the model node: it calls the LLM, which either answers directly or asks to use a tool. - The conditional edge in the middle asks tool_calls? — did the model request any tools? If yes, the arrow goes right to the tools node, which actually runs those tools.
- The dashed arrow loop back with results is the important one: after running tools we go back to the model so it can read the results and decide what to do next. That back-arrow is a cycle — a loop a plain chain can't have.
- If instead there were no tool_calls, the model is finished, so that arrow goes straight to
END. The loop keeps spinning (model → tools → model) until the model stops asking for tools.
In short: This model↔tools loop is the ReAct agent: think, act with a tool, observe the result, think again — repeat until done. L5 explores that back-edge (the cycle) in depth.
Requires: pip install langgraph
react_graph.pyfrom langgraph.graph import StateGraph, START, END, add_messages
from langgraph.prebuilt import ToolNode
from typing import Annotated, TypedDict
class S(TypedDict):
messages: Annotated[list, add_messages]
model_with_tools = model.bind_tools([get_weather, add])
def call_model(s): return {"messages": [model_with_tools.invoke(s["messages"])]}
def should_continue(s):
return "tools" if s["messages"][-1].tool_calls else END
g = StateGraph(S)
g.add_node("model", call_model)
g.add_node("tools", ToolNode([get_weather, add]))
g.add_edge(START, "model")
g.add_conditional_edges("model", should_continue) # branch: tools or END
g.add_edge("tools", "model") # the loop back
app = g.compile()
ToolNode (LangGraph's prebuilt tool executor), a model node, and a cycle. Now you can add anything: a reflection node before END, a step counter in state, a router to sub-agents. That freedom is the reason to drop below create_react_agent.And here is that agent as code — remarkably short. It is the same three moves as the diagram: define a tiny State, register a model node and a tools node, then add the conditional edge and the loop-back edge.
- The State
Sholds onlymessageswith theadd_messagesreducer — so every model reply and tool result appends to the running conversation (the reducer from Lab L4.1 doing its job). call_modelis the model node: it sends the current messages to the tool-aware model and returns its reply as a new message.ToolNode([...])is LangGraph's prebuilt node that runs whatever tools the model asked for — you don't write it.should_continueis the router: it peeks at the last message (s["messages"][-1]) and returns"tools"if the model requested tool calls, otherwiseEND. This is the tool_calls? decision from the diagram.- The wiring makes the cycle:
START → model, then a conditional edge frommodel(branch to tools or END), andadd_edge("tools", "model")— the loop back — sends tool results to the model to think again.
What the output means: After compile(), app is a working ReAct agent: invoke it with a question and it will call tools and loop until it can answer without them.
Try this: Compare this to the diagram box by box — model, tools, the should_continue branch, the loop-back edge. Everything create_react_agent hid is right here, which is exactly why dropping to raw LangGraph gives you control.
Common pitfalls expert
| Pitfall | Fix |
|---|---|
Forgetting the reducer on messages | Use Annotated[list, add_messages] or each node overwrites history |
| Returning the whole state from a node | Return only the keys you changed |
| Using a chain when you need branching/loops | That's exactly what LangGraph is for — model it as a graph |
| Conditional edge mapping to a missing node | Every return value must map to a real node or END |
| A cycle with no exit condition | Ensure the router can reach END; set a recursion limit (L5) |
| Over-engineering a linear task into a graph | If it's a straight line, a chain (L2) is simpler |
Exercises expert
Exercise L4.1 — Triage graph
Context: A routing graph that runs only the matching branch is the canonical use of conditional edges.
Your task: Build a triage graph end to end: a classify node and three reply nodes wired with a conditional edge, and test all three routes.
Requirements:
- One classify node plus three reply nodes
- A conditional edge routes to the matching reply node
- Exercise all three routes
- Confirm only the matching node runs for each input
💡 Hint: The classify node writes a category into state; the conditional edge maps each category to its reply node.
Exercise L4.2 — Add a reflection node
Context: Inserting a reflect node turns L1's reflection architecture into a graph — a cycle that must be bounded so it can't loop forever.
Your task: Take the ReAct graph and insert a reflect node between the model and END that critiques the draft, routing back to the model if weak or to END if good.
Requirements:
- Add a quality signal to the state that the reflect node sets
- A conditional edge routes 'revise' back to the model or 'ok' to END
- Cap the number of revisions with a counter
- Confirm a weak draft triggers a revision and a good one ends
💡 Hint: The reflect node writes a quality field; the edge reads it; a counter guarantees termination even if quality never clears.
Show hint
Add a quality field to state. The reflect node sets it; a conditional edge routes "revise" → "model" or "ok" → END. Cap revisions with a counter so it can't loop forever.
Exercise L4.3 — Visualize it
Context: Seeing your code rendered as a graph is the fastest way to catch a mis-wired edge.
Your task: Call get_graph().draw_mermaid() (or the ASCII variant) on your triage graph and compare the rendered diagram to the lab's diagram.
Requirements:
- Render your compiled graph as a diagram
- Compare it to the reference diagram from the lab
- Look for a mis-wired or missing edge
- Confirm the structure matches what you intended
💡 Hint: A wrong or missing edge jumps out visually far faster than it does by reading the builder code.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: In LangGraph a node reads the shared State and returns an update that gets merged in — it never mutates the world directly, which is what makes runs reproducible.
Your task: Model a graph as a dict State plus node functions that each return a partial update.
Requirements:
- Each node takes the state and returns a partial update dict
- The driver merges each update into the shared state
- Nodes don't mutate the state in place
- Show two nodes running in sequence, the second seeing the first's update
💡 Hint: Return-then-merge, never mutate; the merge step is what a real StateGraph does between nodes.
Show solution
Nodes do work by returning partial state updates. Runnable stdlib:
def node_a(state):
return {"count": state.get("count", 0) + 1}
def node_b(state):
return {"label": f"count is {state['count']}"}
state = {}
for node in (node_a, node_b):
update = node(state)
state.update(update) # merge the update into shared state
print(state) # {'count': 1, 'label': 'count is 1'}
A node never mutates the world directly — it returns an update, and the graph merges it. That is what makes runs reproducible.
Context: Some state fields overwrite; others — like messages — append. That append behavior is a reducer, and it's exactly what add_messages is.
Your task: Model a state merge where messages uses an add-reducer and other keys overwrite.
Requirements:
- Register a reducer for
messagesthat appends - Keys with no reducer overwrite by default
- Merging a messages update grows the list rather than replacing it
- Show a non-message key being overwritten in the same merge
💡 Hint: A per-key reducer table decides how an update combines with the old value; without it, each node would clobber the conversation.
Show solution
A reducer decides how an update combines with the old value. Runnable:
REDUCERS = {"messages": lambda old, new: old + new} # append, don't replace
def merge(state, update):
out = dict(state)
for k, v in update.items():
if k in REDUCERS:
out[k] = REDUCERS[k](out.get(k, []), v)
else:
out[k] = v # default: overwrite
return out
s = {"messages": [{"role": "user", "content": "hi"}], "step": 1}
s = merge(s, {"messages": [{"role": "ai", "content": "hello"}], "step": 2})
print(len(s["messages"]), "messages; step =", s["step"]) # 2 messages; step = 2
Without the reducer, each node would clobber the conversation. add_messages is exactly this append-reducer.
Context: A normal edge always goes A→B; a conditional edge runs a function on the state to pick the next node — the branch a linear chain simply can't express.
Your task: Model a graph with a router that branches on state.
Requirements:
- A node classifies/updates the state
- A routing function reads the state and returns the next node's name
- Different states route to different nodes
- Show at least two inputs taking different branches
💡 Hint: The router is a plain function state→node-name; runtime routing on state is the reason to drop from a chain to a graph.
Show solution
Conditional edges are branch logic over state. Runnable:
def classify(state):
return {"kind": "refund" if "refund" in state["q"].lower() else "general"}
def route(state):
return "refund_node" if state["kind"] == "refund" else "general_node"
NODES = {
"refund_node": lambda s: {"answer": "Refunds take 30 days."},
"general_node": lambda s: {"answer": "How can I help?"},
}
def run(q):
state = {"q": q}
state.update(classify(state))
nxt = route(state) # conditional edge picks the node
state.update(NODES[nxt](state))
return state["answer"]
print(run("I want a refund")) # Refunds take 30 days.
print(run("hello")) # How can I help?
Routing on state at runtime is the branch a linear chain simply cannot express — the reason to drop to a graph.
Context: ReAct expressed as an explicit graph is a model node, a tools node, and a conditional edge that loops back or ends — which is precisely what create_react_agent compiles.
Your task: Model the compiled ReAct graph's execution offline.
Requirements:
- A model node either requests a tool or produces a final answer
- A tools node executes the requested tool and records the observation
- A
should_continueedge loops back to the model or routes to END - Drive the node transitions manually and bound the steps
- Show it returning the right answer
💡 Hint: Three pieces — model node, tools node, and one conditional edge — loop until the model stops asking for tools.
Show solution
ReAct = model node + tools node + a should_continue edge. Runnable:
TOOLS = {"add": lambda a, b: a + b}
END = "__end__"
def model_node(state):
if not state.get("tool_ran"):
return {"next_call": ("add", (2, 3))} # ask for a tool
return {"answer": state["last_obs"], "next_call": None}
def tools_node(state):
name, args = state["next_call"]
return {"last_obs": TOOLS[name](*args), "tool_ran": True}
def should_continue(state):
return END if state.get("next_call") is None else "tools"
def run():
state = {}
node = "model"
for _ in range(10):
if node == "model":
state.update(model_node(state))
node = should_continue(state)
elif node == "tools":
state.update(tools_node(state))
node = "model"
elif node == END:
return state["answer"]
return "recursion limit"
print(run()) # 5
This is what create_react_agent compiles: two nodes and one conditional edge that loops until the model stops asking for tools.
Context: Before you compile a graph it should be structurally sound: START and END reachable, and no edge pointing at an undefined node. LangGraph's compile() runs this check for you.
Your task: Write a validator over an adjacency map that catches dead edges and an unreachable END before 'compile'.
Requirements:
- Flag any edge target that isn't a defined node or END
- Verify END is reachable from START (e.g. via BFS/DFS)
- Return whether the graph is valid plus the list of problems
- Show it passing a good graph and failing a broken one
💡 Hint: Collect every referenced node, check each target exists, then traverse from START to confirm END is reachable — that's what compile validates.
Show solution
Catch dead edges and unreachable ends statically. Runnable:
def validate(edges, start="START", end="__end__"):
nodes = set(edges) | {t for ts in edges.values() for t in ts}
problems = []
# every edge target must be a real node or END
for src, targets in edges.items():
for t in targets:
if t not in edges and t != end:
problems.append(f"{src} -> undefined node '{t}'")
# END must be reachable from START (BFS)
seen, stack = set(), [start]
while stack:
n = stack.pop()
if n in seen: continue
seen.add(n)
stack += list(edges.get(n, []))
if end not in seen:
problems.append("END not reachable from START")
return (not problems), problems
g = {"START": ["model"], "model": ["tools", "__end__"], "tools": ["model"]}
print(validate(g)) # (True, [])
print(validate({"START": ["gone"]})) # (False, [... undefined ..., END not reachable])
LangGraph's compile() does this structural check for you; modeling it shows what "compile" is really validating.
Context: The production graph is a typed State with an add-messages reducer, a model node, a tools node, and a conditional edge — the same State+Nodes+Edges you modeled offline, now with the real reducer and your model (Claude or OpenAI).
Your task: Write the real StateGraph using documented langgraph APIs. (Needs the libraries installed.)
Requirements:
- Define a typed State with
messages: Annotated[list, add_messages] - Add a model node that calls a Claude model bound to tools
- Add a
should_continueconditional edge to a tools node or END - Wire START → model and compile the builder
- Label it as needing
langgraph/langchain-anthropic+ a key; note aToolNodefills the tools node
💡 Hint: Same State/Nodes/Edges as the offline model; add_messages is the append reducer and bind_tools lets the model emit calls — verify imports against your version.
Show solution
Correct StateGraph. Needs pip install langgraph langchain-anthropic + ANTHROPIC_API_KEY:
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain_anthropic import ChatAnthropic # Claude
# from langchain_openai import ChatOpenAI # OpenAI
from langchain_core.tools import tool
@tool
def add(a: int, b: int) -> int:
"Add two integers."
return a + b
class State(TypedDict):
messages: Annotated[list, add_messages] # the append reducer
model = ChatAnthropic(model="claude-opus-4-8").bind_tools([add]) # Claude
# model = ChatOpenAI(model="gpt-5.5").bind_tools([add]) # OpenAI
def call_model(state: State):
return {"messages": [model.invoke(state["messages"])]}
def should_continue(state: State):
last = state["messages"][-1]
return "tools" if getattr(last, "tool_calls", None) else END
builder = StateGraph(State)
builder.add_node("model", call_model)
builder.add_edge(START, "model")
builder.add_conditional_edges("model", should_continue, {"tools": "tools", END: END})
graph = builder.compile()
# graph.invoke({"messages": [{"role": "user", "content": "add 2 and 3"}]})
Same State+Nodes+Edges you modeled offline, now with the real reducer and your model (Claude or OpenAI — swap the commented import/model line). A ToolNode would fill the "tools" node. Verify imports against your version.
✓ Checkpoint — you can move on when you can…
- Explain State, Nodes, Edges, and the compile→invoke flow.
- Define typed state and say what a reducer does (and why
messagesneeds one). - Build a multi-node graph where nodes return partial updates.
- Add a conditional edge that routes by state.
- Draw the ReAct agent as a two-node graph with a cycle.
diagnose node, a conditional edge that routes read-only findings straight to a report but risky fixes through a safety_gate node, and a loop back to re-diagnose after acting. The routing function is the safety policy. L5 adds the human-approval pause that makes that gate real. See the safety-gate build →Knowledge check check yourself
What does a reducer like add_messages do to a state field, and how does a field with no reducer behave?
Show answer
add_messages makes the messages field append new messages instead of overwriting — which is what gives the graph its accumulating conversation "memory." A field with no reducer is last-write-wins: a node's return value simply overwrites the old value.What can a conditional edge do that a normal edge (and a linear chain) cannot?