DEV Community

Cover image for LangGraph in 2026: Modeling an Agent as a State Machine
Gabriel Anhaia
Gabriel Anhaia

Posted on

LangGraph in 2026: Modeling an Agent as a State Machine


Picture your agent halfway through a two-hour run. Step forty-one. The Kubernetes pod it lives in gets evicted. The loop dies. The conversation dies. The partial work dies. When the pod comes back, the agent starts from step zero and charges you for the whole thing again.

That is the moment a raw loop stops being enough. A while loop keeps its state in local variables, and local variables do not survive a process restart. A state machine keeps its state in a database, so it does.

LangGraph is the tool most production teams reach for when they hit that wall. The primitives are few. What follows is a runnable graph, durable checkpoints, a human-in-the-loop pause, then the honest part: when a graph beats an ad-hoc loop, and what the extra ceremony costs you.

The raw loop works until it meets the real world

A loop-based agent is easy to read. It calls a model, parses a tool call, runs the tool, appends the result, and loops. You can hold the whole thing in your head.

The real world does not care that you can hold it in your head. It has process crashes, mid-run deploys, node reboots, and approval gates where a compliance officer takes ninety minutes to click "approve." A loop survives none of those. Its state lives and dies with the Python interpreter.

The shift is this: you stop writing the agent as a function that calls itself, and start writing it as an explicit graph whose state is persisted between steps. The model still decides what happens next. But what runs next, what state it sees, and what it commits belong to the framework, not your loop body.

Four primitives, and only four

LangGraph in 2026 is a narrower, lower-level library than the LangChain many people remember from 2023. Agent work moved here; the legacy AgentExecutor is deprecated in current LangChain. Judge it on its own terms, and on its own terms it has four things worth learning.

State. A TypedDict you define. It is the single source of truth during a run. Every node reads it, every node writes to it, and the shape is checked at compile time.

Nodes. Plain Python callables. Each takes the current state and returns a partial update as a dict. Anything you can do in Python, a node can do.

Edges. Static (add_edge("a", "b")) or conditional. A conditional edge calls a routing function that reads state and names the next node. That function is where the model's last decision turns into control flow.

Checkpointers. The persistence layer. Every time a node commits, the new state is saved under the current thread_id. Swap the checkpointer and the same graph runs in memory, on SQLite, or on Postgres without touching node code.

Learn those four and you can read any LangGraph codebase. Interrupts, subgraphs, the prebuilt ReAct agent — all of it is built on top.

A three-node graph you can run

Pin your versions. LangGraph ships patches fast, and pip install -U will break you eventually.

pip install \
  "langgraph==1.1.6" \
  "langgraph-checkpoint-sqlite==3.0.3" \
  "langchain-anthropic"
Enter fullscreen mode Exit fullscreen mode

Build something concrete: triage a support ticket, decide whether it needs a refund, then draft a refund email or escalate. Three nodes, one branch, real state.

Start with the state type.

from typing import Annotated, Literal, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain_anthropic import ChatAnthropic


class TicketState(TypedDict):
    messages: Annotated[list, add_messages]
    ticket_text: str
    category: Literal["refund", "other", ""]
    draft_reply: str
Enter fullscreen mode Exit fullscreen mode

add_messages is a reducer: it appends new messages instead of overwriting the list. Fields without a reducer use last-write-wins. Getting that distinction wrong is the single most common LangGraph bug — a field you expected to accumulate quietly overwrites instead.

Now the nodes. Each returns only the fields it wants to change. Claude is a sensible default for the model calls.

llm = ChatAnthropic(model="claude-opus-4-8", max_tokens=1024)


def classify(state: TicketState) -> dict:
    prompt = (
        "Classify this ticket as 'refund' or "
        "'other'. Reply with one word.\n\n"
        f"Ticket: {state['ticket_text']}"
    )
    resp = llm.invoke(prompt)
    label = resp.content.strip().lower()
    if label not in ("refund", "other"):
        label = "other"
    return {"category": label}


def draft_refund(state: TicketState) -> dict:
    prompt = (
        "Draft a short, empathetic refund "
        "approval email. Max 4 sentences.\n\n"
        f"Ticket: {state['ticket_text']}"
    )
    resp = llm.invoke(prompt)
    return {"draft_reply": resp.content}


def escalate(state: TicketState) -> dict:
    return {"draft_reply": "ESCALATED: human required."}
Enter fullscreen mode Exit fullscreen mode

Each node is a plain function you can unit-test with a fake state dict. Wire them together with a routing function on the branch out of classify.

def route(state: TicketState) -> str:
    if state["category"] == "refund":
        return "draft_refund"
    return "escalate"


