AI & ML / Agents & orchestration / 03_langchain_langgraph.md

LangChain, LangGraph and the agent stack

Updated 3 interview angles 8 min read source
On this page8
  1. LangChain 1.x: create_agent and middleware
  2. The mechanism: supersteps over versioned channels
  3. Running it on Postgres
  4. Where it breaks in production
  5. Choosing the stack
  6. The trade not taken
  7. Related
  8. Interview angle

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

python
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) or ProviderStrategy (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.mcp namespace; the established path is langchain-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:

text
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:
text
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= on invoke/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:

python
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)           # resumes
text
first 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:

text
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

python
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. PostgresSaver implements delete_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 with EncryptedSerializer.from_pycryptodome_aes() (key in LANGGRAPH_AES_KEY), passed as PostgresSaver(conn, serde=...), which means your own connection rather than from_conn_string.

Where it breaks in production

  • thread_id is 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 as multitask_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_agent pins 9,999. A runaway tool loop runs for thousands of model calls before it stops. Own the limit: ModelCallLimitMiddleware(run_limit=...) or recursion_limit in 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.dumps of an updates chunk fails as soon as it carries message objects; serialise with model_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_turns defaults to 10 and raises MaxTurnsExceeded, 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.

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 to async.
    • 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() (or HumanInTheLoopMiddleware on create_agent); on resume the node re-runs from the top, so side effects before the interrupt repeat.
    • Follow-up: a try/except Exception around the tool made the approval gate vanish. Why? GraphInterrupt subclasses Exception; the pause was caught and the run continued.
    • Follow-up: a reviewer resumed another tenant’s thread. thread_id is a capability: namespace it by tenant and authorise every call.
  • “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 your MAX_STEPS. Set the budget explicitly.