A loop asks the model “what should I do next?” on every turn. A graph writes down the moves that are allowed and lets the model choose inside them — and that single change is what turns a demo agent into something you can debug, resume, and trust with a refund.
The first support agent I put in front of real customers issued the same $80 refund twice. Not because the model was dumb — because the run crashed after the refund call and the retry started from the top, re-read the ticket, reached the same conclusion, and paid out again. There was no record of where it had got to, because there was no “where”: the whole agent was a while loop with a list of tools, and its only memory of progress was a message history that the retry threw away.
The fix wasn’t a better prompt or a bigger model. It was drawing the thing. Once the agent was an explicit graph — classify, check, decide, gate, execute — two of the three holes closed on their own: execute became a node you can only reach through gate, and a crashed run picked up at the node it had finished instead of at the top. The third hole — making the payout itself safe to replay — stayed my job, and I’ll come back to it. But it went from a mystery to one bounded problem with a name.
That practice has a name now too: graph engineering. It’s the discipline of designing an agent’s control flow as an explicit graph — nodes that do work, edges that decide what runs next, and one state object that threads through both — instead of letting the flow emerge from whatever the model felt like doing this time.
Prompt engineering asks “what do I say?” Context engineering asks “what should be in the window?” Graph engineering asks “which steps exist, and what is allowed to follow what?” The answer stops being a paragraph in a system prompt and becomes a data structure you can read, diff, test, and draw on a whiteboard.
Chain, loop, graph
Three shapes cover almost every agent people ship. They’re not competing philosophies — they’re different amounts of structure, and the right one depends on how much of the flow you actually know in advance.
| Shape | Who decides what runs next | Good at | Falls over when |
|---|---|---|---|
| Chain | You, at write time | Known pipelines: extract → summarize → format | Anything needs to branch, retry, or wait |
| Loop | The model, every turn | Open-ended work where the steps aren’t knowable | You need a guarantee — approval, budget, ordering |
| Graph | You define the options; state or the model picks one | Real workflows: branches, parallel checks, gates, retries | The task is genuinely one straight line (then it’s overhead) |
The three parts
Every graph framework is built on the same three ideas. The code here is LangGraph; learn the ideas and the API is a detail — there’s a survey of what else implements this shape near the end.
State — the one object that moves
A typed dict every node reads from and returns updates to. Nodes never mutate it; they return a patch and the framework merges it. That’s what makes a node replayable.
Nodes — the units of work
A node is just a function. Some call a model, most don’t: a database lookup, a validation, a Stripe call, another whole graph. If a step can be plain code, make it plain code — deterministic nodes are the cheap, fast, testable parts of your agent.
Edges — what may follow what
A fixed edge always goes to the same place. A conditional edge runs a function over the state and returns the name of the next node. That function is your routing policy, and it lives in version control instead of in a paragraph of prompt.
Here’s the whole idea in twenty-odd lines — a ticket comes in, gets classified, gets answered:
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
ticket: str
intent: str
reply: str
def classify(state: State) -> dict:
return {"intent": intent_of(state["ticket"])} # LLM call, or a rule
def answer(state: State) -> dict:
return {"reply": draft(state["ticket"], state["intent"])}
builder = StateGraph(State)
builder.add_node("classify", classify)
builder.add_node("answer", answer)
builder.add_edge(START, "classify")
builder.add_edge("classify", "answer")
builder.add_edge("answer", END)
graph = builder.compile()
graph.invoke({"ticket": "where is order A-91?"})
# {'ticket': '...', 'intent': 'tracking', 'reply': 'It shipped Tuesday...'}
That’s still a chain — a straight line. It becomes a graph the moment one edge starts making a decision.
The engineering lives in the edges
Nodes are the boring part. Anyone can write a function that calls a model. The design work — the part that decides whether your agent is trustworthy — is in what you allow to follow what.
def where_next(state: State) -> str:
# a pure function of state: no model call, no I/O, trivially testable
if state["intent"] == "refund": return "lookup_order"
if state["intent"] == "tracking": return "shipping"
return "escalate"
builder.add_conditional_edges(
"classify", # the node we are leaving
where_next, # what decides
["lookup_order", "shipping", "escalate"], # where it may go
)
That third argument is the map from what the function returns to where the run goes. Return node names, as here, and a plain list is enough. Return abstract labels instead — "too_big", "ok" — and you pass a dict that maps each label to a node, which is worth doing once the label and the node stop meaning the same thing.
Now look at what you got for free. Routing is a function you can run over 200 past tickets in a unit test, with no model in the loop. A reviewer can see, in a diff, that escalate was added as the default. And refund can no longer happen from anywhere — there is exactly one path to it, and it goes through the gate.
When a routing decision needs the model’s judgement, keep the model inside a node that writes its verdict into state, and let the edge read that field. Edges stay pure and cheap; the model’s decision becomes a value you can log, evaluate, and replay.
Four patterns you’ll use again and again
Nearly every production graph is a mix of these four. They’re worth knowing by name, because once you see the shape you stop reinventing it.
1 · Router
One node, several possible successors, a function that picks one. This is the pattern from the last section — the workhorse, and usually the first edge you write.
2 · Fan-out and join
Three independent checks have no reason to run one after another. Point three edges out of one node and they run in parallel; point them all at one successor and it runs once, after the slowest finishes. The catch: three nodes now write to the same state at the same time, so any field they share needs a reducer — a merge rule — or the last writer silently wins.
3 · Guarded cycle
Draft → verify → draft again is how you get self-correction. A cycle without a counter is how you get a bill. Every cycle needs a written exit: attempts, budget, or wall-clock.
4 · Human checkpoint
A node that stops mid-run, hands a payload to a person, and continues from that exact spot when the answer comes back — even if that’s tomorrow, from a different process. This is the pattern that makes an agent safe to point at money; how a paused run survives that wait is the next section.
The fan-out reducer is the one that bites people, so it’s worth seeing:
import operator
from typing import Annotated, TypedDict
class State(TypedDict):
findings: Annotated[list, operator.add] # concatenates: parallel-safe
risk: str # no reducer: last write wins
for check in ("fraud", "stock", "policy"):
builder.add_edge("split", check) # all three start together
builder.add_edge(check, "decide") # decide runs once, after all three
def fraud(state: State) -> dict:
return {"findings": ["fraud: clean"]} # appended, not overwritten
And the guard on a cycle. The counter is the whole trick, so the node that retries is the node that has to increment it:
def draft(state: State) -> dict:
return {"reply": write(state), "attempts": state["attempts"] + 1}
def after_verify(state: State) -> str:
if state["passed"]: return "done"
if state["attempts"] >= 3: return "human" # give up honestly
return "retry"
builder.add_conditional_edges("verify", after_verify,
{"done": END, "retry": "draft", "human": "escalate"})
Durability falls out for free
Here’s the part that fixed my double refund. Because the graph knows what a “step” is, the framework can save the state after every node — a checkpoint — keyed by a thread id you choose. Crash after refund and the retry resumes after refund, not from the top.
The same mechanism gives you the human checkpoint: pausing is just a checkpoint that nobody has resumed yet.
from langgraph.types import interrupt, Command
from langgraph.checkpoint.postgres import PostgresSaver
def approval_gate(state: State) -> dict:
decision = interrupt({ # whatever your UI needs to show
"action": "refund", "order": state["order"], "amount_usd": state["amount"],
})
return {"approved": decision == "approve"}
with PostgresSaver.from_conn_string(DB_URL) as cp:
cp.setup() # creates the tables, once at deploy
graph = builder.compile(checkpointer=cp) # without this it can pause, never resume
config = {"configurable": {"thread_id": "ticket-8412"}}
graph.invoke({"ticket": "refund my order A-91"}, config)
graph.get_state(config).next # ('approval_gate',) - parked, nothing lost
# ...two hours later, a different process, same thread_id
graph.invoke(Command(resume="approve"), config)
A node that is not safe to run twice — the third hole from the opening story. Checkpointing means a node can be replayed after a crash, so anything with a side effect — charging a card, sending mail, writing a row — needs an idempotency key derived from state. “Resumable” and “idempotent” are not the same promise, and the gap between them is exactly where duplicate refunds live.
Worked example: the refund graph
Put the four patterns together and you get something that looks like a real system, because it is one. Same task as the opening story — refund a customer — drawn instead of improvised.
Four things in that drawing are what separate it from a diagram someone sketched once and left behind:
| What you add | What it buys you |
|---|---|
| A checkpoint after every node | A crash at execute resumes at execute, not back at step one |
| One thread id per ticket | The gate can wait two days, and the run survives a deploy in between |
| A routing function, not a prompt | Replay 200 past tickets through the router in a test, with no model calls |
| A retry edge with a cap | Two tool timeouts get absorbed; the third escalates instead of looping |
The loop version
One while loop, eight tools, a 900-word system prompt asking it to please get approval for refunds over $50. It usually does. When it doesn’t, the log is a wall of messages and the answer to “why did it refund without asking?” is we think the instruction got lost around turn 14. A crash mid-run starts over. Checks run one at a time: 6–8s per ticket.
The graph version
Refunds over $50 cannot reach execute without passing approval_gate — not as an instruction, as a topology. The three checks run in parallel: ~2s. A crash resumes at the last completed node. “Why did it escalate?” is answered by one line in the checkpoint history, and the routing rule that produced it is eight lines of Python in a diff.
When not to reach for a graph
Graphs are not free. You pay in a state schema, a framework, and a mental model your teammates have to learn. Skip it when:
- It’s genuinely a straight line. Extract → summarize → post has no branches. Write the three function calls.
- The steps really aren’t knowable. Open-ended research is a loop with good tools. A graph whose every edge says “ask the model” is a loop wearing a costume — and a slower one.
- You’re still finding the shape. Prototype as a loop, watch where it actually goes, then draw the graph you observed. Designing the topology before you’ve seen one real run is how you get twelve nodes where four would do.
Nodes that exist only to pass state to the next node. Conditional edges with one destination. A state object with fourteen fields where four are ever read. If your graph diagram needs a legend, you’ve modelled your org chart instead of your workflow — collapse it until every node earns its box.
The shape outlives the framework
Every example here is LangGraph, because it’s what most people meet first. But nothing in the last four sections is LangGraph’s idea. Explicit states, allowed transitions, retries with a cap, a step that waits for a human — workflow engines were doing all of that a decade before anyone put a model inside a node. What’s new is only that some of the nodes now call one.
| Where you’ll meet the shape | What it calls the pieces | How explicit the topology is |
|---|---|---|
| LangGraph | State, nodes, edges, checkpointer | Fully — the graph is the artifact you write and review |
| OpenAI Agents SDK | Agents and handoffs | Control passes agent to agent and context rides in the conversation, not a typed state object — fewer primitives, less written down |
| Role-based frameworks (CrewAI and kin) | Agents with roles and goals | Implied by descriptions — quick to start, hard to point at when it misroutes |
| Durable execution (Temporal, Restate, DBOS) | Workflows and steps | The checkpoint half without the model half — often paired with one of the above |
| Step Functions, BPMN engines | States and transitions | The same shape, pre-LLM, with retries and approval steps already in the box |
Choosing between them is a real decision, and “most explicit” isn’t automatically right — a handoff framework gives you less to configure, which is the better trade when the steps genuinely aren’t knowable. What ports is the three questions: what are the nodes, what is allowed to follow what, and where does a run park when something has to wait? Answer those and you can carry the design to whatever you use next quarter. Skip them and no framework saves you.
The durable-execution engines land on exactly the caveat from earlier. Temporal replays a workflow’s decisions exactly once, but the work those decisions trigger is at-least-once by default — so anything with a side effect still needs an idempotency key. That isn’t a LangGraph quirk you can switch tools to escape. It’s what “resumable” costs, in every system that sells it.
How to draw your first one
The fastest path from “I have an agent” to “I have a graph”, in the order that actually works:
- 1 · List the decisions, not the steps. Every place the flow can go two ways is an edge. Everything between two decisions is a node.
- 2 · Write the state first. Four or five fields. If you can’t name what moves between nodes, you don’t have the shape yet.
- 3 · Make every step you can deterministic. Lookups, validation, formatting — plain functions. Reserve model calls for judgement.
- 4 · Put a gate in front of anything irreversible. Money, email, deletes. A human checkpoint costs one node.
- 5 · Add a checkpointer on day one. Not when it breaks in production — you’ll want the run history the first time you debug a route.
The four disciplines stack. The harness decides what the model can touch; context engineering decides what it sees; loop engineering decides how the whole thing repeats; graph engineering decides what is allowed to happen next. It’s the layer that turns “the agent usually does the right thing” into “the agent cannot do the wrong thing” — and that sentence is the entire difference between a demo and production.
If you’re preparing for an FDE or AI engineering interview, this is worth practising out loud: given a workflow, name the nodes, name the edges, say where the checkpoint goes and why. It’s the fastest way to show you’ve shipped one.
LangChain, “3 Years of Graph Engineering with LangGraph” — how the framing emerged from real deployments · the StateGraph reference for the edge API used above · Analytics Vidhya, “Graph Engineering for AI Agents: A Complete Guide in LangGraph” · Anthropic, “Building effective agents” — the workflow patterns these topologies formalise · our companion posts on harness, context and loop engineering.