builder = StateGraph(TicketState)
builder.add_node("classify", classify)
builder.add_node("draft_refund", draft_refund)
builder.add_node("escalate", escalate)

builder.add_edge(START, "classify")
builder.add_conditional_edges(
    "classify",
    route,
    {"draft_refund": "draft_refund",
     "escalate": "escalate"},
)
builder.add_edge("draft_refund", END)
builder.add_edge("escalate", END)

graph = builder.compile()
Enter fullscreen mode Exit fullscreen mode

compile() is not optional. It checks that every node is reachable, every edge points at something real, and the state type lines up. A typo in a node name fails here, at build time, not at step forty-one in production.

You have written more lines than a raw loop would need. That is the point. Each line makes explicit something the loop left implicit.

Checkpoints so crashes stop costing you

So far the graph holds state for exactly one run. Kill the process and it is gone. Attach a checkpointer and the state survives.

from langgraph.checkpoint.sqlite import SqliteSaver

initial = {
    "messages": [],
    "ticket_text": "Order #8821 never arrived. "
    "I want a full refund.",
    "category": "",
    "draft_reply": "",
}

with SqliteSaver.from_conn_string(
    "checkpoints.sqlite"
) as saver:
    graph = builder.compile(checkpointer=saver)
    config = {"configurable": {"thread_id": "t-42"}}
    graph.invoke(initial, config)
Enter fullscreen mode Exit fullscreen mode

The thread_id is the key. Every node transition writes a checkpoint row keyed by (thread_id, checkpoint_id). If the process dies mid-graph, reconnect to the same thread and resume from the last committed checkpoint. Swap SqliteSaver for PostgresSaver and the same graph runs across a horizontally scaled service. SQLite for your laptop, Postgres for a real deployment.

One operational note the docs soft-pedal: checkpoint tables grow monotonically. An agent running a million threads a month with ten checkpoints each writes ten million rows a month. Set a retention policy before your database bill becomes an incident.

A human in the loop, with a pause that survives

The refund graph as written approves refunds with nobody looking. In production you do not do that. LangGraph gives you interrupt() for exactly this: a node pauses the graph, surfaces a payload to whoever is asking, and resumes when someone supplies a value.

from langgraph.types import interrupt, Command


def approve_refund(state: TicketState) -> dict:
    decision = interrupt(
        {"question": "Approve this refund?",
         "draft": state["draft_reply"]}
    )
    if decision == "approve":
        return {}
    return {"draft_reply": "Refund denied by reviewer."}
Enter fullscreen mode Exit fullscreen mode

Route draft_refund through this node instead of straight to END, and the first invoke returns early. State is persisted at the interrupt point. To resume, call the graph again on the same thread_id with a Command. This reuses the checkpointer-compiled graph and the config from the checkpoints section above — interrupt() needs a checkpointer to pause and resume.

first = graph.invoke(initial, config)
payload = first["__interrupt__"][0].value
# surface payload to a human via Slack, email, a dashboard

second = graph.invoke(Command(resume="approve"), config)
print(second["draft_reply"])
Enter fullscreen mode Exit fullscreen mode

Pause a run for ninety minutes while a compliance officer reads the transcript, then resume as if nothing happened. A raw loop cannot do that without you hand-writing the checkpointer, the resume logic, and the interrupt protocol yourself. People have. It is a two-week project and it is never quite right.

When a graph beats a loop, and what it costs

The decision is not "is LangGraph good." It is good. The decision is whether your agent needs a state machine at all.

Stay with a raw loop when the run is short, stateless between invocations, needs no human approval, and its failure mode is "retry the whole thing." Most first agents fit here. Shipping a loop to production is not a sin — it is the right default until it is not.

Reach for LangGraph when any of these is true: a crash between steps is expensive, the run needs a human in the loop, its state must outlive the process, or you need to replay and inspect past runs at the state level. The costs are real. The graph is verbose next to a fifteen-line loop. Reducers trip up every newcomer. The cleanest path still leans on LangChain model wrappers. langgraph.js typically trails the Python version by about a minor release.

None of that is disqualifying. All of it is the price of a process that wakes up on the other side of a pod eviction and finishes the job. Pick from the trade-offs, not from a blog headline.


If you want the full version of this: durable Postgres deployments, LangGraph Studio for time-travel debugging, subgraphs, and the framework comparison against the OpenAI Agents SDK and Pydantic AI. That is what The AI Engineer's Library is for. Agents in Production covers building and shipping multi-step agents like this one; Observability for LLM Applications covers the tracing, evals, and cost tracking that keep them honest once they are live.

The AI Engineer's Library — Observability for LLM Applications and Agents in Production, side by side

Top comments (0)