Accueil / Articles

Ingénierie des boucles : quatre boucles imbriquées pour des agents IA auto-améliorants

Ingénierie des boucles pour des agents d’IA auto-amélioratifs : quatre boucles imbriquées (ReAct, tentative répétée, sondage de file d’attente, montée en pente) ainsi qu’un juge, un compteur de budget et une vérification de modification.

Éditeur : REACTAPP.TOP32 min de lecture7374 mots

Sources: Loop Engineering: Building Self-Improving AI Agents with Four Nested Loops

Pendant environ deux ans, travailler avec un modèle a signifié alterner les tâches. Un ingénieur rédige une instruction, lit la réponse obtenue, puis fournit manuellement le contexte suivant. Cette routine comporte un coût caché, une sorte de taxe d’orchestration, car la vitesse de l’ensemble du processus est limitée par la rapidité avec laquelle une personne peut examiner les éléments. Pour y remédier, il faut changer de focale : cesser de manipuler l’outil pour commencer à concevoir le système qui le gère.

Trois idées structurent ce changement.

  • L’influence monte d’un cran. Le rôle passe de celui de celui qui rédige des instructions précises à celui de celui qui conçoit des boucles de répétition, pour finalement atteindre une position similaire à celle d’un directeur. L’être humain définit l’objectif et la condition d’arrêt, tandis que l’agent prend en charge chaque étape du parcours entre eux.
  • Les boucles autonomes possèdent une anatomie reconnaissable. Elles trouvent du travail, le distribuent aux sous-agents, vérifient les résultats par des tests indépendants et enregistrent leur état, ce qui permet à l’humain de se retirer du cycle de tri, de correction et de vérification.
  • La conception des objectifs occupe la position centrale. C’est ici que le fonctionnement interne de la boucle prend du recul. AlphaEvolve, qui a battu les records de multiplication matricielle en vigueur depuis 1969, ainsi que Kiro d’Amazon, qui transforme des objectifs de haut niveau en exigences et spécifications, illustrent ce schéma : il suffit de bien définir la fonction de scoring pour que la machine trouve la réponse.

Pour concevoir un boucle autour d’un agent, il faut disposer d’un modèle mental solide de cet agent à l’intérieur. Ainsi, l’ordre de construction logique commence par un travailleur simple qui exécute des commandes d’outil, on ajoute ensuite un deuxième modèle qui évalue ses résultats (afin que le système cesse d’évaluer ses propres travaux), on relie les deux en boucle, et on enregistre les gains sur disque, car les modèles oublient tandis qu’un répertoire ne l’ fait pas. Ensuite viennent des mécanismes de défense contre les agents qui manipulent les métriques, du type de tricherie observée lors des tests d’o3 d’OpenAI par METR, puis un indicateur qui suit les coûts dans un environnement pouvant être riche ou pauvre en tokens, et enfin le passage d’un script de 70 lignes à quelque chose capable de fonctionner en production.

Ce qui suit est une implémentation fonctionnelle de toute cette idée, utilisant un processus d’underwriting d’assurance comme environnement de test.

Pourquoi il s’agit d’un problème d’architecture

Passer d’un modèle qui répond aux questions à un agent capable d’effectuer des actions de manière fiable n’est pas une simple question d’écriture de prompts. C’est un problème de conception. La plupart des tutoriels montrent un agent qui utilise un seul outil. Les systèmes réels nécessitent en même temps une tolérance aux pannes, une capacité d’autocorrection, des limites de dépenses, une détection de modifications non autorisées et une amélioration continue.

La méthode utilisée ici s’appelle ingénierie de boucles : on empile plusieurs boucles de contrôle, chacune ayant une tâche distincte, afin que l’ensemble du système soit plus résistant que n’importe quelle boucle isolée.

L’underwriting d’assurances constitue un domaine pratique. Les décisions proviennent d’un petit ensemble fixe (accepter, modifier, refuser, orienter vers un autre service), les règles sont explicites, et les données historiques fournissent des exemples de bonnes et de mauvaises réponses. Les mêmes principes s’appliquent à l’examen de code, à l’analyse de documents, au tri des demandes de support et au traitement des dossiers médicaux.

Quatre boucles, une à l’intérieur de l’autre

Le logiciel ordinaire maîtrise la complexité grâce à des couches d’abstraction. L’ingénierie des boucles maîtrise l’instabilité d’un agent par le biais de couches de retours d’information :

┌──────────────────────────────────────────────────────┐
│  Loop 4 — Hill Climbing (self-improvement over time) │
│  ┌────────────────────────────────────────────────┐  │
│  │  Loop 3 — Event Queue Poller (orchestration)   │  │
│  │  ┌──────────────────────────────────────────┐  │  │
│  │  │  Loop 2 — Verification & Retry           │  │  │
│  │  │  ┌────────────────────────────────────┐  │  │  │
│  │  │  │  Loop 1 — ReAct Agent              │  │  │  │
│  │  │  │  (think → act → observe → think)   │  │  │  │
│  │  │  └────────────────────────────────────┘  │  │  │
│  │  └──────────────────────────────────────────┘  │  │
│  └────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────┘

Chaque boucle a une responsabilité, une défaillance contre laquelle elle se protège et un signal de retour auquel elle réagit. La discipline essentielle consiste à empêcher que les problèmes ne se propagent d’une boucle à l’autre.

Le domaine

Avant l’architecture, voici les données. Une demande d’assurance correspond à un enregistrement simple :

{
  "id": "APP-001",
  "applicant_age": 34,
  "state": "FL",
  "property_type": "residential",
  "coverage_amount": 450000,
  "claim_history_5yr": 1,
  "credit_score_tier": "good",
  "flood_zone": true,
  "has_flood_rider": false,
  "business_use": false
}

Chacune des 20 applications de exemple dispose également d’une étiquette de référence :

"APP-001": {
  "split": "train",
  "expected_decision": "modify",
  "expected_flags": ["flood_rider_required", "windstorm_exclusion"]
}

Une décision relève de l’une de quatre catégories :

  • accepter : risque standard, approbation telle quelle
  • modifier : approbation avec des conditions ou des réserves
  • refuser : le risque dépasse le seuil acceptable
  • référer : un souscripteur senior doit l’examiner

Une petite base de connaissances contient cinq règles de souscription cachées concernant les zones inondables, l’historique des sinistres, le score de crédit, les plafonds d’assurance et les restrictions propres à chaque État. L’agent n’en est pas informé dans ses instructions initiales ; il doit les trouver et les appliquer en utilisant des outils. Seize applications constituent l’ensemble d’entraînement, et quatre sont réservées aux tests.

Boucle 1 : un agent ReAct

Protège contre : un modèle qui prend des décisions sans preuves. Signal de retour : le graphe des appels d’outils, indiquant si l’agent a consulté tous les éléments pertinents avant de prendre une décision.

ReAct, abréviation de reason plus act, est le modèle de base. L’agent réfléchit à ce dont il a besoin, appelle un outil, lit le résultat, réfléchit à nouveau, et répète le processus jusqu’à ce qu’il prenne une décision définitive :

System Prompt
     │
     ▼
[Think] → What information do I need?
     │
     ▼
[Act]   → Call tool (lookup_application, retrieve_rules, ...)
     │
     ▼
[Observe] → Tool returns result
     │
     ▼
[Think] → What does this mean? What next?
     │
    ... (repeat until decision is made)
     │
     ▼
[Act]   → draft_decision (terminal tool call)

