AI Engineering · Agentic Systems

LangGraph, State Machines & Execution Graphs

Why a straight-line pipeline (a DAG) quietly fails as an AI agent, and how LangGraph fixes it with three primitives: state, nodes, and edges.

Updated Jul 2026 · Read ~18 min · from live session + project recap


// the big idea

A DAG runs each step once, forward, and can never loop back — perfect for ETL, fatal for agents. An agent needs to retry, remember, and self-correct, which requires cycles. LangGraph adds cycles to the execution graph and gives you a shared, typed state that every node reads and writes — turning a one-shot script into a decision system.

0.0

Project recap — the DSPY "phase-2" analyzer

The session opened by revisiting a project that motivates everything after it: a PR-risk analyzer that decides whether a talent agency should hire or fire a content creator for a brand collaboration. It fuses three parallel data sources, then compiles a verdict with DSPY.

SQLite

Structural metrics

Per-creator numbers: follower count, bot percentage, average sponsorship ROI. The "math" layer.

FAISS index

Drama corpus

Semantic chunks of leaked Reddit / Discord "drama," retrieved by meaning, not keyword.

Neo4j

Relationship graph

Creator↔brand↔creator ties: collaborations, "beefs," and shared hidden managers.

A rag_engine.py exposes query_sqlite, query_faiss, and query_neo4j. A ThreadPoolExecutor(max_workers=3) fires all three in parallel for a given creator (the "parallel RAG strike"). A DSPY signature then plays a "brutal, data-literate PR risk analyst" and returns strict JSON: a verdict (hire/fire), a cancel_velocity score 0–10, and a receipts summary.

⭐ TRICK · teacher/student compile

DSPY optimization uses a heavy teacher model (Llama-3.3-70B) to generate perfect reasoning traces, then a cheap student model (GPT-4o-mini) runs those traces in production. You compile once with BootstrapFewShot, save the optimized state to JSON, and never pay the expensive optimization cost again at serve time.

⚠ PITFALL · dependencies you forgot to start

In the demo the Neo4j query failed with "cannot connect" — the instructor had not started the Docker container holding the graph DB. The agent still produced a verdict from SQLite + FAISS and flagged the graph as down. Lesson: a well-built agent degrades gracefully when one source is unavailable rather than crashing.

The relevance to today: this analyzer is a one-shot forward pass — retrieve, compile, answer. That works because the question is well-defined. The rest of the lesson is about what happens when a question isn't, and a single forward pass is no longer enough.


1.0

Notebook summary

Copy-ready. Every line here is derivable from the detailed notes below.

DAG — directed acyclic graph

  • 3 properties → directed (edges one-way), acyclic (no loops back), single-pass (each node runs exactly once)
  • Built at build time — developer wires X→Y before running
  • Guarantees termination (always reaches END) — great for ETL / Airflow
  • Fatal for agents → no retry, no memory, no self-correct
  • ✎ doodle: 3 boxes left→right, one arrow each, big X over any backward arrow

Agent — decision system

  • Cyclic: can loop back to re-run a step
  • Decides at runtime which node is next
  • Holds state (memory) across steps
  • ⭐Mantra: DAG = script · Agent = decision system

3 ways a DAG collapses

  • 1. Multi-step reasoning → step 4 finds missing data, can't loop to step 1
  • 2. Statelessness → multi-turn chat; each msg processed in isolation, no memory of clarifying Q
  • 3. Self-correction → bad JSON (trailing comma) crashes parser; no route back to fix
  • ⚠"Not a prompt problem" — no prompt can make a DAG loop

LangGraph cycles — 3 mechanisms

  • iteration · feedback · self-correction
  • Common pattern = reasoning loop: LLM proposes → validator checks → approve OR retry (until max_retries) → escalate/fallback
  • ⚠always bound loops with max_retries (token budget)

LangChain vs LangGraph

