Applied AI · Agent Orchestration

State Machines & Execution Graphs

Why the linear pipelines that served retrieval so well collapse the moment a system has to decide, and how LangGraph replaces them with graphs that can loop, remember, and correct themselves.

Updated July 2026 · Read ~22 min · Session: DAGs → LangGraph


// the big idea

A DAG is a script: it runs each step exactly once, top to bottom, and can never go back. An agent is a decision system: it must loop, remember prior turns, and retry when it gets something wrong. LangGraph is the orchestration layer that adds cycles to the execution graph so agents can do all three — built from three primitives: State, Nodes, and Edges.

§ 1 — Notebook Summary

The one-page revision copy

Dense, hand-copyable. Fully derivable from the detailed notes below.

A · Phase Dos recap (the project this builds on)

RAG system → "hire or fire" a content creator for a brand deal. 3 parallel retrieval engines feed DSPy reasoning.

B · Why DAGs fail for agents

DAG = 3 properties
3 places a DAG collapses
⭐Hammer vs screw → pipelines aren't bad, they're a tool. Great for deterministic ETL/retrieval, catastrophic when the system must decide. ✎ hammer aimed at a screw
⭐Build-time vs run-time → DAG picks its path when you write it; an agent picks its path at runtime. That's the whole difference.

C · LangGraph vs LangChain

LangChain LangGraph
Component library Orchestration layer
Atomic ops: prompts, LLM wrappers, parsers, retrievers Coordinates when ops run, routing, loops, memory
The workers / machines The factory-floor manager
💡LangChain lives inside LangGraph nodes. An LLM call in a node is still a LangChain op; LangGraph just governs flow. ✎ big box "LangGraph" with small "LC" boxes as nodes inside

D · The three pillars

⭐Node return = partial dict only (changed keys). Cardiologist adds cardiology findings; doesn't rewrite the whole chart.
⚠TypedDict is non-negotiable → plain dict + a typo key (catagory vs category) fails silently, mis-routes tickets for 48h. TypedDict → KeyError on first test run.

E · Execution cycle (per invoke)

F · Reducers — overwrite vs append

Overwrite Append
Replace value entirely Add to a list
category, retry_count messages, logs, audit trail, retrieved chunks
One current value at a time Accumulates across steps
⚠Wrong reducer corrupts memory. messages on overwrite → agent gets amnesia every turn.
⚠State explosion → append forever → blows the context window → inference fails. Fix = memory trimming (keep last ~20 = 10 exchange pairs).

G · Cycle patterns in LangGraph

⭐Always bound the loop with max_retries — a cycle without a cap is an infinite loop / runaway token bill.

§ 2 — Detailed Study Notes

The full lesson

A frustrated customer writes: "My payment was deducted twice but my subscription still shows inactive. Fix it now or I'm disputing with my bank." That single message is three problems wearing one coat — a billing bug, a broken account state, and an escalation threat. A traditional pipeline classifies it once, routes it once, answers once, and gets it wrong. It has no way to notice its own mistake and try again. The gap between that pipeline and a system that could actually handle the message is the entire subject of this lesson.

1.1

Where we left off — the Phase Dos project

Before the theory, a recap of the working system this lesson extends. Phase Dos is a RAG pipeline that decides whether a content creator is safe to hire for a brand deal. It pulls from three data sources at once, reasons over them, and returns a verdict.

Think of it as a background-check firm for influencers. One clerk reads the numbers, one reads the gossip, one maps who's connected to whom, and a senior analyst reads all three reports before signing off.

SQLite

The numbers

Structured metrics for 30 creators: follower count, real vs bot followers, average sponsorship ROI. Queried with a parameterized ? statement.

FAISS

The gossip

A vector index of "drama" text — leaked Reddit / Discord chunks. Semantic search pulls every chunk mentioning a creator; returns "no drama" if none.

Neo4j

The web

