Chains vs. State Graphs: Why LangGraph Exists

Linear chains are DAGs. Agents need cycles, shared state, and checkpoints. A look at the abstraction shift.

Updated

Every code sample in this post was run against langgraph 1.2.18 and langchain-core 1.7.0. The examples use plain Python functions in place of LLM calls, so you can run them without an API key and see exactly how the runtime behaves.

1. Chains: function composition

A chain is a fixed pipeline, fn∘⋯∘f2∘f1f_n \circ \dots \circ f_2 \circ f_1. In LangChain, chains are built with LCEL (LangChain Expression Language): every component is a Runnable, and | composes them into a RunnableSequence.

chain.py
from langchain_core.runnables import RunnableLambda, RunnableParallel
normalize = RunnableLambda(lambda s: s.strip().lower())
count = RunnableLambda(lambda s: {"text": s, "words": len(s.split())})
chain = normalize | count
chain.invoke(" Hello State Graphs ")
# {'text': 'hello state graphs', 'words': 3}
# Fan-out is also a chain: run branches on the same input, collect a dict.
RunnableParallel(upper=RunnableLambda(str.upper), length=RunnableLambda(len)).invoke("abc")
# {'upper': 'ABC', 'length': 3}

With a model in the middle, the canonical chain is prompt | model | parser. You get invoke, batch, stream, and async variants for free, because the composed chain is itself a Runnable.

The defining property: a chain is a DAG. Data flows forward, each step runs once per invocation, and the shape is fixed when you build it. Branching (RunnableBranch) chooses one forward path; it never goes back.

Where chains break

The trouble starts when the output of a step decides what happens next, possibly more than once:

  1. Loops. “Call tools until the model stops asking for them” or “revise until the critic approves” needs a cycle. A DAG can’t express it.
  2. Decisions on accumulated state. Routing based on everything that has happened so far, not just the previous step’s output.
  3. Pausing for a human. Stop before sending an email, wait minutes or days for approval, then continue.
  4. Durability. A 20-step run that crashes at step 17 should resume at step 17, not start over.
  5. Inspection. You want to see (and sometimes edit) the intermediate state of a run.

You can wrap a chain in a Python while loop, and that’s roughly what early agent executors did. But then the control flow lives in imperative code the framework can’t see, so it can’t checkpoint, pause, or replay it.

2. State graphs: a state machine over shared state

A LangGraph StateGraph has four parts:

PartWhat it is
StateA schema (usually a TypedDict) shared by every node.
NodesFunctions that take the current state and return a partial update: only the keys they change.
EdgesFixed (a → b) or conditional (a function inspects state and returns the next node’s name).
ReducersPer-key rules for merging an update into the state. The default is overwrite.

START and END are special nodes marking entry and exit. compile() validates the graph and returns a Runnable, so a compiled graph supports invoke, stream, and batch just like a chain.

Reducers decide how updates merge

Conceptually, each step computes the new state as the old state merged with the node’s update, key by key. A key without a reducer is overwritten; a key annotated with a reducer is combined:

reducers.py
import operator
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
class S(TypedDict):
last: str # no reducer: overwrite
log: Annotated[list[str], operator.add] # reducer: append
def a(state: S):
return {"last": "a", "log": ["a ran"]}
def b(state: S):
return {"last": "b", "log": ["b ran"]}
g = StateGraph(S)
g.add_node("a", a)
g.add_node("b", b)
g.add_edge(START, "a")
g.add_edge("a", "b")
g.add_edge("b", END)
g.compile().invoke({"last": "", "log": ["start"]})
# {'last': 'b', 'log': ['start', 'a ran', 'b ran']}

For chat history, LangGraph ships add_messages. It appends new messages and replaces a message whose ID already exists, which is what lets you edit history. The prebuilt MessagesState is just a state with messages: Annotated[list, add_messages].

The execution model: super-steps

LangGraph’s runtime is modeled on Google’s Pregel (bulk synchronous parallel graph processing). A run proceeds in super-steps:

  1. Every node that is active in this step runs, in parallel if there are several.
  2. At the end of the step, their updates are merged into the state through the reducers.
  3. Edges are evaluated against the new state to decide which nodes are active next.
  4. The run ends when no nodes are active (or a node routes to END).

