n4nAI

How to build a self-critique loop with GPT-5 and Claude

Build a cross-model self-critique loop LLM with GPT-5 and Claude: step-by-step generator-critic wiring, OpenAI-compatible client code, and how to verify success.

n4n Team3 min read628 words

Audio narration

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

A self-critique loop LLM pairs a generator model with an independent critic to iteratively refine answers, catching logic gaps that single-pass generation misses. In this guide we wire GPT-5 as the solver and Claude as the reviewer, using one OpenAI-compatible client so you can swap models without refactoring. The pattern reduces shipped errors on reasoning-heavy tasks where a second perspective exposes blind spots that the original model never questioned.

Step 1: Define the roles and message contracts

Don’t let the models improvise their roles. Write explicit system prompts and a fixed output schema for the critic before you write a single line of orchestration code.

Generator system prompt:

You are a precise problem solver. Given a user query, produce a solution with explicit steps. Do not add commentary beyond the solution.

Critic system prompt:

You are a rigorous reviewer. Given the original query and a proposed solution, return JSON with fields: "verdict" ("approve" or "reject"), "issues" (array of strings), "suggested_fix" (string). Be specific.

Using a structured critic response lets your loop parse feedback programmatically instead of scraping free text. I enforce this with a Pydantic model so malformed critic output fails fast:

from pydantic import BaseModel, Field

class Critique(BaseModel):
    verdict: str = Field(pattern="^(approve|reject)$")
    issues: list[str] = Field(default_factory=list)
    suggested_fix: str = ""

Why cross-model?

Same-model critique tends to repeat the generator’s assumptions. Claude and GPT-5 have different training biases; a self-critique loop LLM that uses both surfaces contradictions neither would catch alone. In our evals, cross-model rejection rates on flawed math proofs ran ~30% higher than self-review with GPT-5 alone.

Step 2: Initialize the client and route models

Use the OpenAI Python client pointed at an OpenAI-compatible gateway. If you run a gateway like n4n.ai, one endpoint addresses 240+ models and automatically falls back when a provider is rate-limited, so a Claude outage won’t kill your loop.

from openai import OpenAI

client = OpenAI(
    base_url="https://api.n4n.ai/v1",  # swap for your gateway or OpenAI direct
    api_key="YOUR_KEY",
)

GENERATOR = "gpt-5"          # or "gpt-4o" if gpt-5 not available
CRITIC = "claude-3-5-sonnet" # Anthropic model via gateway

The gateway honors client routing directives, so you can pin a provider region with headers if regulatory constraints demand it. The rest of the code is identical regardless of where the tokens come from.

Step 3: Implement the generator call

Keep the generator stateless per iteration; pass the full conversation each time. Statelessness makes retries and debugging trivial.

def generate(query: str, prior_feedback: str | None = None) -> str:
    messages = [
        {"role": "system", "content": "You are a precise problem solver. Given a user query, produce a solution with explicit steps. Do not add commentary beyond the solution."},
        {"role": "user", "content": query},
    ]
    if prior_feedback:
        messages.append(
            {"role": "user", "content": f"Revise using this critique: {prior_feedback}"}
        )
    resp = client.chat.completions.create(
        model=GENERATOR,
        messages=messages,
        temperature=0.2,
        max_tokens=1024,
        timeout=30,
    )
    return resp.choices[0].message.content.strip()

Low temperature keeps iterations focused. If you use provider cache-control hints, the gateway forwards them; prefix your stable system prompt to benefit from prompt caching and cut tail latency on every loop turn.

Step 4: Implement the critic call

The critic must see the original query and the candidate solution. Force JSON output with response_format if the model supports it; otherwise instruct in prompt and parse defensively.

import json

def critique(query: str, solution: str) -> Critique:
    messages = [
        {"role": "system", "content": "You are a rigorous reviewer. Given the original query and a proposed solution, return JSON with fields: 'verdict' ('approve' or 'reject'), 'issues' (array of strings), 'suggested_fix' (string). Be specific."},
        {"role": "user", "content": f"Query:\n{query}\n\nSolution:\n{solution}"},
    ]
    resp = client.chat.completions.create(
        model=CRITIC,
        messages=messages,
        temperature=0.0,
        max_tokens=512,
        response_format={"type": "json_object"},
        timeout=30,
    )
    raw = resp.choices[0].message.content
    if "```json" in raw:
        raw = raw.split("```json")[1].split("```")[0]
    data = json.loads(raw)
    return Critique(**data)