Dans LangGraph, cela devient un graphe d’états composé de deux nœuds et d’une arête conditionnelle. L’état contient l’ID de l’application, la liste des messages, les champs de décision, les leçons, les dépenses en cours ainsi qu’un compteur d’étapes :

class UnderwritingState(TypedDict):
    application_id: str
    messages: Annotated[list[AnyMessage], add_messages]  # reducer accumulates
    decision: str
    rationale: str
    flags: list[str]
    recommended_premium_adj: float
    lessons: list[str]
    spend: float
    _steps: int

Un détail mérite attention. L’annotation add_messages pousse LangGraph à ajouter des messages à la liste à chaque étape au lieu de la remplacer, de sorte que l’agent conserve toute la conversation au cours de chaque appel à une fonctionnalité pour une application donnée.

Le générateur prend le prompt système ainsi que le compteur de coûts partagé et lie les outils au modèle :

def build_loop1(system_prompt: str, meter: CostMeter):
    model = ChatAnthropic(model="claude-sonnet-4-6").bind_tools(TOOLS)

Dans cet élément se trouvent le nœud d’agent, le nœud d’outils, la fonction de routage et l’assemblage du graphe :

    def agent_node(state: UnderwritingState) -> dict:
        prompt = system_prompt
        if state.get("lessons"):
            prompt += "\n\nUnderwriting lessons:\n" + "\n".join(
                f"- {l}" for l in state["lessons"]
            )
        msgs = [SystemMessage(content=prompt)] + list(state["messages"])
        response = model.invoke(msgs)
        meter.record_from_message(response)          # track spend per call
        return {
            "messages": [response],
            "_steps": state.get("_steps", 0) + 1,
            "spend": meter.spent,
        }

    def tools_node(state: UnderwritingState) -> dict:
        last = state["messages"][-1]
        tool_results, updates = _dispatch_tools(last.tool_calls)
        return {"messages": tool_results, **updates}  # updates captures draft_decision output

    def should_continue(state) -> Literal["tools", "__end__"]:
        if state.get("_steps", 0) >= MAX_STEPS:   # hard cap at 8 steps
            return END
        if getattr(state["messages"][-1], "tool_calls", None):
            return "tools"
        return END

    g = StateGraph(UnderwritingState)
    g.add_node("agent", agent_node)
    g.add_node("tools", tools_node)
    g.set_entry_point("agent")
    g.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END})
    g.add_edge("tools", "agent")
    return g.compile()

Les quatre outils

L’agent dispose de quatre outils précisément, suffisants pour couvrir tout ce dont il a besoin :

@tool
def lookup_application(app_id: str) -> dict:
    """Return the full application record for the given application ID."""
    # reads from applications.json — deterministic, no LLM call
    ...

@tool
def retrieve_rules(risk_factor: str) -> list[str]:
    """Return underwriting rules matching the given risk factor keyword.
    Use terms like: flood, claims, credit, coverage, state, business."""
    # keyword match against rules_kb.json - forces explicit rule lookup
    ...

@tool
def fetch_risk_signal(signal_type: str) -> dict:
    # simulated external data pull (credit bureaus, flood maps, etc.)
    ...

@tool
def draft_decision(
    decision: Literal["accept", "modify", "decline", "refer"],
    rationale: str,
    flags: list[str],
    recommended_premium_adj: float,
) -> dict:
    """Finalize the underwriting decision with structured output."""
    # terminal action - structured schema forces the agent to commit explicitly
    ...

lookup_application ne lit que des données. retrieve_rules effectue une recherche par mot-clé dans un ensemble de règles fixe, ce qui impose une recherche explicite. fetch_risk_signal simule la récupération de données externes telles que celles des agences de crédit ou des cartes des inondations. draft_decision est l’action finale et elle est la plus importante : elle oblige l’agent à prendre une décision écrite, accompagnée d’une justification et de drapeaux spécifiques, de sorte que le résultat structuré provient directement de l’appel à l’outil, sans nécessiter de traitement de texte libre.

Pourquoi le plafond de pas compte

MAX_STEPS = 8 empêche les chaînes d’outils infinies. Un modèle confus pourrait appeler la fonction de recherche de règles pour « inondation » vingt fois, à la recherche d’une réponse qui n’existe pas, gaspillant ainsi des tokens et du temps. Lorsque le nombre de pas atteint ce plafond, l’état reçoit la décision ran_out_of_steps, un signal que Loop 2 peut reconnaître pour tenter à nouveau.

Boucle 2 : vérifier et réessayer

Protège contre : une seule réponse incorrecte due à une logique erronée. Signal de retour d’information : le juge déterministe is_correct(), sans implication de modèle.

La boucle 1 peut échouer. L’agent peut interpréter à tort un indicateur, ignorer une règle ou atteindre la limite de pas autorisés. La boucle 2 détecte ces échecs et permet à l’agent de tenter à nouveau en fournissant des retours d’information ciblés :

def run_loop2(
    app_id: str,
    system_prompt: str,
    meter: CostMeter,
    max_retries: int = 3,
) -> tuple[dict, bool]:
    state = run_loop1(app_id, system_prompt, meter)
    for attempt in range(max_retries):
        decision = state.get("decision", "ran_out_of_steps")
        if decision == "refer":
            _write_human_queue(app_id, state)   # human-in-the-loop path
            return state, True
        if is_correct(decision, app_id):         # deterministic check
            return state, True
        if attempt >= max_retries - 1:
            return state, False                  # exhausted retries
        # construct targeted feedback for next attempt
        expected = load_historical_decisions()[app_id]["expected_decision"]
        feedback = HumanMessage(content=(
            f"Your decision was '{decision}' but this application requires '{expected}'. "
            f"Review the risk factors carefully and call draft_decision again."
        ))
        extra_messages = list(state.get("messages", [])) + [feedback]
        state = run_loop1(app_id, system_prompt, meter, extra_messages=extra_messages)
    return state, False

Quelques choix de conception méritent d’être expliqués en détail.

Le juge est déterministe. La fonction is_correct() lit le fichier des décisions historiques et compare des chaînes de caractères. Elle n’appelle jamais de modèle. Utiliser un second modèle comme juge ajouterait une deuxième source d’instabilité, alors que ici la vérité absolue reste simplement la vérité absolue.

Le renvoi constitue un résultat géré. Lorsque l’agent affiche refer, le Boucle 2 enregistre l’affaire dans human_queue.json et signale un succès. Le transfert à une personne est considéré comme un état final valide et non comme quelque chose à réessayer.

Les retours d’information indiquent la cible. Au lieu de « réessayer », le message indique à l’agent quelle catégorie de décision est correcte et lui demande d’examiner à nouveau les facteurs de risque. Cela oriente la prochaine tentative vers la bonne réponse, contrairement à un rééchantillonnage aveugle qui pourrait mener dans la mauvaise direction.

L’histoire est conservée. Les messages supplémentaires incluent toute la conversation précédente ainsi que les retours d’information, de sorte que lors de sa prochaine exécution, l’agent voit tout ce qu’il a déjà tenté et il est moins susceptible de reprendre le même chemin erroné.

Boucle 3 : le polleur de file d’événements

Protège contre : un couplage trop étroit entre celui qui génère le travail et celui qui le consomme. Signal de retour d’information : le contenu de pending_applications.json, un fichier simple servant de file d’attente.

Cette boucle agit comme orchestrateur. Elle récupère les éléments de la file d’attente en attente, fait exécuter une application à travers les Boucles 1 et 2, met à jour l’état d’exécution et, de temps en temps, déclenche la Boucle 4 :

