LangGraph Deep Dive: How Production Agents Actually Run

Jul 10 2026 · 14 min · Sieon Lee

LangGraph is the orchestration runtime under most serious LangChain agents: a graph of nodes over shared state, checkpointed after every step. The checkpoint layer is the part that matters in production. It buys you resumability, human approval gates, time travel, and failure recovery, but it is also where most of the operational pain shows up.

I have spent the last months building agent systems where the agent is not a demo but a long-running process that must survive restarts, wait for humans, and fail in explainable ways. This is the deep dive I wish I had read first: what LangGraph actually is, the mental model that makes its API predictable, the persistence decisions that matter in production, and what the 1.2 release line changes. All code targets langgraph==1.2.8, the current release as of July 6, 2026.

What LangGraph is, and what it is not

The naming still confuses teams, so here is the split I use. LangChain is the agent framework: model integrations, tool abstractions, and the create_agent loop. LangGraph is the runtime underneath: durable execution, streaming, persistence, and human-in-the-loop control. Since the simultaneous 1.0 GA on October 22, 2025, LangChain's create_agent literally runs on LangGraph's execution engine, so if you use modern LangChain agents, you are already running LangGraph whether you knew it or not.

The practical consequence is simple: drop down to the LangGraph API when you need to own the control flow. The common decision rule is that linear pipelines stay in plain LangChain, while anything that loops, branches, pauses for a human, or must resume after a crash belongs in LangGraph. Most production agents end up in the second category sooner than expected.

The mental model: state, supersteps, checkpoints

A LangGraph program is three things: a state schema, nodes that return partial updates to that state, and edges that decide what runs next. The runtime executes in supersteps and, when a checkpointer is attached, persists state snapshots for the thread. Once you internalize that, the API stops feeling like magic and starts looking like a durable state machine.

flowchart LR
  Input["Input state"] --> Step1["Superstep 1: node updates"]
  Step1 --> CP1["Checkpoint 1"]
  CP1 --> Step2["Superstep 2: route + update"]
  Step2 --> CP2["Checkpoint 2"]
  CP2 --> Step3["Superstep 3: continue or stop"]
  Step3 --> CP3["Checkpoint 3"]

Engineering Insight: Treat every node as a deterministic state transition as much as possible. If a node reads external state, calls a tool, or writes to another system, decide whether that side effect belongs before the checkpoint, after the checkpoint, or behind a human approval gate. Bugs usually appear where that boundary was left implicit.

Here is the smallest thing I would call a real graph, with persistence wired in:

# pip install langgraph==1.2.8
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver

class ReviewState(TypedDict):
    draft: str
    verdict: str

def review(state: ReviewState) -> dict:
    flagged = "TODO" in state["draft"]
    return {"verdict": "revise" if flagged else "pass"}

def revise(state: ReviewState) -> dict:
    return {"draft": state["draft"].replace("TODO", "done")}

builder = StateGraph(ReviewState)
builder.add_node("review", review)
builder.add_node("revise", revise)
builder.add_edge(START, "review")
builder.add_conditional_edges(
    "review",
    lambda s: "revise" if s["verdict"] == "revise" else END,
)
builder.add_edge("revise", "review")

graph = builder.compile(checkpointer=InMemorySaver())

result = graph.invoke(
    {"draft": "ship it TODO", "verdict": ""},
    {"configurable": {"thread_id": "review-42"}},
)

Two details in that snippet carry most of the production weight. First, the state is a TypedDict, not a loose dict: in production every field should be explicit and serializable, because every field is going into a checkpoint row. Second, the thread_id in the config is the unit of persistence: same thread, same accumulated state; new thread, blank slate. Keep thread ids under 255 characters if Postgres is in your future.

Persistence is the product

The checkpointer is the first architecture decision that has consequences after the demo works. The options are clearer than the docs make them look:

Checkpointer Backing Use it for
InMemorySaver process memory development only; resets on restart
SqliteSaver local file local testing that must survive restarts
PostgresSaver PostgreSQL production, full stop

PostgresSaver is the production answer, and wiring it is mercifully boring:

# pip install langgraph-checkpoint-postgres psycopg[binary]
from langgraph.checkpoint.postgres import PostgresSaver

DB_URI = "postgresql://agent:secret@db:5432/agents"

