Loop Lab

The same agent loop, built twice.

Once by hand. Once on LangGraph. Both driving one shared, schema-validated MCP tool layer that neither implementation is allowed to bypass. Then killed, timed, counted and crashed on purpose.

Measured on Darwin 25.5.0 arm64, Python 3.14.6, mcp 1.28.1, langgraph 1.2.9, langgraph-checkpoint-sqlite 3.1.0 · source and raw results

00The verdict, before the evidence

It is close to a wash, and the ways it is not a wash are not the ways you would guess.

The LangGraph version came out 23 lines longer than the loop it replaced, pulled in 27 extra dependencies, and added 0.267s to every run. On crash-safe checkpoint and resume, the thing people actually adopt it for, the two are exactly tied: both recover byte-identical state under a real kill -9, and both re-execute the in-flight step exactly once.

What LangGraph genuinely buys is a human-in-the-loop primitive that survives process death, a checkpoint store that keeps the whole thread rather than just the head, and a runaway guard the hand-rolled loop does not have. What it costs is three sharp edges that stay invisible until something goes wrong.

The most useful finding belongs to neither. The durable run record, the one artifact this whole exercise was about protecting, was the one piece of state that neither checkpointer protected, because it lived in the tool layer. Durability is a property of a system, not of an orchestrator.

01The two state machines

Six nodes, one shared policy module, one shared MCP tool layer. Only the wiring and the persistence differ.

By hand

no framework · 254 lines

orient | plan | decide ---+--> act --> verify --> record | | | | +<-------------------------+ | +--> END all criteria met +--> HALT tripwire fired
State
one dict, mutated in place
Persistence
whole state rewritten after every node, temp → fsync → os.replace, plus the directory fsync
Resume
read state.json, re-enter at next_node
Escalation
terminal status plus an --approve flag

On LangGraph

langgraph 1.2.9 · 277 lines

START | orient --> plan --> decide --+--> act --> verify --> record ^ | | +-----|-------------------------+ | +--> END +--> interrupt() (GraphInterrupt)
State
a TypedDict of channels; nodes return partial dicts that get merged
Persistence
AsyncSqliteSaver, committed at super-step boundaries
Resume
re-invoke the same thread_id with None
Escalation
interrupt() then Command(resume=...)

02Are they even doing the same thing?

Every number below is worthless if the two loops behave differently. All four scenarios, both implementations, digests diffed.

bench/parity.py
ScenarioOutcomeStatusSpentFields that differ
baselineidenticaldone$1.80none
irreversibleidenticalescalated$0.60history, records
overspendidenticalescalated$0.60history, records
verify_stuckidenticalescalated$1.40history, records

Both loops stop in the same place, before the same step, having spent the same amount, on every scenario. The three escalating scenarios differ on exactly two bookkeeping fields, and that divergence is finding 04 below, not a defect in the port.

03The scoreboard

Shaded cell means that column measured better. Blank on both sides means a tie.

bench/run_all.sh · raw JSON in bench/results/
MeasurementBy handLangGraph
Code lines for the loop itself
Shared layer, identical for both: 472 lines. Blanks and comment-only lines excluded. With docstrings also stripped it is 231 against 256, so the gap is not my prose.
254277
Python distributions installed
Two throwaway virtualenvs, built from scratch. The framework costs 27 extra.
2956
site-packages on disk
+28.2 MB.
46.9 MB75.1 MB
Import time, median of 7
Importing exactly what each implementation needs.
0.3192s0.5615s
Full baseline run, median of 7
Process start, MCP server spawn, 24 loop steps, exit.
0.7133s0.9804s
Recovers identical state after SIGKILL
Killed 1.0s into a 3.0s tool call, then resumed.
yesyes
Recovers identical state after a crash mid-commit
Process death after the tool returned, before anything was persisted.
yesyes
Side-effecting steps replayed on resume
At-least-once at the node boundary, in both. Neither gives you exactly-once.
1 step, once1 step, once
Canonical checkpoint file is self-sufficient
Copy the file alone and resume from it. state.json is 1,407 bytes; checkpoints.sqlite is 4,096 bytes with 251,352 bytes sitting in a -wal sidecar.
yesno
Persisted state says why the run died
After an injected mid-run exception. Most of this gap is a choice I made, not the framework: the hand-rolled driver owns the state dict and writes a verdict on the way out. LangGraph can do the same in two lines via aupdate_state, tested. The finding is that it does not by default.
yes, status failed plus the messagenot by default, status running
Halts before an irreversible action
And a separate process can answer the halt in both.
yesyes
Escalation written to the run record
Resuming an interrupt re-runs the whole node, including side effects above it.
1 time2 times
Runaway protection
The baseline run needs 24 super-steps. Measured: it fails at 23.
nonerecursion_limit, default 25
Human-in-the-loop primitive
interrupt() plus Command(resume=...), and it survives process death.
14 lines, written for this testbuilt in