def run_event_loop(
    system_prompt: str,
    meter: CostMeter,
    pending_path: Path = DEFAULT_PENDING,
    state_path: Path = DEFAULT_STATE,
    once: bool = False,
) -> None:
    run_state = load_run_state(state_path)
    verify_judge_checksum(run_state.get("judge_checksum", ""))   # tamper check
    total = len(json.loads(pending_path.read_text(encoding="utf-8-sig"))
                if pending_path.exists() else [])
    processed = 0
    while True:
        pending = (json.loads(pending_path.read_text(encoding="utf-8-sig"))
                   if pending_path.exists() else [])
        if not pending:
            if once:
                print("[loop3] queue empty - done")
                break
            print("[loop3] queue empty - sleeping...")
            time.sleep(POLL_INTERVAL)
            continue
        app_id = pending[0]
        processed += 1
        print(f"[loop3] {processed}/{total}  {app_id} ...", flush=True)
        try:
            result_state, passed = run_loop2(app_id, prompt_with_lessons, meter)
        except BudgetExhaustedError:
            raise                                # budget exhaustion is fatal
        except Exception as exc:
            print(f"[loop3] {app_id} ERROR: {exc!r} - skipping")
            # remove from queue and continue - one bad app cannot block the rest
            remaining = [x for x in pending if x != app_id]
            pending_path.write_text(json.dumps(remaining), encoding="utf-8")
            continue
        # ... update state, distill lesson, trigger hill-climbing

Pourquoi un fichier pour la file d’attente

La file d’attente est un fichier JSON et non un flux Redis ou une file SQS, intentionnellement, car pour le développement local il est facile à examiner (il suffit d’ouvrir le fichier pour voir ce qui est en attente), facile à reprendre (il suffit de redémarrer après une panne et la file reste intacte) et découplé (ce qui la remplit n’a pas besoin de connaître le consommateur). Un système de production utiliserait un véritable broker de messages, et le contrat d’interface, qui est une liste d’ID, ne changerait pas.

Détection de modifications non autorisées

Avant de traiter quoi que ce soit, Loop 3 vérifie la valeur de référence :

verify_judge_checksum(run_state.get("judge_checksum", ""))

Le virement fonctionne de la manière suivante :

def compute_judge_checksum() -> str:
    content = _HIST.read_bytes()           # historical_decisions.json
    return hashlib.sha256(content).hexdigest()


def verify_judge_checksum(expected: str) -> None:
    actual = compute_judge_checksum()
    if actual != expected:
        raise RuntimeError(
            f"Judge checksum mismatch - historical_decisions.json was tampered with."
        )

Un hash SHA-256 du fichier des décisions historiques est stocké dans l’état de fonctionnement lors de l’initialisation et vérifié à chaque démarrage du cycle d’événements. Si quelqu’un modifie les données de référence afin d’avantager l’agent, le système refuse de fonctionner. Cela ferme une faille discrète dans les systèmes d’autamélioration : le moyen le moins coûteux d’augmenter un score consiste à modifier l’outil d’évaluation.

Ignorer ou planter

La structure try/except dans le Cycle 3 trace une ligne de démarcation claire. L’épuisement du budget est à nouveau soulevé, ce qui arrête tout. Toute autre exception est enregistrée, l’application en cause est retirée de la file d’attente, et la suivante est traitée. Ainsi, une seule application défectueuse ne peut pas paralyser le processus, tandis que l’épuisement des ressources financières est, par conception, fatal, car le système ne devrait pas continuer à fonctionner sans rien à dépenser.

Cycle 4 : montée en pente

Protège contre : une instruction fixe qui ne s’adapte jamais à l’expérience. Signal de retour : la précision de l’entraînement ainsi que le verdict d’audit.

Tous les huitièmes décisions (HILL_CLIMB_EVERY = 8), la Boucle 3 passe le contrôle à la Boucle 4, qui exécute un cycle de réflexion, de mutation, d’évaluation et de validation afin d’améliorer automatiquement l’instruction du système :

seed prompt
     │
     ▼
evaluate on training set → score, failures
     │
     ▼
┌────────────────────────────────┐
│  for each round:               │
│                                │
│  reflect(current, failures)    │  ← LLM analyzes failure patterns
│       │                        │
│       ▼                        │
│  candidate_prompt              │
│       │                        │
│       ▼                        │
│  evaluate(candidate, train)    │  ← full eval run
│       │                        │
│       ▼                        │
│  audit_prompt(candidate)       │  ← anti-cheating check
│       │                        │
│       ▼                        │
│  if score > best AND clean:    │
│      best = candidate          │  ← keep
│  else:                         │
│      discard                   │  ← revert
└────────────────────────────────┘
     │
     ▼
write best_prompt.txt

Réfléchir

REFLECT_TEMPLATE = """You are improving the system prompt of an insurance underwriting agent.

CURRENT PROMPT:
{prompt}
The agent got these decisions WRONG on training applications:
{failures}
Look for PATTERNS in the failures. Infer GENERAL underwriting rules that would fix them.
Do NOT memorize specific application IDs or applicant details.
Write an improved system prompt. Reply with ONLY the new prompt text."""


def reflect(current_prompt: str, failures: list[dict]) -> str:
    client = anthropic.Anthropic()
    failure_text = "\n".join(
        f"- APP {f['app_id']}: agent said '{f['got']}', expected '{f['expected']}'. "
        f"Application data: {f['application']}"
        for f in failures
    )
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1500,
        messages=[{"role": "user", "content": REFLECT_TEMPLATE.format(
            prompt=current_prompt,
            failures=failure_text,
        )}],
    )
    return response.content[0].text.strip()

L’instruction interdisant de mémoriser des IDs d’application spécifiques ou des détails des demandeurs a une grande importance. Sans elle, le modèle pourrait générer une instruction du type « pour APP-003, refuser systématiquement », qui obtiendrait une note parfaite sur les données d’entraînement mais échouerait complètement face à des cas qu’il n’a jamais vus. La nouvelle instruction doit se baser sur les schémas observés dans les échecs, et non sur des cas individuels.

Évaluer

def evaluate_prompt(system_prompt: str, split: str = "train", meter=None):
    tasks = load_tasks(split)
    failures = []
    for task in tasks:
        state = run_loop1(task["id"], system_prompt, meter)
        decision = state.get("decision", "ran_out_of_steps")
        if not is_correct(decision, task["id"]):
            failures.append({
                "app_id": task["id"],
                "got": decision,
                "expected": task["expected_decision"],
                "application": task["application"],
            })
    score = (len(tasks) - len(failures)) / len(tasks)
    return score, failures

Chaque prompt de candidat est soumis à la boucle 1 sur les 16 applications d’entraînement. Cela est coûteux : 16 exécutions du modèle par tour, soit au moins 80 au total sur cinq tours, mais le signal est fiable : un prompt doit fonctionner sur l’ensemble de la distribution d’entraînement et non sur un échantillon isolé.

Gate

def improve(seed_prompt: str, lessons: list[str], meter: CostMeter, rounds: int = 5) -> str:
    best = with_lessons(seed_prompt, lessons)
    best_score, failures = evaluate_prompt(best, split="train", meter=meter)
    for r in range(1, rounds + 1):
        candidate = reflect(best, failures)
        score, cand_failures = evaluate_prompt(candidate, split="train", meter=meter)
        verdict = audit_prompt(candidate)        # anti-cheat check
        if score > best_score and verdict == "VERDICT: CLEAN":
            best, best_score, failures = candidate, score, cand_failures
            BEST_PROMPT_PATH.write_text(best, encoding="utf-8")
            print(f"round {r}: {score:.0%} KEPT  ({len(cand_failures)} failures)")
        else:
            reason = "cheating" if verdict != "VERDICT: CLEAN" else f"{score:.0%} <= {best_score:.0%}"
            print(f"round {r}: {score:.0%} REVERTED ({reason})")
    return best