A relationship graph: brand collaborations, beefs (feuds), and second-degree links like a shared hidden manager. Cypher fetches incoming + outgoing edges.

creator name SQLite metrics FAISS drama chunks Neo4j relationships DSPy compiler ChainOfThought HIRE / FIRE ThreadPool(3)
fig 1 · three engines queried in parallel → DSPy reasons → verdict + cancel velocity

The retrieval layer, rag_engine.py, exposes one query function per store and fires all three in parallel with a thread pool. The compiler.py layer wraps a DSPy signature — a "brutal PR risk analyst" — in ChainOfThought reasoning, then uses BootstrapFewShot to auto-generate few-shot demonstrations from a golden set of 30 examples (15 clean, 10 messy, 5 edge cases). The verdict is JSON: a HIRE/FIRE call plus a cancel velocity from 0 (untouchable saint) to 10 (already cancelled, just not announced).

⭐ TRICK · teacher–student

A big, expensive model (Llama 3.3 70B) plays teacher: it generates high-quality reasoning traces once, offline. A small, cheap model (GPT-4o-mini) plays student: it runs those distilled prompts in production. You pay for quality once and serve it forever — the optimized prompt + demos are frozen into optimized_pr_state.json.

⚠ PITFALL · corporate fluff

The signature explicitly excludes "synergy / authentic connection / brand alignment" marketing language from the analysis. That fluff always paints creators positively, so it biases the verdict. Feeding your model self-serving PR text is a quiet way to poison an evaluation.

💡 TIP · argparse entry points

python compiler.py optimize uses an argparse flag to trigger the BootstrapFewShot run and save the optimized state. Optimization is expensive, so you run it once and reload the saved JSON — never re-optimize on every request.


1.2

What a DAG actually is

A DAG (Directed Acyclic Graph) is a one-way, no-loops, run-once flowchart of work. If you've built an Airflow pipeline, you've built a DAG. The name encodes three mathematical guarantees, and each one that makes DAGs great for data pipelines is exactly what makes them useless for agents.

Analogy · a factory conveyor belt

A part rides the belt through station after station in fixed order and drops off the end. The belt can't reverse. It can't send a defective part back to an earlier station. It processes each part exactly once. Perfect for assembly; hopeless for anything that needs a second look.

directed

One direction

Every edge points from a source to a destination. Flow goes X→Y, never Y→X. The developer fixes this at construction time.

acyclic

No cycles

No path leads back to a node or its ancestors. This guarantees termination — the graph always finishes — but forbids loops.

single-pass

Run once

Each node executes exactly once. No mechanism for iteration. If step 5 produces garbage, there's no way back to step 3.

input classify respond output every node runs exactly once — deterministic, no way back
fig 2 · the DAG: linear, single-pass, deterministic
⭐ TRICK · the reframe that unlocks it

Pipelines aren't bad — they're a category of tool. A hammer is brilliant for a nail and catastrophic for a screw. A DAG is brilliant for deterministic ETL and catastrophic the moment the system must make a decision, retry a failed step, or route based on what an LLM said. Don't ask "is the DAG good?" Ask "is this job a nail or a screw?"


1.3

The three places a DAG collapses

Three concrete failure modes show why agents need more than a pipeline. Each one is architectural — no amount of prompt engineering fixes it, because the capability simply doesn't exist in the shape of a DAG.

1 · Multi-step reasoning chains

Ask a financial agent: "Is Q4 cash strong enough for the proposed acquisition?" It must fetch the balance sheet, fetch the acquisition cost, reason about the two, then check outstanding liabilities. 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 fails outright or answers confidently on incomplete data.

2 · Multi-turn conversations

The agent asks "What error do you see?" The user replies "authentication failed on login." A DAG processes each message in isolation — when the second arrives, it has no memory of asking the first. It gives a generic answer about auth errors, having forgotten it was mid-diagnosis. This is statelessness, and for chatbots it's not an inconvenience, it's a disqualifier.

3 · Self-correction & error recovery

