cd ../back to blog
$Guide//June 4, 2026//8 min read

Construire une boucle d’agent IA, garde-fous compris

Construis une vraie boucle d’agent sur la passerelle OpenAI-compatible de Brievio : plafond d’itérations, aiguillage d’outils validé et budget par exécution sur des tokens honnêtes.

Un agent, c’est ce que tu obtiens quand tu mets l’ usage des outils dans une boucle. Un appel d’outil répond à une question ; un agent appelle un outil, lit le résultat, décide de la suite, et continue jusqu’à ce que la tâche soit réellement terminée — chercher, puis lire le meilleur résultat, puis vérifier un prix, puis rédiger la réponse. Le mécanisme est simple et ce sont les mêmes quatre temps à chaque fois : le modèle demande un outil, tu l’exécutes, tu réinjectes le résultat, tu rappelles le modèle. Le difficile, ce n’est pas la boucle. Ce sont les garde-fous qui l’empêchent de tourner sans fin ou de te facturer discrètement 40 $ sur une seule exécution.

Cet article construit une vraie boucle d’agent exécutable contre https://api.brievio.com/v1 avec le SDK Python d’OpenAI, puis l’enveloppe dans les quatre contrôles qui la rendent sûre à mettre en production : un plafond d’itérations strict, un aiguillage des outils validé, un budget coût par exécution lu sur des décomptes de tokens honnêtes, et une gestion saine des cas où le modèle dérape. Chaque extrait s’exécute tel quel ; remplace claude-sonnet-4-6 par gemini-2.5-pro et le même code pilote un autre modèle.

La boucle, et pourquoi elle a besoin d’un plafond

Voici tout le moteur. C’est la boucle d’usage d’outils que tu connais déjà, avec un ajout qui change tout : for step in range(MAX_ITERS) au lieu de while True.

agent_loop.py
# La boucle d'agent avec un plafond d'itérations strict. Modèle -> tool_calls ->
# exécution -> on réinjecte les résultats -> on recommence, jusqu'à ce que le modèle
# réponde en prose ou qu'on atteigne le plafond. Ce plafond, c'est la différence
# entre « agent » et « facture incontrôlée ».
from openai import OpenAI
import json

client = OpenAI(
    api_key="sk-brievio-...",
    base_url="https://api.brievio.com/v1",
)

MAX_ITERS = 8   # la plupart des tâches finissent en 2-4 tours ; 8, c'est une marge généreuse.

def run_agent(question: str, model: str = "claude-sonnet-4-6") -> str:
    messages = [
        {"role": "system", "content": "You are a helpful research agent. "
         "Use the tools when you need live data. Answer directly when you "
         "already know enough."},
        {"role": "user", "content": question},
    ]

    for step in range(MAX_ITERS):
        resp = client.chat.completions.create(
            model=model,
            messages=messages,
            tools=TOOLS,
            tool_choice="auto",
        )
        msg = resp.choices[0].message

        # Aucun outil demandé -> c'est la réponse finale. Terminé.
        if not msg.tool_calls:
            return msg.content

        # On ajoute le tour de l'assistant EXACTEMENT tel que renvoyé — il porte les
        # identifiants de tool_call que les messages suivants doivent référencer.
        messages.append(msg)

        # On exécute chaque appel demandé et on ajoute un message tool par identifiant.
        for call in msg.tool_calls:
            result = dispatch(call)              # voir l'extrait suivant
            messages.append({
                "role": "tool",
                "tool_call_id": call.id,          # DOIT correspondre à l'id de l'appel
                "content": json.dumps(result),
            })
        # On boucle : le modèle voit maintenant la sortie de l'outil et continue.

    # Plafond atteint sans résolution. Échoue bruyamment — ne boucle pas en silence à l'infini.
    raise RuntimeError(f"agent did not finish within {MAX_ITERS} iterations")

Ce for borné est la ligne la plus importante d’un agent. Un modèle compétent sur une tâche bien cadrée finit en deux à quatre tours. Mais les modèles s’embrouillent : ils lancent deux fois la même recherche, s’enfoncent dans une impasse, ou — l’échec classique — appellent un outil, obtiennent un résultat qui ne leur plaît pas, et le rappellent avec des arguments quasi identiques, à l’infini. Un while True transforme ça en facture sans limite et en requête bloquée. Le plafond convertit « tourne sans fin » en « échoue après 8 tentatives avec une erreur claire », ce que tu peux intercepter, journaliser et dont tu peux te remettre. Choisis le nombre selon ta tâche : une recherche en un coup en demande 2, un agent de recherche multi-étapes peut-être 10. Règle-le délibérément ; ne le laisse pas sans limite.

