n4nAI

Designing a reflection loop that knows when to stop

A practical guide to building an agent reflection loop that terminates reliably, with code patterns, stop conditions, and tradeoffs for LLM engineers.

n4n Team5 min read1,047 words

Audio narration

Coming soon — every post will get a voice note here.

An agent reflection loop lets a model critique and revise its own output, but unbounded iteration burns tokens and latency without improving quality. The hard part is not triggering reflection—it’s knowing when the loop should terminate. This guide gives an ordered path to build a stop-aware agent reflection loop that you can ship without guessing.

1. Define termination before you write the loop

Treat stop conditions as first-class requirements, not afterthoughts. A loop needs at least one hard ceiling (max iterations or timeout) and one quality signal (acceptable score or no meaningful delta). Without both, you will watch a model rephrase the same sentence ten times while your p95 latency climbs.

Write the contract first:

from dataclasses import dataclass

@dataclass
class LoopConfig:
    max_iterations: int = 5
    min_score: float = 0.8
    min_delta: float = 0.02   # absolute score change below this = stagnation
    timeout_ms: int = 30000
    token_budget: int = 8000  # hard cap on total tokens across steps

If the loop hits max_iterations, timeout_ms, or token_budget, it returns the best draft seen so far and flags converged=False. Never let the caller assume success. The agent reflection loop is a local optimizer, not a theorem prover.

2. Separate generation from critique

Coupling the writer and the critic in one prompt invites lazy self-approval. Split them. The generator produces a candidate; the critic evaluates against a fixed rubric. The loop then decides whether to revise.

def run_loop(task: str, cfg: LoopConfig):
    draft = generator.complete(task)
    best_draft, best_score = draft, 0.0
    prev_score = None
    stale = 0

    for i in range(cfg.max_iterations):
        critique = critic.evaluate(task, draft)
        if not critique.valid:
            critique.score = 0.0  # fail safe
        if critique.score > best_score:
            best_draft, best_score = draft, critique.score
        if critique.score >= cfg.min_score:
            return best_draft, True, i, best_score
        if prev_score is not None and abs(critique.score - prev_score) < cfg.min_delta:
            stale += 1
            if stale >= 2:
                return best_draft, False, i, best_score
        else:
            stale = 0
        draft = generator.revise(task, draft, critique)
        prev_score = critique.score
    return best_draft, False, cfg.max_iterations, best_score

Keep the critic stateless. Pass only the task, the current draft, and an explicit rubric. Stateful critics drift because they start referencing their own prior notes instead of the artifact.

3. Force structured critique output

Natural-language critiques are unparsable and unreliable for branching. Require JSON. A minimal schema:

{
  "approved": false,
  "score": 0.62,
  "issues": ["missing edge case for null input", "tone too casual"],
  "suggested_fix": "add guard clause and formalize language"
}

Validate before trusting the signal. In Python:

from pydantic import BaseModel, ValidationError

class Critique(BaseModel):
    approved: bool
    score: float
    issues: list[str]
    suggested_fix: str | None = None

def parse_critique(raw: str) -> Critique | None:
    try:
        return Critique.model_validate_json(raw)
    except ValidationError:
        return None

If validation fails, treat it as approved=False with a score of 0.0, not as a crash. A malformed critique that defaults to “keep going” can spin the loop to its hard ceiling every time.

4. Measure delta, not just absolute score

A score above threshold is sufficient to stop, but many tasks plateau below it. The agent reflection loop must detect when further revisions stop moving the needle. Track the last score; if |score - last_score| < min_delta for two consecutive steps, break. This prevents the classic failure where a model makes cosmetic edits that bump the score by 0.005 each turn.

Use a bounded scoring function. For text, a simple rubric-weighted sum works:

def score_draft(draft: str, rubric: dict) -> float:
    total = 0.0
    for criterion, weight in rubric.items():
        if criterion in draft:   # naive check; replace with real eval
            total += weight
    return min(total, 1.0)

For code, execute tests and map pass rate to score. Never let the generator score itself on the same axis it wrote.

5. Use a different model for the judge

Self-critique by the same weights is biased toward agreement. For objective tasks (code correctness, schema compliance), route the critic to a smaller, cheaper model or a different family. You trade nuance for independence.

