Graphs, Not Chains

Why LangGraph models an agent as an explicit graph of nodes and edges instead of a hidden loop, and the core primitives that make that possible.

Author Avatar

Fernando

  ·  9 min read

The problem #

create_agent packages the whole tool-calling loop behind .invoke(), and most of the time that is exactly what you want. But the loop is still hidden. You cannot easily log what happened between steps, branch differently depending on how many tool calls have already run, skip the model entirely for a cached input, or pause after a specific step and resume later. The loop has one shape, and that shape is fixed.

Sometimes you need to see the loop and control it directly. That is exactly the case LangGraph exists for.

The concept #

LangGraph models an agent as a StateGraph: an explicit graph of nodes and edges instead of a chain or a black-box loop. Everything it does beyond that, merging updates, running nodes in parallel, checkpointing a run, is built on three primitives (state, nodes, edges) executed through a model borrowed from distributed graph processing.

State: a schema with merge rules #

State is a typed schema, usually a TypedDict, that describes the data flowing through the graph. Every node reads from this state and returns updates to it.

Left alone, a state update behaves like a plain dictionary assignment: the new value replaces the old one. That is fine for something like a status flag, but wrong for a conversation history, where each node’s output should add to what is already there instead of erasing it. To change that behavior you annotate the field with a reducer, a function shaped (existing_value, new_value) -> merged_value that LangGraph calls instead of doing a plain overwrite.

The graph in this chapter uses exactly one state field:

1class State(TypedDict):
2    messages: Annotated[list[AnyMessage], add_messages]

add_messages is LangGraph’s built-in reducer for message lists, and it does more than concatenate. When a node returns {"messages": [new_message]}, add_messages appends it to the existing list, unless the new message shares an id with a message already in the list, in which case it replaces that message in place rather than duplicating it. That id-based matching is what lets a node stream updates to the same assistant message without flooding the history with near-duplicates. Drop the Annotated[..., add_messages] and leave the field a plain list, and every node’s return value overwrites the whole list, silently discarding everything that came before it.

Nodes: plain functions, partial updates #

Nodes are plain Python functions. Each one takes the current state and returns a partial update: a dict containing only the keys it changed, not the whole state object. call_model in the example below returns {"messages": [response]} and nothing else, because there is nothing else for it to report. call_tools returns {"messages": results} for the same reason. LangGraph takes that partial dict, looks up the reducer registered for each key it contains, and merges it into the state that existed before the node ran. A node never has to know what the whole state object looks like: it reads what it needs and reports only what changed.

Edges: fixed and conditional routing #

Edges connect nodes and define control flow. A regular edge, add_edge(a, b), always routes from a to b. There is no state to inspect and no decision to make. A conditional edge, add_conditional_edges(a, router, mapping), is what expresses branching. After node a finishes, LangGraph calls router with the current state; router returns a key, and that key is looked up in mapping to find the actual destination node. In the example, should_continue returns either the string "tools" or the sentinel END, and the mapping {"tools": "tools", END: END} translates each return value into a real node name. That indirection is deliberate: a routing function’s return values do not have to be node names at all, which keeps the branching logic decoupled from exactly how the graph happens to be wired. START and END are sentinel nodes marking where execution enters and leaves the graph, not real nodes with bodies of their own.

Supersteps: how execution actually advances #

None of this runs like an ordinary Python call stack. LangGraph’s runtime, called Pregel, borrows its execution model from Google’s Pregel algorithm for large-scale graph processing, the same bulk-synchronous-parallel style later popularized by systems like Apache Giraph. Execution proceeds in discrete rounds called supersteps. In each superstep, every node that is currently active runs (in parallel, if more than one is active), and each one reads the state as it stood at the start of that superstep and writes its own partial update. Those writes stay invisible to every other node until the superstep ends: two nodes active in the same superstep never see each other’s output, only whatever the state looked like when the superstep began. Once every active node finishes, LangGraph merges the writes according to each field’s reducer, works out which nodes are active for the next superstep (anything reachable by an edge from a node that just wrote), and the cycle repeats until nothing is active.

For the two-node graph in this chapter, that plays out as a short chain of supersteps: superstep 1 runs agent, the only node reachable from START. The conditional edge inspects the result, and if there was a tool call, tools becomes active for superstep 2. tools runs, and its result routes back to agent for superstep 3, and so on until should_continue returns END instead of "tools". This is also the natural unit for durability: a checkpointer, which chapter 05 covers, saves a snapshot of state after every superstep, which is why “superstep” keeps showing up as the boundary that matters once a graph needs to persist or resume.

Minimal example #

The same search agent from the last two chapters, rebuilt as an explicit two-node graph with a conditional edge deciding whether to loop back into the tool node or stop:

 1from typing import Annotated
 2
 3from typing_extensions import TypedDict
 4from langchain_core.messages import AnyMessage, HumanMessage, ToolMessage
 5from langchain_core.tools import tool
 6from langchain_openai import ChatOpenAI
 7from langgraph.graph import StateGraph, START, END
 8from langgraph.graph.message import add_messages
 9