Deux conditions doivent être remplies avant qu’un candidat ne soit accepté. Premièrement, score > best_score ; il doit donc surpasser strictement le meilleur score actuel. Les égalités sont rejetées, ce qui empêche l’évolution imprévue des prompts. Deuxièmement, l’auditeur doit retourner VERDICT: CLEAN pour confirmer que le candidat ne contient aucune réponse codée à l’avance.

L’auditeur des prompts

AUDIT_SYSTEM = (
    "You are a security auditor for AI systems. "
    "Check whether the prompt contains any hardcoded application IDs (e.g. APP-001) "
    "or hardcoded decisions for specific applications. "
    "These would constitute cheating — memorizing answers instead of learning rules. "
    "Reply with exactly one of:\n"
    "VERDICT: CLEAN\n"
    "VERDICT: CHEATING\n"
    "Followed by a one-sentence explanation."
)


def audit_prompt(evolved_prompt: str) -> str:
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=200,
        system=AUDIT_SYSTEM,
        messages=[{"role": "user", "content": f"PROMPT TO AUDIT:\n{evolved_prompt}"}],
    )
    return response.content[0].text.strip().split("\n")[0]   # first line only

Il s’agit d’une configuration où un LLM agit en juge, mais limitée à une seule question structurelle binaire : le prompt contient-il des IDs spécifiques ou des réponses mémorisées ? C’est le type de classification pour lequel les juges basés sur des modèles sont fiables. L’auditeur ne évalue jamais la qualité, le style ou la correction ; il cherche uniquement un certain schéma.

Mémoire des leçons

En plus de la méthode de montée sur colline, le système conserve une liste lessons qui persiste entre les exécutions. Lorsqu’une application échoue encore après chaque tentative, il appelle la fonction distill_lesson() :

DISTILL_PROMPT = """An insurance underwriting agent made a wrong decision.

Application details: {application}
Agent decided: {got}
Correct decision: {expected}
Write ONE short, general underwriting rule that would prevent this mistake.
Do NOT reference this specific application ID or any applicant names/details.
Reply with only the rule, as a single sentence."""


def distill_lesson(application: dict, got: str, expected: str) -> str:
    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=150,
        messages=[{"role": "user", "content": DISTILL_PROMPT.format(...)}],
    )
    return response.content[0].text.strip()

Chaque leçon est ensuite ajoutée à toutes les exécutions ultérieures de Loop 1 via with_lessons():

def with_lessons(base_prompt: str, lessons: list[str]) -> str:
    if not lessons:
        return base_prompt
    lessons_text = "\n".join(f"- {l}" for l in lessons)
    return base_prompt + f"\n\nUnderwriting lessons learned:\n{lessons_text}"

Il s’agit d’un apprentissage à quelques exemples sans descente de gradient. Le système apprend à partir des erreurs en temps réel en collectant des connaissances compressées sous forme de règles en langage naturel. Une fonction inspirée du protocole CL-BENCH vérifie si ces leçons sont réellement utiles :

def measure_gain(base_prompt: str, lessons: list[str], split: str = "test", meter=None) -> float:
    stateless_score, _ = evaluate_prompt(base_prompt, split=split, meter=meter)
    augmented = with_lessons(base_prompt, lessons)
    stateful_score, _ = evaluate_prompt(augmented, split=split, meter=meter)
    return stateful_score - stateless_score   # positive = lessons help

Contrôle du budget avec un compteur de coûts

Chaque appel de modèle passe par un CostMeter commun, transmis de Boucle 4 à Boucle 3, puis à Boucle 2 et enfin à Boucle 1, de sorte que toutes les dépenses s’accumulent en un seul endroit :

BUDGET_USD = 2.00
INPUT_PRICE_PER_TOKEN  = 3.00  / 1_000_000
OUTPUT_PRICE_PER_TOKEN = 15.00 / 1_000_000


class CostMeter:
    def record(self, input_tokens: int, output_tokens: int) -> None:
        if self.spent >= self._budget:              # check BEFORE adding
            raise BudgetExhaustedError(
                f"Budget ${self._budget:.2f} exhausted at ${self.spent:.4f}"
            )
        self.spent += input_tokens * INPUT_PRICE_PER_TOKEN \
                    + output_tokens * OUTPUT_PRICE_PER_TOKEN

L’ordre des opérations est essentiel : vérifiez le budget avant d’ajouter les coûts. Ainsi, l’erreur est détectée dès que la limite est atteinte, plutôt qu’après qu’un dépassement ait déjà été enregistré. BudgetExhaustedError remonte dans la hiérarchie des boucles, et la Boucle 3 la relance plutôt que de l’ignorer, de sorte que l’épuisement du budget met toujours fin à toute l’exécution.

État d’exécution avec configuration idempotente

run_state.json enregistre tout ce qui s’est produit : les applications traitées, les listes des réussites et des échecs, les leçons acquises, les décisions prises depuis la dernière montée en puissance ainsi que le checksum du juge. Son initialisation est préventive :

def _init_run_state() -> dict:
    path = _STATE_DIR / "run_state.json"
    state = {}
    if path.exists():
        try:
            content = path.read_text(encoding="utf-8-sig").strip()  # handles Windows BOM
            if content:
                state = json.loads(content)
        except (json.JSONDecodeError, UnicodeDecodeError):
            pass                                 # corrupt file → start fresh
    if not state:
        state = dict(_DEFAULT_RUN_STATE)
    state["judge_checksum"] = compute_judge_checksum()   # always refresh
    path.write_text(json.dumps(state, indent=2), encoding="utf-8")
    return state

Trois habitudes de résilience se manifestent ici. La lecture avec utf-8-sig supprime silencieusement les marques d’ordre des octets de Windows. Un fichier vide ou endommagé revient à l’état par défaut au lieu de planter. De plus, le checksum du juge est recomputé à chaque démarrage, sans jamais faire confiance à la valeur issue d’une exécution précédente.

Trois façons de l’exécuter

Il existe trois manières d’y accéder, chacune utilisant uniquement une partie du stack.

Le mode unique exécute uniquement le Boucle 1, ce qui est pratique pour déboguer une application et observer quels outils l’agent appelle et pourquoi :

python -m src.main --mode single --app-id APP-001

Le mode boucle d’événements exécute les boucles 1, 2 et 3 pour chaque application en attente et peut déclencher la boucle 4 après chaque huit décisions. Le flag --once vide la file d’attente et termine l’exécution au lieu de continuer à vérifier indéfiniment :

python -m src.main --mode event-loop --once

Le mode d’amélioration exécute directement le Cycle 4, avec cinq tours de montée en pente sur la partie de formation. Il évalue ensuite le meilleur prompt trouvé par rapport à la partie de test réservée et affiche l’amélioration obtenue :

python -m src.main --mode improve --rounds 5

Une seule application, du début à la fin

C’est le flux de données complet pour une application unique qui traverse le système :