Note l’autre garde-fou caché en pleine vue : gérer le cas où le modèle n’appelle pas d’outil. Quand msg.tool_calls est vide, c’est que le modèle a décidé qu’il en sait assez pour répondre — c’est ta sortie, pas une erreur. Une boucle qui suppose que chaque tour produit un appel d’outil va soit planter, soit ne jamais se terminer. Ramifie sur les deux issues à chaque itération.

Aiguillage des outils : le modèle propose, ton code dispose

Le modèle ne touche jamais à tes systèmes. Il émet un nom de fonction et une chaîne JSON d’arguments, puis s’arrête ; c’est ton code qui décide d’honorer ou non cet appel. Cette frontière, c’est toute l’histoire de sécurité d’un agent, et c’est donc dans la fonction d’aiguillage que vit la validation — non par souci de propreté, mais parce que chaque argument est une sortie de modèle non fiable, exactement comme un champ de formulaire rempli par un inconnu.

dispatch.py
# Aiguillage des outils avec validation. Le modèle PROPOSE un appel ; ton code
# en DISPOSE. Chaque argument est une sortie de modèle non fiable — analyse-la,
# vérifie que le nom est bien un nom que tu as enregistré, et valide les types avant d'exécuter.
def get_weather(city: str, unit: str = "celsius") -> dict:
    if not isinstance(city, str) or not city.strip():
        raise ValueError("city must be a non-empty string")
    if unit not in ("celsius", "fahrenheit"):
        raise ValueError(f"unsupported unit: {unit!r}")
    return {"city": city, "temp": 18, "unit": unit, "sky": "clear"}

# Liste blanche : un modèle ne peut appeler que ce que tu as explicitement enregistré.
TOOL_IMPLS = {"get_weather": get_weather}

TOOLS = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "Get current weather for a city.",
        "parameters": {
            "type": "object",
            "properties": {
                "city": {"type": "string"},
                "unit": {"type": "string",
                         "enum": ["celsius", "fahrenheit"]},
            },
            "required": ["city"],
        },
    },
}]

def dispatch(call) -> dict:
    name = call.function.name
    fn = TOOL_IMPLS.get(name)
    if fn is None:
        # Le modèle a halluciné un outil. Ne fais pas planter la boucle — renvoie
        # l'erreur comme résultat d'outil pour que le modèle puisse se corriger.
        return {"error": f"unknown tool: {name}"}

    try:
        args = json.loads(call.function.arguments)   # toujours une CHAÎNE JSON
    except json.JSONDecodeError:
        return {"error": "arguments were not valid JSON"}

    try:
        return fn(**args)
    except (TypeError, ValueError) as e:
        # Mauvais arguments (mauvais type, champ manquant, hors plage). Réinjecte le
        # message ; le modèle réessaie en général avec un appel corrigé.
        return {"error": str(e)}

Trois modes de défaillance sont gérés ici, et tous réinjectent l’erreur au modèle au lieu de faire planter la boucle. Un nom d’outil halluciné que le modèle a inventé mais que tu n’as jamais enregistré — la liste blanche l’attrape. Un JSON malformé dans la chaîne d’arguments — rare sur un vrai modèle phare, mais tu l’analyses quand même de façon défensive. Et des valeurs d’arguments incorrectes — mauvais type, champ requis manquant, un enum que le modèle a inventé. Dans tous les cas, renvoyer {"error": "..."} comme résultat d’outil vaut mieux que lever une exception, parce que le modèle lit ce message au tour suivant et corrige en général son propre appel. Un agent capable de se remettre de ses propres erreurs est bien plus robuste qu’un agent qui meurt au premier mauvais argument.

Garde la liste blanche serrée. TOOL_IMPLS.get(name) signifie qu’un modèle — authentique ou non — ne pourra jamais invoquer que les fonctions que tu as explicitement enregistrées. Ce simple dictionnaire est ton rayon d’impact. Si un outil supprime des données, débite une carte ou envoie un e-mail, place-le derrière une confirmation explicite plutôt que de laisser la boucle le déclencher de façon autonome.

Le garde-fou de budget : les boucles renvoient un contexte qui grossit