Two consequences worth remembering. Parallel branches see the same snapshot of state, never each other’s in-flight updates. And if two parallel nodes write the same key and that key has no reducer, LangGraph raises an error because it can’t know which value wins. Give the key a reducer.

3. Your first cycle: draft, critique, revise

The smallest graph that a chain can’t express is a loop with a stopping rule. Here a writer and a critic iterate until the score is good enough or a revision budget runs out:

flowchart TD
S((start)) --> write --> critique
critique -. "score < 80 and revisions < 5" .-> write
critique -. "good enough or budget spent" .-> E((end))
Solid edges always fire; the dotted conditional edge loops back or exits.
draft_loop.py
from typing import Literal, TypedDict
from langgraph.graph import StateGraph, START, END
class DraftState(TypedDict):
draft: str
score: int
revisions: int
def write(state: DraftState):
n = state["revisions"] + 1
return {"draft": f"draft v{n}", "revisions": n} # an LLM call in real life
def critique(state: DraftState):
return {"score": 40 + 20 * state["revisions"]} # an LLM grader in real life
def route(state: DraftState) -> Literal["write", "__end__"]:
if state["score"] >= 80 or state["revisions"] >= 5: # quality bar OR budget
return END
return "write"
builder = StateGraph(DraftState)
builder.add_node("write", write)
builder.add_node("critique", critique)
builder.add_edge(START, "write")
builder.add_edge("write", "critique")
builder.add_conditional_edges("critique", route)
app = builder.compile()
for update in app.stream({"draft": "", "score": 0, "revisions": 0}, stream_mode="updates"):
print(update)
{'write': {'draft': 'draft v1', 'revisions': 1}}
{'critique': {'score': 60}}
{'write': {'draft': 'draft v2', 'revisions': 2}}
{'critique': {'score': 80}}

Notice that the routing logic is plain data on the graph, not hidden in a while loop. LangGraph can draw it (app.get_graph().draw_mermaid()), stream each step, and checkpoint between steps.

4. The agent loop is the same shape

A tool-calling agent is that loop with different nodes: the model node decides whether to call tools, and a tools node executes them and feeds results back.

stateDiagram-v2
direction LR
[*] --> agent
agent --> tools: tool_calls present
tools --> agent: ToolMessage results
agent --> [*]: final answer
The ReAct-style agent loop as a state graph.
agent.py
from langchain.chat_models import init_chat_model
from langchain_core.tools import tool
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph, MessagesState, START
from langgraph.prebuilt import ToolNode, tools_condition
@tool
def multiply(a: int, b: int) -> int:
"""Multiply two integers."""
return a * b
llm = init_chat_model("<provider>:<model>") # any chat model that supports tool calling
llm_with_tools = llm.bind_tools([multiply])
def call_model(state: MessagesState):
return {"messages": [llm_with_tools.invoke(state["messages"])]}
builder = StateGraph(MessagesState)
builder.add_node("agent", call_model)
builder.add_node("tools", ToolNode([multiply]))
builder.add_edge(START, "agent")
builder.add_conditional_edges("agent", tools_condition) # routes to "tools" or END
builder.add_edge("tools", "agent") # the cycle
app = builder.compile(checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "study-1"}}
app.invoke({"messages": [("user", "What is 6 times 7?")]}, config)

Running this graph with a scripted model produced the message sequence you’d expect:

HumanMessage 'What is 6 times 7?'
AIMessage '' # tool_calls=[multiply(a=6, b=7)]
ToolMessage '42'
AIMessage '6 x 7 = 42'

tools_condition checks whether the last AIMessage has tool_calls. ToolNode runs each call and returns ToolMessages tagged with the matching tool_call_id, which is how the model knows each call was answered. (LangChain’s create_agent builds this same loop for you; build it by hand once so you know what it does.)

5. What you get from the graph

Persistence and threads

Compile with a checkpointer and every super-step is saved as a checkpoint, grouped by thread_id. Reuse a thread ID to continue a conversation; use a new one to start fresh.