pending_applications.json
          │
          │  [Loop 3 reads APP-009]
          ▼
    run_loop2("APP-009", prompt, meter)
          │
          │  [Loop 2 calls Loop 1]
          ▼
    run_loop1("APP-009", prompt, meter)
          │
          │  [LangGraph StateGraph]
          ▼
    agent_node → ChatAnthropic.invoke([SystemMsg, HumanMsg])
          │
          │  response: tool_call(lookup_application, {"app_id": "APP-009"})
          ▼
    tools_node → lookup_application.invoke({"app_id": "APP-009"})
          │
          │  returns: {id: APP-009, state: TX, coverage: 1200000, ...}
          ▼
    agent_node → ChatAnthropic.invoke([..., ToolMessage])
          │
          │  response: tool_call(retrieve_rules, {"risk_factor": "coverage"})
          ▼
    tools_node → retrieve_rules.invoke({"risk_factor": "coverage"})
          │
          │  returns: ["Coverage > $1M requires mandatory refer. Flag: high_coverage"]
          ▼
    agent_node → ChatAnthropic.invoke([..., ToolMessage])
          │
          │  response: tool_call(draft_decision, {decision: "refer", ...})
          ▼
    tools_node → draft_decision.invoke({...})
          │
          │  updates state: {decision: "refer", flags: ["high_coverage"], ...}
          ▼
    should_continue → END (no more tool calls)
          │
          ▼
    Loop 2: decision == "refer" → write human_queue.json → return (state, True)
          │
          ▼
    Loop 3: passed=True → update run_state.json → remove APP-009 from queue
          │
          │  [decisions_since_last_improvement becomes 9 >= 8]
          ▼
    Loop 4: improve(seed_prompt, lessons, meter, rounds=5)
          │
          │  reflect → evaluate → audit → gate → write best_prompt.txt
          ▼
    Loop 3: continue with APP-010 using new best_prompt

Leçons tirées de sa création

1. Séparez l’oracle de l’agent. Le juge (is_correct, evaluate_prompt) ne partage aucun code avec l’agent (run_loop1). Le juge lit des fichiers statiques tandis que l’agent appelle un modèle. Laissez le modèle évaluer sa propre sortie, et vous obtiendrez un acolyte qui ne rate jamais.

2. Rendez l’évaluation déterministe. La correction pour chaque application, la précision d’entraînement et la précision de test sont toutes déterministes pour les mêmes entrées. La seule source de hasard réside dans les réponses du modèle, de sorte que tout score peut être reproduit à partir du prompt et de la graine.

3. Utilisez un outil de réponse finale pour un résultat structuré. Un outil draft_decision est plus efficace que l’analyse de texte narratif. Son schéma définit le vocabulaire (accepter | modifier | refuser | renvoyer), les champs obligatoires (flags, rationale, recommended_premium_adj) ainsi que leurs types, ce qui oblige l’agent à être explicite.

4. La lutte contre la triche n’est pas optionnelle dans les systèmes d’amélioration automatique. Dès qu’un système peut modifier ses propres instructions, il cherchera des raccourcis, le plus évident étant de mémoriser les réponses d’entraînement. La fonction audit_prompt() détecte automatiquement ce phénomène. Sans elle, la méthode de montée sur colline produit inévitablement des prompts avec une précision quasi parfaite lors de l’entraînement, mais une précision quasi aléatoire lors des tests.

5. Les marques d’ordre des octets et les encodages constituent des bugs en environnement de production. Si vous développez sur Windows ou recevez des fichiers d’utilisateurs de Windows, il est impératif d’utiliser encoding="utf-8-sig" à chaque lecture. La BOM générée par le Bloc-notes de Windows provoque json.JSONDecodeError: Unexpected UTF-8 BOM, ce qui est difficile à diagnostiquer car le fichier semble parfaitement normal dans un éditeur.

6. Vérifiez le budget avant de dépenser, et non après.

# WRONG: allows one overspend before raising
self.spent += cost
if self.spent > self._budget:
    raise BudgetExhaustedError(...)
# CORRECT: raises before the overspend registers
if self.spent >= self._budget:
    raise BudgetExhaustedError(...)
self.spent += cost

L’ordre est important lorsque plusieurs appels au modèle ont lieu rapidement les uns après les autres. Si l’on ajoute d’abord et que l’on vérifie plus tard, le budget peut être dépassé par la somme des coûts de plusieurs appels avant que l’erreur ne se manifeste.

7. Vider les lignes de progression dans les boucles longues.

print(f"[loop3] {processed}/{total}  {app_id} ...", flush=True)

Sur Windows, la sortie standard est mémoisée tant que rien n’est indiqué autrement. En omettant flush=True, la ligne « processing APP-009 » apparaît après le retour de l’appel API, ce qui annule l’utilité d’un indicateur de progression. Il faut toujours vider le buffer des lignes avant toute opération I/O bloquante.

8. Gardez le budget utilisé pour la méthode de montée en gradins séparé de celui du cycle d’événements. L’épuisement du budget pendant cette méthode doit être détecté au niveau de cet appel afin d’éviter que le cycle d’événements ne plante. Après que huit évaluations d’applications aient consommé la majeure partie d’un budget de 2 $, il ne reste presque plus de marge. Le cycle d’événements détecte spécifiquement l’erreur BudgetExhaustedError générée par cette méthode et continue avec les applications restantes :

try:
    system_prompt = run_hill_climbing(system_prompt, lessons, meter)
    run_state["decisions_since_last_improvement"] = 0
except BudgetExhaustedError:
    print(f"[loop3] hill-climbing skipped — budget exhausted at ${meter.spent:.4f}")
    run_state["decisions_since_last_improvement"] = 0

Application du modèle ailleurs

La conception à quatre boucles n’est pas liée à l’assurance. Pour l’adapter, remplacez ces éléments :

  • Outils de la boucle 1. Ici : recherche, règles, signal, décision. Pour vous : outils de récupération et d’action adaptés à votre domaine.
  • Vérité de référence. Ici : le fichier des décisions historiques. Pour vous : votre propre ensemble de données étiquetées.
  • Juge. Ici : comparaison de chaînes de caractères. Pour vous : un test de correction spécifique à votre tâche.
  • Leçons apprises. Ici : règles d’underwriting. Pour vous : les schémas d’erreurs propres à votre domaine.
  • Indication pour l’algorithme de montée sur colline. Ici : l’indication du système d’underwriting. Pour vous : l’indication du système de votre agent.
  • Budget. Ici : 2,00 $. Pour vous : le coût prévu pour chaque exécution.

Une contrainte est impérative : le juge doit être déterministe et indépendant de l’agent. S’il n’est pas possible de définir un test de correction déterministe pour votre tâche, le système ne peut pas s’améliorer de manière fiable.

Essayer

Configuration et les quatre commandes :

# 1. Install
cd underwriting_loop
pip install -e ".[dev]"
export ANTHROPIC_API_KEY=sk-ant-...
# 2. Run a single application (debug mode - cheapest)
python -m src.main --mode single --app-id APP-001
# 3. Drain the pending queue with event loop
python -m src.main --mode event-loop --once
# 4. Run standalone hill-climbing (5 rounds)
python -m src.main --mode improve --rounds 5
# 5. Run the full offline test suite (no API key needed)
pytest tests/ -v

Le résultat de l’ascension de côte ressemble à ceci :

seed score: 44%  (9 failures)
round 1: 56% KEPT  (7 failures)
round 2: 56% REVERTED (score 56% <= best 56%)
round 3: 69% KEPT  (5 failures)
round 4: 75% KEPT  (4 failures)
round 5: 69% REVERTED (score 69% <= best 75%)
Final test evaluation...
spend:              $1.8342
test score:         75%
gain (vs baseline): +25%

Le prompt de départ obtient 44 % de performance lors de l’entraînement. Cinq tours plus tard, le prompt gagnant atteint 75 % sur l’ensemble de test non utilisé pour l’entraînement, ce qui représente une hausse de 25 points par rapport au affinage automatique des prompts sans ingénierie manuelle des prompts.

