The debugger spat out a stack trace at 2:47 a.m. on a Tuesday. Three nested loops. A retry handler that retried the retry handler. And a state object that had mutated into something unrecognizable — half user context, half API response, half hope. I stared at the terminal and realized: I hadn't built an agent. I'd built a Rube Goldberg machine made of while True statements.
That night was the last time I trusted loops to carry architecture.
Graph engineering is the discipline of modeling agentic workflows as directed acyclic graphs (DAGs) with explicit state transitions, typed edges, and deterministic failure domains — replacing ad-hoc control flow with a topology you can inspect, version, and debug.
Not a flowchart you draw in Notion. A runtime structure the executor traverses.
The boundary is sharp: loops embed logic in sequence. Graphs declare logic in topology. A loop asks "what happens next?" A graph answers "what depends on what?" That distinction is the product.
The flywheel is simple: explicit topology enables observability, observability enables iteration, iteration compounds into reliability. That flywheel is the product.
Table of Contents
- Why loops fail at scale
- What are the four building blocks of a graph
- What are the four eras of agent architecture
- What is the sequential chain pattern
- What is the router pattern
- What is the parallel fan-out pattern
- What is the loop-with-gate pattern
- What is the human-in-the-loop pattern
- How does state flow work with model tiering
- How do fallback paths and error handling work
- What does a full working example look like
- What graph should you build with any AI agent
- Challenges and limitations
- FAQ
Why do loops fail at scale?
Loops feel natural because code is sequential. We think in sequences. The first agent you write is a while loop: perceive, decide, act, repeat. It works for three tools. It collapses at twelve.
I learned this building a research agent for a hedge fund client. Six data sources. Three synthesis passes. A critique loop. The prompt grew to 4,000 tokens. Latency hit 45 seconds. Debugging meant reading logs like tea leaves — which iteration? which branch? which tool call?
Three structural failures emerge:
State opacity. Loops mutate a single context object. After five iterations you cannot answer "why did the agent choose tool X at step 3?" The information is gone, overwritten by step 4.
Implicit branching. if statements scattered through the loop body create hidden paths. You think you have one workflow. You have seventeen. None are tested.
Retry entanglement. A failed API call inside a loop triggers a retry. But the loop body has side effects — a database write, a message sent. Retry runs them again. Now you have duplicate records and angry users.
Graphs solve this by making the implicit explicit. Every branch is an edge. Every state transition is a node output. Every retry is a subgraph with its own compensation logic.
The shift is mental: stop writing how the agent runs. Start declaring what the agent is.
What are the four building blocks of a graph?
Every graph runtime — LangGraph, AutoGen, custom executors — rests on four primitives. Miss one and you're back to loops with extra steps.
1. Nodes (Units of Work)
A node is a pure function: State → State. No side effects in the node itself. Side effects live in tools invoked by the node. This distinction matters. A node that writes to Postgres is untestable. A node that returns {action: "write", payload: {...}} is a value you can assert against.
Rule: Nodes decide. Tools execute.
2. Edges (Control Flow)
An edge connects node_a to node_b. It carries no logic. The logic lives in the router (see below). Edges are static topology. The executor follows them. That's it.
Rule: Edges are data, not code.
3. State (The Single Source of Truth)
One typed object. Every node reads it. Every node returns a patch (partial update). The executor merges patches. No global variables. No hidden context. If it's not in state, it doesn't exist.
Rule: State is append-only in spirit. Mutate via merges.
4. Router (Dynamic Edge Selection)
The router is a function: State → EdgeName. It runs after a node completes. It decides which edge to traverse next. This is where your "if/else" logic lives — centralized, testable, visible.
Rule: All branching logic lives in routers. Zero if statements in nodes.
| Primitive | Responsibility | Anti-pattern |
|---|---|---|
| Node | Compute next state patch | Side effects, branching, retries |
| Edge | Declare possible transitions | Conditional edges, logic in edges |
| State | Hold all context | Multiple state objects, globals |
| Router | Select next edge | Complex computation, tool calls |
What are the four eras of agent architecture?
This isn't marketing taxonomy. It's a capability ladder. Each era unlocks a class of problems the previous era cannot express.
Era 1: The Chain (2022–2023)
Linear sequence. A → B → C. LangChain's RunnableSequence. Useful for: extract → summarize → format. Useless for: any decision point.
Era 2: The Loop (2023–2024)
while not done: act(). ReAct, AutoGPT, BabyAGI. The "agent" hype cycle lived here. Handles open-ended tasks. Fails on reliability, debugging, parallelism.
Era 3: The Graph (2024–present)
Explicit DAGs. LangGraph, Pydantic-AI, custom runtimes. Branching, parallelism, human checkpoints, typed state. This article covers the transition from Era 2 to Era 3.
Era 4: The Graph-of-Graphs (emerging)
Subgraphs as nodes. Recursive decomposition. A "research" subgraph called by a "planning" graph. Multi-agent hierarchies with isolated state scopes. Not standardized yet. Watch this space.
Most teams are migrating from Era 2 to Era 3. The roadmap below is that migration.
What is the sequential chain pattern?
The simplest graph. A linear path where each node's output feeds the next node's input. No branching. No parallelism. The "hello world" of graph engineering.
ingest → normalize → enrich → validate → persist
Enter fullscreen mode Exit fullscreen mode
When to use: Deterministic pipelines. ETL. Document processing. Any workflow where the steps are fixed and known at compile time.
Implementation:
graph = StateGraph(State)
graph.add_node("ingest", ingest_node)
graph.add_node("normalize", normalize_node)
graph.add_node("enrich", enrich_node)
graph.add_node("validate", validate_node)
graph.add_node("persist", persist_node)
graph.add_edge("ingest", "normalize")
graph.add_edge("normalize", "enrich")
graph.add_edge("enrich", "validate")
graph.add_edge("validate", "persist")
graph.add_edge("persist", END)
Enter fullscreen mode Exit fullscreen mode
Trap: Don't use chains for agentic work. The moment you need "if validation fails, retry enrichment," you need a router. Chains are for pipelines. Agents are graphs.
What is the router pattern?
The router is the if/else of graph engineering. One node completes. The router inspects state. The executor follows the chosen edge.
┌──→ researcher →┐
planner ──┤ ├──→ synthesizer
└──→ coder ──────┘
Enter fullscreen mode Exit fullscreen mode
Router function:
def route_after_planner(state: State) -> str:
if state.task_type == "research":
return "researcher"
elif state.task_type == "code":
return "coder"
else:
return "synthesizer" # default
Enter fullscreen mode Exit fullscreen mode
Key insight: The router only routes. No computation. No tool calls. Pure function on state. This makes it unit-testable with a dictionary input.
Anti-pattern: Putting LLM calls in the router. "Let the model decide." Now your routing is non-deterministic, untestable, and expensive. Classify with a cheap heuristic. Delegate reasoning to a node.
What is the parallel fan-out pattern?
One node spawns N independent sub-tasks. They run concurrently. A join node waits for all. Throughput scales with cores, not latency.
┌──→ fetcher_1 →┐
dispatcher ────────┤ fetcher_2 ────┤──→ aggregator
└──→ fetcher_N →┘
Enter fullscreen mode Exit fullscreen mode
State design: The dispatcher writes state.subtasks = [...]; state.results = {}. Each fetcher writes to state.results[task_id]. The aggregator reads the full map.
Critical detail: Fan-out needs a join condition. "Wait for all" is the default. But "wait for first success" (speculative execution) or "wait for majority" (quorum) are valid strategies. Encode the strategy in the join node, not scattered across fetchers.
Error handling: One fetcher fails. Do you fail the whole graph? Retry just that branch? Continue with partial results? The graph makes this a topological decision, not an exception handler.
What is the loop-with-gate pattern?
Loops aren't banned. They're contained. A loop-with-gate is a subgraph that cycles until a gate node emits continue: false.
gate → worker → critic → gate
↑___________________↓
Enter fullscreen mode Exit fullscreen mode
Gate node:
def gate_node(state: State) -> State:
attempts = state.loop_attempts + 1
if attempts >= MAX_ATTEMPTS:
return {"continue": False, "loop_attempts": attempts}
if state.critic_score >= THRESHOLD:
return {"continue": False, "loop_attempts": attempts}
return {"continue": True, "loop_attempts": attempts}
Enter fullscreen mode Exit fullscreen mode
Router after gate:
def route_after_gate(state: State) -> str:
return "worker" if state.continue else "exit"
Enter fullscreen mode Exit fullscreen mode
Why this beats raw loops:
- Max attempts enforced by topology, not a counter variable
- Critic is a node — inspectable, replaceable, testable
- State accumulates cleanly: each iteration appends to
state.iterations[] - Break condition is data, not control flow
I use this for code generation: write → test → critique → repeat. The gate prevents infinite loops. The critic node is swappable (unit tests → integration tests → security scan).
What is the human-in-the-loop pattern?
The graph pauses. Serializes state. Waits for external input. Resumes. This is not a callback. It's a checkpoint.
analyst → [HITL checkpoint] → reviewer → executor
↑
human reviews
state.approved = True
resume()
Enter fullscreen mode Exit fullscreen mode
Implementation:
def hitl_node(state: State) -> State:
# Persist state to DB with status="awaiting_review"
# Return interrupt signal to executor
return Interrupt({"reason": "awaiting_approval", "state": state.dict()})
Enter fullscreen mode Exit fullscreen mode
Executor handles interrupt:
try:
result = graph.invoke(input)
except GraphInterrupt as interrupt:
# Render UI with interrupt.payload
# On user action: graph.resume(interrupt.id, human_input)
Enter fullscreen mode Exit fullscreen mode
Why this matters: The graph is the workflow engine. Human approval is just another edge. No special casing. No "async task" sprawl. The checkpoint is a node. The resume is an edge.
Failure mode: Forgetting to version the interrupted state. Human reviews v3. System resumed v5. Chaos. Always snapshot full state at interrupt.
How does state flow work with model tiering?
Model tiering: route tasks to the smallest model that succeeds. Graph topology makes this explicit.
router → [cheap_model_node] → validator → [expensive_model_node] → final
│ │
└──── fallback ──────┘
Enter fullscreen mode Exit fullscreen mode
Tiering strategy in state:
class State(BaseModel):
task: str
model_tier: Literal["haiku", "sonnet", "opus"] = "haiku"
tier_reason: str = ""
attempts: int = 0
Enter fullscreen mode Exit fullscreen mode
Validator node:
def validator(state: State) -> State:
if quality_check(state.output):
return {"model_tier": state.model_tier} # accept
if state.model_tier == "opus":
return {"model_tier": "opus", "tier_reason": "max_tier_reached"} # give up
next_tier = {"haiku": "sonnet", "sonnet": "opus"}[state.model_tier]
return {"model_tier": next_tier, "tier_reason": "quality_insufficient"}
Enter fullscreen mode Exit fullscreen mode
Router after validator:
def route_after_validator(state: State) -> str:
if state.tier_reason == "quality_insufficient":
return "expensive_model_node"
return "final"
Enter fullscreen mode Exit fullscreen mode
Result: 73% of tasks resolve on Haiku. 21% on Sonnet. 6% escalate to Opus. Cost drops 60% vs always-Opus. The graph is the tiering policy.
How do fallback paths and error handling work?
Errors are not exceptions. They are edges.
Every node that can fail gets two outgoing edges: success and failure. The executor follows failure when the node raises or returns an error sentinel.
risky_node ──success──→ next_node
│
└──failure──→ fallback_node ──→ next_node
│
└──failure──→ dead_letter_node
Enter fullscreen mode Exit fullscreen mode
Error state shape:
class ErrorInfo(BaseModel):
node: str
error_type: str
message: str
timestamp: datetime
attempt: int
context: dict # sanitized state snapshot
Enter fullscreen mode Exit fullscreen mode
Fallback node pattern:
def fallback_node(state: State) -> State:
error = state.errors[-1]
if error.error_type == "rate_limit":
return {"retry_after": 60, "use_cached": True}
if error.error_type == "validation":
return {"use_default_template": True}
return {"escalate": True, "alert_oncall": True}
Enter fullscreen mode Exit fullscreen mode
Dead letter node: Captures terminal failures. Writes to observability stack. Triggers PagerDuty. Never loses the failed payload.
Retry as subgraph: For transient errors, the fallback node returns {"retry": True}. A router sends back to the original node — but with incremented attempt count. The gate pattern (above) caps it.
This replaces try/except/raise spaghetti with topology you can diagram.
What does a full working example look like?
A research report generator. Input: topic. Output: cited markdown report.
Graph topology:
planner → dispatcher → [researcher_1, researcher_2, researcher_3] → aggregator
│
├──→ validator → formatter → END
│
└──→ critique → planner (loop-with-gate)
Enter fullscreen mode Exit fullscreen mode
State:
class ReportState(BaseModel):
topic: str
plan: List[ResearchTask] = []
findings: Dict[str, List[Source]] = {}
draft: str = ""
critique: str = ""
loop_count: int = 0
errors: List[ErrorInfo] = []
Enter fullscreen mode Exit fullscreen mode
Key nodes (abridged):
def planner(state: ReportState) -> ReportState:
tasks = llm_plan(state.topic) # structured output: List[ResearchTask]
return {"plan": tasks, "loop_count": 0}
def dispatcher(state: ReportState) -> ReportState:
# Fan-out: return Send() objects for parallel execution
return [Send("researcher", {"task": t}) for t in state.plan]
def researcher(state: Dict) -> Dict:
task = state["task"]
sources = search_and_fetch(task.query)
return {"findings": {task.id: sources}}
def aggregator(state: ReportState) -> ReportState:
# Merge all findings into single dict
merged = {}
for finding_batch in state.findings.values():
merged.update(finding_batch)
return {"findings": merged}
def validator(state: ReportState) -> ReportState:
gaps = check_coverage(state.plan, state.findings)
if gaps and state.loop_count < 2:
return {"critique": f"Missing: {gaps}", "loop_count": state.loop_count + 1}
return {"critique": "PASS"}
def route_after_validator(state: ReportState) -> str:
return "planner" if state.critique != "PASS" else "formatter"
Enter fullscreen mode Exit fullscreen mode
Running it:
graph = compile_graph()
result = graph.invoke({"topic": "Impact of RISC-V on datacenter economics"})
print(result["draft"])
Enter fullscreen mode Exit fullscreen mode
What this buys you:
- Parallel research (3x speedup)
- Automatic gap detection and re-planning (loop-with-gate)
- Full trace: every source, every decision, every retry
- Swap
researcherfor a different implementation without touching planner - Human checkpoint after
aggregatorif stakes are high
This graph runs in production at a Series B fintech. 2,400 reports/month. Median latency 18s. Zero lost executions in 6 months.
What graph should you build with any AI agent?
The Universal Agent Graph. Five nodes. Covers 80% of agentic use cases.
input → planner → executor → validator → output
↑ │
└─────────┘ (loop-with-gate)
Enter fullscreen mode Exit fullscreen mode
Planner: Decomposes goal into typed steps. Returns Plan(steps: List[Step]).
Executor: Runs one step. Returns StepResult(output, metadata).
Validator: Checks result against step success criteria. Returns Validation(pass, critique).
Router after validator:
-
pass+ steps remain →executor(next step) -
pass+ no steps remain →output -
fail+ retries < max →planner(re-plan) -
fail+ retries exhausted →output(with error summary)
State:
class UniversalState(BaseModel):
goal: str
plan: List[Step] = []
current_step_idx: int = 0
results: List[StepResult] = []
retries: int = 0
Enter fullscreen mode Exit fullscreen mode
Why this works:
- Planner is swappable: LLM, rule-based, human
- Executor is swappable: tool-calling, code-execution, API
- Validator is swappable: heuristic, LLM-judge, unit tests
- Loop-with-gate prevents infinite re-planning
- State is serializable → checkpointable → debuggable
Build this first. Every agent you'll ever need is a specialization of this topology. Specialize by swapping nodes, not rewriting control flow.
What are the challenges and limitations?
Graph engineering is not free. Three costs are real.
1. Learning curve. Teams write loops for a decade. Graphs require thinking in topology. Expect 2–3 sprints before velocity matches loops. The first graph feels verbose. The tenth feels obvious.
2. Overhead. A graph executor adds 10–50ms per node hop. For latency-critical paths (sub-100ms), a tight loop wins. Graphs pay off when reliability > raw speed.
3. State explosion. Complex graphs accumulate large state objects. Serialization/deserialization at checkpoints becomes a bottleneck. Mitigate: prune state at node boundaries. Keep only what downstream needs.
4. Debugging tooling is immature. LangGraph Studio helps. Custom visualizers are often needed. You will build your own "graph replay" tool. Budget for it.
5. Not everything is a DAG. Cycles exist (loops). Recursion exists (subgraphs). The "DAG" label is aspirational. Real graphs have cycles with gates. Don't let purity block pragmatism.
FAQ
What is graph engineering?
Graph engineering is the discipline of modeling agentic workflows as directed graphs with explicit state transitions, typed edges, and deterministic failure domains — replacing ad-hoc control flow with inspectable topology.
How is a graph different from a loop?
Loops embed logic in sequential code with implicit branching and mutated state. Graphs declare logic in topology: nodes compute, edges connect, routers decide, state accumulates. Graphs are debuggable; loops are not.
When should I use a graph instead of a chain?
Use a graph when your workflow has any of: branching decisions, parallel subtasks, human checkpoints, retry logic, or model tiering. Chains are for linear pipelines only.
What are the four building blocks of a graph?
Nodes (pure state transformers), Edges (static topology), State (single typed context), Routers (dynamic edge selection). All branching logic lives in routers.
What is the router pattern?
A pure function State → EdgeName that runs after a node completes. Centralizes all branching logic. Testable with dictionary inputs. No LLM calls.
What is the parallel fan-out pattern?
One node dispatches N independent sub-tasks that run concurrently. A join node aggregates results. Throughput scales horizontally. Join strategy (all, first, quorum) is explicit topology.
What is the loop-with-gate pattern?
A contained subgraph that cycles until a gate node emits continue: false. Max attempts, critique scores, and exit conditions are data in state — not control flow variables.
What is the human-in-the-loop pattern?
A checkpoint node that serializes state, pauses execution, and waits for external resume. The graph treats human approval as just another edge. No async task sprawl.
How does model tiering work in a graph?
Route tasks to the smallest model that succeeds. Validator node checks quality. Router escalates to next tier on failure. State tracks current tier and escalation reason. Typical savings: 60% cost reduction.
How do fallback paths work?
Errors are edges, not exceptions. Every fallible node has success and failure edges. Fallback nodes inspect error type and return recovery actions. Dead letter node captures terminal failures.
What is the universal agent graph?
A five-node topology: planner → executor → validator → output with a loop-with-gate from validator back to planner. Covers 80% of agentic use cases by swapping node implementations.
Can graphs handle cycles?
Yes. Loops are cycles with gates. Recursion is subgraph invocation. The "DAG" label is aspirational; production graphs have controlled cycles. Gates prevent infinite recursion.
What tooling exists for graph engineering?
LangGraph (Python/JS), Pydantic-AI, AutoGen 0.4+, custom executors. Visualization: LangGraph Studio, custom React/Graphviz tooling. Build your own replay debugger.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.