LangChain LangGraph
component library orchestration layer
atomic ops (prompt, LLM, parser, retriever) coordinates them over time + memory
the workers / machines the factory-floor manager
  • ⭐LangGraph uses LangChain — LangChain lives inside nodes
  • ✎ doodle: big box "LangGraph" containing small boxes "LC comp" wired by arrows

3 pillars

  • State = typed dict = RAM; every node reads/writes it
  • Nodes = Python fns; take state → return partial state dict
  • Edges = wiring:
    • standard → A always → B (unconditional)
    • conditional → router fn reads state, returns next-node name
  • ✎ doodle: State box in center; nodes as circles around it; solid arrows = standard, dashed diamond = conditional

Execution cycle (graph.invoke)

  • validate input vs TypedDict schema (1st line of defense)
  • entry node → run → return partial dict → merge via reducer
  • evaluate outgoing edge (standard or conditional) → pick next node
  • repeat until END sentinel

State design

  • TypedDict = formal contract → catches typo/type bugs before runtime
  • 3 principles: minimal surface · explicit typing · clear ownership
  • ⚠plain dict + typo (categry) = silent empty-string, 48-hr prod bug

Reducers — overwrite vs append

Overwrite Append
replaces value adds to a list
category, retry_count messages, logs, retrieved docs
"one current value" "accumulate history"
  • ⚠append → state explosion (context overflow); fix = memory trimming (keep last ~20 msgs = 10 turns)

Node contract (3 rules)

  • accepts exactly one arg = state (no globals)
  • does one computation (LLM / DB / calc / validate)
  • returns only the keys it changed (partial return)
  • ✎ analogy: cardiologist adds findings, doesn't rewrite the whole chart

2.1

The ticket that breaks a pipeline

A support message lands: "My payment was deducted twice but my subscription is still showing as inactive. I need this fixed immediately or I am disputing with my bank." A classic retrieval pipeline does the only thing it knows how to do — it classifies the message once, routes it once, and answers once. But read the message again: it is not one problem, it is three — a billing error, a broken account state, and an escalation threat. There is no single category that fits all three, so the pipeline picks one, answers it, and silently drops the other two.

That failure isn't a bug in the prompt. It's a limit of the shape of the system. This lesson is about recognizing that shape, naming it (a DAG), and replacing it with one that can think in loops.

💡 TIP · you've already used this

The "bot answers, you get frustrated, you type CONNECT ME TO AN AGENT, a human takes over" flow in apps like food-delivery support is a stateful agent with conditional routing and a human-in-the-loop escalation edge. You've been on the receiving end of exactly the architecture this lesson builds.

2.2

Pipelines are a tool, not a default

A pipeline is a fixed, linear sequence of steps decided in advance. Early in the course, retrieval pipelines did exactly this: a query flows through a FAISS retriever, into a prompt template, to the model, and back as a structured answer. Deterministic, bounded, and — for that job — effective.

Analogy · right tool, wrong job

A hammer is a brilliant instrument for driving a nail and a catastrophic one for removing a screw. Pipelines aren't bad; they're a category of tool. The moment a system must decide, retry a failed step, or route based on what a model just said, you've picked up the hammer and you're staring at a screw.

The distinction that matters is when the control flow is decided. A pipeline's route is fixed at build time. An agent chooses its route at runtime, based on what it has seen so far. Everything else in this lesson follows from that one difference.

2.3

What a DAG actually guarantees

A DAG (directed acyclic graph) is a pipeline expressed formally, with three mathematical properties. Understanding the properties is what lets you see precisely why it can't behave like an agent.

directed

Edges are one-way

Every edge has a direction; execution flows source → destination, never in reverse. The developer fixes this at construction time.

acyclic

No path back

No node can reach itself or an ancestor. This guarantees termination — the graph always reaches END.

single-pass

Each node runs once

With no cycles, there's no mechanism for iteration. If step 5 is wrong, you can't loop to step 3 and retry.

classify respond output forbidden in a DAG
Fig 2.3 — a DAG runs each node exactly once; the backward edge an agent needs is structurally impossible.
⚠ PITFALL · propagating a confident wrong answer