The LLM emits almost-valid JSON with a trailing comma. The parser node crashes and the user sees an error. A well-built agent instead writes a parse_error field to state; a conditional edge reads it, routes back to the LLM with the malformed text and a "fix the formatting" instruction, then re-parses. The user sees a slight delay, not a failure.

⚠ PITFALL · "I'll just add more prompt"

The instinct is to make the prompt smarter so the model never asks for missing data or never emits bad JSON. It won't save you. With structured output, parse failures are inevitable; with multi-step tasks, missing data is inevitable. You cannot instruct a DAG to loop back — the architecture has no such edge. Recovery is a graph-shape problem, not a wording problem.


1.4

DAG vs state machine — the same ticket, two shapes

Put the two side by side on the customer-service example. In the DAG, the request flows input → classify → respond → output, each box firing once. In a state machine, the graph can loop: classify, validate, and if the output is bad, retry — routing back to classification; if it can't be solved, escalate to a human.

input classify validate output escalate → human retry on bad output — loop back on escalation risk
fig 3 · the state machine: cyclic, chooses its path at runtime
DAG pipeline
  • Deterministic — every ticket takes the same path
  • Path fixed at build time
  • Classifies once, responds once, misses the rest
State machine
  • Intelligent — path chosen at runtime
  • Can loop, retry, branch, escalate
  • Reads full state; addresses all sub-problems
⭐ TRICK · the one-line summary to memorize

A DAG is a script; an agent is a decision system. The pipeline decides its route when you write it. The agent decides its route while it runs. Everything else in this lesson is a consequence of that single sentence.


1.5

LangGraph vs LangChain — different jobs, different layers

LangChain is a component library; LangGraph is an orchestration layer. They operate at different levels of abstraction, and the most common mistake newcomers make is conflating them — reaching for one when the job needs the other.

LangChain gives you the atomic pieces: prompt templates that format messages, LLM wrappers that handle API calls, output parsers that turn raw text into structured data, retrievers that query vector stores. It's exceptional at the atomic level. LangGraph solves a different problem: how do I coordinate many LLM calls, tool invocations, and lookups over time — with memory, and with the ability to loop or branch?

Analogy · the CNC factory floor

LangChain components are the machines — a CNC cutter, a quality inspector, a packaging robot. Each is essential and does one job well. LangGraph is the factory-floor management system: it decides the order of operations, routes products between stations, and triggers rework when a defect appears. The machines without the manager have no coordination, no feedback, no intelligence.

💡 TIP · they compose, not compete

LangGraph doesn't replace LangChain — it uses LangChain components as the contents of its nodes. An LLM call inside a LangGraph node is still written in LangChain. LangGraph only governs when that node runs, what it receives, where its output goes, and whether execution should loop, branch, or stop. Umbrella (LangGraph) containing tools (LangChain).


1.6

The three pillars — State, Nodes, Edges

Every LangGraph system — from a two-node toy to a multi-agent production graph with human-in-the-loop checkpoints — is built from exactly three primitives. Master these and you can design any agentic system.

STATE — shared TypedDict (the memory / RAM) node A node B node C edge edge every node reads + writes state
fig 4 · nodes compute, edges route, state is the memory they all share

State — the memory layer

State is a typed dictionary that holds all data shared across every node in the graph. Its engineering analogy is RAM in a running process: every function reads from it and writes back to it. A poorly designed state schema corrupts the agent's memory and causes silent failures; a well-designed one makes behavior transparent and predictable.

⚠ PITFALL · the silent typo that ships

With a plain dict, one node writes catagory (typo) while the router reads category. Neither raises an exception. The graph completes, the field is silently empty, and every ticket routes to the default handler for 48 hours before someone traces it. A TypedDict turns this into a KeyError on the first test invocation — fixed in 30 seconds instead of two days.

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

