Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 the while 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:

  1. 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.
  2. 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.
  3. The model decides when to stop. if not response.tool_calls is 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.
  4. 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.

  1. Define the state — a TypedDict (the documented default), a dataclass (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 all Nodes and Edges”.
  2. add_node("name", fn) for each unit of work.
  3. Add edges — add_edge, add_conditional_edges, with START as the entry point.
  4. 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 the State has 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 left you 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 a plan field. Without right there 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: for BinaryOperatorAggregate it runs at write time, but for DeltaChannel it 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 left and keeps right. 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 messages list 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:

  • Overwrite bypasses 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 in Overwrite is how you actually reset it; without it, “clear the history” is inexpressible.
  • UntrackedValue marks 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 an interrupt.

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:

ChannelSemanticsUse for
LastValue“stores the last value written to it, overwriting any previous value” — the defaultscalars, 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
Topica 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
UntrackedValuenever checkpointedconnections, 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 DeltaChannel when 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:

ArgumentWhat it is
statethe current state, as the schema defines it
configa RunnableConfig carrying thread_id, tags, tracing metadata
runtimethe 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 / Command for 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 current state of 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; graph targets 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.

CheckpointerStore
Persistsgraph state snapshotsapplication-defined key-value data
Scopea single threadacross threads
Memory typeshort-term, thread-scopedlong-term, cross-thread
Use forcontinuity, HITL, time travel, fault toleranceuser preferences, learned facts
Accessthread_id in the configread/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_id is the primary key. “Without it, the checkpointer cannot save state or resume execution after an interrupt.” Reusing a thread_id resumes that thread; a new value starts an empty one.
  • checkpoint_ns identifies 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 older checkpoint_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:

RuleWhySymptom if violated
No bare try/except around itthe pause is an exception; you catch itno interrupt surfaces; the run continues with None
Do not reorder interrupt calls in a noderesume values are matched “strictly index-based”answers arrive against the wrong questions
Do not return complex valuespayloads go to the caller, not into stateserialisation errors at pause time
Side effects before it must be idempotentthe node re-runs from the topthe “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 only Command pattern intended as input to invoke()/stream()/stream_events(). The other parameters (update, goto, graph) are designed for returning from node functions. Do not pass Command(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 next nodes) is a no-op.”

  • Replay = find a checkpoint with get_state_history(config) (reverse chronological order) and invoke with 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.” Then invoke(None, ...) continues from the fork.
  • get_state(config) returns the current StateSnapshot: values, next nodes, 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

ElementRemove it and…
State as a TypedDictnodes 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 dictyou must return the whole state, and every concurrent node clobbers the others
route_after_plannercontrol flow returns to the model; the graph loops until the recursion limit throws
Command(goto=...) from approvalthe 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 configthe checkpointer cannot key the state; resume loads nothing
tools_node as a separate nodetool execution moves inside a model node, so it re-runs on every replay of that node

The knobs that matter

  • checkpointer — InMemorySaver for tests, SqliteSaver for a laptop, PostgresSaver for anything multi-process. A durability decision, not a performance one.
  • recursion_limit — maximum super-steps per execution; exceeding it raises GraphRecursionError. A standalone config key, not a configurable one. 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=K on DeltaChannel bounds a value read to .
  • interrupt_before / interrupt_after — static breakpoints for debugging.
  • Middleware, if you are on create_agent — ModelCallLimitMiddleware, SummarizationMiddleware, and HumanInTheLoopMiddleware cover 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

Axiswhile tool_calls: loopLangGraph
Lines for a 3-step task~20~120 plus a schema
Survives a process restartnoyes, at super-step granularity
Pause for human approvalinvent itinterrupt()
Deterministic stop conditionnoadd_conditional_edges
Parallel branches with merged writesinvent itoutgoing edges + reducers
Replay/fork a failed runnoget_state_history + update_state
State must be serialisablenoyes
Side effects must be idempotentonly on retryalways — nodes re-run
Debug artifactsthe transcripttranscript + 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 for loops. 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. UntrackedValue handles 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.

MetricWhy it is the claim
resume success rate after a killthe headline durability claim
duplicate side effects per 100 runsthe failure durability causes
steps lost on restartcheckpoint granularity
checkpoint bytes per threadthe DeltaChannel decision
time-to-approvewhether HITL is a feature or a queue
p95 wall-clockwhat 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 messages to DeltaChannel (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.