snapshot = app.get_state(config) # current values + which nodes run next
history = list(app.get_state_history(config)) # every checkpoint, newest first

The agent run above left 5 checkpoints in its thread, one per step. Because each checkpoint is a full snapshot, you can time travel: resume from an earlier checkpoint, or fork from it with edited state. InMemorySaver is for development; production uses a durable saver such as the Postgres or SQLite checkpointers (langgraph-checkpoint-postgres, langgraph-checkpoint-sqlite).

Human-in-the-loop

interrupt() pauses the graph mid-node and surfaces a value to the caller. Resuming with Command(resume=...) makes interrupt() return that value.

approval.py
from typing import TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command, interrupt
class Approval(TypedDict):
action: str
approved: bool
def propose(state: Approval):
return {"action": "send email to customer"}
def approve(state: Approval):
decision = interrupt({"question": f"Approve: {state['action']}?"})
return {"approved": decision == "yes"}
builder = StateGraph(Approval)
builder.add_node("propose", propose)
builder.add_node("approve", approve)
builder.add_edge(START, "propose")
builder.add_edge("propose", "approve")
builder.add_edge("approve", END)
app = builder.compile(checkpointer=InMemorySaver()) # interrupts require a checkpointer
config = {"configurable": {"thread_id": "hitl-1"}}
paused = app.invoke({"action": "", "approved": False}, config)
paused["__interrupt__"] # [Interrupt(value={'question': 'Approve: send email to customer?'}, ...)]
app.invoke(Command(resume="yes"), config)
# {'action': 'send email to customer', 'approved': True}

Streaming

stream_mode picks what you see while the graph runs: "updates" (each node’s partial update, as in the draft loop), "values" (the full state after each step), or "messages" (LLM tokens as they’re generated, for chat UIs). You can pass several modes at once.

6. When a chain is still the right call

Anthropic’s guidance draws the same line: workflows run LLMs through predefined code paths, while agents let the model direct its own process. Start with the simplest thing that works.

Use a chain (workflow) when…Use a state graph when…
The steps and their order are known up frontThe model’s output decides the next step
Each step runs onceYou need loops: tool use, retries, self-critique
A request completes in one callRuns pause for humans or must survive crashes
Branching is a simple one-time choiceRouting depends on accumulated state
Latency and cost must be predictableYou accept variable steps for better task performance

Examples: summarize a document, extract fields to JSON, or a single-shot RAG answer are chains. A coding agent, a research assistant that decides when it has enough sources, or an approval workflow are graphs. The two compose: a graph node can call a chain, and a compiled graph is itself a Runnable.

7. Pitfalls

  • Returning the whole list with an append reducer. With operator.add, returning state["log"] + ["new"] duplicates the existing items. Return only the new items.
  • No reducer on messages. Each update replaces the history instead of extending it. Use add_messages or MessagesState.
  • Trusting the recursion limit to stop a bad loop. It caps super-steps per run, but in LangGraph 1.2 the default is 10,007 (older tutorials say 25), so a runaway loop can burn a lot of tokens first. Put a budget in state, as in the draft loop, and pass an explicit {"recursion_limit": N} in the config.
  • Side effects before interrupt(). They run again on resume (see above).
  • Unserializable state. Checkpointers serialize state, so keep clients, connections, and file handles out of it; pass them in through closures or config.
  • Unbounded message history. Every step re-sends the whole transcript. Trim or summarize old messages.

Study checklist

  1. Run draft_loop.py. Raise the threshold to 120 and confirm the budget (not the score) ends the run.
  2. Change route to always return "write", run with {"recursion_limit": 6}, and read the GraphRecursionError.
  3. Add a second node that runs in parallel with critique and writes the same key without a reducer. Read the error, then fix it with a reducer.
  4. Swap the scripted model in agent.py for a real one and print app.get_state_history(config).
  5. Put an interrupt() in front of the tools node so a human approves each tool call.

References

Official documentation

Courses and video

Foundations

Books

  • Mayo Oshin and Nuno Campos, Learning LangChain (O’Reilly, 2025): LangChain and LangGraph, including agent architectures
  • Chip Huyen, AI Engineering (O’Reilly, 2025): the broader system design context, including RAG and agents