10
11class State(TypedDict):
12    messages: Annotated[list[AnyMessage], add_messages]
13
14
15@tool
16def search(query: str) -> str:
17    """Search the internet for the given query."""
18    return f"Results for {query}: ..."
19
20
21model = ChatOpenAI(model="gpt-5", temperature=0).bind_tools([search])
22
23
24def call_model(state: State) -> dict:
25    response = model.invoke(state["messages"])
26    return {"messages": [response]}
27
28
29def call_tools(state: State) -> dict:
30    last_message = state["messages"][-1]
31    results = [
32        ToolMessage(content=search.invoke(call["args"]), tool_call_id=call["id"])
33        for call in last_message.tool_calls
34    ]
35    return {"messages": results}
36
37
38def should_continue(state: State) -> str:
39    last_message = state["messages"][-1]
40    return "tools" if last_message.tool_calls else END
41
42
43builder = StateGraph(State)
44builder.add_node("agent", call_model)
45builder.add_node("tools", call_tools)
46builder.add_edge(START, "agent")
47builder.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END})
48builder.add_edge("tools", "agent")
49
50graph = builder.compile()
51
52result = graph.invoke(
53    {"messages": [HumanMessage(content="What's the weather in Tokyo now?")]}
54)

Reading this against the mechanics above: State declares one field, messages, carrying the add_messages reducer, so every node’s returned messages accumulate onto the history instead of replacing it. call_model and call_tools are nodes in exactly the sense described earlier: functions that take state and return a partial dict, neither one needing to know what the other does or touching any part of the state it did not change. should_continue is the router behind the conditional edge: it looks at the last message and returns "tools" if the model asked for a tool call, END otherwise. The graph itself is assembled with add_node for each node, add_edge(START, "agent") for the fixed entry point, add_conditional_edges("agent", should_continue, {"tools": "tools", END: END}) for the branch, and a fixed add_edge("tools", "agent") sending tool results straight back to the model. builder.compile() validates that structure (no unreachable nodes, no dangling edges) and hands back a runnable with the same .invoke() interface as everything else in LangChain.

The same graph, drawn out:

flowchart TD
    S(["START"]) --> A["agent"]
    A -->|"tool call requested"| T["tools"]
    A -->|"no tool call"| E(["END"])
    T --> A

Every superstep from the earlier walkthrough is one hop across this diagram: agent running is a superstep, the branch out of it is the conditional edge being evaluated, and tools -> agent is the loop that keeps going until the branch points at END instead of back into tools.

This is the same behavior as the hand-rolled loop from chapter 02 and the create_agent call from chapter 03, just with the branch (agent -> tools or agent -> END) written down as a visible edge instead of buried inside a while-loop condition.

Trade-offs: when the explicit graph earns its keep #

None of this is free. Chapter 03’s create_agent produces the equivalent of this graph, model, tools, and loop, in a single call. Building it explicitly here costs a state schema, two node functions, a router function, and four calls to wire the graph together before it can even run. For an agent that just calls tools until it is done, with no branch to control and no reason to pause mid-run, that is strictly more code for the same outcome, and chapter 03’s advice still holds: reach for create_agent first.

What the extra code buys is visibility and control that create_agent does not expose. Every state transition is a value you can log, inspect, or unit-test on its own, because it is a named node returning a named field, not a step buried inside someone else’s loop. Every branch is a function you write, so “retry on this condition” or “skip the model for a cached input” is a router, not a workaround bolted onto a loop shape that was not built to bend. And because state is snapshotted at the boundary of every superstep, the graph is checkpointable in a way a hidden loop fundamentally is not: there is a well-defined point after which a run’s progress is durable, which is exactly what chapter 05 builds on.

The cost of that control is that you now own rules create_agent used to enforce for you. A reducer has to be declared explicitly on any field that should accumulate rather than overwrite, and a node has to return a partial update rather than mutate the state object it was handed, because both the merge logic and the checkpointing layer assume it will. Skip either one and the graph still runs, it just silently loses history or produces results that depend on scheduling details you never intended to rely on. An explicit graph is the right tool the moment you need to see or steer the loop. It is overkill the moment all you actually need is the loop.

Summary #

A StateGraph turns an agent’s loop into three things you can name and inspect: state with explicit merge rules, nodes that report partial updates, and edges, including conditional ones, that decide what runs next. Underneath, LangGraph advances that graph one superstep at a time: running whatever nodes are active, merging their writes through each field’s reducer, then deciding what becomes active next, the same bulk-synchronous model Pregel popularized for large-scale graph processing. Chapter 03’s create_agent hides all of this behind a loop because most agents never need to touch it. This chapter is for the ones that do: once you can see the superstep boundary, you can also snapshot it, which is what chapter 05’s checkpointers turn into a graph that survives a restart and resumes exactly where it left off.