درس 1 — La boucle

Une boucle que vous contrôlez

6 دقائق قراءة

هذا المحتوى غير مترجم بعد. تُعرض النسخة الأصلية.

Une boucle que vous contrôlez

Le SDK google-genai sait exécuter vos fonctions Python tout seul : vous passez une liste de fonctions, il appelle le modèle, exécute ce qu'il demande, rappelle le modèle, et vous rend la réponse finale. Pour un prototype, c'est parfait. Pour un agent qui modifie un dépôt, c'est inacceptable : entre deux tours, personne ne vérifie la permission, ne prend de point de reprise, ne tronque une sortie de 80 000 caractères ni ne compte les tokens. Le harness commence au moment où vous désactivez cet automatisme.

Le mécanisme

Le function calling fonctionne en deux temps. Vous déclarez des outils (nom, description, schéma JSON des arguments). Le modèle répond soit par du texte, soit par une ou plusieurs parties function_call : il demande, il n'exécute rien. Votre programme exécute, puis renvoie une partie function_response portant le même id. Le modèle ne voit jamais votre code, seulement les déclarations et les résultats.

sequenceDiagram
  participant H as Harness (agent.py)
  participant M as Gemini
  participant O as Outils
  H->>M: système + historique + 5 déclarations
  M-->>H: function_call run_command("python -m pytest -q")
  H->>H: permission, point de reprise, journal
  H->>O: exécution dans le bac à sable
  O-->>H: sortie (tronquée à 4 000 caractères)
  H->>M: function_response (même id)
  M-->>H: texte sans appel
  H->>H: statut « terminé », métriques

Une trajectoire est la suite complète de ces échanges pour une tâche. Un tour est un aller-retour avec le modèle. Sur la démo de ce projet, la trajectoire réelle compte 13 à 17 tours.

La mise en œuvre

Le point de départ : désactiver l'exécution automatique et garder le client. Un genai.Client() créé à la volée et non référencé se ferme, et l'appel suivant échoue.

files/harness/model.py
self.client = genai.Client(http_options=types.HttpOptions(timeout=timeout_s * 1000))
...
config = types.GenerateContentConfig(
    system_instruction=system,
    tools=[types.Tool(function_declarations=[
        types.FunctionDeclaration(name=t["name"], description=t["description"],
                                  parameters_json_schema=t["parameters"])
        for t in tools
    ])],
    automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),  # c'est NOTRE boucle
)

La boucle elle-même (agent.py) vérifie trois conditions d'arrêt avant chaque appel, puis traite la réponse :

files/harness/agent.py
while True:
    if report.turns >= self.s.max_turns:                      # 25 par défaut
        report.status = "tours épuisés"; break
    if report.prompt_tokens + report.output_tokens >= self.s.session_token_cap:
        report.status = "budget épuisé"; break
    if last_prompt > self.s.compact_ratio * self.s.context_budget:
        history, n = context.compact(...)                     # leçon 5

    reply = self.model.generate(SYSTEM, history, DECLARATIONS)
    history.append(reply.content)                             # contenu complet, signatures comprises
    calls = [p.function_call for p in reply.content.parts or [] if p.function_call]
    if not calls:
        report.status, report.final_text = "terminé", text; break
    responses = [types.Part(function_response=types.FunctionResponse(
        id=c.id, name=c.name, response=self.execute(c.name, dict(c.args or {}), report)))
        for c in calls]
    history.append(types.Content(role="user", parts=responses))

Trois choix méritent d'être défendus.

  1. On ajoute reply.content tel quel, pas une reconstruction. Les modèles Gemini récents attachent une signature de raisonnement (thought_signature) aux appels ; la reconstruire à la main la perd et dégrade les tours suivants.
  2. Les tokens de raisonnement sont comptés en sortie. usage_metadata.thoughts_token_count est facturé au tarif de sortie : l'oublier sous-estime le coût.
  3. Le modèle est une interface, pas une classe concrète. Model.generate() et Model.summarize() suffisent, ce qui permet un modèle scénarisé (scripted.py) qui rejoue des appels d'outils sans réseau. Les tests de la boucle tournent donc en 5 secondes, sans clé.

Une dernière garde vient de l'expérience : trois appels identiques consécutifs (même outil, mêmes arguments) arrêtent la session avec le statut « boucle détectée ». Un modèle qui relit le même fichier en boucle consomme des tokens sans rien produire.

Résultat obtenu

$ make test          # 2026-10-15
............................                                             [100%]
28 passed in 3.55s
$ make run           # sans clé : scénario hors ligne
Mode hors ligne : modèle scénarisé (aucune clé, aucun appel réseau).
Avant  : 4 failed, 2 passed in 0.09s
Après  : 6 passed in 0.03s
  statut                terminé
  tours                 7
  appels d'outils       9

Ce qui casse en production

SymptômeCauseParade
400 INVALID_ARGUMENT au deuxième tourfunction_response sans l'id de l'appel, ou parties réordonnéesrenvoyer id=c.id, une réponse par appel, dans l'ordre
L'agent « oublie » son raisonnement entre deux tourscontenu du modèle reconstruit à la main, signature perdueajouter reply.content intact à l'historique
Facture deux fois plus élevée que prévutokens de raisonnement ignorés dans le calculcandidates_token_count + thoughts_token_count au tarif de sortie
Une session tourne 40 minutesaucune limite de tours ni de tokensMAX_TURNS, SESSION_TOKEN_CAP, détection de répétition
client has been closedclient créé dans une fonction et libéréun client par instance de GeminiModel, conservé

La règle de décision

Gardez l'exécution automatique du SDK pour les assistants dont les outils sont en lecture seule et sans coût. Dès qu'un outil écrit, exécute ou coûte, écrivez la boucle : c'est 60 lignes, et c'est le seul endroit où les contrôles des leçons suivantes peuvent s'insérer. À surveiller : la distribution du nombre de tours par tâche, et le taux de sessions qui finissent autrement que par « terminé ».

Pratique

Votre agent répond correctement au premier tour, puis l'API renvoie une erreur 400 dès qu'il appelle deux outils dans la même réponse. Que vérifiez-vous en premier ?

Un collègue propose de garder automatic_function_calling activé et d'ajouter les contrôles de permission dans chaque fonction Python exposée. Quel est le défaut principal ?

Exercice. Ajoutez une condition d'arrêt « durée maximale » (MAX_SECONDS, 300 par défaut) avec le statut « délai dépassé », et un test hors ligne qui la déclenche.

Voir la solution

Dans Settings : max_seconds: int = field(default_factory=lambda: int(_env("MAX_SECONDS", "300"))).

Dans la boucle, avec les autres gardes :

if time.monotonic() - t_start >= self.s.max_seconds:
    report.status = "délai dépassé"
    break

Test : un ScriptedModel dont generate est enveloppé pour dormir 0,2 s, avec max_seconds=0 via dataclasses.replace. Le statut doit être « délai dépassé » après 0 tour. Ne mettez pas cette garde dans l'outil : un appel au modèle lent (60 s de raisonnement) doit lui aussi compter.

  • Je sais désactiver l'exécution automatique des outils et écrire la boucle
  • Je sais construire une function_response valide pour plusieurs appels simultanés
  • Je compte les tokens de raisonnement dans le coût
  • Je teste ma boucle sans clé avec un modèle scénarisé
التالي