← all posts
// agents · langgraph

Controlled loops: cycles without the infinite part

Cycles are the honest answer to "why a graph runtime at all." A pipeline that only flows forward is a chain wearing extra ceremony. The moment an edge points backwards (draft, critique, revise, critique again), you have something a chain can't express and something that can run forever. The mental model post covers the shape. This one is about the forever part.

Our due-diligence reports run a critique-revise loop per section: a drafting node writes, a critic node reads the draft against the source findings, and a conditional edge either routes back to the drafter with the critique attached or exits toward compile. Retry-with-feedback for flaky tool calls is the same topology with a different payload. On the whiteboard, elegant.

the night it ping-ponged

Late May, nightly batch, about 40 companies. Morning: 39 reports and one GraphRecursionError.

The trace read like two polite colleagues undoing each other's edits. The critic flagged a risk paragraph as too assertive, so the reviser softened it. The critic then found it too hedged and asked for directness, so the reviser swung back to within a few words of the original. I'd raised recursion_limit to 80 back when the graph outgrew the default of 25; after the run's normal steps, that left the loop room for roughly 25 laps, and it spent every one of them. A few dollars of tokens, unattended, overnight, oscillating between two phrasings of one sentence.

The runtime did its job; my exit condition hadn't.

exit conditions that actually fire

The original exit was "the critic reports no blocking issues." The flaw is structural, and I should've seen it coming: an LLM critic can always find something, because you asked it to critique and it's obliging. Same failure mode I keep circling in feedback loops: the judge is just another model, with opinions that wobble.

If your loop exits when the critic feels satisfied, you've built a loop that terminates by luck.

What I run now, all three at once:

  • a lap counter in state, incremented by the reviser, hard cap at 3 (it lives in state, so it survives a checkpoint resume instead of resetting to zero)
  • a convergence check that diffs the artifact, covered below
  • recursion_limit as the seatbelt behind both, sized so it should never fire

Structured verdicts helped as much as the caps. The critic must emit severities now, and only blocking issues route backwards; style nits get recorded and ignored. Average laps per section dropped from about 2.8 to 1.4 the week I changed that.

convergence is a diff, not a feeling

The ping-pong taught me the actual lesson: measure the artifact, never ask the model whether it feels done.

def should_loop(state):
    if state["revisions"] >= 3:
        return "compile"
    if ratio(state["draft"], state["prev_draft"]) > 0.92:
        return "compile"
    return "revise"

ratio is difflib's SequenceMatcher, straight from the stdlib (zero tokens, no latency worth mentioning). Two consecutive drafts above 0.92 similarity mean the loop is polishing commas, and polishing never justifies another full-context lap. I also keep hashes of the last two drafts: if draft N matches draft N-2, that's oscillation, exit immediately. That one check would have saved my May night, which is why it's now the first thing I add to any loop anywhere.

where I've landed on loops

Loops earn their keep when a second pass measurably improves the artifact, and mine mostly stop improving after lap two, somewhere around 80% of sections judging by a month of spot checks. Cost grows linearly with laps while quality flattens fast, so a cap of 3 is where the curve was pointing anyway. There's a quieter failure I watch for too: heavy critique loops sand the voice off prose. Converged and beige is still a failure, just a polite one.

For agents that run longer than a night, this whole discipline is the entry fee: the long-running agents problem is this exact problem with more zeros on it.

Cap everything.

A cycle that can't prove it's converging is an infinite loop on a payment plan, and the recursion limit is the repo man. Useful. But you'd rather never meet him.

#langgraph#loops#reliability