Because a DAG can't loop, a bad result at step 5 doesn't stop the machine — it propagates forward until the system either crashes or returns a confidently wrong answer. That silent-wrong-answer mode is more dangerous than a crash, because nothing signals the failure.

2.4

Three places a DAG collapses

Three concrete scenarios show the same limitation from different angles. In each, no prompt engineering can save the architecture — the missing capability doesn't exist in a DAG.

2.4.1 · Multi-step reasoning chains

Ask a financial agent: "Is the company's Q4 cash position strong enough to support the proposed acquisition?" It must fetch the balance sheet, fetch the acquisition cost, reason about the two numbers, account for outstanding liabilities, then recommend. If at step 4 it discovers the liability data is incomplete, a DAG has no path back to step 1 to fetch it. It either answers on bad data or fails outright. A production agent must loop back, request the missing data, and re-run the chain.

2.4.2 · Follow-up conversations (statelessness)

User: "My account isn't working." Agent: "What error do you see?" User: "Authentication failed when I try to log in." A DAG processes each message in isolation — the second message arrives with no memory of the first, so it doesn't know this is an answer to a clarifying question. It emits a generic "authentication errors" reply, having forgotten it was mid-diagnosis.

⚠ PITFALL · statelessness is a disqualifier, not an inconvenience

A DAG is stateless between invocations — every call is a fresh start. For multi-turn conversation, which is the primary interface for enterprise chatbots, that isn't merely inconvenient; it's an architectural disqualifier.

2.4.3 · Self-correction & error recovery

An agent generates a structured JSON report and the model emits almost-valid JSON with a trailing comma. In a DAG the parser node hits bad JSON, raises, and the pipeline crashes — the user sees an error. In LangGraph the parser writes a parse_error field into state; a conditional edge downstream reads it and routes back to a "fix formatting" node, which hands the malformed JSON back to the model with instructions to repair it. The parser re-runs, succeeds, and the user sees a slight delay instead of a failure.

⭐ TRICK · errors are data, not exceptions

The move that unlocks self-correction: don't raise on a bad output — write the failure into state as a field and let a conditional edge decide what to do next. Parsing failures are inevitable whenever an LLM produces structured output, so recovery has to be part of the graph, not an afterthought.

2.5

Cycles: iteration, feedback, self-correction

LangGraph's core innovation is allowing cycles in the execution graph — a path that leads back to an already-visited node. That single capability enables three things a DAG forbids: iteration, feedback, and self-correction.

The most common cycle is the reasoning loop: the model proposes an action, a validator evaluates it, and the validator either approves the output or — once a max_retries ceiling is hit — routes to an escalation or fallback node.

input classify validate respond escalate bad output → retry (< max)
Fig 2.5 — the reasoning loop: validate approves, escalates, or loops back to retry until the retry ceiling.
⚠ PITFALL · never loop unbounded

A cycle without a ceiling can run forever and blow the token budget. Always bound the loop with a max_retries counter; when it's reached, route to a fallback instead of retrying again. Cyclic ≠ infinite.

2.6

LangGraph vs LangChain

The most common mistake for engineers new to this ecosystem is conflating the two. They operate at different levels of abstraction, and using one where you need the other is the root cause of most agent failures.

LangChain — component library

  • Building blocks for atomic AI operations
  • Prompt templates, LLM wrappers, output parsers, retrievers
  • Exceptional at the atomic level
  • Stateless: one request in, one result out

LangGraph — orchestration layer

  • Coordinates many calls over time, with memory
  • Decides when a step runs, what it receives, where output goes
  • Controls whether to loop, branch, or stop
  • Stateful across the whole run
Analogy · the factory floor

LangChain components are the machines — a CNC mill, a quality inspector, a packaging robot. LangGraph is the factory-floor management system that decides the order of operations, routes products between stations, and triggers rework when a defect is found. The machines are essential, but without the management layer they have no coordination, feedback, or intelligence.

