Première exécution réelle de l'évaluation, 20 h 48 : l'agent ne répond plus. Les journaux défilent : 112 appels à un outil Filtre et 73 à google:python_interpreter, deux outils qui n'existent pas. Le modèle avait reçu un paramètre filtres: list[Filtre] (des objets imbriqués) et s'était mis à « écrire du Python » au lieu d'appeler l'outil, en boucle, jusqu'au quota. Cette leçon construit l'agent et montre les trois réglages qui l'ont rendu fiable.
Le mécanisme : un outil, un format de sortie, deux bornes
flowchart TD Q["Question + historique de la conversation"] --> CB["before_agent_callback : tour_requetes = []"] CB --> M["Gemini 2.5 Flash<br/>instruction = méthode + catalogue + date du jour"] M -->|"appel d'outil"| T["interroger(métriques, dimensions, filtres texte, période, tri, limite, graphique)"] T -->|"ErreurSemantique / RequeteRefusee"| M T -->|"ok : extrait des lignes"| ST["state.tour_requetes += SQL, lignes, spec, coût"] ST --> M M -->|"set_model_response (output_schema)"| R["Reponse : reponse, limites[], suites[]"] M -.->|"max_llm_calls = 8"| X["arrêt propre : réponse d'échec"]
| Élément | Rôle | Fichier |
|---|---|---|
instruction (fonction) | méthode en 6 points + catalogue généré depuis le YAML + date du jour | agent.py |
interroger | seul outil ; compile, valide, exécute, choisit le graphique, écrit l'état | idem |
output_schema=Reponse | force la réponse finale en JSON : reponse, limites, suites | idem |
before_agent_callback | remet à zéro la liste des requêtes du tour | idem |
RunConfig(max_llm_calls=8) | coupe toute boucle | idem |
Le modèle ne renvoie jamais les données à l'interface. Les lignes, le SQL, la spécification du graphique et le coût vont dans l'état de session (tool_context.state), que l'API lit après le tour. Le texte du modèle ne sert qu'à commenter. Un chiffre inventé dans la phrase ne peut donc pas contaminer le tableau ni le graphique, qui viennent de BigQuery.
La mise en œuvre
L'outil : des paramètres plats
def interroger(
metriques: list[str],
tool_context: ToolContext,
dimensions: Optional[list[str]] = None,
filtres: Optional[list[str]] = None, # « pays=France », « categorie!=Socks|Underwear »
date_debut: Optional[str] = None,
date_fin: Optional[str] = None,
tri: Optional[str] = None,
ordre: Literal["asc", "desc"] = "desc",
limite: int = 100,
graphique: Optional[Literal["nombre", "ligne", "barres", "lignes_multiples", "carte_chaleur"]] = None,
) -> dict:
"""Interroge les ventes via la couche sémantique et renvoie le résultat. ..."""ADK construit la déclaration de fonction à partir de la signature et de la docstring. Avec list[Filtre] (un modèle Pydantic), la déclaration contenait un $defs et une référence $ref ; Gemini 2.5 Flash a répondu par des appels Filtre(...) séparés puis par du pseudo-code. Avec des chaînes dimension=valeur1|valeur2, analysées par Filtre.depuis_texte côté code, le même modèle produit du premier coup :
CALL interroger {"metriques": ["articles_vendus"], "filtres": ["categorie=Jeans", "pays=France"],
"date_debut": "2025-01-01", "date_fin": "2026-01-01", "dimensions": []}Les erreurs de compilation et de garde-fou reviennent au modèle sous forme de {"statut": "refus", "erreur": "..."}. Le message contient la correction (« Valeurs exactes : ... | Brasil | ... »), ce qui suffit au modèle pour réessayer.
La sortie structurée
class Reponse(BaseModel):
reponse: str = Field(description="Réponse en français, 2 à 5 phrases, chiffres arrondis et unités.")
limites: list[str] = Field(description="1 à 3 limites ou précautions de lecture du résultat.")
suites: list[str] = Field(description="3 questions de suite pertinentes, formulées comme l'utilisateur.")
Agent(name=APP, model=Gemini(model=config.MODEL, retry_options=types.HttpRetryOptions(initial_delay=2, attempts=5)),
instruction=lambda ctx: instruction(), tools=[interroger], output_schema=Reponse,
before_agent_callback=reinitialiser_tour,
generate_content_config=types.GenerateContentConfig(temperature=0))ADK 2.10 accepte output_schema avec des outils : il ajoute un outil interne set_model_response que le modèle appelle en dernier. L'interface reçoit un JSON validé ; les « questions de suite » deviennent des boutons sans analyse de texte fragile. instruction est une fonction pour que la date du jour soit recalculée à chaque tour (« le mois dernier » change le 1er du mois).
La conversation
Assistant.demander() réutilise la session ADK quand l'interface renvoie un conversation_id. Le modèle voit donc l'historique et comprend « Et par rayon, trimestre par trimestre ? » après une question sur le chiffre d'affaires mensuel (capture de la leçon suivante).
Lancez une question réelle en ligne de commande :
.venv/bin/python -m assistant.agent "Quel est le taux de retour au Brésil par rayon en 2025 ?"Résultat obtenu le 2026-10-15 :
En 2025, le taux de retour au Brésil était de 28,4 % pour le rayon Femme et de 27,1 % pour le rayon Homme.
[barres] 2 lignes, 31.5 Mo facturés
SELECT
p.department AS departement,
SAFE_DIVIDE(COUNTIF(oi.status = 'Returned'), COUNTIF(oi.status IN ('Complete', 'Returned'))) AS taux_retour
FROM `bigquery-public-data.thelook_ecommerce.order_items` AS oi
LEFT JOIN `bigquery-public-data.thelook_ecommerce.products` AS p ON p.id = oi.product_id
LEFT JOIN `bigquery-public-data.thelook_ecommerce.users` AS u ON u.id = oi.user_id
WHERE oi.created_at < CURRENT_TIMESTAMP()
AND oi.created_at >= TIMESTAMP(@date_debut)
AND oi.created_at < TIMESTAMP(@date_fin)
AND u.country IN UNNEST(@f0)
GROUP BY 1
ORDER BY taux_retour DESC
LIMIT 100
Limites :
- Le taux de retour est calculé sur les articles livrés (statut Complete + Returned), les commandes en cours ne sont pas prises en compte.
- Le Brésil est orthographié 'Brasil' dans les données.« Brésil » est devenu Brasil grâce au catalogue, et la limite citée est la définition du YAML, mot pour mot. Les tests tests/test_agent.py rejouent ce circuit avec un faux modèle scripté (une valeur fausse, refusée avant BigQuery, puis corrigée) : make test le vérifie sans réseau.
Ce qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
Appels à des outils inexistants (Filtre, google:python_interpreter), boucle jusqu'au quota | paramètre d'outil en objets imbriqués ($ref) | paramètres plats (chaînes, listes de chaînes), analysés et validés par le code |
| Une question consomme des dizaines d'appels modèle | aucune borne sur la boucle outil-modèle | RunConfig(max_llm_calls=8) et réponse d'échec propre (LlmCallsLimitExceededError) |
| L'agent écrit « résultats limités aux 500 premières lignes » alors qu'il y en a 313 | le champ tronque signifiait « extrait de 60 lignes pour le modèle », il l'a lu comme une limite de la requête | champs explicites : nb_lignes_total, seules_les_premieres_lignes_sont_montrees_ici, limite_de_lignes_atteinte |
| Le chiffre de la phrase diffère du tableau | le modèle arrondit ou recalcule | tableau et graphique issus de l'état, jamais du texte ; l'évaluation compare les valeurs de la requête |
429 RESOURCE_EXHAUSTED en rafale | quota Vertex AI partagé | HttpRetryOptions (attente croissante), plafond d'appels, et un message 503 sobre côté API |
La règle de décision
- Un seul outil tant que les questions se ramènent à « métriques × axes × filtres × période ». Ajoutez un second outil (par exemple une recherche de valeurs de marque) seulement quand l'évaluation montre une famille d'échecs qu'il résout.
output_schemadès qu'une interface consomme la réponse ; texte libre seulement pour un chat sans boutons.- Température 0 : la même question doit produire la même requête, sinon l'évaluation n'a pas de sens.
Pratique
Vous ajoutez à interroger un paramètre comparer_a: {periode_debut, periode_fin} (objet). Après déploiement, des appels d'outils inconnus apparaissent dans les journaux. Quelle correction tentez-vous en premier ?
C'est la cause observée dans ce projet : les objets imbriqués dans la déclaration de fonction perturbent l'appel d'outil. Aplatir le schéma règle la cause. Un modèle plus grand masque parfois le symptôme pour plus cher ; une consigne textuelle ne change pas le schéma ; augmenter le plafond prolonge la boucle.
Le directeur financier signale qu'une réponse annonçait « environ 963 000 » alors que le tableau affiche 963 290. Faut-il corriger ?
Le texte commente, le tableau fait foi. Ce partage est volontaire : le modèle reste lisible, la valeur exacte reste disponible et vérifiable. Ce qui serait grave est un écart de valeur, pas d'arrondi ; l'évaluation le détecte en comparant les valeurs de la requête, pas le texte.
Exercice. Ajoutez dans Reponse un champ confiance: Literal["haute", "moyenne", "basse"] et la règle qui le remplit. Où placez-vous la règle : dans l'instruction du modèle, ou dans le code après le tour ? Justifiez.
Voir la solution
Dans le code. La confiance se déduit de faits observables, pas d'une impression du modèle :
def confiance(requetes: list[dict], question_refusee: bool) -> str:
if question_refusee or not requetes:
return "basse"
derniere = requetes[-1]
if len(requetes) > 2 or len(derniere["lignes"]) >= 500: # corrections multiples ou limite atteinte
return "moyenne"
return "haute"Appelez-la dans Assistant.demander() après le tour et ajoutez le champ au dictionnaire renvoyé, sans toucher à output_schema. Un LLM interrogé sur sa propre confiance répond « haute » de façon peu informative ; le nombre de corrections et l'atteinte de la limite sont des signaux mesurables, que l'on peut tester avec le faux modèle.
- Je sais pourquoi les paramètres d'outil doivent rester plats
- Je sais où vivent les données d'un tour (état de session) et pourquoi pas dans le texte
- Je sais borner une boucle d'agent avec
RunConfig(max_llm_calls=...) - J'ai lancé
python -m assistant.agentsur une question réelle