Published on

State of Grace

Authors
  • avatar
    Name
    Benjamin Lee
    Twitter

A demo agent that falls over loses nothing but face. A production one loses a customer. The difference is what happens to its memory when things go wrong.

Watch an agent demo and it looks effortless. The model reasons, calls a tool, reasons again, and arrives at an answer while the audience nods. Then the process crashes at step nine of eleven—a timeout, a dropped connection, a machine rebooted by an overzealous cloud provider—and the whole edifice evaporates. There is nothing to resume, because there was never anything to resume from. The state lived in memory, and memory is the first thing to die.

This is the quiet problem that separates a clever prototype from a system real users can lean on. An agent that cannot survive its own infrastructure is a parlour trick. LangGraph, an open-source framework for building stateful, multi-actor applications with large language models, is built around exactly this anxiety. Its core abstraction is a graph: nodes are functions that perform work, edges define control flow, and a typed state object flows through the entire execution. Crucially, the framework provides durable execution out of the box—agents persist through failures and resume automatically from precisely where they stopped.

That is a meaningful departure from simpler chain-based agents. In a chain, state lives implicitly in the message history, which is to say it lives nowhere anyone can inspect. In LangGraph state is explicit, typed and managed by the framework. That explicitness is dull to describe and indispensable in practice—it is what makes production deployment tractable. As of October 2025, LangGraph Platform, since rechristened LangSmith Deployment, has been used by nearly 400 companies to put agents into production.

Everything flows through the state

Start with the object that ties it all together: a TypedDict you define yourself.

from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph.message import add_messages

class AgentState(TypedDict):
    messages: Annotated[list, add_messages]  # reducer: appends, doesn't overwrite
    steps_taken: int
    requires_review: bool

add_messages is a reducer. When two nodes both update messages, the values are merged—appended—rather than one silently clobbering the other. You can define custom reducers for any field that needs merge semantics.

The nodes themselves are plain functions. Each receives the full state and returns a partial update:

from langchain_anthropic import ChatAnthropic
from langgraph.prebuilt import ToolNode

llm = ChatAnthropic(model="claude-sonnet-4-6").bind_tools(tools)

def call_model(state: AgentState) -> dict:
    response = llm.invoke(state["messages"])
    return {
        "messages": [response],
        "steps_taken": state["steps_taken"] + 1,
    }

tool_node = ToolNode(tools)

Conditional edges then decide where execution goes next, based on the current state:

from langgraph.graph import StateGraph, END

def route(state: AgentState) -> str:
    if state["requires_review"]:
        return "human_review"
    last = state["messages"][-1]
    if getattr(last, "tool_calls", None):
        return "tools"
    return END

graph = StateGraph(AgentState)
graph.add_node("agent", call_model)
graph.add_node("tools", tool_node)
graph.set_entry_point("agent")
graph.add_conditional_edges("agent", route)
graph.add_edge("tools", "agent")
app = graph.compile()

None of this is glamorous. That is rather the point.

Where the magic actually lives

Checkpointing is what separates a LangGraph agent from a demo agent. Every state transition is persisted to a backend, and three consequences follow. An agent can run for hours or days and survive process restarts. You can inspect the state of any in-flight agent at any moment. And a failed run resumes from the last successful checkpoint, not from scratch—which is where we came in.

LangGraph ships three checkpointer backends, and the choice is mostly a question of ambition.

BackendWhen to use
MemorySaverDevelopment and testing only — data disappears on restart
SqliteSaverSingle-server deployments, local persistence
PostgresSaverDistributed systems, horizontal scaling
from langgraph.checkpoint.postgres import PostgresSaver

checkpointer = PostgresSaver.from_conn_string(
    "postgresql://user:pass@host:5432/langgraph"
)
app = graph.compile(checkpointer=checkpointer)

# Each run is keyed by thread_id — same thread resumes the same conversation
config = {"configurable": {"thread_id": "user-session-789"}}
result = app.invoke({"messages": [HumanMessage("Summarize Q3 results")]}, config)

# Resume after failure — picks up from last checkpoint automatically
app.invoke(None, config)

The thread ID is how LangGraph ties checkpoints to a specific conversation or task. Use a stable identifier—user ID, session ID, job ID—according to the use case. Get this wrong and two conversations bleed into one; get it right and an agent can be interrupted, forgotten about for a day and picked up as if nothing had happened.

When the machine asks permission

Sometimes the safest thing an agent can do is stop and ask. LangGraph's interrupt mechanism pauses execution at a defined point and waits for external input before continuing. This is how one builds approval flows, escalation paths and review gates:

from langgraph.types import interrupt

def human_review_node(state: AgentState) -> dict:
    # Execution pauses here — the graph is frozen in the checkpointer
    decision = interrupt({
        "question": "Agent wants to delete production data. Approve?",
        "context": state["messages"][-3:],
    })
    return {"requires_review": False, "approved": decision == "yes"}

The graph resumes when you call app.invoke again with the same thread_id and the human's response. No polling, no timeouts. The state simply waits in the checkpointer until someone picks it back up—patient in a way software rarely is.

Not every job needs the cathedral

Restraint matters. Per the LangChain docs, not every agentic use case warrants LangGraph's full machinery.

ScenarioRecommendation
Single-turn Q&A with toolscreate_react_agent from langgraph.prebuilt
Multi-step with basic retrycreate_react_agent with recursion_limit
Long-running with persistenceLangGraph + PostgresSaver
Human approval requiredLangGraph + interrupt
Multiple coordinating agentsLangGraph multi-agent supervisor pattern

Start with create_react_agent; it covers most agentic tasks with zero boilerplate. Reach for the graph API when you genuinely need explicit branching, persistence or human gates—and not a moment sooner.

Two kinds of memory

An agent that forgets everything between sessions is merely polite, not useful. LangGraph supports two memory scopes. Short-term, or in-thread, memory is the message history within a single thread_id, managed automatically by the checkpointer. Long-term, or cross-thread, memory holds facts that should persist across separate conversations—user preferences, past decisions, learned context—stored externally in a vector store or key-value store and loaded into state at the start of each run.

Most production agents want both. Short-term memory comes free with checkpointing. Long-term memory does not: it requires designing a retrieval step at graph entry, which is the sort of chore that is easy to defer and expensive to skip.

The bottom line

LangGraph's pitch is unfashionably plain. It makes the control flow of an agent as explicit, inspectable and testable as the rest of the application. The state is typed. The transitions are declared. The persistence is built in. None of that will impress a demo audience, because a demo never crashes at step nine. Production always does—and on that day, the boring framework is the one still standing.


Sources: