Part I: The mental model
StateGraph(State) a typed state schema (TypedDict / Pydantic / dataclass)
|
v
add_node / add_edge nodes are functions state -> partial-state update
add_conditional_edges a router function chooses the next node at run time
|
v
graph.compile( lower the builder into a Pregel runtime (a Runnable)
checkpointer=...,
interrupt_before=...)
|
v
app.invoke / app.stream run one super-step at a time (Pregel / bulk-synchronous)
config={thread_id}
|
v
channels + reducers each state key is a channel, writes merge via a reducer
|
v
checkpointer.put() after every super-step, snapshot the state to the thread
|
v
interrupt() / get_state pause for a human, inspect, time-travel, resume
The one-sentence identity. LangGraph makes an agent an explicit stateful graph, nodes that read and write a typed shared state, edges both fixed and conditional that decide what runs next, executed as a sequence of Pregel super-steps whose every boundary can be checkpointed, streamed, interrupted, and resumed. The classic way to build an agent is an implicit loop, call the model, if it asked for a tool run the tool, feed the result back, repeat until it stops. That loop lives inside a class where you cannot see it, cannot pause it, and cannot persist it. LangGraph turns the loop inside out. The control flow becomes a value you construct, draw, and modify, and the agent's memory becomes a schema rather than a pile of local variables.
Two ideas carry the whole design. First, state is a set of typed channels with reducers. Each key in the state schema is a channel. A node does not mutate state, it returns a small dict naming the keys it wants to change, and each channel's reducer decides how that write merges with what is already there. A plain key keeps only the last write, a key annotated with a reducer accumulates. This is what makes message history, parallel branches, and map-reduce first-class rather than hand-rolled. Second, execution is a series of super-steps and every super-step boundary is a durable checkpoint. Because the state lives in channels and each step end is a snapshot keyed by a thread, persistence, time-travel, and human-in-the-loop are not features bolted on top, they are the same mechanism looked at from three angles.
Everything here is verified against the main branch
in July 2026. LangGraph moves quickly and the repository is a
monorepo of several packages, so where an exact file path is
likely to shift I name the component by its role and say so.
Part II: Using it
LangGraph is a pure-Python library and installs anywhere Python runs, including macOS, because the graph itself does no GPU work, it orchestrates calls to models that live behind an API or a local server. The core package plus the pieces you usually want look like this.
pip install langgraph
# checkpointers for real persistence live in separate packages
pip install langgraph-checkpoint-sqlite
pip install langgraph-checkpoint-postgres
# a model provider through a langchain integration
pip install langchain-openai
# the local dev server and Studio (in-memory backend)
pip install "langgraph-cli[inmem]"
The smallest useful graph is a chatbot with memory. Note the
reducer on messages and the checkpointer passed at
compile time, they are what make the second turn remember the
first.
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.checkpoint.memory import InMemorySaver # MemorySaver is the legacy alias
from langchain_openai import ChatOpenAI
class State(TypedDict):
messages: Annotated[list, add_messages] # the reducer appends and merges by id
llm = ChatOpenAI(model="gpt-4o-mini")
def chatbot(state: State) -> dict:
# return only the keys you change, not the whole state
return {"messages": [llm.invoke(state["messages"])]}
builder = StateGraph(State)
builder.add_node("chatbot", chatbot)
builder.add_edge(START, "chatbot")
builder.add_edge("chatbot", END)
app = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "user-1"}}
app.invoke({"messages": [("user", "hi, I am Sam")]}, config)
app.invoke({"messages": [("user", "what is my name?")]}, config) # answers "Sam"
Two invoke calls share one thread_id,
so the checkpointer loads the first turn's messages before the
second turn runs. Give a new thread_id and the
memory resets, which is exactly how one server holds thousands of
independent conversations.
Now make it an agent by adding a tool and a cycle. This is the ReAct loop drawn out by hand, which is the thing LangGraph exists to make visible.
from langgraph.prebuilt import ToolNode, tools_condition
from langchain_core.tools import tool
@tool
def get_weather(city: str) -> str:
"""Return the weather for a city."""
return f"It is sunny in {city}."
llm_with_tools = llm.bind_tools([get_weather])
def agent(state: State) -> dict:
return {"messages": [llm_with_tools.invoke(state["messages"])]}
builder = StateGraph(State)
builder.add_node("agent", agent)
builder.add_node("tools", ToolNode([get_weather]))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition) # -> "tools" or END
builder.add_edge("tools", "agent") # the cycle that makes it ReAct
app = builder.compile()
tools_condition reads the last message. If the model
emitted a tool call it routes to tools, otherwise to
END. The edge from tools back to
agent is the loop, and it is a line you can see and
change rather than a hidden while. If you want that
exact graph in one line, the prebuilt does it,
create_react_agent(llm, [get_weather],
checkpointer=InMemorySaver()), and it compiles down to the
same nodes and edges you just wrote. That constructor is now
deprecated in favor of create_agent in the
langchain package, which wraps the identical runtime
with a middleware system, so the graph underneath is unchanged.
The mistakes newcomers make, in rough order of frequency. First,
forgetting the reducer. Declare
messages: list without Annotated[list,
add_messages] and each write replaces the list instead of
appending, and two nodes writing it in one step raise
InvalidUpdateError. Second, forgetting
thread_id. With a checkpointer set but no
thread_id in the config there is no thread to
persist to, and memory, resume, and interrupts silently do
nothing. Third, returning the whole state from a
node instead of a partial update, which fights the reducers.
Fourth, mutating the incoming state in place,
which the runtime does not expect, always return new values.
Fifth, infinite loops, a cyclic graph with no
terminating branch trips the recursion limit (25 super-steps by
default) and raises GraphRecursionError, raise the
limit only when the loop genuinely needs more steps.
Part III: When it is the right tool
The first comparison is with plain LangChain, because the two are
constantly confused and they sit at different layers. LangChain
proper is a component library, model wrappers, prompt templates,
output parsers, retrievers, and LCEL, which pipes Runnables into
a directed acyclic chain like prompt | model | parser.
LCEL is the right tool for straight-through pipelines with no
loops and no durable state. LangChain's legacy
AgentExecutor is the implicit loop described above,
correct but opaque. LangGraph is the orchestration layer for
anything stateful, cyclic, or controllable. As of the langchain
1.0 line the recommended agent constructor,
create_agent, is built on LangGraph, and
create_react_agent in langgraph.prebuilt
is the transitional form. LangGraph did not replace LangChain, it
became the runtime underneath LangChain's agents.
Why a graph beats an implicit loop, which is the load-bearing
argument of this chapter. You can see the control flow,
app.get_graph().draw_mermaid() prints it. Every
super-step is a checkpoint, so persistence, resume-after-crash,
and time-travel come for free. You can interrupt before a
sensitive node for human approval and then resume. You can stream
the state after each node or the tokens as they generate. You can
add branches, run nodes in parallel by fanning out, and nest a
whole graph as a single node, without rewriting a monolith. And
you get fault tolerance inside a step, if one node fails the
completed nodes' writes are saved as pending writes so a retry
does not recompute them. None of this is available when the loop
is a private method.
The honest cases for something else. If you have one linear
tool-call loop with no persistence, no human step, and no
streaming need, LangGraph is overhead, write the
while loop against the model SDK directly.
LlamaIndex Workflows offers an event-driven,
step-function model, a strong choice inside a retrieval-heavy
stack. CrewAI is higher-level and role-and-task
oriented, faster to stand up and more opinionated, with less
control over the exact state machine. Microsoft
AutoGen centers on multi-agent conversation. The
OpenAI Agents SDK, successor to Swarm, is a
lightweight handoff model flavored toward one provider.
Temporal and similar durable-execution engines
win when your real need is durability and retries of long
workflows rather than LLM-specific state, though LangGraph's
checkpointer covers much of that within the LLM domain. The rule
of thumb, LangGraph earns its complexity when you need durable,
inspectable, human-supervised, or multi-actor control, and for a
single deterministic prompt-to-answer call an LCEL chain or a raw
model call is cleaner.
One architecture note. The models a graph calls have to be served somewhere, and in production those endpoints are frequently high-throughput inference servers like vLLM or SGLang. LangGraph is agnostic to which, a node simply calls a chat-model Runnable, so the orchestration and the serving are cleanly separated concerns. If the agent ideas here are the draw, the self-improving agents and applied generative AI classes cover the surrounding design space.
Part IV: The full life of one invoke
The specimen. One call
app.invoke({"messages": [("user", "...")]},
{"configurable": {"thread_id": "t1"}}) on the ReAct graph
from Part II, compiled with a checkpointer. I follow it from
compilation through the super-steps to the checkpoint on disk.
Stage 1: compile lowers the builder to a Pregel graph
builder.compile() walks the nodes and edges and
lowers them into a Pregel object. Each state key
becomes a channel, a LastValue channel by default,
or a BinaryOperatorAggregate-style channel when the
key carries a reducer like add_messages. Each node
becomes a PregelNode that subscribes to (reads)
certain channels and writes to others. Edges become trigger
channels, add_edge("a", "b") arranges for
b to fire when a writes, and a
conditional edge compiles to a branch that runs your router and
writes to the chosen node's trigger. START and
END are sentinel endpoints. The compiled graph is a
Runnable, so it exposes invoke, stream,
batch, and their async twins, and can itself be used
as a node inside a larger graph.
Stage 2: enter the loop and load the checkpoint
invoke opens the Pregel loop (conceptually
langgraph/pregel/loop.py). It reads
thread_id and checkpoint_ns from the
config, asks the checkpointer for the latest
CheckpointTuple on that thread, and rebuilds channel
values from it, or starts empty for a new thread. The input dict
is then applied as writes to the input channels, which for a
messages graph runs the add_messages reducer to seed
state["messages"] with the user turn.
Stage 3: the super-step loop, plan then execute then update
The loop repeats a three-phase cycle borrowed from bulk-synchronous parallel computing.
plan which nodes are triggered by channels updated last step?
execute run those nodes concurrently, each on a read-only state snapshot
(writes are buffered, nodes cannot see each other this step)
update at the barrier, apply all buffered writes through the reducers,
bump each channel's version
save checkpointer.put() the new values + versions + metadata to the thread,
put_writes() records pending writes for fault tolerance
stream emit to the caller per stream_mode (values / updates / messages ...)
advance newly updated channels trigger the next nodes; END or none -> stop
For the ReAct specimen the steps read cleanly. Step one runs
agent, the model returns an AIMessage
with a tool call, and tools_condition routes to
tools. Step two runs the ToolNode,
which executes get_weather and appends a
ToolMessage, and the edge back to agent
re-triggers it. Step three runs agent again, this
time the model answers in plain text,
tools_condition routes to END, and the
loop halts. The important discipline is that nodes never talk to
each other directly, they only write channels, and the reducer at
the barrier is the only place writes combine. That is what makes
two parallel writers to a reducer channel well-defined and two
parallel writers to a plain channel an error.
Stage 4: interrupts, if any
If a node calls interrupt(payload), or the graph was
compiled with interrupt_before=["tools"], the loop
stops at that boundary, writes the interrupt into the checkpoint,
and returns control carrying an __interrupt__ marker
instead of a final answer. The thread's checkpoint now has a
non-empty next, which is precisely what "paused"
means. The run can sit there for milliseconds or for days. A
later invoke with Command(resume=value)
reloads that checkpoint and continues.
Stage 5: return
With no pending interrupt and END reached, the loop
reads the final channel values and returns them as the output
state. The whole trace, in one breath, input written to channels,
a handful of super-steps each snapshotted to the thread,
communication between nodes only ever through channel writes
merged by reducers, and a durable history you can list, inspect,
and replay.
Part V: Internals deep dives
Deep dive: StateGraph, channels, and reducers
The state schema can be a TypedDict (the common
case), a Pydantic BaseModel when you want node
outputs validated at run time, or a dataclass. Whatever the form,
each key becomes a channel, and the channel type is chosen by
whether the key is annotated with a reducer. A bare key is a
LastValue channel, it keeps only the most recent
write and raises if two nodes write it in the same super-step. An
Annotated[T, reducer] key becomes an accumulating
channel whose reducer combines each write with the running value.
operator.add concatenates lists,
add_messages appends messages and merges them by id,
supports a RemoveMessage sentinel to delete, and
coerces (role, content) tuples into proper Message
objects. The channel implementations live in
langgraph/channels/, and beyond the two staples there
are Topic (a pub/sub list), EphemeralValue
(visible only to the next step, not carried forward), and managed
values such as IsLastStep and
RemainingSteps in langgraph/managed/
that the runtime maintains for you.
Two conveniences follow. MessagesState is a prebuilt
TypedDict that is nothing but
messages: Annotated[list, add_messages], so most chat
graphs subclass it and add their own keys. And a graph can declare
separate input, output, and internal schemas, so private working
state never leaks into the public output.
Because layout of memory is a typed schema and merging is a
reducer, the graph's state is composable in the same way a
well-typed data structure is, which is what lets parallel branches
and map-reduce have a defined meaning.
Deep dive: the Pregel runtime
The runtime is named after Google's Pregel graph-processing
system, and it inherits Pregel's bulk-synchronous model.
Computation is a series of super-steps separated by barriers.
Inside a step the active nodes run in parallel and cannot see one
another's writes. Between steps every write is applied and the
next step's nodes are selected. This is not an implementation
detail you can ignore, it is the semantics, and it is why a
fan-out of parallel branches all observe the same consistent
snapshot and their results land together at the barrier rather
than racing. A PregelNode is a bundle of channel
subscriptions (its triggers and reads), a bound function, and a
set of channel writes. The loop machinery (conceptually
pregel/loop.py and pregel/algo.py)
computes the next tasks, an executor runs them on a thread pool or
the async event loop, and the algorithm applies the writes and
bumps versions. The recursion limit, 25 super-steps by default,
caps how long a cyclic graph may run before
GraphRecursionError, which is the guardrail against
an agent that never decides to stop.
Deep dive: the checkpointer, persistence, and time travel
BaseCheckpointSaver (in the
langgraph-checkpoint package under
langgraph/checkpoint/base.py) defines the contract,
put, put_writes, get_tuple,
list, and their async forms. A Checkpoint
is a versioned snapshot of every channel value. A
CheckpointTuple bundles the config, the checkpoint,
metadata, the parent config, and any pending writes. A checkpoint
is addressed by thread_id, a
checkpoint_ns for subgraphs, and a monotonically
increasing checkpoint_id, and a thread is just the
linear history of one run. Implementations range from
InMemorySaver for development to
SqliteSaver and AsyncSqliteSaver
(langgraph-checkpoint-sqlite) and
PostgresSaver and AsyncPostgresSaver
(langgraph-checkpoint-postgres) for production.
Serialization runs through a SerializerProtocol,
default JsonPlusSerializer, which knows how to encode
LangChain messages and common Python types.
The pending_writes field is the fault-tolerance
trick. If a super-step has three nodes and the second raises, the
first node's completed writes are already stored, so on retry only
the failed node and the not-yet-run node execute, and no
successful work is repeated. Node-level retries are governed by a
RetryPolicy. On the inspection side,
get_state(config) returns a
StateSnapshot with values,
next (the nodes queued to run), config,
metadata, created_at,
parent_config, and tasks, and
get_state_history(config) yields every snapshot for
the thread, newest first, each carrying its own
checkpoint_id in its config.
Time travel falls straight out of that. Pass a past snapshot's
config back into invoke or stream and
the graph replays from that point.
update_state(config, values, as_node=...) writes new
values as if a named node had produced them, which forks a new
branch of history from the chosen checkpoint. Together they give
you inspect, edit, and rewind.
Because durable state is addressed by thread and logical
checkpoint id rather than by a live process, you can crash, move
machines, and resume, and you can branch the past to explore a
different continuation, which an implicit loop simply cannot
do.
Deep dive: streaming
stream and astream take a
stream_mode. The modes that matter,
"values" emits the entire state after each step,
"updates" emits just the {node: update}
delta from each node, "messages" emits LLM token
chunks as (chunk, metadata) tuples for
token-by-token UIs, "custom" emits whatever a node
chooses to push, and "debug" emits verbose
step events. Pass a list of modes and you receive
(mode, data) tuples so you can drive several
consumers at once.
for chunk in app.stream({"messages": [("user", "weather in Paris?")]},
config, stream_mode="updates"):
print(chunk) # {'agent': {...}} then {'tools': {...}} then {'agent': {...}}
To surface progress from inside a node, accept a
StreamWriter parameter or call
get_stream_writer() and push events that appear under
stream_mode="custom". For deep instrumentation
astream_events gives the fine-grained Runnable event
stream, on_chat_model_stream,
on_chain_start, and the rest, at the cost of volume.
The tidy part is that streaming reads the same channel state the
checkpointer snapshots, so what you show a user and what you
persist are one source of truth.
Deep dive: human-in-the-loop and interrupts
There are two mechanisms. The static one,
compile(interrupt_before=[...], interrupt_after=[...]),
pauses at fixed nodes, which is the natural way to place a review
gate right before a tool runs. The dynamic one,
interrupt(payload) called inside a node, pauses
anywhere and hands payload back to the caller. Both
require a checkpointer, because a pause is nothing more than a
checkpoint whose next is non-empty. The run returns
with an __interrupt__ value carrying the payload, and
the thread sits durably paused.
from langgraph.types import interrupt, Command
def review(state: State) -> dict:
decision = interrupt({"proposed": state["draft"]}) # pauses here
return {"approved": decision == "yes"}
# ... run until it pauses, inspect, then resume:
app.invoke(Command(resume="yes"), config)
Resuming with Command(resume=value) reloads the
checkpoint and continues. On resume the interrupting node
re-executes from its start, but interrupt() returns
the supplied value instead of pausing again, using the stored
resume value so an already-answered interrupt does not fire twice.
The practical corollary, any side effect that sits before an
interrupt() in the same node runs a second time on
resume, so keep interrupts early in a node. Common patterns are
approve or reject a tool call, edit the proposed arguments before
it runs, or ask the human a clarifying question mid-run, and
because you can also update_state before resuming, a
reviewer can correct the agent's plan and let it proceed.
Deep dive: routing, Command, and the Send API
Conditional edges are the everyday router,
add_conditional_edges(source, router, path_map) where
router(state) returns the next node's name, a list of
names to fan out, or END, and the optional
path_map makes the diagram and the intent readable. A
node can also skip the separate edge and return a
Command, Command(update={...}, goto="next")
both updates state and routes in one move,
Command(goto=Send(...)) fans out, and
Command(graph=Command.PARENT, goto=...) steps from a
subgraph up into its parent.
The Send API is how map-reduce works. A router
returns a list of Send objects, one per subtask, each
carrying its own private input, and each spawns the target node as
its own task in the same super-step.
from langgraph.types import Send
def fan_out(state) -> list:
# invoke "summarize" once per document, each with its own input
return [Send("summarize", {"doc": d}) for d in state["docs"]]
builder.add_conditional_edges("split", fan_out, ["summarize"])
All those summarize tasks run in parallel and their
writes fan back into a reducer channel at the barrier, so you map
a variable-length list of subtasks over one node and gather the
results without touching a thread pool yourself. This is the
payoff of the bulk-synchronous model, fan-out and fan-in have a
precise meaning because the barrier defines when results combine.
Deep dive: prebuilt and the functional API
The langgraph.prebuilt package ships the shortcuts.
create_react_agent builds a ready ReAct graph, an
agent node plus a ToolNode wired by
tools_condition. ToolNode executes the
tool calls found in the last AIMessage and appends
the resulting ToolMessages, with error handling.
tools_condition is the standard route-to-tools-or-end
branch. InjectedState and InjectedStore
let a tool receive the live graph state or the long-term store
without the model having to supply them as arguments. As noted,
create_react_agent is deprecated in favor of
create_agent in langchain, which is the
same runtime with a middleware layer, so it stays a good thing to
read even as the entry point moves.
For teams that prefer imperative code to a graph object,
langgraph.func offers the functional API.
@task marks a unit of work that is checkpointed and
retryable, and @entrypoint(checkpointer=...) marks
the top-level function, inside which you call tasks, receive
futures, and use interrupt() exactly as in the graph
API. It trades the explicit graph for plain Python control flow
while keeping persistence, streaming, and human-in-the-loop,
because under the hood it still compiles to Pregel. Separately,
long-term memory that outlives any single thread lives in a
BaseStore (InMemoryStore, or a
Postgres-backed store), which holds namespaced key-value memory
with put, get, and search,
optionally with embeddings for semantic recall. Pass
store=... to compile() and a node or a
tool can read and write user-level memory across threads.
Part VI: Reading the repository
LangGraph is a monorepo of packages under libs/,
langgraph (the core), checkpoint (base
saver interfaces plus the in-memory saver and the store),
checkpoint-sqlite, checkpoint-postgres,
prebuilt, cli, and the SDKs
(sdk-py, sdk-js). All paths below
verified on main in July 2026 and may drift as the
monorepo evolves.
Stage 0, orientation. Read the top-level
README, then the concept docs on the low-level graph
API, persistence, streaming, and human-in-the-loop, and run one
example against the local dev server with langgraph dev.
Questions to hold, what is a channel, what is a thread, and what
does compile() return.
Stage 1, the graph builder.
libs/langgraph/langgraph/graph/, the state builder
(state.py, where schema keys are lowered to channels),
the message helpers (message.py, add_messages
and MessagesState), and the base graph and branch
(graph.py). Questions, how does a state key become a
channel, and how does add_conditional_edges compile
to a branch that writes a trigger channel.
Stage 2, the runtime.
libs/langgraph/langgraph/pregel/, the
Pregel class and the loop, read side by side with
langgraph/channels/ (LastValue,
the binary-operator aggregate, Topic,
EphemeralValue) and
constants.py and types.py
(START, END, Send,
Command, interrupt,
StateSnapshot). Questions, what selects the next
tasks, where are writes applied, and where is the checkpoint
written.
Stage 3, persistence.
libs/checkpoint/langgraph/checkpoint/,
base.py (BaseCheckpointSaver,
Checkpoint, CheckpointTuple), the
serializer, and the in-memory saver, then
libs/checkpoint-sqlite and
libs/checkpoint-postgres for the real backends, and
langgraph/store/ for long-term memory. Questions,
what exactly is in a Checkpoint, and how do
pending_writes make resume-after-failure correct.
Stage 4, streaming and interrupts. The streaming
path in langgraph/pregel/ and the
langgraph/stream/ package, plus how
interrupt() raises, how the loop catches it and
writes it into the checkpoint, and how Command(resume=...)
feeds it back.
Stage 5, prebuilt and functional.
libs/prebuilt (create_react_agent,
ToolNode, tools_condition) and
libs/langgraph/langgraph/func (the
@entrypoint and @task decorators).
Where not to start, the LangGraph Platform server, the SDKs, and the CLI are deployment surface and read best after the runtime is clear, and the managed-value and cache machinery is worth meeting only once the plain channel story is solid.
Part VII: Hands-on labs
None of these need a GPU, only an API key for a chat model (or a
local model behind an OpenAI-compatible endpoint). Log and output
shapes vary with the fast pace of main.
Lab 1: memory across turns. Concept, threads and checkpoints.
Build the two-line chatbot from Part II with an
InMemorySaver. Invoke twice on one
thread_id and confirm the second turn remembers the
first. Invoke a third time on a fresh thread_id and
watch the memory reset. Then call
app.get_state(config) and read the
StateSnapshot.
Lab 2: hand-built ReAct versus prebuilt. Concept, the explicit loop.
print(app.get_graph().draw_mermaid()) # the agent/tools/tools_condition cycle
# now swap in the prebuilt and compare the drawing:
from langgraph.prebuilt import create_react_agent
agent = create_react_agent(llm, [get_weather])
print(agent.get_graph().draw_mermaid())Confirm the two graphs behave identically on the same input, and notice that the prebuilt is the same nodes and edges you wrote by hand. This is the whole thesis in one lab, the shortcut lowers to a graph you could have drawn.
Lab 3: time travel. Concept, checkpoints as history.
Run a multi-step graph, then iterate
app.get_state_history(config) and print each
snapshot's next and checkpoint_id. Pick
an earlier snapshot's config and pass it back to
invoke to replay from there. Then call
update_state to edit one value and resume, and watch
the run take a different path from the fork.
Lab 4: a human approval gate. Concept, static interrupts.
app = builder.compile(checkpointer=InMemorySaver(),
interrupt_before=["tools"])
app.invoke({"messages": [("user", "delete all files")]}, config)
# returns with __interrupt__ before the tool runs
print(app.get_state(config).next) # ('tools',) -- paused
app.invoke(None, config) # approve and continue
Inspect the proposed tool call through get_state,
then either resume with invoke(None, config) to
approve, or update_state to edit the arguments first
and only then resume.
Lab 5: dynamic interrupt and resume. Concept, interrupt/resume.
Put value = interrupt({"question": "confirm?"})
inside a node, run until it pauses, read the payload from the
returned __interrupt__, and resume with
app.invoke(Command(resume="yes"), config). Add a
print before the interrupt() and observe
it fire twice, which teaches why side effects belong after the
interrupt.
Lab 6: streaming modes. Concept, what to show a user.
Stream the ReAct graph with stream_mode="updates",
then "values", then "messages", and
compare what each yields. Add a StreamWriter progress
event inside a node and read it back under
stream_mode="custom".
Lab 7: map-reduce with Send. Concept, fan-out and fan-in.
Give the state a docs list and a
summaries: Annotated[list, operator.add] channel.
Write a router that returns one Send("summarize", {"doc":
d}) per document, and confirm all summaries arrive gathered
in summaries after a single super-step.
Part VIII: Questions and model answers
Understanding checks. Answer aloud before reading.
1. What is LangGraph in one sentence?
A runtime that models an agent as an explicit stateful graph, nodes that read and write a typed shared state over fixed and conditional edges, executed as bulk-synchronous Pregel super-steps whose every boundary can be checkpointed, streamed, interrupted, and resumed.
2. How is the graph's state actually stored, and what does a reducer do?
Each key of the state schema is a channel. A plain key is a
LastValue channel that keeps the most recent write, a
key annotated with a reducer becomes an accumulating channel. A
node returns a partial dict of the keys it changes, and at the
super-step barrier each channel's reducer merges the writes, so
add_messages appends messages while a bare key would
overwrite.
3. Why does a graph beat an implicit agent loop?
Because control flow becomes data. You can draw it, checkpoint every step for persistence and time-travel, pause it for a human and resume, stream intermediate state, fan out and nest subgraphs, and recover from a mid-step failure using pending writes. A loop hidden inside a class offers none of these.
4. What is a super-step, and why does the barrier matter?
A super-step is one plan-execute-update cycle. The nodes selected in the plan phase run in parallel on a consistent snapshot and cannot see each other's writes, and only at the barrier are all writes applied through reducers and a checkpoint taken. The barrier is what gives fan-out and fan-in a precise meaning and makes each step a durable, resumable unit.
5. What is in a checkpoint, and why can a run resume on another machine?
A checkpoint is a versioned snapshot of every channel value plus
metadata, addressed by thread_id,
checkpoint_ns, and checkpoint_id.
Because durable state is keyed by thread and logical id rather
than by a live process, any machine with access to the
checkpointer can load the latest tuple and continue.
6. How does human-in-the-loop work, and what does it require?
A pause is a checkpoint whose next is non-empty.
Either compile with interrupt_before or
interrupt_after, or call interrupt(payload)
inside a node. The run returns with __interrupt__ and
sits durably paused, and Command(resume=value)
continues it. Both require a checkpointer, since there is nowhere
to pause without one.
7. On resume, why can a side effect run twice?
The interrupting node re-executes from its start on resume, and
interrupt() returns the stored resume value instead
of pausing. Any code before the interrupt therefore runs a second
time, so side effects should sit after the interrupt or be made
idempotent.
8. What are the main streaming modes?
"values" for the full state after each step,
"updates" for per-node deltas, "messages"
for LLM token chunks with metadata, "custom" for data
a node pushes through a StreamWriter, and
"debug" for verbose events. A list of modes yields
(mode, data) tuples.
9. What does the Send API give you that a conditional edge does not?
A conditional edge routes to one of a fixed set of nodes. A router
returning a list of Send objects invokes the same
node many times in one super-step, each with its own private
input, which is map-reduce. The results fan back into a reducer
channel at the barrier.
10. How do LangGraph and LangChain relate now?
LangChain is the component library and LCEL for acyclic pipelines,
LangGraph is the stateful, cyclic orchestration runtime. In the
current langchain line the recommended agent constructor
create_agent is built on LangGraph, so LangGraph
became the runtime underneath LangChain's agents rather than a
competitor to them.
11. When is LangGraph the wrong tool?
For a single deterministic prompt-to-answer call, where an LCEL
chain or a raw model call is cleaner, and for a lone linear
tool-call loop with no persistence, no human step, and no
streaming, where a plain while loop is less
machinery. LangGraph earns its weight only when you need durable,
inspectable, supervised, or multi-actor control.
12. A cyclic agent runs forever. What stops it, and what should you check?
The recursion limit, 25 super-steps by default, raises
GraphRecursionError. The real fix is a terminating
branch, a tools_condition-style router that routes to
END once the model stops asking for tools, rather
than simply raising the limit.
Part IX: Design lessons
Make control flow data, not code. An agent's loop
is a graph you build, draw, and edit, not a private
while. Once the flow is a value, you can inspect it,
version it, and change it at run time, which is the same move as
representing a workflow as a DAG in a scheduler or a state machine
as a table rather than nested conditionals.
Represent shared memory as a typed schema with reducers. Turning state into channels with merge rules makes concurrency, accumulation, and map-reduce well-defined instead of ad hoc. This is the same win as CRDTs in collaborative editing and reducers in a front-end store, put the merge rule next to the data and composition becomes checkable.
One barrier, one checkpoint. Because every super-step ends in a durable snapshot, persistence, resume-after-crash, and time-travel are one mechanism rather than three subsystems. Systems that make their unit of progress durable by construction, write-ahead logs and event-sourced aggregates among them, get recovery almost for free.
Address state by thread and logical id, not by process. A checkpoint keyed by thread and checkpoint id can be loaded anywhere, so a run survives a crash or a migration and can even be forked to explore an alternate past. Never let the running process become the only home of important state.
Unify persistence, streaming, and human-in-the-loop. All three read the same channel state at the same step boundaries, so they are three views of one truth rather than three features that can disagree. Designing one mechanism to serve several needs beats three mechanisms that must be kept consistent.
Prebuilt is a shortcut, not a ceiling.
create_react_agent lowers to a graph you could have
written by hand, so the easy path and the fully custom path are
the same substrate. Good frameworks make the convenient thing a
thin layer over the general thing, so you never hit a wall when
you outgrow the default.
Part X: Memorization framework
The one-sentence summary. LangGraph compiles a typed state schema into channels and a set of nodes into a Pregel graph, runs it one bulk-synchronous super-step at a time, merges each step's writes through reducers, and checkpoints the result to a thread, and out of that single mechanism fall streaming, time-travel, human-in-the-loop, and fault tolerance.
StateGraph(State) -> add_node / add_edge / add_conditional_edges
-> compile(checkpointer, interrupt_before) -> a Pregel Runnable
-> invoke/stream(config={thread_id})
-> super-step: plan -> execute (parallel) -> update (reducers) -> checkpoint
-> interrupt() pauses, Command(resume=...) continues
-> END: read channels, return state
The chain mapped to source:
builder libs/langgraph/langgraph/graph/ (state.py, message.py, graph.py) channels libs/langgraph/langgraph/channels/ (LastValue, aggregate, Topic) runtime libs/langgraph/langgraph/pregel/ (the Pregel loop, super-steps) types/constants langgraph/types.py, constants.py (Send, Command, interrupt, START/END) checkpoint libs/checkpoint/langgraph/checkpoint/ (BaseCheckpointSaver, Checkpoint) store libs/checkpoint/langgraph/store/ (BaseStore, long-term memory) prebuilt libs/prebuilt (create_react_agent, ToolNode, tools_condition) functional libs/langgraph/langgraph/func (@entrypoint, @task)
Memorize these blocks:
- State is channels: each schema key is a channel, plain keys keep the last write,
Annotated[T, reducer]keys accumulate, nodes return partial updates that merge at the barrier. - Execution is super-steps: plan the triggered nodes, execute them in parallel on a snapshot, update all channels through reducers at the barrier, checkpoint, advance. Recursion limit 25 by default.
- A checkpoint is a thread snapshot: versioned channel values keyed by
thread_idandcheckpoint_id, withpending_writesfor resume-after-failure, which is why runs resume anywhere and history can be replayed and forked. - Interrupts need a checkpointer: static
interrupt_before/afteror dynamicinterrupt(), resume withCommand(resume=...), and side effects before an interrupt run twice. - Prebuilt lowers to a graph:
create_react_agentis an agent node plus aToolNodewired bytools_condition, now transitional toward langchain'screate_agent.
Part XI: Papers and further reading
The ideas in this walkthrough trace back to a short list of papers, and each one rewards a direct read. Where this site covers the same ground in depth, the companion link points there.
- Yao et al., ReAct, Synergizing Reasoning and Acting in Language Models, 2022. The reason-act-observe cycle that the agent-tools graph of Part II draws out as explicit nodes and edges.
- Malewicz et al., Pregel, A System for Large-Scale Graph Processing, SIGMOD 2010. The graph-processing system the runtime is named after, vertices computing in super-steps and communicating only between them.
- Valiant, A Bridging Model for Parallel Computation, 1990. The bulk-synchronous parallel model behind the plan-execute-update loop and its barrier.
- Schick et al., Toolformer, Language Models Can Teach Themselves to Use Tools, 2023. An early demonstration that a model can decide when to call a tool, the ability
tools_conditionroutes on. - Shinn et al., Reflexion, Language Agents with Verbal Reinforcement Learning, 2023. Agents that critique their own attempts across retries, a pattern that leans on exactly the durable per-thread state the checkpointer provides. The self-improving agents class on this site surveys this line.
- Wang et al., Plan-and-Solve Prompting, Improving Zero-Shot Chain-of-Thought Reasoning by Large Language Models, 2023. A root of the plan-then-execute agent shape that planner and executor graphs implement, and the GPT Researcher walkthrough reads a working planner-executor agent end to end.
- Wu et al., AutoGen, Enabling Next-Gen LLM Applications via Multi-Agent Conversation, 2023. The conversation-centered alternative weighed in Part III, covered in the AutoGen walkthrough.
- Shapiro et al., Conflict-Free Replicated Data Types, 2011. The formal version of putting the merge rule next to the data, which is the design lesson the reducer channels echo.
Part XII: Final takeaway
If the model-calling and tool-calling pieces underneath a node are
the gap, the applied
generative AI and
self-improving agents
classes build them up, and the serving layer a production node
talks to is the subject of the vLLM and
SGLang chapters. Then come back and read
one compiled graph again, watch a single invoke take
a few super-steps, checkpoint each one, and pause for a human, and
the agent will stop feeling like a mysterious loop and start
reading like the state machine it always was.