class TicketState(TypedDict):
    query: str
    category: Literal["billing", "technical", "escalation"]  # not bare str
    retry_count: int                        # overwrite reducer
    messages: Annotated[list, add]          # append reducer

Three principles for a good state schema:

Minimal surface area

Exactly the fields nodes use to talk to each other. No more, no less.

Explicit typing

Every field has the most specific type possible — Literal["billing","technical"], not bare str. The tighter the type, the more the checker catches before runtime.

Clear ownership

Reading the schema should make it obvious which node writes which field.

Nodes — the workers

A node is a Python function that takes the current state, does one computation, and returns a partial dictionary of only the keys it changed. The computation can be an LLM call, a DB query, an API call, or pure logic. The contract has three parts:

  1. Accepts exactly one argument — the current state. No extra args, no global variables, no external mutable state. Whatever's in state is all it knows about the world.
  2. Performs its computation — it has full read access to every field in the state it received.
  3. Returns a partial dict — only the changed keys, not the whole modified state. LangGraph merges it in; untouched keys keep their values.
⭐ TRICK · the cardiologist rule

A cardiologist doesn't rewrite the entire patient chart — they add their cardiology findings and leave the ER's notes untouched. A classifier node returns {"category": "billing"}, not a reproduction of the full state. This "partial return" is what keeps nodes focused, modular, and composable.

nodes.py — partial return
def classify(state: TicketState) -> dict:
    result = llm.invoke(state["query"])     # a LangChain op, inside a node
    return {"category": result.category}    # only the changed key

Edges — the wiring

Edges connect nodes. There are two kinds, and the second is where an agent's intelligence lives.

Standard edge

Unconditional wiring. After node A, always go to node B. Like calling functions in fixed order. Example: always log after every step.

Conditional edge

Dynamic routing. A router function reads the state at runtime and returns the name of the next node. Like an if/else that decides where to go. This is how the graph "thinks."

⚠ PITFALL · the router is not a node

A router function follows a different contract from a node. It does not return a state dict, and LangGraph does not merge its return value into state. It only examines state and returns a string — the name of the next node. Mixing up the two contracts (returning state from a router, or a node name from a node) is a schema mismatch that breaks routing.

graph.py — wiring both edge types
def route_by_category(state: TicketState) -> str:
    return state["category"]   # returns a node NAME, not state

builder.add_edge("classify", "log")            # standard: always
builder.add_conditional_edges(                 # conditional: depends
    "log", route_by_category,
    {"billing": "billing_node", "technical": "tech_node"},
)

1.7

The execution cycle — from invoke to END

Understanding what happens between graph.invoke(state) and the final returned state is essential for debugging. The loop is the same every time.

invoke(state) validate schema run node merge (reducer) into global state evaluate edge END sentinel next node → repeat
fig 5 · validate → run → merge → route → repeat until END
  1. graph.invoke(initial_state) — LangGraph validates the input against the TypedDict schema. A missing or wrong-typed key raises an error before any node runs. This is the first line of defense.
  2. Execution begins at the entry point; LangGraph passes the full current state to the node.
  3. The node runs and returns a partial dict.
  4. LangGraph merges the returned dict into the global state, applying each field's reducer (overwrite or append).
  5. It evaluates the outgoing edge — a standard edge goes to the fixed next node; a conditional edge calls the router and uses its returned string to look up the next node.
  6. Repeat until execution reaches the END sentinel.
💡 TIP · why the formal graph is worth it

You could write raw if/else routing between functions — and every team does before adopting LangGraph. It fails at scale because the routing logic is buried inside functions: invisible, untraceable, unreplayable. LangGraph externalizes control flow into declared edges, so you can visualize the whole graph before running it, stream/inspect state at any node, and replay a failed run. In production that's not a luxury — it's operational necessity.


1.8

Reducers — overwrite vs append

A reducer is the rule for how a node's returned value merges into the existing state. Every field has one, and picking the wrong one corrupts the agent's memory in ways that are painful to diagnose.

Overwrite