with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
    checkpointer.setup()  # creates tables, run once
    graph = builder.compile(checkpointer=checkpointer)

What the tutorials undersell is the operational tail. A checkpoint is written after every node execution, so a chatty agent on a busy thread grows the table fast, and after enough turns you have a very large, very slow table. The working pattern is a TTL job that deletes checkpoints for threads idle longer than N days. This is not a theoretical cleanup task: LangChain's 2026 State of Agent Engineering report ties more than 60% of production agent incidents to state management.

flowchart TB
  Thread["Thread ID"] --> State["Current graph state"]
  State --> Node["Node executes"]
  Node --> Delta["State update"]
  Delta --> Save["Persist checkpoint"]
  Save --> Next["Route next node"]
  Save --> Debug["Replay or branch later"]
  Save --> Resume["Resume after deploy or crash"]

Production Tip: Design checkpoint retention before launch. A production checkpointer is not just a persistence adapter; it is a database with growth, indexes, retention policy, backup strategy, and incident response implications.

Version 1.2 attacks the same problem from the schema side with DeltaChannel, a beta channel type that stores only the incremental delta at each step rather than re-serializing the full accumulated value, with snapshot_frequency=K controlling how often a full snapshot is written. For long-running threads with large accumulating state, this changes the scaling behavior from “size of history” toward “size of change.” It is beta; I would adopt it for new high-volume state fields and leave stable graphs alone until it graduates.

The same checkpoint stream also gives you time travel: fetch any historical checkpoint, branch a new execution from it, and replay. For debugging non-deterministic agent behavior, replaying from the checkpoint just before the weirdness is dramatically faster than reconstructing inputs by hand. LangGraph Studio builds a debugger on the same primitive: it renders the graph, lets you inspect state at any node, replay any run from any checkpoint, and branch a new execution path from that point. The lesson generalizes: once state snapshots are the source of truth, every debugging tool you wish you had becomes a query over checkpoints.

Human-in-the-loop without hacks

Approval gates used to mean polling loops or duct-taped queues. The current API is interrupt() and Command, and it is good enough to build around, as long as you respect two sharp edges.

from langgraph.types import interrupt, Command

def publish_gate(state: ReviewState) -> dict:
    decision = interrupt({
        "question": "Publish this draft?",
        "draft": state["draft"],
    })
    return {"verdict": "approved" if decision == "yes" else "rejected"}

Calling interrupt() pauses the graph and surfaces the payload to the caller under interrupt. The run ends cleanly with state checkpointed. Hours or days later, you resume the same thread:

sequenceDiagram
  participant G as Graph
  participant C as Checkpointer
  participant H as Human
  G->>C: Save state before approval
  G-->>H: Return interrupt payload
  H-->>G: Resume with decision
  G->>C: Load thread checkpoint
  G->>G: Restart interrupted node
  G->>C: Save approved path
graph.invoke(
    Command(resume="yes"),
    {"configurable": {"thread_id": "review-42"}},
)

Sharp edge one: when execution resumes, the entire node restarts from the beginning, not from the interrupt line. Any side effect before the interrupt() call runs twice. Keep interrupting nodes pure up to the interrupt, or make the pre-interrupt work idempotent. Sharp edge two: with multiple interrupts, resume values must be paired with the pending interrupt identity, so changing the interrupt structure is a workflow compatibility change your type checker will not catch.

Both constraints follow directly from the checkpoint mental model: the runtime does not save Python stack frames, it saves state and replays nodes. Once you see that, the rules stop feeling arbitrary and start looking like normal distributed-systems tradeoffs.

Common Mistake: Putting an irreversible side effect before interrupt() because “the human approval happens later.” On resume, the node starts again. If the side effect sends an email, creates a ticket, publishes a post, or charges a card, you just made approval replay unsafe.

What 1.2 adds for production hardening

The 1.2 line is aimed at the "my agent hung at 3am" class of problem. Three additions matter.

Per-node timeouts. You can now cap a node attempt with a hard wall-clock limit, an idle limit that resets on progress, or both:

from langgraph.pregel import TimeoutPolicy

builder.add_node(
    "research",
    research_node,
    timeout=TimeoutPolicy(run_timeout=120, idle_timeout=30),
)