When you split models, provider reliability becomes a concern. If you run the critic on a separate endpoint, an inference gateway that honors client routing directives—such as n4n.ai—lets you pin the judge to a specific model and automatically fall back when that provider is rate-limited, without rewriting your retry logic. The gateway also forwards cache-control hints, so repeated critique prompts with stable rubrics hit cache and cut cost.

Example client call with routing hint:

import openai

client = openai.OpenAI(base_url="https://api.n4n.ai/v1", api_key="KEY")
resp = client.chat.completions.create(
    model="anthropic/claude-3-haiku",
    messages=[{"role": "user", "content": critique_prompt}],
    extra_headers={"x-routing-pin": "judge-pool"}
)

The app code stays identical if the pinned pool degrades; the gateway shifts to a fallback.

6. Escalate instead of spinning

When the loop exits without convergence, return the best draft and a machine-readable status. Do not silently ship a half-revised answer.

result = {
    "draft": best_draft,
    "converged": bool,
    "iterations": i,
    "final_score": best_score,
    "issues": critique.issues if critique else []
}

Downstream code can decide to show a warning, request human review, or call a stronger model. The agent reflection loop is a local optimizer; it is not a guarantee of correctness.

7. Cap token spend explicitly

Reflection multiplies token usage by N iterations. Meter it. If you use per-token usage metering at the gateway layer, log usage.total_tokens per step and abort the loop when a budget is exceeded.

total_tokens = 0
for i in range(cfg.max_iterations):
    resp = generator.revise_with_usage(task, draft, critique)
    total_tokens += resp.usage.total_tokens
    if total_tokens > cfg.token_budget:
        break  # preserve remaining context for other calls

A token budget is often stricter than max_iterations because later revisions tend to have longer critiques and fuller drafts. Set the budget from your worst-case cost per call, not a guess.

8. Test the loop with simulated critics

Before relying on a live model, mock the critic to emit scripted scores: always-increasing, flat, oscillating, and crashing. Verify the loop stops at the right step and returns the best draft. This catches off-by-one errors in delta logic that only show up after you’ve paid for 10k tokens.

def fake_critic(scores):
    return lambda *a, **k: Critique(approved=False, score=next(scores), issues=[], suggested_fix=None)

Run it in CI. A reflection loop with untested stop logic is a latency bomb.

Common pitfalls

Critic and generator share context verbatim. The critic sees its own prior suggestions and rubber-stamps them. Strip prior critique text from the critic prompt; keep only the task and current draft.

Rubric too vague. “Make it better” yields random scores. Write the rubric as testable criteria: “Handles empty list, returns 400 on missing field, uses passive voice zero times.”

No validation on critique JSON. A malformed response flips the loop into an infinite revise cycle if you default to approved=False and never delta-check. Fail safe, not spin.

Ignoring latency in user-facing paths. A 5-step reflection loop adds 2–4 seconds worst case. For synchronous UX, drop max_iterations to 2 and use async background refinement.

Over-trusting score thresholds. A score of 0.81 on a broken SQL query is still broken. Use the critic score as a heuristic, not a validator. Run real tests where possible.

Forgetting to reset stale counter. If you only check delta against the immediate previous step but reset stale incorrectly, you may exit on a single flat step that follows a jump. Require two consecutive flat steps.

Tradeoffs

Quality vs cost. Each extra iteration buys marginal gains at linear token cost. Most improvement happens in iterations 1–2; beyond 4, you mostly pay for stagnation detection.

Independent judge vs same-model critic. A separate judge costs an extra model call but catches echo bias. For low-stakes text, same-model is fine; for code, separate is mandatory.

Strict delta stop vs hard max. Delta stopping saves tokens but can exit early on slow-ramp tasks. Keep the hard max as backstop.

Synchronous vs asynchronous reflection. Blocking loops simplify code but hurt latency. Fire the first draft immediately, then patch with a refined version if the loop converges within a budget.

Heuristic score vs executable validation. Executable checks (tests, schema validators) are stronger signals but require task-specific harnesses. Heuristics generalize but lie quietly.

An agent reflection loop is only as good as its stop logic. Build the termination contract first, measure deltas, separate the judge, and always return a status the caller can act on. Ship the loop with a budget, not a hope.

Tagsreflection-loopagent-designllm-agentsself-critique

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All self-reflective & self-improving agents posts →