Replaces the value entirely. Correct for a field with exactly one current value: category (a ticket has one classification at a time), retry_count (read, add one, write back).

Append

Adds to a list. Correct for anything that accumulates across steps: messages, error logs, audit trails, retrieved document chunks.

Field Reducer Why
category overwrite Only one current classification exists; the old one is irrelevant.
retry_count overwrite Tracks a single evolving counter.
messages append Conversation history must accumulate every turn.
error_log append Audit trail grows; nothing should be discarded.
⚠ PITFALL · overwrite on messages = amnesia

If messages used overwrite, each node would replace the whole conversation with its own contribution. By the second turn the agent has forgotten everything it said — full amnesia. Conversation history is the canonical case that must append.

⚠ PITFALL · state explosion → context overflow

Append forever and a long-running agent accumulates thousands of messages. The context window is finite; once messages exceeds it, the inference call fails. The fix is memory trimming: before appending, truncate to the most recent N entries. A common conservative choice is 20 (≈10 exchange pairs) — enough context to stay coherent, with headroom for the system prompt, retrieved docs, and current query.

memory trimming before append
def chat_node(state: TicketState) -> dict:
    trimmed = state["messages"][-20:]        # keep last ~10 exchanges
    reply = llm.invoke(trimmed + [user_msg])
    return {"messages": [user_msg, reply]}   # append reducer adds these

1.9

Cycles — the core innovation

A cycle is a path in the graph that leads back to an already-visited node. Forbidden in a DAG, this is exactly what LangGraph adds — and it enables three things a pipeline can't do.

iteration

Loop over work

Run the same node repeatedly, refining until a condition is met.

feedback

Act on results

A later node's output changes what an earlier node does on the next pass.

self-correction

Fix mistakes

Detect a bad output, route back, and try again with guidance.

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

⭐ TRICK · always cap the loop

A cycle without a bound is an infinite loop and a runaway token bill. Every reasoning loop needs a max_retries guard so the validator can give up gracefully and route to a fallback. This also handles the token-budget concern: iterations are fixed, so cost is bounded. Uncapped loops are the first of the five classic LangGraph production failures (alongside state-mutation bugs, LLM hallucinations, router schema mismatches, and state explosion).


1.10

Interview & practice questions

Read each answer aloud in a couple of sentences.

Why can't you fix a DAG's inability to loop back with a better prompt?Q1

Because looping back is a structural capability, not a linguistic one. A DAG has no edge that points to an earlier node, so no matter how you word the instruction, there is no path for execution to return. The fix is a different graph shape — a cycle — which is exactly what LangGraph provides.

What are the three defining properties of a DAG, and which one disqualifies it for agents?Q2

Directed (edges have one direction), acyclic (no path back to a node, guaranteeing termination), and single-pass (each node runs exactly once). The acyclic + single-pass combination is the disqualifier: agents need to loop, retry, and iterate, none of which a DAG can express.

Explain the difference between LangChain and LangGraph.Q3

LangChain is a component library of atomic operations — prompt templates, LLM wrappers, output parsers, retrievers. LangGraph is an orchestration layer that coordinates those components over time with memory, looping, and branching. LangGraph doesn't replace LangChain; it uses LangChain components as the contents of its nodes.

What are the three pillars of LangGraph, and how do they relate?Q4

State is the shared TypedDict that acts as memory. Nodes are functions that read state and return a partial dict of changed keys. Edges route between nodes — standard edges unconditionally, conditional edges via a router that returns the next node's name at runtime.

Why is a TypedDict preferred over a plain dict for state?Q5

It establishes a formal contract for every key's name and type, so a typo or wrong type raises an error on the first test run rather than failing silently in production. Without it, a misspelled key produces an empty field and mis-routed logic that can go undetected for days and look like a business-logic bug instead of a typo.

What must a node return, and why not the full state?Q6

