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, . In LangChain, chains are built with LCEL
(LangChain Expression Language): every component is a Runnable, and | composes them into a
RunnableSequence.
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 | countchain.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:
- 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.
- Decisions on accumulated state. Routing based on everything that has happened so far, not just the previous step’s output.
- Pausing for a human. Stop before sending an email, wait minutes or days for approval, then continue.
- Durability. A 20-step run that crashes at step 17 should resume at step 17, not start over.
- 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:
| Part | What it is |
|---|---|
| State | A schema (usually a TypedDict) shared by every node. |
| Nodes | Functions that take the current state and return a partial update: only the keys they change. |
| Edges | Fixed (a → b) or conditional (a function inspects state and returns the next node’s name). |
| Reducers | Per-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:
import operatorfrom typing import Annotated, TypedDictfrom 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:
- Every node that is active in this step runs, in parallel if there are several.
- At the end of the step, their updates are merged into the state through the reducers.
- Edges are evaluated against the new state to decide which nodes are active next.
- 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))
from typing import Literal, TypedDictfrom 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
from langchain.chat_models import init_chat_modelfrom langchain_core.tools import toolfrom langgraph.checkpoint.memory import InMemorySaverfrom langgraph.graph import StateGraph, MessagesState, STARTfrom langgraph.prebuilt import ToolNode, tools_condition
@tooldef multiply(a: int, b: int) -> int: """Multiply two integers.""" return a * b
llm = init_chat_model("<provider>:<model>") # any chat model that supports tool callingllm_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 ENDbuilder.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 nexthistory = list(app.get_state_history(config)) # every checkpoint, newest firstThe 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.
from typing import TypedDictfrom langgraph.checkpoint.memory import InMemorySaverfrom langgraph.graph import StateGraph, START, ENDfrom 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 front | The model’s output decides the next step |
| Each step runs once | You need loops: tool use, retries, self-critique |
| A request completes in one call | Runs pause for humans or must survive crashes |
| Branching is a simple one-time choice | Routing depends on accumulated state |
| Latency and cost must be predictable | You 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, returningstate["log"] + ["new"]duplicates the existing items. Return only the new items. - No reducer on
messages. Each update replaces the history instead of extending it. Useadd_messagesorMessagesState. - 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
- Run
draft_loop.py. Raise the threshold to 120 and confirm the budget (not the score) ends the run. - Change
routeto always return"write", run with{"recursion_limit": 6}, and read theGraphRecursionError. - Add a second node that runs in parallel with
critiqueand writes the same key without a reducer. Read the error, then fix it with a reducer. - Swap the scripted model in
agent.pyfor a real one and printapp.get_state_history(config). - Put an
interrupt()in front of the tools node so a human approves each tool call.
References
Official documentation
- LangGraph overview
- Thinking in LangGraph: how to decompose a problem into nodes and state
- Graph API: state, reducers, nodes, edges, super-steps
- Workflows and agents: common patterns built as graphs
- Persistence, Interrupts, and Streaming
- LCEL and the Runnable interface
- LangGraph source on GitHub
Courses and video
- LangChain Academy: Introduction to LangGraph: free, video-based, covers state, memory, human-in-the-loop, and streaming
- DeepLearning.AI: AI Agents in LangGraph: about 1.5 hours with Harrison Chase (LangChain) and Rotem Weiss (Tavily); builds an agent from scratch, then in LangGraph
- LangChain on YouTube: release walkthroughs and pattern deep-dives
Foundations
- Anthropic, Building effective agents (2024): the workflow-vs-agent distinction and when to avoid agents
- Lilian Weng, LLM Powered Autonomous Agents (2023): planning, memory, and tool use
- Yao et al., ReAct: Synergizing Reasoning and Acting in Language Models (2022): the agent loop in section 4
- Malewicz et al., Pregel: A System for Large-Scale Graph Processing (SIGMOD 2010): the execution model behind super-steps
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