Le plafond d’itérations borne combien de fois tu appelles le modèle. Il ne borne pas combien chaque appel coûte — et dans une boucle, le coût grimpe à chaque tour. La raison est structurelle : chaque tour renvoie toute la conversation jusqu’ici, plus chaque résultat d’outil qui s’y ajoute. Le premier tour fait peut-être 800 tokens d’entrée ; le sixième, après que cinq sorties d’outils se sont accumulées, peut en faire 6 000. Huit tours bon marché s’additionnent discrètement en une exécution pas si bon marché. La parade, c’est un second plafond, indépendant, sur la dépense, calculé à partir des vrais décomptes de tokens que chaque appel renvoie :

budget_guard.py
# Un garde-fou de budget coût/tokens par exécution. Chaque tour de boucle renvoie un
# contexte qui GROSSIT (historique + sorties d'outils), donc le coût grimpe à chaque
# tour. Lis l'objet usage honnête après chaque appel, chiffre-le, et arrête-toi
# quand l'exécution dépasse son budget — indépendamment du plafond d'itérations.
from decimal import Decimal

# Tarifs Brievio publiés, USD par 1M de tokens (~15 % sous le tarif officiel).
RATES = {
    "claude-sonnet-4-6": {"in": Decimal("2.55"), "out": Decimal("12.75")},
    "claude-haiku-4-5":  {"in": Decimal("0.85"), "out": Decimal("4.25")},
}

def call_cost(model: str, usage) -> Decimal:
    r = RATES[model]
    m = Decimal("1000000")
    return usage.prompt_tokens * r["in"] / m + usage.completion_tokens * r["out"] / m

RUN_BUDGET = Decimal("0.10")   # 10 centimes par exécution d'agent, plafond strict.

def run_agent_budgeted(question: str, model: str = "claude-sonnet-4-6") -> str:
    messages = [{"role": "user", "content": question}]
    spent = Decimal("0")

    for step in range(MAX_ITERS):
        resp = client.chat.completions.create(
            model=model, messages=messages, tools=TOOLS, tool_choice="auto",
        )
        spent += call_cost(model, resp.usage)   # comptabilise les VRAIS tokens, à chaque tour
        if spent > RUN_BUDGET:
            raise RuntimeError(f"run exceeded ${RUN_BUDGET} (spent ${spent:.4f})")

        msg = resp.choices[0].message
        if not msg.tool_calls:
            return msg.content

        messages.append(msg)
        for call in msg.tool_calls:
            messages.append({"role": "tool", "tool_call_id": call.id,
                             "content": json.dumps(dispatch(call))})

    raise RuntimeError(f"agent did not finish within {MAX_ITERS} iterations")

L’essentiel, c’est que resp.usage sur Brievio porte les décomptes honnêtes de tokens d’entrée et de sortie que le vrai modèle a réellement traités — le total cumulé est donc de l’argent réel, pas une estimation. Lire usage après chaque tour et s’arrêter à RUN_BUDGET signifie qu’un agent embrouillé qui brûlerait sinon huit tours coûteux se fait couper dès qu’il franchit les dix centimes, peu importe le nombre d’itérations que ça a pris. Deux plafonds, deux modes de défaillance couverts : le plafond d’itérations arrête les boucles infinies, le budget arrête les boucles coûteuses. Tu veux les deux, car une boucle peut être courte et chère ou longue et bon marché, et aucun des deux seul ne te protège de l’autre.

Bon à savoir pour le calcul : les appels en échec 4xx/5xx ne sont pas facturés sur Brievio, donc un nouvel essai contre un outil capricieux ou une erreur transitoire en amont ne vide pas le budget de l’exécution — tu ne comptabilises le coût que pour les appels qui ont réellement renvoyé un résultat. La courbe de dépense suit ainsi le travail accompli, pas les erreurs absorbées. Le schéma complet pour borner la dépense par appel et par utilisateur se trouve dans le guide plafonner la dépense de l’API.

Maîtriser la facture de tokens à mesure que la boucle grossit

Borner le coût est une chose ; le réduire en est une autre. Parce que chaque tour renvoie un préfixe qui grossit, le même contexte est payé encore et encore — c’est exactement la forme pour laquelle le cache de prompt est fait. Marque les parties statiques de la requête (le prompt système, les définitions d’outils) comme cachables, et dès le second tour tu paies une fraction du tarif d’entrée sur tout ce qui n’a pas changé. Dans une boucle qui renvoie le même catalogue d’outils de plusieurs milliers de tokens et le même prompt système à chaque tour, c’est de loin le plus gros levier sur la facture.