A node returns a partial dictionary containing only the keys it changed. LangGraph merges that into the global state, leaving untouched keys as they were. Returning only the delta keeps nodes focused and modular — the cardiologist adds findings rather than rewriting the whole chart.

When do you choose an overwrite reducer versus an append reducer?Q7

Overwrite for fields with a single current value — a ticket's category or a retry counter. Append for fields that accumulate across steps — conversation messages, logs, audit trails, retrieved chunks. Using overwrite on messages gives the agent amnesia; using append without limits risks state explosion.

What is state explosion and how do you prevent it?Q8

An append field grows without bound in a long-running agent until it exceeds the context window and the inference call fails. Prevent it with memory trimming: before appending, truncate the list to the most recent N entries (commonly ~20, about ten exchanges), leaving headroom for the system prompt and retrieved context.

How does a conditional edge's router differ from a node?Q9

A node returns a partial state dict that LangGraph merges into state. A router returns a plain string — the name of the next node — and its return value is never merged into state. The router only inspects state and declares where to go next.


1.11

Key takeaways

DAG directed · acyclic · single-pass state machine build-time vs runtime LangChain = components LangGraph = orchestration State · Nodes · Edges TypedDict contract partial return standard vs conditional edge router returns node name reducer: overwrite / append memory trimming state explosion reasoning loop + max_retries teacher–student distillation

Build it · Chapter 11 · Atlas

Build it — assemble the whole agent as a graph

Now the payoff: every piece so far becomes a node. Retrieve (07) and synthesize (08) are wired with a conditional edge — no context, no synthesis. This is the entire QA agent expressed as State + Nodes + Edges, answering the multi-hop question flat search couldn't.

File atlas/agent.pyRun python3 -m atlas.agentDeps Python 3 · stdlib only

atlas/agent.py — retrieval + synthesis, wired as an execution graph:

"""Chapter 11 — assemble the whole QA agent as an execution graph.

Everything so far becomes a node: extract (06) builds the graph, retrieve (07)
does the vector-jump + traversal, synthesize (08) turns topology into an answer.
The engine (09/10) wires them with a conditional edge — no answer, no synthesis.
"""
from atlas.engine import StateGraph, END
from atlas.extract import build_graph, CORPUS
from atlas.retrieve import index, hybrid
from atlas.dspy_lite import ChainOfThought, stub_lm

GRAPH = index(build_graph(CORPUS))
SYNTH = ChainOfThought("context, question -> answer",
                       instructions="Answer only from the graph context.")


def retrieve(state):
    entry, context = hybrid(GRAPH, state["question"], cap=6)
    return {"entry": entry, "context": context}


def synthesize(state):
    out = SYNTH(stub_lm, context=state["context"], question=state["question"])
    return {"answer": out["answer"]}


def has_context(state):
    return "found" if state["context"].strip() else "empty"


def build_agent():
    g = StateGraph()
    g.add_node("retrieve", retrieve).add_node("synthesize", synthesize)
    g.set_entry("retrieve")
    g.add_conditional_edges("retrieve", has_context,
                            {"found": "synthesize", "empty": END})
    g.add_edge("synthesize", END)
    return g.compile()


if __name__ == "__main__":
    agent = build_agent()
    q = "Who leads the company that competes with the startup Microsoft acquired?"
    final = agent.invoke({"question": q}, trace=True)
    print("\nQ:", q)
    print("entry node:", final["entry"])
    print("A:", final["answer"])

Run it — you'll see:

  [retrieve] -> {'entry': 'Alpha', 'context': '(Microsoft)-[acquired]->(Alpha)\n(Nimbus)-[competes_with]->(Alpha)\n(Elena)-[works_for]->(Nimbus)'}
  [synthesize] -> {'answer': 'The graph connects Microsoft, Alpha, Nimbus, Elena.'}

Q: Who leads the company that competes with the startup Microsoft acquired?
entry node: Alpha
A: The graph connects Microsoft, Alpha, Nimbus, Elena.

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 ↗.