⭐ TRICK · they compose, not compete

LangGraph doesn't replace LangChain — it uses LangChain components as the contents of its nodes. The LLM call inside a node is still a LangChain operation; LangGraph just governs when it runs and where its output flows. Picture one big "LangGraph" box with small LangChain components wired inside it.

2.7

Three pillars: state, nodes, edges

Whether it's a two-node toy or a multi-agent system with sub-graphs and human checkpoints, every LangGraph app is built from exactly three primitives. Master these and you can design any agentic system.

state

The memory layer

A typed dict holding all data across every node. Like RAM in a running process — every function reads from it and writes back to it.

nodes

The workers

Python functions (or LLM calls) that accept the state, do one computation, and return an updated slice of state.

edges

The wiring

Connections between nodes. Standard edges are unconditional (A→B); conditional edges route dynamically at runtime.

state TypedDict · shared node A node B node C router solid = standard edge · dashed = conditional edge
Fig 2.7 — nodes orbit a shared state; standard edges are fixed wiring, a conditional edge asks a router which node comes next.
2.8

The execution cycle

Knowing what happens between graph.invoke() and the final state is what makes an agent debuggable. The loop is the same every time.

graph.invoke(state) → validate schema → run entry node → merge via reducer → evaluate edge → next node ↻ END
  1. Validate. The input dict is checked against the TypedDict schema. A missing key or wrong type raises before any node runs — the first line of defense.
  2. Run a node. From the entry point, LangGraph passes the full current state to the node, which does its work (LLM call, DB lookup, computation) and returns a partial state dict.
  3. Merge. The returned dict is merged back into the global state using each field's reducer (overwrite for scalars, append for lists).
  4. Evaluate the edge. A standard edge routes unconditionally to the declared next node; a conditional edge calls the router function, passes it the state, and uses the returned string to look up the next node.
  5. Repeat until execution reaches the END sentinel.
💡 TIP · observability is why you formalize

You could write this routing as buried if/else in Python — every team does before adopting LangGraph, and it fails at scale. Declaring edges explicitly lets you visualize the graph before running, stream and inspect state at any node, and replay a failed run from the same initial state. In production that's not a luxury — it's an operational necessity.

2.9

State design: TypedDict & reducers

State is the most consequential design decision in a LangGraph app. Every node writes to it and every routing decision reads it, so a sloppy schema corrupts the agent's memory in ways that are miserable to debug.

2.9.1 · Why TypedDict, not a plain dict

A TypedDict is a formal contract: every key's type and presence is declared once and inherited by every node. Read a key that isn't in the schema and you get a static type error before the code runs.

state.py — the formal contract
from typing import TypedDict, Literal, Annotated
import operator

class SupportState(TypedDict):
    ticket_text: str
    # Literal is more specific than str — the checker enforces valid values
    category: Literal["technical", "billing", "escalation"]
    retry_count: int
    parse_error: str
    # append reducer: new messages accumulate instead of overwriting
    messages: Annotated[list[dict], operator.add]
⚠ PITFALL · the 48-hour typo

With a plain dict, one engineer writes category while another node writes the typo categry. Neither raises. The graph completes, the final state is silently missing a field, and every ticket routes to the default handler on an empty string. Billing tickets get misrouted for 48 hours before someone traces it. A TypedDict turns that into a key error on the first test run, fixed in 30 seconds.

Well-designed state follows three principles:

minimal surface

Exactly the fields nodes need to communicate — no more, no less.

explicit typing

Each field as specific as possible: Literal["technical","billing"], not bare str.

clear ownership

Reading the schema makes it obvious which node writes which field.

2.9.2 · Reducers: overwrite vs append

Every field has a reducer — the rule for merging a node's returned value into existing state. Pick the wrong one and you corrupt memory.

Strategy Behavior Use for Why
Overwrite Replaces the field's value entirely category, retry_count There's only one current value at a time; old value is irrelevant
Append Adds new values to a list messages, error logs, audit trails, retrieved chunks Data must accumulate across turns; overwriting would erase history
Analogy · amnesia