Deux ou trois habitudes pratiques aident aussi. Garde le prompt système et les définitions d’outils stables sur toute l’exécution — un outil ajouté en cours de boucle ou un horodatage dans le prompt système invalide le cache et double discrètement ton coût d’entrée. Et si un outil peut renvoyer un mur de données (une page web entière, une requête de mille lignes), résume ou tronque le résultat avant de l’ajouter à messages ; le modèle en a rarement besoin en entier, et chaque octet que tu ajoutes est renvoyé à chaque tour suivant. La boucle d’agent est singulièrement sensible à l’enflure du contexte précisément parce que ce contexte est renvoyé N fois, pas une seule.

Mémoire de conversation : que transporter d’une exécution à l’autre

Tout ce qui précède, c’est la mémoire au sein d’une seule exécution — la liste messages est la mémoire de travail de l’agent, et y ajouter des éléments, c’est ainsi que le modèle se souvient de ce qu’il a déjà consulté. Pour un agent multi-tour qui dialogue avec un utilisateur sur plusieurs requêtes, tu transportes cette liste : persiste messages par session (Redis, une colonne de base de données, où tu veux), recharge-la à la requête suivante, et ajoute le nouveau tour utilisateur. La boucle est identique ; seul l’état de départ change.

Ce qu’il faut gérer, c’est la croissance sans limite. Une session de longue durée accumule de l’historique jusqu’à devenir coûteuse à chaque appel et, à terme, à déborder la fenêtre de contexte. Deux stratégies courantes : garder une fenêtre glissante des N derniers tours et abandonner les plus anciens, ou résumer périodiquement l’historique ancien en une note compacte et remplacer les tours bruts par celle-ci. Les deux échangent un peu de fidélité contre une taille de contexte bornée et prévisible. Quel que soit ton choix, le garde-fou de budget par exécution ci-dessus s’applique toujours — c’est le filet qui rattrape une session devenue plus grosse que prévu.

Une seule clé pour un agent qui monte en gamme

Une propriété utile de cette construction derrière Brievio : un agent peut changer de modèle en cours de tâche sans rien changer d’autre. Lance les tours bon marché sur un modèle plus petit et monte en gamme vers un modèle phare seulement quand la tâche est difficile — fais passer l’aiguillage d’outils facile par Haiku 4.5 à 0,85 $ en entrée / 4,25 $ en sortie, et bascule vers Sonnet ou une autre famille pour la réponse finale exigeante en raisonnement. Parce qu’ une seule clé couvre tous les modèles derrière un unique base_url, cette montée en gamme est une modification d’une ligne sur la chaîne model à l’intérieur de la boucle — pas de second SDK, pas de second schéma d’authentification, pas de seconde relation de facturation. Le contrat complet requête/réponse, champs d’outils compris, est dans la documentation Chat Completions, et la liste des modèles en direct avec les identifiants exacts est sur la page des modèles.

Ça ne compte, bien sûr, que si le modèle à l’autre bout est authentique : une boucle d’agent ne pardonne pas un substitut dégradé, car un modèle qui rate les arguments d’outils ou ignore un outil va brûler des itérations et de la dépense à courir après ses propres erreurs. Brievio sert les vrais modèles de première partie, honore l’appel d’outils natif et rapporte des décomptes de tokens honnêtes — ce qui fait que la boucle comme le calcul de budget fonctionnent réellement.

À retenir : quatre garde-fous, puis en production

La boucle elle-même tient en une douzaine de lignes. Ce qui la rend prête pour la production, c’est la frontière autour d’elle :

  • Plafonne les itérations. Un for borné plutôt qu’un while True. Échoue bruyamment au plafond au lieu de tourner sans fin.
  • Gère le cas sans outil. Un tool_calls vide est la sortie, pas une erreur. Ramifie dessus à chaque tour.
  • Valide chaque aiguillage. Liste blanche des noms d’outils, analyse des arguments, vérification des types — et réinjecte les erreurs au modèle au lieu de planter.
  • Budgète l’exécution. Lis usage à chaque tour, chiffre-le face aux tarifs publiés, arrête-toi à un plafond de dépense strict. Surveille le contexte qui grossit, et cache le préfixe statique pour limiter le coût renvoyé.

Réussis ces quatre points et tu as un agent qui fait un vrai travail multi-étapes, se remet de ses propres erreurs, et dont le coût dans le pire des cas est celui que tu as choisi plutôt que découvert sur une facture. Pars du guide sur l’usage des outils s’il te faut d’abord la mécanique d’un appel unique, puis enveloppe-le dans la boucle et les quatre garde-fous ci-dessus.