On expiry the runtime raises NodeTimeoutError, clears that attempt's writes, and hands off to the retry policy. The constraint to plan around: timeouts apply to async nodes only, so a synchronous blocking call still cannot be preempted. If you want reliable timeouts, your nodes need to be async.

Error handlers. add_node(..., error_handler=...) runs recovery after retries exhaust, receives a typed NodeError, and returns a Command for state updates and routing. Failure handling becomes part of the graph instead of a try/except pyramid around invoke.

Streaming v3. The new content-block-centric streaming API (version="v3" on astream_events) exposes typed per-channel projections: run.values, run.messages, run.lifecycle, run.subgraphs. In practice this replaces the event-type string matching that made v2 consumers brittle; the direction of travel is agent-level streams rather than token streams.

Streaming: what to actually listen to

Streaming is where LangGraph's graph-level position pays off in UX. Unlike chain-level streaming, LangGraph streams with visibility into which node is running, across independent modes: values for the full state after each step, updates for what each node changed, messages for generated LLM tokens, plus custom, checkpoints, and debug.

The pattern that works for agent UIs is to drive the progress indicator from updates, because it tells you which station of the pipeline is active, and drive the text area from messages. Subscribing to values for UI is a trap on large states: you re-receive the whole accumulated state every superstep, which is exactly the payload problem DeltaChannel exists to avoid on the persistence side.

async for mode, chunk in graph.astream(
    inputs,
    config,
    stream_mode=["updates", "messages"],
):
    if mode == "updates":
        show_active_node(chunk)
    else:
        append_tokens(chunk)

If you are starting fresh on 1.2, aim at streaming v3 instead of string-matching v2 event types. The typed projections make consumers less brittle, which matters once UI state depends on more than token text.

Engineering Insight: In a production UI, users do not need every token from every internal model call. They need to know which phase is active, what has been approved, what is waiting, and what artifact changed. Graph-level streaming maps to that product surface better than raw token streaming.

How Hermes Uses LangGraph

Hermes is a useful case study because its publishing workflow is exactly the kind of agent system that outgrows a simple LangChain loop. A user does not ask for “one model call.” They ask for a finished article: plan the piece, research sources, write the draft, revise it, verify diagrams and examples, push a WordPress draft, wait for approval, and later publish only on explicit instruction. That is workflow orchestration with durable state, not prompt chaining.

The production flow looks like this:

flowchart LR
  User["User request"] --> Planner["Planner"]
  Planner --> Research["Research agent"]
  Research --> Writer["Writer"]
  Writer --> Reviewer["Reviewer"]
  Reviewer --> Gate["Human approval"]
  Gate --> Publisher["Publisher"]
  Publisher --> Forge["Forge canonical store"]
  Forge --> WordPress["WordPress draft"]

LangGraph fits at the orchestration layer. Hermes has native tools for files, web research, terminal execution, browser inspection, messaging, memory, and MCP integrations. Forge is the publishing boundary: it stores canonical markdown, tracks remote WordPress IDs, detects divergence, and keeps drafts separate from published posts. LangGraph can make those responsibilities explicit. Each node owns a phase, each edge encodes the next decision, and the shared state carries the article slug, research package, source list, review failures, preview URL, and publish status.

Hermes needs a graph because the workflow has constraints that are easy to lose in an unstructured agent loop:

  • planning and research can fail independently from writing;
  • writing can produce an artifact that needs validation before it touches WordPress;
  • preview links and remote IDs must survive process restarts;
  • human approval can happen hours later;
  • publication must be impossible unless the current user message explicitly asks for it.

Checkpoints belong after every durable boundary: after the plan is accepted, after research sources are collected, after the draft is saved, after validation passes, after Forge creates or updates the WordPress draft, and after a human approval decision is recorded. If Hermes restarts between “draft pushed” and “user approves publication,” the graph should resume with the same slug, remote post ID, last pushed hash, and preview URL instead of rerunning the content pipeline and risking a second remote write.

Human approval is an interrupt, not a boolean flag buried in state. The publisher node should pause with a payload that includes the title, preview URL, status, and the exact action being requested. Resume should carry an explicit decision such as approve_publish or request_changes. That design makes approval auditable and prevents the dangerous failure mode in content automation: an agent silently converting “looks good” into a publish operation.