Ce que les boucles apportent

L’ingénierie des boucles n’est pas un cadre préétabli. C’est une méthode pour diviser la fiabilité de l’agent en couches, chacune ayant une responsabilité unique et un mécanisme de feedback unique. Les quatre boucles répondent à quatre modes de défaillance distincts :

  • Boucle 1 : gère le rattachement aux faits ; l’agent doit consulter des preuves avant de prendre une décision.
  • Boucle 2 : gère les défaillances sur une seule exécution ; un mauvais cheminement de raisonnement ne doit pas entraîner définitivement l’échec d’une application.
  • Boucle 3 : gère le débit et la résilience ; une application défectueuse ne doit pas bloquer le flux de travail.
  • Boucle 4 corrige les erreurs récurrentes en réécrivant la requête elle-même à partir des échecs accumulés.

En les empilant, en ajoutant un juge déterministe, en protégeant tout par un contrôle budgétaire et une détection de modifications, le système s’améliore de manière mesurable avec le temps, sans aucune intervention humaine.

Avantages

Adoptez ce design en étant vigilant. La plus grande prudence émise dans les études sur les boucles d’agents est celle d’Addy Osmani concernant la « reddition cognitive » : confier le contrôle total à la boucle et cesser de lire ses résultats. Les avantages restent réels et mesurables.

  1. Amélioration automatique sans intervention humaine. La méthode de montée sur colline a porté la précision de l’entraînement de 44 % à 75 % sans travail manuel sur les requêtes, et le système a extrait lui-même les règles d’underwriting appropriées à partir des schémas d’échec.
  2. Isolation des pannes entre les couches. Une application défectueuse ne met pas tout le système en panne. Le boucle 3 détecte les erreurs propres à chaque application, les enregistre et continue son fonctionnement. BudgetExhaustedError est la seule exception qui remonte jusqu’au sommet, et c’est intentionnel.
  3. Progrès mesurables et auditable. La fonction measure_gain() fournit un chiffre concret, en tenant compte des enseignements tirés selon que le système est ou non sans état, sur l’échantillon réservé à l’évaluation. L’amélioration peut être présentée aux parties prenantes sous forme de pourcentage et de coût en dollars.
  4. Évaluation à l’abri des manipulations. Le checksum SHA-256 de la valeur de référence empêche l’échec silencieux le plus courant chez les systèmes auto-améliorants, à savoir que quelqu’un modifie les réponses pour faire paraître les scores meilleurs. Si les checksums ne correspondent pas, rien ne s’exécute.
  5. Connaissances qui s’accumulent. Les leçons tirées des échecs persistent d’une exécution à l’autre, de sorte que chaque nouvelle invocation de Loop 1 démarre de manière plus intelligente que la précédente sans avoir à tout réapprendre.
  6. Découplage entre producteur et consommateur. Grâce à une file d’attente de fichiers, ce qui génère la liste en attente est séparé du pipeline d’évaluation. La file peut être mise en pause, reprise, examinée ou rejouée sans que le code de l’agent ne soit modifié.
  7. L’escalade humaine est un état de premier plan. Les décisions prises via refer sont enregistrées dans human_queue.json et sont considérées comme validées. Le cycle ne tente pas à nouveau de résoudre quelque chose qui doit être décidé par un humain.
  8. Les tentatives sont ciblées. Le retour d’information après une erreur indique ce qui n’a pas fonctionné, par exemple "vous avez dit modifier mais la bonne réponse est de se référer aux facteurs de risque, reconsidérez-les". Cela permet des progrès plus rapides que le rééchantillonnage aléatoire.

Quel en est le coût

  1. Les tokens augmentent rapidement. Cinq tours de montée de colline sur 16 applications d’entraînement nécessitent au moins 80 exécutions de Loop 1, en plus des appels de réflexion et d’audit. Une exécution avec --mode improve a consommé 1,83 $ sur un budget de 2,00 $. Dépasser le budget n’est pas un cas particulier : c’est la fin normale d’une exécution.
  2. La latence est réelle. Une application traitée en mode complet peut prendre de 30 à 60 secondes, et 16 applications en mode boucle d’événements prennent des minutes. Cela convient bien aux tâches par lots, mais est inadapté à un usage interactif.
  3. Le non-déterminisme cache les échecs. La boucle 1 utilise un modèle en temps réel, de sorte que la même requête dans la même application peut générer différentes séquences d’outils. Un échec peut ne pas se reproduire, et des chiffres d’exactitude significatifs nécessitent plusieurs exécutions d’évaluation.
  4. La configuration est complexe. Avant la première exécution, il faut disposer de l’état de l’exécution, de la file d’attente, des décisions historiques avec leurs checksums, d’un fichier de meilleures requêtes et de la liste des leçons. Cela représente un ordre de grandeur plus d’infrastructure qu’une simple appel à agent.invoke().
  5. L’overfitting ne disparaît jamais. Sans la protection d’audit, la méthode de montée sur colline produit finalement une requête qui mémorise les réponses d’entraînement, obtenant des scores parfaits sur les données d’entraînement mais presque au hasard sur les données de test. L’audit est la seule chose qui empêche l’autos’amélioration de se transformer en auto-tricherie.
  6. La reddition cognitive constitue un véritable mode de défaillance. Plus le cycle devient fiable, moins quelqu’un lit ses résultats, et lorsque l’on cesse de lire, on ne remarque plus que le cycle a commencé à échouer d’une nouvelle manière. Les cycles non surveillés nécessitent une surveillance externe, car un cycle ne peut pas s’auditer lui-même pour vérifier sa correction sémantique.
  7. Le débogage concerne l’ensemble de la pile d’appels. Une mauvaise décision peut provenir d’une mauvaise réflexion dans le Cycle 4, d’une règle de rubrique manquante dans le Cycle 2, d’un outil qui n’a rien renvoyé dans le Cycle 1 ou d’un état d’exécution corrompu dans le Cycle 3. Traquer une défaillance à travers quatre cycles imbriqués est plus difficile que de la traquer dans une seule fonction.

Faut-il vraiment créer un cycle ?

Une analyse des configurations de boucle publique donne des conseils clairs : bouclez les tâches répétitives, vérifiables et à forte valeur ajoutée, dotées d’un vérificateur intégré, et pour tout le reste, demandez simplement une fois. Cela se résume à deux questions.

  1. Le travail est-il répétitif, ou nécessite-t-il de nombreuses étapes imprévisibles ? Si une seule demande suffit, une boucle ajoute des coûts inutiles sans améliorer l’architecture.
  2. L’agent peut-il déterminer par lui-même s’il a terminé ? Sans test, sans format attendu ni réponses de référence, rien ne met fin à la boucle, qui continue indéfiniment.

Les deux réponses doivent être oui. Si l’une d’elles est non, évitez la boucle.

Cas appropriés

Travaux répétitifs et en grande quantité. Sixteen demandes d’assurance, 500 fichiers de code, 1 000 tickets de support. La boucle amortit son coût de mise en place lorsque la tâche est exécutée de nombreuses fois et non une seule fois.

Une vérification déterministe de l’exécution existe. Des étiquettes de référence, un ensemble de tests, un validateur de schéma ou un ensemble de règles binaires de réussite/échec. Le juge ne doit jamais faire appel à un modèle, car un juge basé sur un modèle introduit sa propre fiabilité incertaine dans le processus.

