LangGraph
TL;DR — LangGraph is “a low-level orchestration framework for building, managing and deploying long-running, stateful agents”
[as of 2026-10]. You declare a typed state, write nodes that return partial updates, wire them with edges, and compile with a checkpointer. Each state key has its own reducer, defaulting to overwrite; the reducer is what makes concurrent writes deterministic. Execution proceeds in super-steps (Pregel/BSP): nodes in a step run in parallel and their channel writes are invisible until the next step. Checkpointing at those boundaries buys durable resume,interrupt()for human approval, and time travel. The cost is real: more concepts, state that must be serialisable, side effects that must be idempotent, and debugging that spans the transcript, the graph, and the checkpoint history. LangGraph is to thewhile tool_calls:loop what a transaction log is to an in-memory write.
1. Why this exists
Here is the canonical agent loop, and it is fine:
messages = [system, user]
while True:
response = model(messages, tools)
if not response.tool_calls:
break
messages += [response] + [execute(tc) for tc in response.tool_calls]
It is fine until it is not. Four failures arrive together, and they arrive the moment the task stops fitting inside one HTTP request:
- A long task dies and takes its progress with it. The loop’s entire state is a Python list in one process’s memory. A deploy, an OOM, a dropped connection at step 39 of 50 discards 38 steps of work, and the retry replays the same irreversible side effects.
- You cannot pause for a human. “Send this refund email” needs approval. There is nowhere to be stopped: the loop is a stack frame. Your options are to block a thread for five minutes, or to poll a database you invented.
- The model decides when to stop.
if not response.tool_callsis a model output used as a control-flow condition. There is no code path that says “terminate”; there is a hope that the model stops asking for tools. - Nothing is inspectable after the fact. You have the final transcript and nothing else — not which step was which, not the state at the failure, not a way to fork from step 30 and change one input.
You can fix each of these by hand — a runs table, a status column, a resume
endpoint, an approval queue, idempotency keys, a replay test — and that is a real
option: it is a small durable-execution engine. LangGraph is the argument that
you should not write it, plus the one thing a hand-rolled engine usually gets
wrong: the semantics of applying concurrent partial updates to shared state,
which is where the reducers come from.
The framing to keep. An agent loop is a computation with side effects and no crash recovery. Every durable system solves that the same way — an append-only log of committed state, from which you can resume. LangGraph’s checkpointer is that log, and its super-step is the commit boundary. If you would not reach for a database for a task, do not reach for this either.
2. How it works
2.1 The four-step construction
LangGraph’s construction is a fixed sequence; deviating from it is the most common source of confusion.
- Define the state — a
TypedDict(the documented default), adataclass(if you want defaults), or a Pydantic model (for recursive validation, “though note that Pydantic is less performant”). The schema “will be the input schema to allNodesandEdges”. add_node("name", fn)for each unit of work.- Add edges —
add_edge,add_conditional_edges, withSTARTas the entry point. compile(checkpointer=..., store=...)— not optional. “You MUST compile your graph before you can use it.” Compilation checks the structure (orphaned nodes and similar) and attaches runtime arguments.
flowchart LR
S(["START"]) --> P["planner"]
P -->|"tool_calls"| A["approval (interrupt)"]
P -->|"no tool_calls"| E(["END"])
A -->|"approved"| T["tools"]
A -->|"rejected"| P
T --> P
Everything downstream of this section is a consequence of what state is.
2.2 State and reducers — the real semantics
The documentation is explicit about the mechanism:
“Reducers are key to understanding how updates from nodes are applied to the
State. Each key in theStatehas its own independent reducer function. If no reducer function is explicitly specified then it is assumed that all updates to that key should override it.”“Every reducer is a binary function with two positional arguments: Left argument: The current value already stored in state. Right argument: The update returned by a node.”
Read every symbol:
- (the left argument) — the value already committed to
the channel for key . It exists because a node’s return value is a
proposal, not the new state: the runtime collects proposals from every node
in the super-step and then applies them one at a time. Without
leftyou can only replace; you cannot accumulate, and a fan-in of two parallel writers could not be merged at all. - (the right argument) — the value this node returned for
key . A node returns a partial dict: keys it omits are not touched, which
is why an agent node can return
{"messages": [...]}without clobbering aplanfield. Withoutrightthere is nothing to apply. - — a pure binary function selected per key by
Annotated[k, reducer]. It is the only place where merge policy lives. It must be free of side effects and depend only on its two arguments: forBinaryOperatorAggregateit runs at write time, but forDeltaChannelit runs at reconstruction time, so a reducer that reads a clock or a global will produce different state depending on when history is replayed. - The default reducer discards
leftand keepsright. That is the right default because overwrite is the least surprising behaviour for scalars —{"status": "done"}should set the status — and because it costs nothing: no merge function, no copy of an accumulating structure.
What breaks if you omit a reducer on an accumulating field. Suppose the
state is {"messages": list} with no annotation. Node A returns
{"messages": [a1]}; node B returns {"messages": [b1]}. The committed value
is whichever was applied last — one of the two messages, silently dropped.
The symptom to memorise. Your agent forgets the middle of the conversation. The
messageslist in the checkpoint has one or two entries no matter how many turns you ran. This is the most common LangGraph bug in existence, and its cause is always the missing reducer.
The standard fix is the built-in add_messages reducer, and the reason to
prefer it over operator.add is specific. operator.add appends blindly: if a
human edits message 3 in place (a human-in-the-loop editing pattern), it
produces a fourth message and the edit is ignored. add_messages keeps track
of message IDs, appends brand-new ones, and overwrites by ID when an existing
one changes — which is also why it deserialises raw dicts into LangChain message
objects, so that ID-matching has IDs to match on:
messages: Annotated[list[AnyMessage], add_messages]
Two further features, both solutions to production problems:
Overwritebypasses a merging reducer. A merging reducer cannot be cleared by returning an empty value — “returning an empty value does not clear the field”, because the empty right argument is merged into the accumulated left. Wrapping the update inOverwriteis how you actually reset it; without it, “clear the history” is inexpressible.UntrackedValuemarks a field that “should exist during graph execution but should never be checkpointed”; on resume it “will be reset to their initial state (or be unavailable)”. A live database connection or a large in-memory index is neither serialisable nor something you want in every checkpoint. Do not use it for anything you need across aninterrupt.
2.3 Channels, and why execution is in super-steps
A reducer is not free-floating: Annotated[k, reducer] selects a channel
for key . The channels that matter:
| Channel | Semantics | Use for |
|---|---|---|
LastValue | “stores the last value written to it, overwriting any previous value” — the default | scalars, input/output values |
BinaryOperatorAggregate | “stores a persistent value that is updated by applying a binary operator to the current value and each new update” | running counters, accumulators |
Topic | a pub-sub channel that can “deduplicate values or … accumulate all values written during a run” | fan-in from several parallel writers |
DeltaChannel | “stores only the incremental delta at each step rather than the full accumulated value” | long, frequently-appended values such as a message list |
UntrackedValue | never checkpointed | connections, caches |
The execution model. LangGraph “is inspired by Pregel and Apache Beam”; the public interface “draws inspiration from NetworkX”. Execution proceeds in super-steps, each with three phases:
- Plan — decide which actors (nodes) run this step.
- Execution — run them in parallel. Crucially, “during this phase, channel updates are invisible to actors until the next step”.
- Update — apply the writes to the channels.
That invisibility is not an implementation detail, it is the point. If two
nodes in the same step both read state["count"] and both write +1, the
runtime sees two writes of the same value and applies the reducer twice, giving
+2 — deterministically, regardless of which thread finished first. Remove the
step boundary and you get a race whose result depends on scheduling. The
super-step is why reducers can be simple pure functions and why parallel agent
branches are reproducible.
The DeltaChannel trade, with arithmetic. With ordinary channels, the full
value of every channel is re-serialised into every checkpoint. Let be the
tokens resident in an accumulating channel at step , growing by per step,
with bytes per token. Storing checkpoints costs
- — the initial context (system prompt, tool schemas). The term is linear in steps.
- — the per-step growth. The factor is again : each step’s tokens are re-serialised into every later checkpoint. This is the term that makes naive checkpointing quadratic.
- — bytes per token, roughly 4 for ASCII-ish JSON (example).
With , , , :
KB. With
DeltaChannel, only per-step writes are stored:
KB — so the full-value cost is
against for the delta channel, and the gap grows
quadratically in .
DeltaChannel is not free: it requires langgraph>=1.2 and is in beta; its
reducer is a bulk reducer (the current value plus a sequence of all writes from
the step, not pairwise); the reducer “runs on reconstruction, not on write”, so
reading a value replays the write history, which snapshot_frequency=K bounds to
by writing a full snapshot every steps; state “is not self-contained
in a single checkpoint”; and downgrading below 1.2 leaves those checkpoints
unreadable.
Rule of thumb. Reach for
DeltaChannelwhen a channel is written every step and grows large; the signal is checkpoint size growing with thread length.
2.4 Nodes, edges, and the routing rule
A node is a sync-or-async function receiving three things:
| Argument | What it is |
|---|---|
state | the current state, as the schema defines it |
config | a RunnableConfig carrying thread_id, tags, tracing metadata |
runtime | the runtime context plus store, stream_writer, execution_info, heartbeat, control for graceful shutdown |
It returns either a partial state update or a Command.
- Multiple outgoing edges run in parallel: “all of those destination nodes will be executed in parallel as a part of the next superstep.”
- The routing rule that saves hours of debugging: “For each node, choose one
routing mechanism: use normal edges for static routing, or use conditional
edges /
Commandfor dynamic routing. Do not mix normal edges and dynamic routing from the same node, because both paths can execute and make graph behavior harder to reason about.” add_conditional_edges("node_a", fn)—fn“accepts the currentstateof the graph and returns a value”, used by default as the destination node name, optionally mapped through a dict.Send(node, state)from a conditional edge implements dynamic map-reduce: parallel workers with different inputs, which a normal edge cannot express because a normal edge gives every destination the same state.Command(update=..., goto=..., graph=..., resume=...)combines a state update with routing;graphtargets a parent from inside a subgraph.
2.5 Persistence: checkpointers versus stores
The two persistence objects answer different questions, and conflating them is the classic design error.
| Checkpointer | Store | |
|---|---|---|
| Persists | graph state snapshots | application-defined key-value data |
| Scope | a single thread | across threads |
| Memory type | short-term, thread-scoped | long-term, cross-thread |
| Use for | continuity, HITL, time travel, fault tolerance | user preferences, learned facts |
| Access | thread_id in the config | read/write from nodes or app code |
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.memory import InMemoryStore
graph = builder.compile(checkpointer=InMemorySaver(), store=InMemoryStore())
result = graph.invoke({"messages": [{"role": "user", "content": "Hi, I'm Bob."}]},
{"configurable": {"thread_id": "thread-1"}})
thread_idis the primary key. “Without it, the checkpointer cannot save state or resume execution after an interrupt.” Reusing athread_idresumes that thread; a new value starts an empty one.checkpoint_nsidentifies which graph or subgraph a checkpoint belongs to:""at the top level,"node_name:uuid"for a subgraph invoked as a node.- The full pointer is the triple
thread_id+checkpoint_ns+checkpoint_id; time travel means invoking with a config that names an oldercheckpoint_id.
Implementations [as of 2026-10]: InMemorySaver (from
langgraph.checkpoint.memory — note the name, not MemorySaver),
SqliteSaver for local workflows, PostgresSaver for multi-process production,
plus MongoDB and Cosmos DB savers. BaseCheckpointSaver requires five methods,
and a missing one raises NotImplementedError. JsonPlusSerializer is the
default and is “not suitable for all types of objects”, with
pickle_fallback=True as the escape hatch; EncryptedSerializer (for example
from_pycryptodome_aes(), reading LANGGRAPH_AES_KEY) can be passed as serde=.
2.6 interrupt — human-in-the-loop that is actually a pause
from langgraph.types import interrupt
def approval_node(state: State):
approved = interrupt("Do you approve this action?") # pauses here
return {"approved": approved}
interrupt() “suspends execution by raising an exception that signals the
runtime to pause”. On resume, “the runtime restarts the entire node from the
beginning — it does not resume from the exact line where interrupt() was
called. This means any code that ran before the interrupt will execute again.”
That one sentence generates all four rules:
| Rule | Why | Symptom if violated |
|---|---|---|
No bare try/except around it | the pause is an exception; you catch it | no interrupt surfaces; the run continues with None |
Do not reorder interrupt calls in a node | resume values are matched “strictly index-based” | answers arrive against the wrong questions |
| Do not return complex values | payloads go to the caller, not into state | serialisation errors at pause time |
| Side effects before it must be idempotent | the node re-runs from the top | the “before the gate” write happens twice |
It requires a checkpointer (durable in production) and a thread_id, and the
graph “waits indefinitely until you resume” via Command(resume=...), whose
value becomes the return value of the interrupt call. The hard constraint:
“
Command(resume=...)is the onlyCommandpattern intended as input toinvoke()/stream()/stream_events(). The other parameters (update,goto,graph) are designed for returning from node functions. Do not passCommand(update=...)as input to continue multi-turn conversations — pass a plain input dict instead.”
Typed resumes use interrupt(..., response_schema=Decision) with a Pydantic
model, TypedDict, or dataclass; a Pydantic model is validated and returned as
an instance, an invalid resume raises pydantic.ValidationError, and a raw
JSON-Schema dict is exposed but not validated (langgraph>=1.2.12). Static
breakpoints (interrupt_before/interrupt_after) are a different tool:
“debugging aids” for stepping through a graph. A plain invoke() surfaces
payloads under result["__interrupt__"]; event streaming (version="v3") puts
them on stream.interrupts, with stream.interrupted set when the run paused.
2.7 Time travel, and the warning that invalidates the naive model
“Replay re-executes nodes — it doesn’t just read from cache. LLM calls, API requests, and interrupts fire again and may return different results. Replaying from the final checkpoint (no
nextnodes) is a no-op.”
- Replay = find a checkpoint with
get_state_history(config)(reverse chronological order) andinvokewith that checkpoint’s config. Nodes before it are not re-run; nodes after it are. - Fork =
update_state(...), which “does not roll back a thread. It creates a new checkpoint that branches from the specified point.” Theninvoke(None, ...)continues from the fork. get_state(config)returns the currentStateSnapshot: values,nextnodes, and the config triple — what you show an operator.
This is what makes agent debugging tractable: fork a failed production thread from the step before the failure, change one input, re-run. It is also a way to send the same email twice, for exactly the reason replay re-executes nodes.
2.8 Streaming
Classic stream_mode values are "values" | "updates" | "messages" | "custom" | "checkpoints" | "tasks" | "debug" and compose into one stream discriminated by
type. For new applications the docs recommend event streaming, the
typed-projection API introduced in LangGraph v1.2, which “gives you separate
iterators per projection (messages, values, subgraphs, output)” instead of a
match chunk["type"] state machine.
3. Implementation
A complete graph against langgraph==1.2.15 / langchain==1.4.5
[as of 2026-10]: typed state with two reducers, a model node, a deterministic
tool node, a conditional edge, a checkpointer, and an approval interrupt.
# pip install "langgraph==1.2.15" "langchain==1.4.5" "langchain-openai"
from typing import Annotated, Literal
from typing_extensions import TypedDict
from langchain.chat_models import init_chat_model
from langchain.messages import AnyMessage, SystemMessage, ToolMessage
from langchain.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import add_messages
from langgraph.types import Command, interrupt
model = init_chat_model("openai:gpt-5.5", temperature=0)
@tool
def send_email(to: str, subject: str, body: str) -> str:
"""Send an email. This action is irreversible."""
return f"sent to {to}"
TOOLS = {"send_email": send_email}
model_with_tools = model.bind_tools([send_email])
def add_ints(left: int, right: int) -> int:
"""Reducer for a scalar counter: committed value + update."""
return left + right
class State(TypedDict):
messages: Annotated[list[AnyMessage], add_messages] # append + merge-by-id
steps: Annotated[int, add_ints] # omit it and it is overwritten
approved: bool
def planner(state: State) -> dict:
"""One model turn. Returns a partial update, never the whole state."""
response = model_with_tools.invoke(
[SystemMessage("Draft the email, then call send_email.")] + state["messages"]
)
return {"messages": [response], "steps": 1}
def route_after_planner(state: State) -> Literal["approval", "__end__"]:
"""Deterministic stop: the harness, not the model, bounds the run."""
if state["steps"] >= 8:
return END
last = state["messages"][-1]
return "approval" if getattr(last, "tool_calls", None) else END
def approval(state: State) -> Command[Literal["tools", "planner"]]:
"""Human gate. Everything above the interrupt re-runs on resume."""
call = state["messages"][-1].tool_calls[0] # pure read: idempotent
decision = interrupt({"action": call["name"], "args": call["args"]})
if decision["approved"]:
return Command(goto="tools", update={"approved": True})
rejection = ToolMessage(
content="The human rejected this action. Do not retry; explain why.",
tool_call_id=call["id"], # must pair with the call
)
return Command(goto="planner", update={"messages": [rejection]})
def tools_node(state: State) -> dict:
"""Deterministic side-effect node. Every tool must be idempotent."""
last = state["messages"][-1]
results = [
ToolMessage(content=TOOLS[c["name"]].invoke(c["args"]), tool_call_id=c["id"])
for c in last.tool_calls
]
return {"messages": results, "steps": 1}
builder = StateGraph(State)
builder.add_node("planner", planner)
builder.add_node("approval", approval)
builder.add_node("tools", tools_node)
builder.add_edge(START, "planner")
builder.add_conditional_edges("planner", route_after_planner, ["approval", END])
builder.add_edge("tools", "planner") # the loop, made explicit
graph = builder.compile(checkpointer=InMemorySaver())
Running it, including the pause and resume:
config = {"configurable": {"thread_id": "outreach-7"}}
paused = graph.invoke(
{"messages": [{"role": "user", "content": "Email ada@example.com one line."}],
"steps": 0, "approved": False},
config,
)
pending = paused["__interrupt__"][0] # plain invoke() surfaces payloads here
print(pending.value) # {'action': 'send_email', 'args': {...}}
# The resume value becomes the return value of interrupt() inside approval().
# Command(resume=...) is the ONLY Command you pass to invoke().
final = graph.invoke(Command(resume={"approved": True}), config)
# Time travel: checkpoints come back in reverse chronological order.
for snapshot in graph.get_state_history(config):
print(snapshot.next, snapshot.config["configurable"]["checkpoint_id"])
Every element, and why it is there
| Element | Remove it and… |
|---|---|
State as a TypedDict | nodes have no contract; nothing is validated at compile time |
Annotated[list, add_messages] | every node’s message list replaces the previous one: the agent forgets |
Annotated[int, add_ints] | steps becomes the last writer’s value, so route_after_planner cannot bound the run |
planner returning a partial dict | you must return the whole state, and every concurrent node clobbers the others |
route_after_planner | control flow returns to the model; the graph loops until the recursion limit throws |
Command(goto=...) from approval | the rejection path has nowhere to go, so the run ends |
interrupt(...) | the email is sent with no human in the loop |
checkpointer=InMemorySaver() | interrupt raises instead of pausing; nothing survives the process |
thread_id in the config | the checkpointer cannot key the state; resume loads nothing |
tools_node as a separate node | tool execution moves inside a model node, so it re-runs on every replay of that node |
The knobs that matter
checkpointer—InMemorySaverfor tests,SqliteSaverfor a laptop,PostgresSaverfor anything multi-process. A durability decision, not a performance one.recursion_limit— maximum super-steps per execution; exceeding it raisesGraphRecursionError. A standaloneconfigkey, not aconfigurableone. The default is 10007[as of 2026-10], and it was already 10,000 in v1.0.6; set it far lower in production.durability— checkpoint write frequency against recovery granularity;snapshot_frequency=KonDeltaChannelbounds a value read to .interrupt_before/interrupt_after— static breakpoints for debugging.- Middleware, if you are on
create_agent—ModelCallLimitMiddleware,SummarizationMiddleware, andHumanInTheLoopMiddlewarecover most of what you would otherwise hand-build here; see LangChain.
4. Numbers and trade-offs
4.1 What you pay per super-step
Two costs, and they behave differently.
Token cost: unchanged by the graph. The graph does not make the loop cheaper; it re-sends the same growing context. Using the model from LangChain section 4.1, with a fixed prefix , per-step growth , and hops:
with the same (example) values , , giving input tokens. What the graph changes is that can now be stable across processes — same prompt, same tool list, same order — which is the precondition for the 0.1x prompt-cache reads described there. Frameworks that reorder tools or inject timestamps destroy the cache; a declared, code-defined graph makes stability the default.
Checkpoint cost: new, and proportional to state. With full-value channels,
storage over steps is — about 179 KB for the
(example) numbers above, growing quadratically; with DeltaChannel, about 27 KB,
growing linearly. The read side of DeltaChannel trades one cost for another:
without snapshots, materialising a value is in thread length.
Latency cost: one write per step. A checkpoint write is a few round trips to
the store. Against an LLM call of hundreds of milliseconds, a 2–10 ms local write
(example) is noise; against a graph of pure-Python nodes it can dominate. That is
the axis durability mode moves: recovery granularity versus write latency,
not throughput.
4.2 Graph versus loop, on the axes that matter
| Axis | while tool_calls: loop | LangGraph |
|---|---|---|
| Lines for a 3-step task | ~20 | ~120 plus a schema |
| Survives a process restart | no | yes, at super-step granularity |
| Pause for human approval | invent it | interrupt() |
| Deterministic stop condition | no | add_conditional_edges |
| Parallel branches with merged writes | invent it | outgoing edges + reducers |
| Replay/fork a failed run | no | get_state_history + update_state |
| State must be serialisable | no | yes |
| Side effects must be idempotent | only on retry | always — nodes re-run |
| Debug artifacts | the transcript | transcript + graph + checkpoint history |
The last two rows are the honest cost. LangGraph imposes requirements that a loop does not: everything in state must survive a round trip through the serializer, and every node must be safe to re-run from the top. You do not get to opt out by “not using interrupts” — a crash and resume does the same thing.
4.3 The step budget, and why the default is dangerous
The recursion limit defaults to 10007 super-steps [as of 2026-10] (it was
already 10,000 in v1.0.6) — at 2 super-steps per planner/tools cycle that is
roughly 5,000 model calls on one thread, each re-sending a growing context,
before the runtime intervenes. Set recursion_limit explicitly, add
ModelCallLimitMiddleware if you are on create_agent, and put a wall-clock
budget in the caller. The model is not a stopping condition; a counter is.
5. Pitfalls
Missing reducer on an accumulating field. Symptom: the messages list has
one or two entries after ten turns; the agent forgets earlier tool results. Fix:
Annotated[list[AnyMessage], add_messages].
Mixing Command(goto=...) with a static edge from the same node. Symptom: a
downstream node runs twice, or a branch you did not intend runs alongside the one
you did. Command “only adds dynamic edges — static edges defined with
add_edge still execute”. Pick one routing mechanism per node.
A non-serialisable object in state, or UntrackedValue used for something you
need after an interrupt. Symptom: either the failure is at checkpoint write
time with a traceback pointing into LangGraph, or the field is empty/default on
resume. Fix: UntrackedValue for connections and caches; regular fields for
anything that must survive a pause.
Side effects before interrupt. Symptom: two database rows, or two emails,
for one approved action. The node restarts from the top; everything before the
interrupt runs again. Put the write after the gate, or make it an upsert.
Bare try/except around interrupt, or reordering interrupt calls in a
node. Symptom: either the graph “runs past” the approval with a None answer
and no __interrupt__ key, or resume values land against the wrong questions.
The pause is an exception; you swallowed it. Matching is index-based; keep the
order stable and prefer one interrupt carrying an object.
Replaying to inspect a run, or passing Command(update=...) as invoke
input. Symptom: side effects fire again during what you thought was a read-only
operation, or the graph “appears stuck” because passing a Command resumes from
the latest checkpoint rather than __start__. Replay re-executes nodes after the
checkpoint; for a new turn on an existing thread, pass a plain input dict.
A thread_id longer than 255 characters with PostgresSaver, or changing a
channel from DeltaChannel to a full-value channel on a live thread. Symptom:
two conversations sharing history, or incomplete state reconstruction.
thread_id is stored in a length-limited column; checkpoint encodings differ
between channel types, so keep channel definitions stable for the life of a
thread.
recursion_limit nested under configurable. Symptom: it is ignored, and
your guard rail is not a guard rail. It is a top-level config key.
Assuming create_react_agent is current. Symptom: deprecation warnings now
and a removal at v2.0.0. See LangChain section 2.7.
6. Using it in a project
Reach for LangGraph when
- The task outlives a request. Minutes to hours, with a real chance of a restart in between. Durable resume is the whole value.
- A human must approve something irreversible.
interrupt()is the cleanest implementation of “pause, surface a JSON payload, resume with a decision”. - Control flow must be code. “Stop after 8 steps”, “if the verifier fails, go back”, “if the amount exceeds 500, escalate” — conditions, not prompt lines.
- You need parallelism with deterministic merge, or the ability to reproduce and fork a failure six weeks later.
Do not use it when
- The task finishes in three steps and holds no state. You are buying a
schema, a runtime, and an idempotency obligation to replace
forloops. The loop in section 1 is the right amount of code. - You cannot make your side effects idempotent and cannot move them. The engine will re-run nodes. If a node sends an SMS that cannot be deduplicated, a durable agent is the wrong architecture.
- You need to keep large state unserialisable on purpose — in-memory models,
file handles, GPU tensors.
UntrackedValuehandles some of it; the checkpoint snapshots the rest. - Latency is the entire product and there is no human in the loop. A checkpoint write per step is a real cost with no recovery story to justify it.
- Your team cannot operate it. Checkpoint schemas, channel types, and serializers are production surface. If nobody owns the Postgres checkpoint tables, a loop plus a retry is safer.
- You actually wanted a workflow, not an agent. If every step is known in advance, a pipeline is simpler, cheaper, and testable.
The one-line decision
Adopt LangGraph when you need durable state, a pause, or deterministic control flow. If you need none of the three, the loop is the better program.
7. How you would prove it worked
Durability has measurable claims, which makes it unusually easy to defend — provided you measure the failure modes, not the happy path.
Baseline. The same task on the while tool_calls: loop, same model and tools.
The experiment is fault injection. Kill the process at a random step (example:
step 7 of 20), restart, resume, and count what happened.
| Metric | Why it is the claim |
|---|---|
| resume success rate after a kill | the headline durability claim |
| duplicate side effects per 100 runs | the failure durability causes |
| steps lost on restart | checkpoint granularity |
| checkpoint bytes per thread | the DeltaChannel decision |
| time-to-approve | whether HITL is a feature or a queue |
| p95 wall-clock | what checkpoint writes cost you |
Two controls make the result honest. First, hold the step budget fixed: if the graph version completes more tasks partly because it runs more model calls, report both success and tokens. Second, count the duplicates explicitly — a durability layer that resumes correctly but sends a second email has moved the failure, not removed it.
Reporting template (example — replace with your own numbers). “On a 200-task refund eval, the plain loop completed 0 of 50 runs killed mid-task; the LangGraph version resumed 48 of 50 (example), with 2 runs re-entering the gate for re-approval. Duplicate side effects were 0 with an idempotency key and 11 without (example) — which is the number we report, because it is the cost of the abstraction. Checkpoint overhead was about 27 KB per 10-step thread after moving
messagestoDeltaChannel(example).”
8. Interview drill
“Why a graph instead of a while loop?”
Strong answer: the loop’s state is a list in one process, its stopping
condition is a model output, and it has no pause and no history. The graph makes
state typed and reducer-defined, makes control flow code, and checkpoints every
super-step — which is what buys durable resume, interrupt, and time travel.
Common wrong answer: “it’s more scalable” or “it’s what LangChain recommends”.
Neither is a mechanism, and the first invites a numbers question you cannot
answer.
“What is a reducer, exactly?”
Strong answer: a per-key pure binary function
.
The default discards the left argument, i.e. overwrite. It exists because a
node returns a proposal and several nodes may write the same key in one
super-step, so merge policy has to live somewhere deterministic. add_messages
is the standard instance: it appends new messages but overwrites by ID, which is
what makes human edit-in-place work; operator.add would append the edit as a
new message instead. To clear such a field you need Overwrite, because a
merging reducer cannot be emptied by returning an empty value.
Common wrong answer: “a function that combines lists” — that is one instance,
not the concept, and it misses that the default is overwrite.
“What is a super-step, and why should I care?” Strong answer: the Pregel/BSP bracket: plan which nodes run, run them in parallel, then apply their channel writes. Channel updates are invisible to actors until the next step, which is what makes concurrent writes deterministic and reducers pure. It is also the checkpoint boundary. Common wrong answer: “one node execution” — a super-step can contain many nodes; it is the barrier.
“Walk me through interrupt.”
Strong answer: it raises a special exception, the runtime checkpoints and
pauses, and on Command(resume=...) the node restarts from the beginning, so
everything before the interrupt runs again and must be idempotent. Resume values
are matched index-based. It needs a checkpointer and a thread_id.
Follow-up: “what if I wrap it in try/except?” → you catch the pause signal
and the interrupt never surfaces.
“Does replay read from cache?” Strong answer: no. Nodes before the checkpoint are not re-executed because their results are saved, but nodes after it — including LLM calls, API requests, and interrupts — fire again and may return different results. Replaying from the final checkpoint is a no-op. Common wrong answer: “yes, it restores state” — true of the checkpoint, false of the continuation, and the distinction is what causes duplicate side effects.
“Where does the memory go as a thread grows, and why is the default recursion
limit a problem?”
Strong answer: with ordinary channels each checkpoint re-serialises the full
channel value, so storage is in thread length; DeltaChannel stores
only per-step writes, making it at the cost of an read that
snapshot_frequency=K bounds to — switch when checkpoint size grows
linearly with thread length. Separately, the default recursion limit is 10007
super-steps [as of 2026-10], roughly 5,000 model calls on one thread, each
re-sending a growing context: a budget incident rather than an error. Set it low,
add a model-call limit, and cap wall-clock in the caller.
Common wrong answer: “the recursion limit prevents infinite loops” — it does,
eventually, and too expensively to count as protection.
Next: Multi-Agent Patterns — what you get, and what you actually pay, when you put several of these graphs on the same task.