04What happened under the kill

Two kill points. The second one is the one that costs money.

Kill point A: SIGKILL inside a tool call

The act step on s2 was slowed to 3 seconds and the whole process group was killed 1.0s in. Nothing had returned yet, so in principle nothing should be lost. Both implementations resumed to byte-identical final state. Neither duplicated a single tool call. The hand-rolled loop left no temp files behind and never wrote a checkpoint that failed to parse.

Kill point B: crash after the side effect, before the commit

Process death at the end of the act node, after the tool returned and before either implementation persisted anything. Both resumed to byte-identical final state, and both re-ran that one step exactly once. At-least-once at the node boundary is the semantics in both cases. Neither gives you exactly-once without idempotent tools.

Then: copy the checkpoint file and try to resume from it alone

This is what a backup script does. The hand-rolled loop's state.json is 1,407 bytes and resumed to completion on its own. LangGraph's checkpoints.sqlite is 4,096 bytes, because SQLite was in WAL mode and the entire run was sitting in a 251,352 byte -wal sidecar. Copying the file the name points at loses the run completely: FAILED EmptyInputError: Received no input for __start__

Not LangGraph's bug. Standard SQLite, inherited from the checkpointer. Worth knowing anyway, because the filename tells you something untrue.

05Three sharp edges

All three are defensible design consequences. All three will surprise someone in production.

interrupt() discards the interrupting node's state update

When a tripwire fires, LangGraph's persisted state says nothing about it. The reason lives only on the pending task's interrupt payload, and the caller has to know to go and read it back. The hand-rolled loop commits status: escalated and the tripwire text before it stops, so its own state says why it stopped.

Resuming an interrupt re-runs the whole node, side effects and all

The escalation was written to the durable run record 2 times by LangGraph and 1 time by the hand-rolled loop. The code reads correctly, the run completes correctly, the final state is correct, and the only evidence of the double write is in the tool layer's own append-only log.

Verified fix: move interrupt() above the side effect. Re-measured: back to 1.

The default recursion limit is one super-step from the baseline run

The baseline scenario needs 24 super-steps. The default recursion_limit is 25. Measured directly: it completes at 24 and raises GraphRecursionError at 23. One more verify retry is four more super-steps. The flip side, stated plainly: the hand-rolled loop has no runaway protection at all, and would have spun forever.

06When the tools go wrong

Three faults injected at the MCP boundary. Both loops saw an identical error surface and the same exit code. What differed was what survived on disk.

bench/failure_modes.py
FaultWhat both loops sawBy hand: persisted statusLangGraph: persisted status
Tool returns the wrong type
act returns cost_usd as the string "0.40"
ToolErrorfailedrunning
Tool never answers
act sleeps 600s against a 2s client deadline
ToolTimeoutfailedrunning
Tool raises
act throws inside the handler
ToolErrorfailedrunning

The schema violation never reached either loop as bad data. The frozen tool-defs.json is loaded verbatim as both inputSchema and outputSchema, and the MCP server rejected its own handler's output before it went back on the wire. That is the tool layer earning its keep, independent of either framework.

07What this does not measure

This list matters as much as the results.