Graph Agents and When the Graph Earns Its Keep
A graph framework introduces an additional control flow abstraction. Here is when cyclic state machines, checkpoint durability, and human approval justify graph infrastructure over plain loops.
A node configured with a static add_edge also returned Command(goto=...). Both fired during execution, the downstream node ran twice, and the paid API tool calls inside it were billed twice.
A graph framework is a control flow layer bolted on top of Python, and it charges rent.
The graph earns its keep when a workflow needs cycles, durable checkpoints, and a human approval gate at the same time. With one of the three, a while loop and a queue are less to maintain.
Chapter 0: Super-steps and Pregel execution
Most diagrams draw LangGraph workflows as directed acyclic graphs. The runtime underneath is Pregel message passing — Malewicz and co-authors' 2010 SIGMOD paper on Google's large-scale graph processing system, where computation advances in synchronized super-steps — and the difference shows up the first time you debug a resume. The API details below track LangGraph 1.2, the current release line.
Execution advances through discrete super-steps:
- A node activates upon receiving inbound state or messages.
- The node runs its function, writes state updates, and yields control.
- The runtime evaluates transitions and dispatches state to downstream nodes.
- Execution terminates when no nodes are active and no messages remain in transit.
Nodes activated concurrently in the same super-step execute in parallel. Sequential transitions span across distinct super-steps.
Three behaviors follow from that:
- Concurrent writes to the same state key conflict when executed within the same super-step.
- On process resumption from a checkpoint, the entire node re-executes because the super-step is the atomic unit of recovery.
recursion_limittracks completed super-steps rather than individual LLM API calls.
Step 1: Design the state schema before node logic
Define the state schema using TypedDict. Field annotations dictate how data accumulates across super-steps.
# graph.py
import operator
from typing import Annotated, TypedDict
from langchain_core.messages import AnyMessage
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import add_messages
class ResearchState(TypedDict):
"""State persisted across research graph super-steps."""
messages: Annotated[list[AnyMessage], add_messages]
findings: Annotated[list[str], operator.add]
question: str
verdict: str
class ResearchInput(TypedDict):
"""Input contract accepted by the graph."""
question: str
class ResearchOutput(TypedDict):
"""Output schema returned to external callers."""
verdict: str
findings: list[str]
def plan(state: ResearchState) -> dict:
"""Generates search plan from input question."""
return {"messages": [("assistant", f"Plan for: {state['question']}")]}
def search(state: ResearchState) -> dict:
"""Retrieves document evidence."""
return {"findings": [f"Evidence for {state['question']}"]}
def critique(state: ResearchState) -> dict:
"""Evaluates sufficiency of findings."""
is_sufficient = len(state.get("findings", [])) >= 2
return {"verdict": "sufficient" if is_sufficient else "insufficient"}
def route(state: ResearchState) -> str:
"""Evaluates state to loop back or terminate."""
return END if state["verdict"] == "sufficient" else "search"
builder = StateGraph(
ResearchState,
input_schema=ResearchInput,
output_schema=ResearchOutput,
)
builder.add_node("plan", plan)
builder.add_node("search", search)
builder.add_node("critique", critique)
builder.add_edge(START, "plan")
builder.add_edge("plan", "search")
builder.add_edge("search", "critique")
builder.add_conditional_edges("critique", route, ["search", END])
graph = builder.compile()The compiled graph enforces a cycle:
input_schema and output_schema restrict public interfaces while allowing internal graph state to carry execution scratchpads.
Channel reducers manage mutation semantics: add_messages merges messages by ID, and operator.add concatenates lists. Keys without reducers use last-write-wins overwrite semantics.
Step 2: Define reducer conflict policies explicitly
When multiple nodes activate concurrently in the same super-step, writing to unreduced keys causes runtime exceptions:
# fanout_conflict.py
from typing import TypedDict
from langgraph.graph import START, StateGraph
class State(TypedDict):
topic: str
notes: list[str]
def west(state: State) -> dict:
return {"notes": ["west: p99 latency normal"]}
def east(state: State) -> dict:
return {"notes": ["east: p99 latency elevated"]}
builder = StateGraph(State)
builder.add_node("west", west)
builder.add_node("east", east)
builder.add_edge(START, "west")
builder.add_edge(START, "east")
graph = builder.compile()
# graph.invoke({"topic": "p99", "notes": []})
# Raises: langgraph.errors.InvalidUpdateError (Can receive only one value per step)Adding Annotated[list[str], operator.add] resolves the error by defining an append policy. However, operator.add appends updates in whatever order concurrent nodes complete.
When deterministic ordering matters, define a custom reducer function that sorts incoming values by node ID or timestamp before writing to state.
Step 3: Dynamic fan-out using Send
When the number of parallel tasks is determined at runtime, use the Send primitive instead of fixed static edges. LangGraph documents it as the way to invoke a node with a custom state that need not match the main graph's schema.
# fanout_send.py
import operator
from typing import Annotated, TypedDict
from langgraph.graph import END, START, StateGraph
from langgraph.types import Send
class MapState(TypedDict):
documents: list[str]
summaries: Annotated[list[str], operator.add]
report: str
class WorkerState(TypedDict):
document: str
def dispatch(state: MapState) -> list[Send]:
"""Spawns parallel worker nodes for each document in the list."""
return [Send("summarise", {"document": doc}) for doc in state["documents"]]
def summarise(state: WorkerState) -> dict:
return {"summaries": [f"Summary: {state['document'][:30]}"]}
def reduce_report(state: MapState) -> dict:
return {"report": "\n".join(state["summaries"])}
builder = StateGraph(MapState)
builder.add_node("summarise", summarise)
builder.add_node("reduce_report", reduce_report)
builder.add_conditional_edges(START, dispatch, ["summarise"])
builder.add_edge("summarise", "reduce_report")
builder.add_edge("reduce_report", END)
graph = builder.compile()Each Send instance delivers an isolated payload to its target node:
Step 4: Typed routing with Command and Literal targets
The Command object combines state updates and dynamic edge transitions into a single return type:
# supervisor.py
from typing import Literal, TypedDict
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command
class TeamState(TypedDict):
task: str
draft: str
review_notes: str
def supervisor(state: TeamState) -> Command[Literal["writer", "reviewer", "__end__"]]:
"""Selects the next node and updates task metadata."""
if not state["draft"]:
return Command(update={"task": state["task"].strip()}, goto="writer")
if not state["review_notes"]:
return Command(goto="reviewer")
return Command(goto=END)
def writer(state: TeamState) -> Command[Literal["supervisor"]]:
return Command(update={"draft": "Draft body"}, goto="supervisor")
def reviewer(state: TeamState) -> Command[Literal["supervisor"]]:
return Command(update={"review_notes": "Validation notes"}, goto="supervisor")
builder = StateGraph(TeamState)
builder.add_node("supervisor", supervisor)
builder.add_node("writer", writer)
builder.add_node("reviewer", reviewer)
builder.add_edge(START, "supervisor")
graph = builder.compile()Using Literal in return type annotations allows static analyzers and graph visualization tools to trace potential paths.
Avoid combining static builder.add_edge("node_a", "node_b") with a dynamic Command(goto="node_b") on the same node, as both transition paths will trigger duplicate executions.
Step 5: Configure checkpoint durability and recursion budgets
Persist execution state to handle node failures and support human approval interruptions:
# run.py
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.errors import GraphRecursionError
from graph import builder
def execute_thread(question: str, thread_id: str) -> dict:
with SqliteSaver.from_conn_string("checkpoints.db") as saver:
graph = builder.compile(checkpointer=saver)
config = {
"configurable": {"thread_id": thread_id},
"recursion_limit": 25,
}
try:
return graph.invoke({"question": question}, config, durability="sync")
except GraphRecursionError:
snapshot = graph.get_state(config)
return {
"verdict": "budget_exhausted",
"findings": snapshot.values.get("findings", []),
}Key operational guidelines:
- Set
durability="sync"to guarantee that state is written to storage before transitioning to the next super-step. LangGraph's reference describes thesyncmode as persisting changes "synchronously before the next step starts"; the alternatives areasyncandexit. - Lower
recursion_limitfrom the default 1000 to a realistic bound (such as 25) to catch cyclical loops early. The graph API docs put that default at 1000 super-steps starting in LangGraph 1.0.6. - Keep state payloads compact. Store large document corpora or raw vector payloads in external object stores, keeping only record IDs and summaries in graph state.
Step 6: Inspect state history rather than stack traces
Because super-steps discard local execution frames upon completion, debug graph behavior by inspecting checkpoint history:
config = {"configurable": {"thread_id": "session-104"}}
for state_snapshot in graph.get_state_history(config):
print(f"Step: {state_snapshot.metadata.get('step')}")
print(f"Node: {state_snapshot.metadata.get('source')}")
print(f"State Values: {state_snapshot.values}\n---")Tracing state mutations across snapshots isolates the exact super-step where state diverged from expected values.
When this is the wrong choice
- The workflow is a straight line. No cycle, no retry edge, nothing that routes backwards. A sequence of function calls does the same work, and you can read the control flow top to bottom instead of reconstructing it from
add_conditional_edgescalls. - You never pause mid-run. Durable checkpointing is what pays for the Pregel model. If a failed run is simply retried from the start,
SqliteSaver, thread IDs, anddurability="sync"are bookkeeping you maintain for a guarantee you do not use. - State is large. The super-step is the atomic unit of recovery, so the whole node re-executes on resume and the whole state is written between steps. Carry big payloads in graph state and you pay that write on every transition.
Tradeoff summary
| Architecture | Control flow mechanism | Primary tradeoff |
|---|---|---|
| Plain Python Loop | While loops with functions | Low overhead; requires custom checkpointing logic for pauses |
| LangGraph | Pregel super-steps on StateGraph | Built-in durable checkpoints and pause/resume; requires state schema discipline |
| Pydantic Graph | Class-based nodes with typed transitions | Compile-time type safety; limited dynamic graph introspection |