If messages used overwrite, every node would replace the whole conversation with its own contribution. By the second turn the agent would have no memory of itself — total amnesia. Conversation history is the canonical case for append.

⚠ PITFALL · state explosion & the fix

Append has a production risk: a long-running agent accumulates thousands of messages until the list exceeds the context window and the inference call fails. The fix is memory trimming — before appending, truncate to the most recent N entries. A conservative standard is ~20 messages (10 exchange pairs): enough context to stay coherent, with headroom for the system prompt, retrieved docs, and the current query.

2.10

Node & edge contracts

2.10.1 · The node contract (pure-function pattern)

A node is a Python function that follows a strict three-part contract. Honoring it is the difference between nodes that work in isolation and nodes that are composable, testable, and production-safe.

1 · input

Accepts exactly one argument: the current state. No extra args, no globals, no hidden external state.

2 · compute

Does one computation — an LLM call, DB query, API call, calculation, or validation. It may read any state field.

3 · partial return

Returns a new dict with only the keys it changed. LangGraph merges it; untouched keys keep their values.

nodes.py — a classifier node
def classify(state: SupportState) -> dict:
    # reads what it needs from state, ignores the rest
    text = state["ticket_text"]
    category = llm_classify(text)   # a LangChain operation inside the node
    # returns ONLY the field it owns — not the whole state
    return {"category": category}
Analogy · the cardiologist

A cardiologist doesn't rewrite the entire patient chart — they add their cardiology findings and leave everything the ER wrote exactly as it was. A node adds its slice and returns only that; the partial-return pattern is what keeps nodes focused and modular.

2.10.2 · Standard vs conditional edges

A standard edge is the simplest connection: after node A returns, execution always proceeds to node B (think logging after every call). A conditional edge is where the intelligence lives — after a node completes, LangGraph calls a router function that reads state and returns the name of the next node.

graph.py — wiring both edge types
builder.add_edge("node_a", "node_b")          # standard: always A -> B

def route_by_category(state: SupportState) -> str:
    # router: returns a NODE NAME, never a state dict
    return state["category"]                    # "technical" | "billing" | "escalation"

builder.add_conditional_edges(
    "classify",
    route_by_category,
    {"technical": "tech_handler",
     "billing":   "billing_handler",
     "escalation":"human_handoff"},
)
⭐ TRICK · router functions break the node contract on purpose

A router function follows a different contract than a node: it does not return a state dict, and LangGraph does not merge its output. Its only job is to examine the state and declare the name of the next node. Mixing up "returns state" (node) with "returns a node name" (router) is a classic beginner bug.


3.0

Interview & practice Q&A

Why can't you just add prompt instructions to make a DAG retry a failed step?

Because looping isn't a prompting capability — it's an architectural one. A DAG is acyclic by definition, so there is no edge that leads back to a previous node, no matter how the prompt is worded. The ability to iterate has to exist in the graph structure; you gain it by moving to a cyclic framework like LangGraph.

What are the three mathematical properties of a DAG, and which one kills agent behavior?

Directed (edges are one-way), acyclic (no path back to a node), and single-pass (each node runs exactly once). The acyclic property is what forbids loops, and single-pass is what prevents iteration — together they make retry, self-correction, and stateful memory impossible.

How does LangGraph relate to LangChain — do you pick one or the other?

They compose. LangChain is a component library for atomic operations (prompts, LLM wrappers, parsers, retrievers); LangGraph is an orchestration layer that coordinates those components over time with shared state. LangGraph nodes typically contain LangChain calls — one is the machine, the other is the factory-floor manager.

What are the three pillars of LangGraph?

State (a typed dict that is the shared memory every node reads and writes), nodes (Python functions that take state and return a partial state update), and edges (the wiring — standard edges route unconditionally, conditional edges route via a router function at runtime).

When do you use an overwrite reducer versus an append reducer?