stateDiagram-v2
  [*] --> Planned
  Planned --> Researched
  Researched --> Drafted
  Drafted --> Validated
  Validated --> AwaitingApproval
  AwaitingApproval --> Drafted: request changes
  AwaitingApproval --> Published: explicit publish approval
  AwaitingApproval --> AwaitingApproval: wait or resume later

This differs from a simple LangChain agent in one important way: the graph is the product contract. A LangChain agent can decide to call tools in a loop, but the durable workflow still has to be reconstructed from logs, prompts, and side effects. In LangGraph, the allowed phases, checkpoints, interrupts, and recovery paths are first-class. That is why LangGraph fits Hermes-style publishing: it gives the agent room to reason while keeping production state, approvals, and external side effects explicit.

Production Tip: Keep the WordPress write behind a dedicated publisher node. Research and writing nodes should never know WordPress credentials, and approval nodes should never perform the publish themselves. Separate capability boundaries are what make graph recovery safe.

Where it is still rough

An honest deep dive should include the unpolished parts. Checkpoint operations are on you: nothing ships TTL or table maintenance out of the box, and you will need it. DeltaChannel and streaming v3 are both beta, which in this ecosystem means the API can shift under you between minor versions. Timeout policies not covering sync nodes is a real limitation if you are wrapping legacy blocking clients. The documentation also churns: the 1.x docs reorganization means many tutorials older than a year show import paths that no longer exist, so the changelog is genuinely the most reliable reference for what is current.

None of these are disqualifying. They are the tax for the one thing LangGraph gets right: durable execution as the default, not an add-on.

My decision rule

After shipping on it, my rule is: use LangGraph when the agent's execution outlives a request. Approval gates, multi-step research jobs, anything a user can walk away from and return to, anything that must survive a deploy mid-run. Skip it when a plain function calling an LLM twice would do; the checkpointer, thread management, and Postgres table are real operational surface, and a linear pipeline does not earn them.

In a modern AI architecture, LangGraph is the workflow runtime, not the whole platform. MCP is how the agent reaches external systems through typed capability boundaries. Memory is how durable user and project context survives beyond a single thread. Tool execution is where side effects happen and therefore where permissions matter. Multi-agent systems are how specialized workers collaborate without collapsing every task into one giant prompt. Human-in-the-loop workflows are the safety layer that keeps irreversible actions explicit.

LangGraph is the correct architectural choice when those pieces need to run as a recoverable process: state evolves over time, tools may fail, humans may pause the run, and the system must resume without forgetting what already happened. If you only need retrieval plus one answer, LangGraph is probably too much. If you need a workflow you can debug, replay, approve, and operate, it is the right level of abstraction.

FAQ

Do I need LangGraph if I already use LangChain's create_agent?

You are already running it: create_agent executes on the LangGraph engine. Drop to the LangGraph API when you need custom control flow, interrupts, or direct checkpoint access, not before.

Which checkpointer should I use in production?

PostgresSaver, with checkpointer.setup() run once at deploy and a TTL cleanup job from day one. InMemorySaver is for tests; SqliteSaver is for local work that should survive restarts.

Why did my node's code run twice after an interrupt?

Because resume restarts the node from its first line; LangGraph replays nodes rather than restoring Python stack frames. Move side effects after the interrupt, or make them idempotent.

Is LangGraph 1.2 safe to upgrade to from 1.0?

The core API is stable across the 1.x line; 1.2's additions are opt-in (timeouts, error handlers, DeltaChannel, streaming v3). The betas are where I would be conservative: adopt them feature by feature, not wholesale.

References

  1. LangChain/LangGraph changelog
  2. langgraph on PyPI
  3. LangGraph persistence docs
  4. LangGraph interrupts docs
  5. interrupt reference
  6. LangGraph 1.0 GA announcement
  7. State management and the persistence layer decision
  8. LangGraph in production patterns
  9. Checkpoint-based state replay
  10. HITL interrupt pattern
  11. LangChain 1.0 vs LangGraph 1.0
  12. Token streams to agent streams
  13. LangChain and LangGraph product concepts
  14. LangGraph overview
  15. Deploy LangGraph in production
  16. LangGraph vs LangChain streaming visibility