LangGraph Orchestration

When a linear chain isn't enough: model your agent as a graph of nodes over shared state, with conditional edges for branching, cycles for the agent loop, checkpointing for durability, and interrupts for human-in-the-loop approval — the control a production agent actually needs.

~3hlanggraphagentsorchestration
Learning objectives
  • Explain why agents need a graph (branching, cycles, shared state) that a linear LCEL chain can't express.
  • Build a StateGraph: typed shared state, nodes that update it, and edges that connect them.
  • Add conditional edges for branching and a cycle for the agent loop.
  • Use checkpointing for durability and interrupts for human-in-the-loop approval.
Watch out

LangGraph's API evolves — verify class/method names against current docs. The concepts (state, nodes, conditional edges, checkpoints, interrupts) are the stable, transferable part.

Note

Python for JavaScript developers:

  • TypedDict — a dict with declared field types; like a TS interface describing a plain object.
  • A "node" is a function state -> partial_state: it reads the shared state and returns the fields to update (LangGraph merges them in).
  • Literal["a", "b"] — a type restricted to specific string values, like a TS string-literal union.
  • add_edge(A, B) wires "after A, go to B"; conditional edges pick the next node from a function's return.

Every code block is commented line-by-line.

Why a graph, not a chain

LCEL chains are linear — data flows one way, start to finish. Real agents aren't linear. They need to:

  • Loop — call a tool, look at the result, maybe call another tool… (a cycle, which a chain can't express).
  • Branch — take different paths depending on intermediate state (route to a tool vs. answer vs. ask a human).
  • Share state — accumulate messages, tool results, and flags that many steps read and write.
  • Pause and resume — stop for human approval, then continue exactly where it left off.

LangGraph models the agent as a graph: nodes (steps) connected by edges (transitions) operating over a single shared state object. It's the difference between a script and a state machine — and production agents are state machines.

The StateGraph

Three pieces: a state schema (what's shared), nodes (functions that update state), and edges (how control moves). Here's a minimal two-node graph:

from typing import TypedDict
from langgraph.graph import StateGraph, START, END

class State(TypedDict):          # the shared state every node reads/writes (like a TS interface)
    question: str
    answer: str

def retrieve(state: State) -> dict:                  # a NODE: takes state, returns the fields to update
    docs = search(state["question"])                 # (your retriever from the RAG track)
    return {"answer": f"context: {docs}"}            # LangGraph MERGES this into the shared state

def generate(state: State) -> dict:                  # second node: uses what retrieve wrote
    reply = llm(state["question"], state["answer"])  # generate from question + retrieved context
    return {"answer": reply}                          # overwrite `answer` with the final text

graph = StateGraph(State)                             # build the graph over the State schema
graph.add_node("retrieve", retrieve)                  # register nodes by name
graph.add_node("generate", generate)
graph.add_edge(START, "retrieve")                     # entry: START -> retrieve
graph.add_edge("retrieve", "generate")                # retrieve -> generate
graph.add_edge("generate", END)                       # generate -> END (done)
app = graph.compile()                                 # compile into a runnable

app.invoke({"question": "What does the Pro plan cost?"})   # runs the graph, returns the final state

That's RAG as a graph — overkill for two linear steps (a chain is simpler), but it's the scaffold everything else hangs on.

Tip

Exercise 1 has you build this two-node StateGraph and inspect the state after each node, seeing how node return values merge into shared state.

Conditional edges: branching

A conditional edge picks the next node by running a function on the current state. This is how a graph routes — e.g. answer directly for a simple question, or go to a tool for a hard one:

from typing import Literal

def route(state: State) -> Literal["tool", "answer"]:   # returns the NAME of the next node
    if needs_lookup(state["question"]):                 # decide based on state
        return "tool"
    return "answer"

graph.add_conditional_edges("classify", route, {       # after "classify", call route() to choose…
    "tool": "call_tool",                                # …if route returns "tool" -> go to call_tool node
    "answer": "direct_answer",                          # …if "answer" -> go to direct_answer node
})

The routing function is plain Python reading plain state — fully testable, no magic. This is the branching a linear chain fundamentally can't do.

Tip

Exercise 2 has you add a conditional edge that routes short questions to a direct answer and long ones through retrieval, and verify both paths.

Cycles: the agent loop as a graph

The agent loop from Topics 1–3 is a cycle: an "agent" node decides an action, a "tools" node runs it, and an edge loops back — until a condition says stop. LangGraph expresses this directly:

def should_continue(state: State) -> Literal["tools", "end"]:
    last = state["messages"][-1]                        # the model's most recent message
    return "tools" if last.tool_calls else "end"        # tool calls pending? loop. Otherwise finish.

graph.add_node("agent", call_model)                     # the model decides (may emit tool calls)
graph.add_node("tools", run_tools)                      # execute the tool calls
graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", should_continue, {"tools": "tools", "end": END})  # branch after agent
graph.add_edge("tools", "agent")                        # THE CYCLE: after tools, go back to the agent
app = graph.compile()

The tools -> agent edge is the reason→act→observe loop you hand-wrote, now as an explicit, inspectable cycle with the stop condition as a first-class edge. LangGraph enforces a recursion limit so the cycle is bounded by default.

Tip

Exercise 3 has you wire this agent↔tools cycle with a should_continue edge and watch it loop until no tool calls remain — the same bounded loop, now a graph.

Durability & human-in-the-loop

Two production features fall out of the graph model almost for free:

Checkpointing. Attach a checkpointer and the graph persists its state after every node, keyed by a thread id. A crash, a restart, or a days-long pause resumes from the last checkpoint — essential for long-running or interruptible agents.

from langgraph.checkpoint.memory import MemorySaver     # a demo checkpointer (use a DB-backed one in prod)
app = graph.compile(checkpointer=MemorySaver())
cfg = {"configurable": {"thread_id": "session-1"}}      # which conversation/run this belongs to
app.invoke({"question": "..."}, config=cfg)             # state is saved after each node under this thread

Human-in-the-loop. Because state is checkpointed, the graph can interrupt before a sensitive node (sending an email, spending money, deleting data), surface the pending action for approval, and resume on a human's go-ahead:

# Pause BEFORE the "send_email" node so a human can approve the drafted action:
app = graph.compile(checkpointer=MemorySaver(), interrupt_before=["send_email"])
app.invoke(initial, config=cfg)          # runs until it hits send_email, then STOPS and returns
# … a human reviews the drafted email in state …
app.invoke(None, config=cfg)             # resume: pass None to continue from the checkpoint through send_email

This is the pattern behind every "the agent drafted this — approve to send?" workflow, and it's why checkpointing matters beyond crash recovery.

Tip

Exercise 4 has you add interrupt_before on a mock "send" node, inspect the paused state, and resume — building the approval gate every real agent that acts on the world needs.

When to reach for LangGraph

Same discipline as always: don't over-build. A linear task is a chain; a simple tool loop is fine with an AgentExecutor. Reach for LangGraph when you need explicit branching, cycles you must control, durable state across pauses, or human-in-the-loop gates — i.e. when the agent is a real state machine you need to inspect, checkpoint, and steer.

Pitfalls

  • Graphifying a linear task. If there's no branching/cycle/state, a chain is simpler — don't add a graph for ceremony.
  • Unbounded cycles. Keep the recursion limit and a clear stop edge; a cycle with a broken condition spins.
  • In-memory checkpointer in production. MemorySaver is volatile — use a DB-backed checkpointer for real durability.
  • Fat state objects. Put only what nodes actually share in state; dumping everything in makes runs hard to reason about.
  • No human gate on irreversible actions. Interrupt before send/pay/delete — don't let an agent take one-way actions unsupervised.

Recap

  • Agents need graphs, not linear chains: branching, cycles, shared state, and pause/resume.
  • A StateGraph = a typed state + nodes (state -> partial update) + edges; conditional edges branch on state and a cycle expresses the agent loop.
  • Checkpointing persists state per thread for durability; interrupts enable human-in-the-loop approval before sensitive nodes.
  • Reach for LangGraph when you need real control (branching/cycles/durability/HITL) — otherwise a chain or AgentExecutor is simpler.
  • Worked examples map to the exercises: a StateGraph (Ex 1), conditional routing (Ex 2), the agent↔tools cycle (Ex 3), and an interrupt-based approval gate (Ex 4).

LangGraph Orchestration — Exercises

Install: pip install langgraph. APIs shift between releases — verify imports against current docs; the state/node/edge/checkpoint/interrupt concepts are the durable part.


Exercise 1 — A two-node StateGraph

Build a StateGraph with a State TypedDict, two nodes (retrieve, generate), and edges START → retrieve → generate → END. Print the state after each node to see the merges.

Reference solution
from typing import TypedDict
from langgraph.graph import StateGraph, START, END

class State(TypedDict):
    question: str
    answer: str

def retrieve(state):
    print("after retrieve sees:", state)
    return {"answer": f"[context for: {state['question']}]"}

def generate(state):
    print("generate sees:", state)     # note it can read what retrieve wrote
    return {"answer": state["answer"] + " -> final answer"}

g = StateGraph(State)
g.add_node("retrieve", retrieve); g.add_node("generate", generate)
g.add_edge(START, "retrieve"); g.add_edge("retrieve", "generate"); g.add_edge("generate", END)
app = g.compile()
print(app.invoke({"question": "Pro plan price?"}))

Each node returns only the fields it updates; LangGraph merges them into the shared state, so generate sees what retrieve wrote. That shared, merged state is what a linear chain lacks.


Exercise 2 — Conditional edge (branching)

Add a classify node and a conditional edge: route short questions (len < 40) to a direct_answer node and longer ones through retrieve. Confirm both paths run.

Reference solution
from typing import Literal

def classify(state): return {}          # no state change; it's just a branch point
def route(state) -> Literal["short", "long"]:
    return "short" if len(state["question"]) < 40 else "long"

g.add_node("classify", classify)
g.add_node("direct_answer", lambda s: {"answer": "quick answer"})
g.add_edge(START, "classify")
g.add_conditional_edges("classify", route, {"short": "direct_answer", "long": "retrieve"})

The route function is plain Python over plain state — testable in isolation, no framework magic. Branching on intermediate state is precisely what a linear LCEL chain cannot express.


Exercise 3 — The agent↔tools cycle

Wire an agent node (calls the model, may emit tool calls) and a tools node, with a should_continue conditional edge and a tools → agent cycle. Run a query needing a tool and watch it loop then stop.

Reference solution
from typing import Literal

def should_continue(state) -> Literal["tools", "end"]:
    last = state["messages"][-1]
    return "tools" if getattr(last, "tool_calls", None) else "end"

g.add_node("agent", call_model)      # returns {"messages": [ai_message]}
g.add_node("tools", run_tools)       # executes tool calls, returns {"messages": [tool_results]}
g.add_edge(START, "agent")
g.add_conditional_edges("agent", should_continue, {"tools": "tools", "end": END})
g.add_edge("tools", "agent")          # the cycle
app = g.compile()

tools → agent is the reason→act→observe loop from Topics 1–2, now an explicit cycle with the stop condition as a first-class edge. LangGraph's recursion limit bounds it by default — the same "always bound the loop" rule, enforced for you.


Exercise 4 — Human-in-the-loop interrupt

Compile a graph with a MemorySaver checkpointer and interrupt_before=["send_email"]. Run it until it pauses at send_email, inspect the drafted state, then resume by invoking with None.

Reference solution
from langgraph.checkpoint.memory import MemorySaver

app = g.compile(checkpointer=MemorySaver(), interrupt_before=["send_email"])
cfg = {"configurable": {"thread_id": "t1"}}

app.invoke({"question": "email the client the quote"}, config=cfg)   # runs, then STOPS before send_email
state = app.get_state(cfg)                                            # inspect the pending/drafted action
print("paused, about to run:", state.next)                           # -> ('send_email',)

# A human approves here…
app.invoke(None, config=cfg)      # pass None to resume from the checkpoint through send_email

Because state is checkpointed after every node, the graph can stop before a sensitive action, surface it for approval, and resume exactly where it paused. This is the "agent drafted this — approve to send?" gate every agent that acts on the world needs. Swap MemorySaver for a DB-backed checkpointer in production.