L’agent peut vérifier son propre travail. Le Agent SDK d’Anthropic qualifie cela de pas le plus sous-estimé. Ici, la fonction is_correct() s’en charge, et dans un agent de programmation, c’est l’ensemble de tests qui le fait. Si l’agent ne peut pas vérifier ses résultats, le cycle reste ouvert.

Des données étiquetées ou des critères de succès réutilisables. La méthode de montée sur colline nécessite une séparation entre données d’entraînement et de test avec des réponses connues. Sans cela, on peut analyser les échecs, mais on ne sait pas si cette analyse a été utile.

Personne ne surveille chaque exécution. Pensez aux lots de souscription effectués pendant la nuit, aux audits planifiés ou aux pipelines de documents, où aucune personne ne se trouve à l’intérieur du boucle interne et où ce type de conception est conçu pour fonctionner. Si quelqu’un peut examiner chaque résultat immédiatement, une instruction directe est plus rapide.

Le coût par exécution d’évaluation est acceptable. Une seule itération de la méthode de montée de colline coûte autant qu’un accès complet à chaque application d’entraînement. Si cela est trop cher, Loop 4 l’est également ; il faut donc l’exécuter moins souvent ou sur un ensemble d’entraînement plus petit.

Vous avez besoin de chiffres. La fonction measure_gain() existe parce que l’affirmation « le système s’est amélioré » doit être quantifiée. Si les parties prenantes exigent des données chiffrées, l’ingénierie des boucles les fournit. Si « ça a l’air mieux » suffit, c’est alors un excès.

Adaptations insuffisantes

Une seule instruction suffit. Réformatage d’un CSV, extraction d’une date à partir d’un document, résumé d’un paragraphe. Ces tâches ont un nombre de étapes prévisible, il suffit donc d’écrire un script ou une instruction unique. Utiliser des boucles pour ces cas représente une surcharge inutile pour un problème simple.

Le critère de « terminé » est purement subjectif. L’instruction « Rendre cet e-mail plus professionnel » ne dispose d’aucun outil de vérification binaire, et l’intervention d’un second modèle évaluant l’aspect esthétique ajoute de la variabilité sans résoudre le problème. Définissez d’abord des objectifs clairs, comme un texte de moins de 100 mots, un ton professionnel et l’absence de points d’exclamation, avant de pouvoir vérifier le résultat.

Les actions sont irréversibles sans révision préalable. Envoi d’e-mails, fusion de demandes de pull request, suppression de données, transfert d’argent. Restreignez strictement les permissions et exigez une pause avant toute suppression, envoi ou paiement. Les boucles permettant d’effectuer des actions irréversibles sans contrôles sont des sources potentielles d’erreurs.

Le budget ne permet pas de couvrir l’évaluation. Si 2,00 $ sont déjà insuffisants, les opérations supplémentaires consomment tout le budget avant même que toutes les demandes de formation ne soient traitées. Le système gère cela avec l’erreur BudgetExhaustedError, mais pour une configuration à budget limité, la solution réelle consiste à utiliser un ensemble de formation plus petit, moins de tours, ou à ne pas lancer de boucle 4 tant que le budget ne le permet pas.

Il n’y a pas de persistance de l’état. Si vous ne pouvez pas enregistrer l’état de l’exécution, les leçons et la file d’attente sur disque ou dans une base de données, vous ne pourrez pas reprendre après une panne, accumuler des leçons au fil des sessions, ni éviter d’épuiser le budget au cours de l’exécution. Les environnements sans état nécessitent une autre architecture.

Vous êtes encore en phase d’exploration. Appeler l’agent une fois à l’aide d’outils adaptés vous montre où l’agent commet des erreurs, tandis que les boucles imbriquées ne révèlent que les dysfonctionnements du système dans son ensemble. Examinez d’abord l’agent unique, et ajoutez des couches une fois que vous connaissez bien ses propres modes de défaillance.

Une dernière règle de prudence vient de Rhys Sullivan, qui met en garde contre deux extrêmes : les boucles de type "tokenmaxxing" qui fonctionnent uniquement pour augmenter les coûts, et les instructions simples et naïves de type "promptchud". Une boucle est justifiée lorsqu’elle reproduit de véritables défaillances du produit avec une distribution de tests réaliste, et elle perd sa pertinence lorsqu’elle semble simplement impressionnante.

Huit propriétés d’une boucle qui fonctionne

Les boucles se brisent généralement de manières peu reluisantes. Elles ne s’arrêtent jamais, épuisent le budget ou restituent du matériel inutilisable présenté comme terminé. Une analyse des configurations de boucles publiques a révélé huit caractéristiques présentes dans chaque boucle fonctionnant de manière fiable, et chacune d’elles correspond à une décision de conception dans cette base de code.

1. Un objectif vérifiable

Définissez ce qu’est « terminé » de manière que la machine puisse le vérifier, et non pour que l’humain se sente satisfait. « Produire une bonne décision d’underwriting » est impossible à vérifier. is_correct(decision, app_id) permet une comparaison avec les décisions historiques : soit elles correspondent, soit elles ne correspondent pas. Sans objectif vérifiable, la boucle ne sait jamais quand s’arrêter et produira des résultats erronés, mais présentés comme achevés. L’objectif doit être un prédicat binaire et non une description de la qualité. La séparation entre données d’entraînement et de test garantit que la solution généralise au-delà de ce qui a été mémorisé.

2. Une arrêt brutale

Max tries, a budget or a time limit, always, with no exceptions. Never assume the model will stop on its own. Grassroots setups like a shell while :; do ... done loop contain nothing on the model side that ends them, so they run until somebody kills the process, and OpenAI's SDK ships max_turns because experience showed models don't reliably end themselves. This codebase has three stops at three levels: MAX_STEPS = 8 in Loop 1 prevents endless tool chains, max_retries = 3 in Loop 2 prevents endless retries, and BUDGET_USD = 2.00 through CostMeter is the ultimate backstop. At the step cap Loop 1 returns ran_out_of_steps, when retries run out Loop 2 returns passed=False, and when the budget is gone the error propagates up and halts everything.

3. Good tools

Les actions disponibles pour l’agent doivent être fiables, clairement décrites et strictement délimitées. La boucle elle-même est simple, et le travail s’effectue via des outils. Selon les directives d’Anthropic, les ensembles d’outils et leur documentation doivent être conçus avec soin, car une description vague entraîne des appels vagues, tandis qu’un outil trop puissant provoque des erreurs irréversibles. Il existe ici quatre outils, chacun chargé d’une tâche spécifique : une récupération pure sans effets secondaires, une recherche par mots-clés sur une base de connaissances statique, une récupération simulée de données externes, ainsi qu’une action de terminal structurée qui impose le respect d’un schéma tapé (Literal["accept", "modify", "decline", "refer"]), accompagnée d’une justification et de drapeaux explicites. Sans ce dernier élément, l’agent renvoie du texte qui nécessite une analyse fragile.

4. Mémoire

Conservez l’historique mais résumez-le afin d’éviter que le contexte ne devienne trop volumineux, et pour toute conversation dépassant une session, enregistrez l’état sur disque. Le système long-running-agent d’Anthropic permet à l’agent de lire l’état précédent depuis git et les fichiers de progression, de faire des progrès progressifs et de laisser l’environnement propre pour la prochaine itération. Une fenêtre de contexte brute ne survit pas aux redémarrages, aux pannes ou aux limites de taille. Deux mécanismes interviennent à des échelles temporelles différentes : au cours d’une seule session, le réducteur add_messages de LangGraph accumule toute la conversation avec l’outil pour une application donnée ; entre les sessions, la liste lessons dans run_state.json rassemble des règles extraites issues de toutes les applications et de tous les redémarrages. La fonction distill_lesson() effectue l’étape de compression : elle stocke une seule règle généralisable plutôt qu’une transcription brute de l’échec, ce qui permet de garder la liste courte et le contexte injecté gérable.