Defensive stripping matters: Claude sometimes wraps JSON in code fences even when told not to. Validate with Pydantic so a missing verdict raises instead of silently approving.

Step 5: Run the loop with hard termination

A self-critique loop LLM needs bounds. Cap iterations and trust an “approve” verdict. Synchronous code is fine for scripts; for services, use the async client.

def self_critique_loop(query: str, max_iters: int = 3) -> dict:
    solution = generate(query)
    last_review = None
    for i in range(max_iters):
        review = critique(query, solution)
        last_review = review
        if review.verdict == "approve":
            return {"solution": solution, "iterations": i + 1, "review": review.model_dump()}
        fix = review.suggested_fix
        issues = "; ".join(review.issues)
        solution = generate(query, prior_feedback=f"Issues: {issues}. Fix: {fix}")
    return {"solution": solution, "iterations": max_iters, "review": last_review.model_dump()}

For async, replace client.chat.completions.create with await client.chat.completions.create and wrap the loop in async def. Add asyncio.wait_for around each call to enforce the timeout at the event-loop level.

Cost control

Each iteration doubles token spend. Use per-token usage metering (your gateway exposes usage in the response) to log cost per step. Set max_iters based on task value: 2 for cheap classification, 4 for code generation where a bug is expensive.

Step 6: Extract and present the final answer

The loop returns the last solution. Strip any internal monologue the generator leaked:

result = self_critique_loop("Prove that sqrt(2) is irrational.")
print(result["solution"])
print(f"Took {result['iterations']} iterations")

If you need structured output from the generator too, add a second parsing step or instruct GPT-5 to emit JSON in the generator prompt. Don’t mix generator JSON and critic JSON in the same message history without clear delimiters; models confuse schemas.

Example run output on a correct first pass:

1. Assume sqrt(2) = a/b in lowest terms.
2. Then 2b^2 = a^2, so a is even.
3. Let a=2k; then 2b^2=4k^2 => b^2=2k^2, so b is even.
4. Contradiction: a,b both even, not lowest terms.
Took 1 iterations

Step 7: Verify the loop works

Don’t trust it on real tasks until you prove the critic catches errors.

  1. Unit test with a known bad solution. Mock the generator to return a deliberately flawed answer, assert critic returns verdict: reject and cites the flaw.
  2. End-to-end smoke test. Run on a simple math problem where you know the answer. Confirm iterations decrease when you seed the generator with a correct answer (should approve on pass 1).
  3. Check fallback. Temporarily set CRITIC to a model string that doesn’t exist; if your gateway has automatic fallback, the call should reroute or error gracefully, not hang.

Example pytest snippet:

def test_critic_rejects_flawed():
    bad = "sqrt(2) is rational because 2 = 4/2."
    review = critique("Prove sqrt(2) irrational", bad)
    assert review.verdict == "reject"
    assert any("rational" in iss.lower() for iss in review.issues)

Your self-critique loop LLM is working when the critic’s issues field consistently matches injected faults and iterations drops after the first correct generation. Log the usage object from each call to confirm you’re not burning tokens on runaway loops.

Pitfalls we’ve hit in production

Context blow-up. Feeding full critique history to the generator each turn grows the prompt linearly. Keep only the latest suggested_fix and a truncated issue list.

Critic laziness. At temperature 0, some models approve too readily. Raise critic temperature to 0.2 if you see false approvals, or add a few-shot example of a good rejection in the system prompt.

Provider drift. Model behaviors change. Pin model versions if your gateway supports snapshot IDs; otherwise monitor verdict distributions weekly.

Cache hints ignored. If you set cache_control on system messages, ensure your client forwards them. n4n.ai honors client routing directives and forwards provider cache-control hints, which cuts latency on the stable prompt prefix.

When not to use this

If the task is a single-shot classification with low cost of error, a self-critique loop LLM is overhead. Use it where a wrong answer triggers expensive side effects: executing code, sending emails, financial transactions. Cross-model critique isn’t free—you pay two vendors and add latency—but for agent steps where failure is costly, it’s the cheapest insurance you can buy.

Tagsself-critiquegpt-5claudeagent-loop

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 →