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.
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.
Dense, hand-copyable. Fully derivable from the detailed notes below.
RAG system → "hire or fire" a content creator for a brand deal. 3 parallel retrieval engines feed DSPy reasoning.
ThreadPoolExecutor(max_workers=3)optimized_pr_state.json; app.py = Flask UI, lazy-loads
DSPy| 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 |
TypedDict shared by all nodes. Analogy
= RAM.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.graph.invoke(state) → validate vs TypedDict schema (1st line of defense)END sentinel| 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 |
messages on overwrite → agent gets amnesia every turn.max_retry → route to
escalation/fallbackmax_retries
— a cycle without a cap is an infinite loop / runaway token bill.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.1Before 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.
Structured metrics for 30 creators: follower count, real vs bot followers, average sponsorship ROI.
Queried with a parameterized ? statement.
A vector index of "drama" text — leaked Reddit / Discord chunks. Semantic search pulls every chunk mentioning a creator; returns "no drama" if none.
A relationship graph: brand collaborations, beefs (feuds), and second-degree links like a shared hidden manager. Cypher fetches incoming + outgoing edges.
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).
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.
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.
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.
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.
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.
Every edge points from a source to a destination. Flow goes X→Y, never Y→X. The developer fixes this at construction time.
No path leads back to a node or its ancestors. This guarantees termination — the graph always finishes — but forbids loops.
Each node executes exactly once. No mechanism for iteration. If step 5 produces garbage, there's no way back to step 3.
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?"
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.
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.
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.
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.
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.
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.
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.
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?
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.
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).
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 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.
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.
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:
Exactly the fields nodes use to talk to each other. No more, no less.
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.
Reading the schema should make it obvious which node writes which field.
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:
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.
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 connect nodes. There are two kinds, and the second is where an agent's intelligence lives.
Unconditional wiring. After node A, always go to node B. Like calling functions in fixed order. Example: always log after every step.
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."
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.
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"},
)
Understanding what happens between graph.invoke(state) and the final returned state is essential
for debugging. The loop is the same every time.
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.END sentinel.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.
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.
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).
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. |
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.
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.
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
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.
Run the same node repeatedly, refining until a condition is met.
A later node's output changes what an earlier node does on the next pass.
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.
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).
Read each answer aloud in a couple of sentences.
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.
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.
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.
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.
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.
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.
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.
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.
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.
max_retries.Build it · Chapter 11 · Atlas
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.
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 ↗.