5. Un vérificateur distinct

Generer, évaluer, corriger, répéter : le créateur ne note jamais son propre travail. Le SDK d’Anthropic qualifie la vérification du travail de l’étape la plus sous-estimée, mais cette vérification doit être indépendante de la génération. Un agent qui se note lui-même devient un simple acquiesceur, et confier uniquement la tâche d’évaluation à un second agent empêche cela. Cette base de code dispose de trois niveaux indépendants de vérification :

  • Vérification par grille d’évaluation (structure). RubricMiddleware teste neuf règles sur le résultat analysé à l’aide de prédicats booléens purs, sans recourir à de modèle.
  • Vérification par évaluateur (correctitude). is_correct() compare la réponse avec l’étiquette enregistrée par une comparaison de chaînes de caractères exacte.
  • Vérification d’audit (lutte contre la triche). audit_prompt() cherche des réponses mémorisées grâce à une classification par modèle binaire, et non à une évaluation de qualité.

La grille d’évaluation et le juge sont entièrement déterministes. L’audit utilise un modèle, mais uniquement pour une question structurelle binaire (“contient-il des IDs d’application ?”), domaine où les juges basés sur des modèles sont les plus fiables.

6. Un plan pour les tâches complexes

Pour une tâche importante et à plusieurs étapes, demandez à l’agent d’écrire un plan avant d’agir ; pour une tâche simple, omettez cette étape. La recherche d’Anthropic sur les agents à long terme a montré qu’il est essentiel de travailler sur une seule fonctionnalité à la fois, car un cycle chargé de corriger tout en même temps échoue, tandis qu’une modification par itération conduit à une solution. Dans cette base de code, la demande précise quatre étapes à suivre dans l’ordre, ce qui constitue un plan intégré : obtenir les données, obtenir les règles, obtenir des signaux externes, prendre une décision. Cet ordre empêche l’agent de passer directement à une décision sans consulter les éléments disponibles, et le plafond de huit étapes permet d’inclure toute la séquence ainsi qu’une tentative de correction supplémentaire.

7. Journalisation

Enregistrez chaque pensée, action et résultat afin d’avoir quelque chose à consulter lors d’une session de débogage à 3 heures du matin. Si une étape n’est pas journalisée, elle n’a pas eu lieu du point de vue du débogage. Les erreurs dans les boucles imbriquées sont presque impossibles à diagnostiquer sans journaux : quelle boucle, quelle application, quelle tentative, quel outil a fourni des résultats incorrects ? Trois niveaux de journalisation permettent de gérer cela. Les lignes de progression telles que [loop3] 3/16 APP-009 ... sont mises à jour en continu, ce qui est indispensable sous Windows où la sortie standard est tamponnée. Les lignes de décision indiquent la décision prise, le statut et le temps d’exécution pour chaque application. Les lignes de confirmation attestent que l’extraction a bien eu lieu.

8. Sens du coût

Les boucles consomment rapidement des jetons, il faut donc commencer par des volumes limités avant d’augmenter progressivement, et observer les premières exécutions avant de laisser une boucle fonctionner sans surveillance. Une seule série d’optimisation sur 16 applications d’entraînement, comprenant cinq tours ainsi que des phases de réflexion et d’audit, coûte près de 2,00 $. Ce n’est qu’une fois que vous connaissez le coût par itération que vous pouvez fixer un budget et décider de continuer ou non. CostMeter suit les dépenses avec quatre décimales et déclenche BudgetExhaustedError avant même qu’un dépassement ne soit enregistré :

# The ordering matters:
if self.spent >= self._budget:          # check BEFORE adding
    raise BudgetExhaustedError(...)
self.spent += cost                       # add AFTER the check clears

Le boucle d’événements détecte également l’épuisement du budget lors de la montée en complexité de manière distincte de l’épuisement pendant le traitement, de sorte qu’un manque de ressources au milieu d’une série d’améliorations ne fait pas perdre le reste de la file d’attente. La boucle fonctionne de manière progressive : elle saute l’étape d’amélioration coûteuse et continue de traiter.

Une liste de vérification avant de la laisser fonctionner seule

Avant de confier à une boucle le soin de fonctionner sans surveillance, vérifiez ce qui suit :

  1. L’objectif est vérifiable par la machine (ici, is_correct() par rapport aux valeurs de référence).
  2. Il existe une limite stricte à chaque niveau (MAX_STEPS, max_retries, BUDGET_USD).
  3. Les outils sont bien définis et documentés (quatre outils, un par tâche).
  4. L’état est conservé après redémarrage (run_state.json ainsi que la liste des leçons).
  5. Le vérificateur est séparé du générateur (middleware de notation et juge déterministe).
  6. Les tâches complexes disposent d’un plan implicite ou explicite (la séquence en quatre étapes mentionnée dans l’énoncé).
  7. Les étapes clés sont enregistrées et mises à jour (les lignes d’avancement [loop3]).
  8. Un budget est défini et suivi dès la première appelée (un CostMeter partagé entre tous les boucles).

Si l’une de ces étapes est omise, le boucle échouera en environnement de production, pas toujours immédiatement mais de manière fiable à terme. La plupart des échecs en production sont dus à l’absence d’étape de vérification, de condition d’arrêt ou d’état persistant. Corrigez d’abord ces points, puis ajustez le reste.

Lectures complémentaires

  • Playwright et TypeScript sans connaître TypeScript : un ingénieur, une couche d’IA et quatre étapes clés — Comment un ingénieur en tests avec de l’expérience en Selenium et C# a développé un framework Playwright en TypeScript à l’aide de l’IA : structure, règles d’architecture, compétences nécessaires, points d’ancrage et un processus en quatre étapes pour gérer les tâches.
  • Ingénierie des boucles pour les agents d’IA : cinq composants, quatre formes possibles et la création d’un modèle LangGraph — Comment concevoir intentionnellement la boucle derrière un agent : ses cinq composants, quatre formes courantes, un agent de recherche capable de vérifier et de réessayer dans LangGraph, des mécanismes de protection en cas d’échec et les situations où il ne faut pas utiliser de boucle.
  • Agents auto-améliorants : guérison, apprentissage et changement contrôlé — Une architecture pour des agents capables de se rétablir en temps de exécution, d’apprendre entre différentes exécutions et de changer uniquement grâce à des mises à jour évaluées et testées, en utilisant LangGraph, MongoDB et Phoenix.
  • Des boucles d’agents aux graphes de flux de travail : état typé, portes de validation et récupération limitée — Pourquoi les agents à exécution prolongée dépassent les limites d’une seule boucle, et comment des nœuds explicites, des arêtes, des artefacts typés, des transitions vérifiées et des permissions par nœud rendent ces agents fiables.
  • Lister les boucles exécutées par un agent IA dans une application mobile — Quatre questions auxquelles chaque boucle d’agent doit répondre, six boucles imbriquées de la touche à la segmentation, et cinq manières pour une vérification d’indiquer qu’elle est terminée sans rien détecter.
  • Ingénierie des boucles : Construire des boucles d’agent avec des vérificateurs et des règles d’arrêt — Un guide pratique pour créer quatre boucles d’agent superposées : les bases de ReAct, les évaluateurs de grille, le routage LangGraph, un contrat de boucle, les déclencheurs d’événements, ainsi que la méthode de montée en pente et les garde-fous contre les échecs.