Tester, mesurer, exploiter
1. Ce qu'il faut prouver
flowchart LR T1["test_grille_valide_et_refuse"] --> G["la grille accepte et refuse ce qu'il faut"] T2["test_boucle_s_arrete_des_que_la_grille_est_satisfaite"] --> S["escalate arrête la boucle, pas de révision inutile"] T3["test_boucle_bornee_par_max_iterations"] --> B["jamais plus de MAX_TOURS tours"]
Les trois propriétés qui comptent dans une boucle : elle juge juste, elle s'arrête quand c'est bon, elle s'arrête toujours.
2. Des faux modèles qui comptent leurs appels
class FauxModele(BaseLlm):
model: str = "faux"
script: list = []
appels: int = 0
async def generate_content_async(self, llm_request, stream=False):
self.appels += 1
yield LlmResponse(content=types.Content(role="model", parts=[types.Part(text=self.script.pop(0))]))Le critique « répond » des notes en JSON, comme le ferait Gemini avec le schéma :
def test_boucle_s_arrete_des_que_la_grille_est_satisfaite():
modeles, f = faux([note(3), note(5)], ["v1 " + TEXTE_OK, "jamais utilisé"])
etat = asyncio.run(a.rediger("brief", agent=a.construire(f)))
assert etat["valide"] and etat["tours"] == 2
assert etat["brouillon"].startswith("v1")
assert modeles["reviseur"].appels == 1 # pas de réécriture après validation
assert [h["valide"] for h in etat["historique"]] == [False, True]def test_boucle_bornee_par_max_iterations():
n = a.MAX_TOURS
modeles, f = faux([note(2)] * n, [f"v{i} " + TEXTE_OK for i in range(1, n + 1)])
etat = asyncio.run(a.rediger("brief", agent=a.construire(f)))
assert not etat["valide"] and etat["tours"] == n and modeles["critique"].appels == nDans test_boucle_bornee_par_max_iterations, que vaut etat["valide"] et pourquoi ?
Le critique donne 2/5 à chaque tour : l'arbitre ne valide jamais. LoopAgent s'arrête après MAX_TOURS, et valide reste False : le texte doit passer par un humain.
3. Exécution réelle
cd files && pip install -r requirements.txt
pytest -q # sans clé
cp .env.example .env # mettez votre clé dans .env
python agent.py # 3 à 9 appels au modèle4. Mesurer avec l'historique
L'arbitre range chaque tour dans state["historique"]. Sur plusieurs briefs, c'est votre tableau de bord :
| Indicateur | Comment le calculer | Ce qu'il révèle |
|---|---|---|
| Taux de validation | part des exécutions avec valide=True | grille trop dure si trop bas |
| Tours moyens | moyenne de tours | coût : 2 × tours appels si validé, 1 + 2 × tours sinon |
| Gain par tour | différence de moyenne des notes entre tours | utile de boucler ? |
| Validé au tour 1 | part des premiers jets acceptés | grille trop indulgente si proche de 100 % |
Sur 50 briefs, 49 textes sont validés dès le tour 1. Que conclure en priorité ?
Une boucle qui ne boucle jamais ne sert à rien. Soit le premier jet est vraiment excellent (à confirmer par des humains), soit le seuil est trop bas. C'est ce qui nous est arrivé avec une moyenne minimale à 4,5.
5. Exploiter
- Brief en entrée, texte et historique en sortie : stockez les deux. L'historique explique pourquoi un texte a été accepté.
- Statut non validé : envoyez-le dans une file de relecture humaine plutôt que de le publier.
- Mentions obligatoires : dans le lab elles sont en dur ; en production, mettez-les dans le brief (
state["mentions"]) et lisez-les dansarbitrer(). - Sur Google Cloud : le même pipeline tourne sur Cloud Run ou Vertex AI Agent Engine avec
GOOGLE_GENAI_USE_VERTEXAI=TRUE, sans modifier le code.
Exercice : rendre les mentions obligatoires paramétrables
Modifiez le code pour que les mentions obligatoires viennent de l'état de session (state["mentions"], une liste) au lieu de la constante.
Voir la solution
def arbitrer(evaluation: dict, texte: str, mentions: list[str]) -> tuple[bool, list[str]]:
...
consignes += [f"mention obligatoire absente : « {m} »" for m in mentions if m not in texte]
...
# dans Arbitre._run_async_impl
ok, consignes = arbitrer(s.get("evaluation") or {}, s.get("brouillon", ""), s.get("mentions", []))
# dans rediger()
state={"brief": brief, "mentions": mentions}Pensez à mettre à jour les appels à arbitrer() dans test_agent.py.
Résultat obtenu
Tests sans clé (2026-10-15) :
test_agent.py::test_grille_valide_et_refuse PASSED
test_agent.py::test_boucle_s_arrete_des_que_la_grille_est_satisfaite PASSED
test_agent.py::test_boucle_bornee_par_max_iterations PASSED
============================== 3 passed in 1.27s ===============================Historique de l'exécution réelle du même jour : 2 tours, 4 appels au modèle (1 rédacteur, 2 critiques, 1 réviseur ; l'arbitre est du code). La moyenne passe de 4,4 à 4,8 ; seul le critère clarte reste à 4.
{'tour': 1, 'evaluation': {'clarte': 4, 'concret': 5, 'appel_action': 5, 'ton': 4, 'correction': 4, ...}, 'valide': False}
{'tour': 2, 'evaluation': {'clarte': 4, 'concret': 5, 'appel_action': 5, 'ton': 5, 'correction': 5, ...}, 'valide': True}Je sais faire
- Je teste l'arrêt sur succès et l'arrêt de sécurité d'une boucle sans clé d'API
- Je compte les appels d'un faux modèle pour détecter une boucle qui tourne trop
- J'exploite l'historique pour mesurer le taux de validation et le coût par texte
- Je route les textes non validés vers une relecture humaine