Overwrite when there is only one meaningful current value — a ticket's category or a retry_count. Append when data must accumulate across steps — conversation messages, error logs, audit trails, retrieved chunks. Using overwrite on messages gives the agent amnesia; using append without trimming risks state explosion.

What is state explosion and how do you prevent it?

An append-reduced field (usually messages) grows without bound in a long-running agent until it overflows the model's context window and the inference call fails. The fix is memory trimming: before appending, truncate the list to the most recent N entries — a common default is ~20 messages (10 turns), which stays coherent while leaving room for the system prompt and query.

What are the three rules of the node contract?

A node accepts exactly one argument (the state), performs exactly one computation, and returns a dict containing only the keys it changed. LangGraph merges that partial return into the global state, leaving untouched fields intact — like a specialist adding findings to a chart rather than rewriting it.

How does a router function differ from a node function?

A node returns a partial state dict that LangGraph merges into state. A router (used by a conditional edge) returns a string naming the next node, and its return value is never merged into state. Its sole job is to read the current state and decide where execution goes next.

Why formalize control flow as a graph instead of writing if/else in Python?

Buried if/else logic is invisible: you can't visualize the flow, inspect state mid-run, or replay a failure without reading every line of code, and every new feature means surgery on production code. Declaring edges explicitly gives you visualization, streaming/inspection, and replay of failed runs — observability that becomes an operational necessity at scale.


4.0

Key takeaways

DAG directed · acyclic · single-pass cycle reasoning loop max_retries statelessness self-correction orchestration layer state · nodes · edges TypedDict reducer overwrite vs append memory trimming partial return standard vs conditional edge router fn graph.invoke

Build it · Chapter 10 · Atlas

Build it — conditional edges make it an agent

A pipeline always runs the same nodes. An agent decides. Add conditional edges — a router reads the state and picks the next node — and the graph can now loop. The moment cycles are possible, a recursion limit stops being optional.

File atlas/engine.pyRun python3 -m atlas.engineDeps Python 3 · stdlib only
Extend Chapter 09's engine.py with the three additions below, then a cyclic graph runs. The full assembled file is in the checkpoint.

engine.py — add conditional edges + a bounded loop:

# Three additions to engine.py — then the graph can branch and loop.

# 1) in StateGraph.__init__:
self.branches = {}                      # name -> (router_fn, mapping)

# 2) a new method on StateGraph:
def add_conditional_edges(self, src, router, mapping):
    """router(state) returns a key; mapping[key] is the next node."""
    self.branches[src] = (router, mapping)
    return self

# 3) on CompiledGraph: pick the next node, then bound the walk.
def _next(self, node, state):
    if node in self.g.branches:
        router, mapping = self.g.branches[node]
        return mapping[router(state)]       # the router decides
    return self.g.edges.get(node, END)

def invoke(self, state, recursion_limit=25, trace=False):
    state = dict(state)
    node = self.g.entry
    for _ in range(recursion_limit):        # cycles are now possible
        if node == END:
            return state
        update = self.g.nodes[node](state) or {}
        state.update(update)
        if trace:
            print(f"  [{node}] -> {update}")
        node = self._next(node, state)
    raise RuntimeError(f"recursion limit ({recursion_limit}) hit — cycle?")


# A cyclic graph: loop through one node until a counter reaches 3.
def tick(state):    return {"n": state["n"] + 1}
def route(state):   return "loop" if state["n"] < 3 else "done"

g = StateGraph().add_node("tick", tick).set_entry("tick")
g.add_conditional_edges("tick", route, {"loop": "tick", "done": END})
print("final:", g.compile().invoke({"n": 0}, trace=True))

Run it — you'll see:

  [tick] -> {'n': 1}
  [tick] -> {'n': 2}
  [tick] -> {'n': 3}
final: {'n': 3}

This is one checkpoint of Atlas — a single project built across notes 04–14. Full code, setup, and the map of all checkpoints: Atlas — Build the notes ↗.