Graph Engineering: Old Structure, New Nodes, and a Working Agent Graph in LangGraph
In July 2026 the industry gave a new name to an old idea: design the graph of steps around your agents, not just the loop inside one. What graph engineering actually means, when a single loop is enough, a review graph with parallel reviewers, a verifier, a bounded fix loop and a human gate that survives restarts, and the failure modes I keep finding in agent graphs.

Bản tiếng Việt: Graph engineering: cấu trúc cũ, node mới, và một agent graph chạy được với LangGraph. Part 9 of the series. Previous: A2A. Next: Orca.
Every few months our field renames what it is doing. Prompt engineering became context engineering, which became harness engineering, and in June someone called the discipline of designing one agent's tool loop "loop engineering". In July it was graphs' turn. Peter Steinberger, who builds OpenClaw, asked on X whether we were "still talking loops or did we shift to graphs yet?", and within hours Hamel Husain had posted an article titled "Loop Engineering Is Dead. Enter Graph Engineering." Both were at least half jokes. A week later the term had several competing definitions, a stream of explainer posts, and Harrison Chase of LangChain saying he was not sure what it meant beyond "basically just LangGraph".
Here is where I stand. The name is marketing. The practice is old: state machines, workflow engines, Airflow DAGs and BPMN diagrams have all been graphs of steps with explicit transitions. What is new, and why the old discipline matters more now rather than less, is what sits inside the nodes. A node used to be a function that did exactly what its code said. Now a node can be an agent that interprets its task, and two runs of the same node can do different things. Explicit structure around that is not bureaucracy. It is how you keep a system made of non-deterministic parts predictable enough to ship.
What a graph is, for agents
An agent graph has three parts.
Nodes do work. A node can be plain code (run the tests, call an API), a single model call (classify this ticket), a tool call, or a full agent loop from part 1 with its own tools.
Edges decide what runs next. A fixed edge always goes to the same node. A conditional edge looks at the state and picks. A fan-out edge starts several branches in parallel, and the number of branches can be decided at runtime.
State is what flows through the graph: a typed record that nodes read and update. The important design decision is how concurrent updates merge. If three parallel reviewers each return a list of findings, do they overwrite each other or append? You decide that per field with a reducer.
Anthropic's "Building effective agents" post drew a line between workflows, where code decides the path, and agents, where the model decides. A graph is how you choose that line per step instead of once for the whole system. The structure you are sure about goes into edges. The judgment you cannot write down goes into nodes.
When a loop is enough
Most of what I build is still one loop. A single agent with good tools, a good context strategy and a harness (parts 1 to 4) handles a surprising range of work, and every node you add is another prompt to maintain, another place for state to go wrong, another thing to evaluate.
I reach for a graph when one of these shows up in traces or evals:
- Independent subtasks that should not share context. A security review and a performance review of the same diff are better done by two calls that cannot see each other's reasoning, both for quality and so one branch's mistake does not contaminate the other.
- A step that must happen, every time. Tests after a fix. A compliance check before sending. If you rely on the model to remember, it will forget on the one run that matters. An edge does not forget.
- A verifier that is not the worker. The agent that wrote the code is the worst judge of it. A separate node with a separate prompt, ideally a separate model, or better still a deterministic check.
- A pause for a human that can last hours. Approval gates need durable state, not a process waiting on
input(). - A loop that needs a hard bound. "Try to fix it" is fine. "Try to fix it forever" is a bill.
If none of these apply, stay with the loop.
A review graph
Here is the shape we use for agent-assisted code review, reduced so it runs without API keys. Three reviewers look at a diff in parallel. A verifier keeps only findings that are real. If something is confirmed, a fixer edits the diff and the tests run; if tests fail it tries again, at most three times. Every path ends at a human who approves the merge.
┌─ review(security) ─┐
START ─┼─ review(perf) ─────┼─► verify ─► fix ─► tests ─► human
└─ review(correct) ──┘ │ ▲ │ ▲
│ └─fail──┘ │
│ (max 3) │
└──── nothing confirmed ──┘
I use LangGraph because it makes all of this explicit and has durable execution built in; the same design works in Google ADK, Microsoft's Agent Framework or plain Python with a state table. Install with pip install langgraph.
# review_graph.py — a small agent graph: fan-out reviewers, a verifier, a fixer loop, a human gate
import operator
from typing import Annotated, Literal, TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import Command, Send, interrupt
class Finding(TypedDict):
reviewer: str
severity: Literal["low", "medium", "high"]
issue: str
class State(TypedDict, total=False):
diff: str
findings: Annotated[list[Finding], operator.add] # reducer: parallel branches append, never overwrite
confirmed: list[Finding]
fix_attempts: int
tests_passed: bool
approved: bool
REVIEWERS = ["security", "performance", "correctness"]
# --- nodes -------------------------------------------------------------------
# Each node is either deterministic code or one bounded agent call. Replace the
# bodies marked LLM with your agent loop from part 1; the graph does not change.
def fan_out(state: State) -> list[Send]:
"""One Send per reviewer: they run in parallel, each with only the diff."""
return [Send("review", {"diff": state["diff"], "reviewer": r}) for r in REVIEWERS]
def review(payload: dict) -> dict:
# LLM: "You are the {reviewer} reviewer. List concrete problems in this diff as JSON."
diff, who = payload["diff"], payload["reviewer"]
found = []
if who == "security" and "eval(" in diff:
found.append({"reviewer": who, "severity": "high", "issue": "eval() on request data"})
if who == "performance" and "for row in rows" in diff:
found.append({"reviewer": who, "severity": "low", "issue": "N+1 query inside loop"})
return {"findings": found}
def verify(state: State) -> dict:
# Deterministic gate (or a reviewer from a *different* model): keep only what can be reproduced.
confirmed = [f for f in state.get("findings", []) if f["severity"] in ("medium", "high")]
return {"confirmed": confirmed}
def route_after_verify(state: State) -> Literal["fix", "human_review"]:
return "fix" if state["confirmed"] else "human_review"
def fix(state: State) -> dict:
# LLM: coding agent with the confirmed findings as its task list.
diff = state["diff"].replace("eval(", "json.loads(")
return {"diff": diff, "fix_attempts": state.get("fix_attempts", 0) + 1}
def run_tests(state: State) -> dict:
return {"tests_passed": "eval(" not in state["diff"]}
def route_after_tests(state: State) -> Literal["fix", "human_review"]:
if state["tests_passed"] or state.get("fix_attempts", 0) >= 3: # hard stop: three tries, then a human
return "human_review"
return "fix"
def human_review(state: State) -> dict:
decision = interrupt({
"diff": state["diff"],
"confirmed": state.get("confirmed", []),
"tests_passed": state.get("tests_passed"),
"question": "Merge this change?",
})
return {"approved": bool(decision.get("approve"))}
# --- wiring ------------------------------------------------------------------
builder = StateGraph(State)
builder.add_node("review", review)
builder.add_node("verify", verify)
builder.add_node("fix", fix)
builder.add_node("run_tests", run_tests)
builder.add_node("human_review", human_review)
builder.add_conditional_edges(START, fan_out, ["review"])
builder.add_edge("review", "verify") # join: verify runs once all reviewers finish
builder.add_conditional_edges("verify", route_after_verify)
builder.add_edge("fix", "run_tests")
builder.add_conditional_edges("run_tests", route_after_tests)
builder.add_edge("human_review", END)
graph = builder.compile(checkpointer=InMemorySaver()) # use SqliteSaver/PostgresSaver outside a demo
if __name__ == "__main__":
config = {"configurable": {"thread_id": "pr-1842"}, "recursion_limit": 25}
diff = "def handler(req):\n data = eval(req.body)\n for row in rows:\n db.get(row)\n"
for step in graph.stream({"diff": diff}, config, stream_mode="updates"):
print(step)
paused = graph.get_state(config)
print("\npaused at:", paused.next, "| question:", paused.tasks[0].interrupts[0].value["question"])
final = graph.invoke(Command(resume={"approve": True}), config)
print("approved:", final["approved"], "| fix attempts:", final["fix_attempts"])
print("findings kept:", [f["issue"] for f in final["confirmed"]])
The output, trimmed slightly:
{'review': {'findings': [{'reviewer': 'security', 'severity': 'high', 'issue': 'eval() on request data'}]}}
{'review': {'findings': [{'reviewer': 'performance', 'severity': 'low', 'issue': 'N+1 query inside loop'}]}}
{'review': {'findings': []}}
{'verify': {'confirmed': [{'reviewer': 'security', 'severity': 'high', 'issue': 'eval() on request data'}]}}
{'fix': {'diff': 'def handler(req):\n data = json.loads(req.body)\n ...', 'fix_attempts': 1}}
{'run_tests': {'tests_passed': True}}
{'__interrupt__': (Interrupt(value={..., 'question': 'Merge this change?'}, ...),)}
paused at: ('human_review',) | question: Merge this change?
approved: True | fix attempts: 1
findings kept: ['eval() on request data']
The reviewer bodies are stubs on purpose. In production each one is a model call with a reviewer-specific prompt, and fix is a coding agent. The graph, which is what this post is about, stays exactly the same.
Five decisions in that file
The fan-out is decided at runtime. fan_out returns a list of Send objects, one per reviewer, and each branch receives only what it needs: the diff and its role. Not the other reviewers' findings, not the conversation history. If tomorrow the reviewers depend on the languages in the diff, fan_out builds that list from the diff and nothing else changes.
The reducer is the concurrency contract. findings is Annotated[list[Finding], operator.add], so three parallel branches append to one list. Without the reducer, LangGraph refuses the update with InvalidUpdateError: At key 'findings': Can receive only one value per step, which is the right behaviour. Silent last-write-wins is how findings disappear.
The join is an ordinary edge. review → verify waits for all the parallel review branches before verify runs once. You get a barrier without writing synchronisation code.
The loop has two bounds. route_after_tests stops after three fix attempts and sends the work to a human whether tests pass or not. recursion_limit in the config is the backstop if I get the routing wrong: LangGraph stops the run after that many steps. Every cycle in an agent graph needs a bound you chose, and a second one you did not have to think about.
The human is a node, not an afterthought. interrupt() pauses the graph, saves its state and returns the payload to whoever is running it. Command(resume=...) continues from the same point, and the value becomes the return value of interrupt(). Because there is a thread_id, "the same point" survives anything the checkpointer survives.
Pausing for hours
An in-memory checkpointer is fine for a demo and useless for an approval that waits overnight. Swap it for SQLite or Postgres (pip install langgraph-checkpoint-sqlite, or langgraph-checkpoint-postgres) and the pause survives a restart. This script starts a review in one process and resumes it in another:
# durable.py — same graph, checkpointed to SQLite: pause in one process, resume in another
import sqlite3
import sys
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.types import Command
from review_graph import builder
graph = builder.compile(checkpointer=SqliteSaver(sqlite3.connect("checkpoints.db", check_same_thread=False)))
config = {"configurable": {"thread_id": "pr-1842"}, "recursion_limit": 25}
if sys.argv[1] == "start":
diff = "def handler(req):\n data = eval(req.body)\n"
graph.invoke({"diff": diff}, config)
print("waiting for a human at:", graph.get_state(config).next)
else: # "approve" or "reject", possibly hours later, from a webhook or a CLI
final = graph.invoke(Command(resume={"approve": sys.argv[1] == "approve"}), config)
print("approved:", final["approved"], "| diff now:", final["diff"].splitlines()[1].strip())
$ python durable.py start
waiting for a human at: ('human_review',)
$ python durable.py approve
approved: True | diff now: data = json.loads(req.body)
In our deployment the start side is triggered by a pull-request webhook, the interrupt payload is posted to the PR as a comment with two buttons, and the button handler is the approve side. The graph does not know any of that. It only knows its thread id.
One rule from the LangGraph docs deserves to be quoted because it has bitten us: when a graph resumes, the interrupted node runs again from the top, so any code before interrupt() executes twice. Side effects before an interrupt must be idempotent, or move them after it, or into their own node.
The failure modes I keep finding
When I review an agent graph someone else built, these are the problems, roughly in order of frequency.
Hidden dependencies between parallel branches. Two branches that look independent both write to the same file, or one quietly assumes the other has run. Parallel branches should share inputs and nothing else. If they need each other's output, they are sequential.
A monolithic orchestrator. One "supervisor" node with a forty-line prompt that decides everything at every step. That is a loop wearing a graph costume, and harder to debug than either. If the routing logic can be written as code, write it as a conditional edge.
Unbounded evaluator loops. A critic sends work back to a worker until the critic is satisfied. Without a hard limit this runs until the money runs out, and in my experience the quality curve is flat after the second round.
Approval fatigue. A human gate on every step teaches the human to click approve without reading. Put gates on irreversible actions and on the final result. Part 4's risk tiers decide where.
Retries that repeat irreversible actions. A node that sends an email and then fails on the next line will send the email again on retry. Give irreversible effects an idempotency key, or put them in a node of their own at the end.
Full state to every node. Passing the entire state, including every previous agent's transcript, to every node is context engineering failing at graph scale (part 3). Each node should get a projection of the state with only what its task needs.
Overengineering. Nine nodes for a task a single loop did fine. The cost is not only tokens. Every node needs its own evals, and a graph nobody evaluates per node is a graph nobody understands.
Agreement is not verification
The most useful criticism of the graph-engineering wave is this one, and several people made it in July: more agents in more boxes do not make a system more correct. Three reviewers running on the same model, with the same context and the same blind spots, will happily agree on the same wrong answer. A graph of such agents can produce well-organised mistakes at scale, and the structure makes the output look more trustworthy than it is.
The defence is to anchor the graph to things that cannot be talked into agreeing. In order of preference:
- Deterministic checks. Tests, type checkers, linters, schema validation, CI gates. In the example,
run_testsis the node I trust most, because it does not have opinions. - A different model as verifier. If a model must judge, use a different model family from the one that did the work, with a prompt that asks it to find problems rather than to confirm.
- Evidence from outside the system. Retrieval against the real source, a query against production data, a comparison with last month's numbers.
- A human at the end, looking at a short summary of what was confirmed and why, not a wall of transcripts.
How I would start
Start with one loop and the evals from part 5. Add a node only when a trace or an eval shows a specific failure the node would fix, and add its eval at the same time. Make the first node you add a real verifier, ideally a deterministic one. Give every cycle a hard stop from day one. Use a durable checkpointer before the first human gate goes live, not after the first lost approval.
The graph is the part of the system you design. The nodes are the part the model improvises. Keep the improvisation inside boxes you understand.
The next three posts move from designing agent systems to running many coding agents at once on a real codebase, starting with Orca.
References
- Sydney Runkle and Harrison Chase, 3 Years of Graph Engineering with LangGraph, LangChain blog (July 2026)
- LangGraph docs, Interrupts (including the rule on side effects before
interrupt) - Anthropic, Building effective agents (workflows versus agents)
- LangGraph on GitHub