LangChain, LangGraph and the agent stack
Article.
What are LangChain and LangGraph now, how does LangGraph actually persist
and resume a run, and when would you use something else? An answer built
on chains and LCEL is a generation out of date. Since 1.0 (October 2025),
LangGraph is the runtime (a graph of steps with checkpointed state) and
LangChain is the high-level agent API on top of it (create_agent plus
middleware, and the provider integrations).
That sentence is the opener; the senior part is the mechanism underneath, the defaults that bite in production, and a reasoned choice against the alternatives.
Versions checked on PyPI on 2026-10-14: LangGraph 1.2.15, LangChain 1.4.5, langchain-core 1.6.10, langgraph-checkpoint 4.3.0, langgraph-checkpoint-postgres 3.1.3. There has never been a LangGraph 1.3.
The legacy chains (LLMChain and friends) live on in langchain-classic.
The hosted platform formerly called LangGraph Platform is LangSmith
Deployment (renamed October 2025), and LangGraph Studio is LangSmith
Studio. The build-it depth (state, routing, streaming, subgraphs) is in
LangGraph, in depth.
LangChain 1.x: create_agent and middleware
from langchain.agents import create_agent
from langchain.agents.middleware import (
HumanInTheLoopMiddleware, ModelCallLimitMiddleware, PIIMiddleware)
from langchain.agents.structured_output import ToolStrategy
agent = create_agent(
model=MODEL, # "provider:model" from config
tools=[lookup_order, issue_refund],
middleware=[
PIIMiddleware("email", strategy="redact"),
ModelCallLimitMiddleware(run_limit=15, exit_behavior="end"),
HumanInTheLoopMiddleware(interrupt_on={
"issue_refund": {"allowed_decisions": ["approve", "edit", "reject"]},
}),
],
response_format=ToolStrategy(RefundResult), # → result["structured_response"]
checkpointer=checkpointer,
)- Middleware replaced subclassing. Hooks run around the loop:
before_agent,before_model,wrap_model_call,after_model,wrap_tool_call,after_agent, each available as a decorator for a one-off. Shipped middleware covers PII, call limits, retries and fallbacks for models and tools, summarisation, context editing, tool selection and human approval. There is no rate-limit middleware: per-user limits are custom middleware or the gateway’s job. - Structured output comes from
response_format:ToolStrategy(the answer as a forced tool call, works everywhere) orProviderStrategy(the provider’s native JSON-schema mode). - Human approval pauses the run before a listed tool; the decision comes back as approve, edit, reject or respond.
- MCP: LangChain 1.4 (2026-09-03) added a beta
langchain.mcpnamespace; the established path islangchain-mcp-adapters.
The mechanism: supersteps over versioned channels
LangGraph compiles a graph into a Pregel / bulk-synchronous-parallel loop. State keys are channels with versions; nodes subscribe to the channels they read. Each superstep:
plan: which nodes' input channels changed?
└─▶ execute those nodes in parallel
└─▶ apply their writes through each key's reducer
└─▶ checkpoint the new channel versions
└─▶ next superstep (or END)Three consequences an interviewer will probe:
- Two writes to one key in a superstep need a reducer. Without one:
InvalidUpdateError: At key 'amount': Can receive only one value per step.
Use an Annotated key to handle multiple values.Annotated[list, add_messages] (or operator.add) declares how
concurrent writes merge; details in
State and reducers.
- Pending writes make partial failure cheap. When three parallel nodes
run and one crashes, the two that finished have their writes stored in
checkpoint_writes; on resume only the failed node runs again. - Durability is a per-call setting,
durability=oninvoke/stream:"sync"writes the checkpoint before the next superstep starts (safest, one database round trip per step),"async"(the default) writes it while the next step runs,"exit"only at the end (fastest, and a crash loses the whole run).
Interrupts re-run the node from the top
interrupt() pauses by raising, and resuming re-executes the node from
its first line, feeding the resume value to the interrupt() call. Run
against LangGraph 1.2.15 with an in-memory checkpointer:
def refund(state: S):
calls["n"] += 1
print(f" refund node: run #{calls['n']}, looking up order (side effect!)")
decision = interrupt({"question": f"Refund ${state['amount']}?"})
print(f" refund node: got decision {decision!r}")
return {"approved": decision == "approve"}
out = app.invoke({"amount": 40, "approved": False}, cfg) # pauses
out = app.invoke(Command(resume="approve"), cfg) # resumesfirst invoke:
refund node: run #1, looking up order (side effect!)
returned: {'question': 'Refund $40?'}
resume:
refund node: run #2, looking up order (side effect!)
refund node: got decision 'approve'
final state: {'amount': 40, 'approved': True}What runs twice, and what swallows the pause
Anything before interrupt() in the same node runs twice: put side effects
after it, or in a separate node, or make them idempotent. And the pause is
an exception, GraphInterrupt, a subclass of Exception, so a defensive
try/except Exception around the call swallows it:
swallowed interrupt:
caught GraphInterrupt: the pause never happens
result: {'amount': 40, 'approved': True}The approval gate silently vanished, and the refund was approved with no
human. Catch specific exceptions, or re-raise GraphInterrupt. More HITL
patterns: Human in the loop.
Running it on Postgres
from langgraph.checkpoint.postgres import PostgresSaver
with PostgresSaver.from_conn_string(DSN) as checkpointer: # a context manager
checkpointer.setup() # once per deploy: creates the tables
app = graph.compile(checkpointer=checkpointer)
app.invoke(inputs, {"configurable": {"thread_id": f"{tenant_id}:{conv_id}"}})from_conn_string returns a context manager, not a saver; passing it
straight to compile() is a common broken snippet. setup() creates
checkpoint_migrations, checkpoints, checkpoint_blobs and
checkpoint_writes. With your own pool, connections need autocommit=True,
row_factory=dict_row and prepare_threshold=0 (the last also matters
behind PgBouncer in transaction mode).
What grows, and what nothing deletes
- The messages channel is rewritten every superstep. Unchanged channels
are not re-stored, but the message list changes each step, so its whole
serialised list is written again: bytes per thread grow roughly with the
square of its length. Long conversations need summarisation or trimming
(
SummarizationMiddleware, context editing), or the beta delta channel added in LangGraph 1.2. - There is no TTL in the open-source checkpointer.
PostgresSaverimplementsdelete_thread(); retention is your job, a scheduled sweep calling it for threads past their age. The same call is the GDPR erasure answer. - The checkpoint store is a code-execution boundary. The serializer’s
own docstring warns that an attacker who can write to the checkpoint
database may trigger code execution on deserialisation. Restrict it with
LANGGRAPH_STRICT_MSGPACK=true, and encrypt at rest withEncryptedSerializer.from_pycryptodome_aes()(key inLANGGRAPH_AES_KEY), passed asPostgresSaver(conn, serde=...), which means your own connection rather thanfrom_conn_string.
Where it breaks in production
thread_idis a bearer capability. Anyone who can submit one can read or resume that conversation. Prefix it with the tenant and authorise it on every call, or one customer resumes another’s approval.- Two requests on one thread at once (“double texting”). Nothing in the
open-source runtime serialises them: both start from the same checkpoint,
and one turn silently drops out of the history. Take a per-thread lock
(
pg_advisory_lock(hashtext(thread_id))on a dedicated connection, or a Redis lock) or queue per thread. LangSmith Deployment exposes the policy asmultitask_strategy(reject, interrupt, rollback, enqueue). - The recursion limit is no longer a budget. The default is 10,007
supersteps (it was 25 until 1.0.5), and
create_agentpins 9,999. A runaway tool loop runs for thousands of model calls before it stops. Own the limit:ModelCallLimitMiddleware(run_limit=...)orrecursion_limitin the config. - A deploy that renames a node breaks every paused thread whose checkpoint points at it. Treat node names like a database schema.
- A client disconnect cancels the graph mid-superstep when the run is tied to the HTTP request. Decouple runs that must finish; the trade-off is in Streaming LLM responses over SSE.
- Streaming crashes on serialisation:
json.dumpsof anupdateschunk fails as soon as it carries message objects; serialise withmodel_dump(). Since LangGraph 1.1,stream(..., version="v2")yields typed{type, ns, data}parts instead of tuples (Streaming).
What to watch: spans per node (OpenTelemetry or LangSmith), the rate of
GraphRecursionError and model-call-limit exits, the age of paused
interrupts, and checkpoint table size per thread.
Choosing the stack
| Option | Choose it for | Watch out for |
|---|---|---|
| Your own loop + MCP | one model, few tools, full control | you build state, retries, HITL |
| LangGraph / LangChain | durable runs, HITL, branching graphs | checkpoint ops, version churn |
| OpenAI Agents SDK | OpenAI-native agents, handoffs | max_turns=10; traces to OpenAI |
| Pydantic AI | typed I/O, Python-first code | durability via an engine you add |
| Claude Agent SDK | a coding/file agent harness | built around Claude Code’s tools |
- Your own loop is 50 lines (The agent loop) and the right answer more often than frameworks admit. Add durability with an engine you already run (Temporal, a job queue) when you need it.
- OpenAI Agents SDK (0.23.1): Responses-native, handoffs, guardrails,
sessions.
max_turnsdefaults to 10 and raisesMaxTurnsExceeded, and tracing is on by default and exports to OpenAI unless disabled, which is a data-residency decision. - Pydantic AI (2.56): typed outputs and dependencies; durable execution through Temporal, DBOS, Prefect, Restate or AWS Lambda integrations (Pydantic AI).
- MCP is the tool layer under all of them, so tools written once as MCP servers survive a framework change (Agent protocols: MCP, A2A and the interop layer).
- Hosted visual builders are a platform risk: OpenAI’s Agent Builder shuts down on 2026-11-30, announced in June.
The trade not taken
LangGraph for every agent. Its checkpointing, interrupts and graph topology earn their keep for long-running, branching, human-approved workflows. For a single-model assistant with a handful of tools, the framework adds a runtime, a checkpoint schema to operate, and a dependency that has shipped a minor version every month or two.
The loop you own is easier to debug and to reason about under a security review; adopt the framework when durability or HITL is a requirement, not in anticipation.
Related
Interview angle 3
- “How does LangGraph persist and resume a run?” - each superstep runs
the nodes whose inputs changed, merges their writes through reducers and
checkpoints the channel versions under a
thread_id; durability defaults toasync.- Follow-up: three parallel nodes, one crashed. What re-runs? Only the one that failed; the others’ pending writes were stored.
- Follow-up: a node charged a card, then the process died before its checkpoint. What happens on resume? The node runs again, so the charge repeats unless the payment call is idempotent.
- “Add approval before refunds, and tell me what runs twice.” -
interrupt()(orHumanInTheLoopMiddlewareoncreate_agent); on resume the node re-runs from the top, so side effects before the interrupt repeat.- Follow-up: a
try/except Exceptionaround the tool made the approval gate vanish. Why?GraphInterruptsubclassesException; the pause was caught and the run continued. - Follow-up: a reviewer resumed another tenant’s thread.
thread_idis a capability: namespace it by tenant and authorise every call.
- Follow-up: a
- “LangGraph, OpenAI Agents SDK, Pydantic AI, or your own loop?” -
durability and HITL point to LangGraph; OpenAI-only and handoffs to the
Agents SDK; typed Python code to Pydantic AI; a small assistant to your
own loop, with MCP for tools either way.
- Follow-up: what stops a runaway loop in each? LangGraph’s default
limit is 10,007 supersteps (9,999 under
create_agent), so effectively nothing; the Agents SDK stops at 10 turns; your own loop at yourMAX_STEPS. Set the budget explicitly.
- Follow-up: what stops a runaway loop in each? LangGraph’s default
limit is 10,007 supersteps (9,999 under