Lab 12d : Tracabilite de la consommation — le contrat C6 rend l’usage LLM observable
Grain #14687 (registre EPITA #14058, contrat C6). La consommation d’une conversation multi-agents n’est pas un detail d’exploitation : c’est une propriete observable du runtime. Jusqu’a la tracons 1, ADK produisait bien usage_metadata sur ses events, mais AdkRunResult ne le remontait pas — la consommation etait invisible depuis le code.
Ce lab execute de vrais tours LLM (seul le provider du .env change selon la machine) et lit, appel par appel, ce que le runtime consomme :
AdkRunResult.usage_turns : un snapshot AdkUsage (prompt / completion / total) par appel LLM du tour ;
AdkRunResult.usage_total : la somme du tour ;
ConversationRunner.usage_total : le profil cumule d’une conversation entiere.
Jamais une restauration du moteur SK (#14058) : ADK reste le runtime, l’usage est simplement rendu observable au-dessus de lui.
1. Configuration
Le track charge son provider LLM depuis la configuration (config/providers.py). La cle n’est jamais lue ni affichee ici — seuls le provider, le modele et le type d’endpoint le sont.
2. Un tour avec outil : deux appels LLM, deux snapshots
L’agent dataset_profile repond a une question en deux appels LLM : le premier decide d’invoquer l’outil, le second interprete son resultat. Chaque appel laisse un snapshot dans usage_turns — la tracons C6 distingue les deux jambes du tour.
from utils.adk_runtime import run_data_agent, AdkUsageresultat =await run_data_agent("Un dataset contient 30 lignes et 5 colonnes. ""Appelle dataset_profile et interprete le resultat.", timeout_seconds=120,)print(f"Appels LLM du tour : {len(resultat.usage_turns)}")for i, usage inenumerate(resultat.usage_turns, start=1):print(f" appel {i} : prompt={usage.prompt_tokens} "f"completion={usage.completion_tokens} total={usage.total_tokens}")print(f"Cumul du tour (usage_total) : {resultat.usage_total}")print(f"Outil invoque : {resultat.tool_was_invoked}")
Appels LLM du tour : 2
appel 1 : prompt=149 completion=19 total=168
appel 2 : prompt=204 completion=76 total=280
Cumul du tour (usage_total) : AdkUsage(prompt_tokens=353, completion_tokens=95, total_tokens=448)
Outil invoque : True
Lecture du resultat
Le tour laisse deux snapshots distincts. Le premier correspond a la decision d’appel d’outil, le second a l’interpretation : la reponse finale ne sort pas du meme appel que la decision. Le cout d’un tour n’est donc pas un nombre, c’est une decomposition — et usage_total en est la somme. Si le provider ne compte pas ses jetons, usage_turns reste vide : le runtime n’invente rien.
Lu dans les sorties commitees : appel 1 : prompt=149 completion=19 total=168 puis appel 2 : prompt=204 completion=76 total=280, cumul AdkUsage(prompt_tokens=353, completion_tokens=95, total_tokens=448). Chaque composante se verifie colonne par colonne : 149 + 204 = 353 prompts, 19 + 76 = 95 completions, 168 + 280 = 448 totaux — et la somme croisee 353 + 95 = 448 referme le compte. L’interpretation coute plus cher que la decision (280 contre 168, soit +67 %), et son prompt grossit de 55 jetons — la place prise par le resultat de l’outil dans le contexte du second appel. C’est exactement la propriete que l’exercice 2 demandera de tester sur un tour quelconque.
3. Profil d’une conversation multi-tours : la memoire a un prix
Le contrat C1b (persistance entre tours, Lab12e) fait revenir tout l’historique a chaque appel. C6 rend ce cout mesurable : dans une conversation de trois tours, le prompt_tokens de chaque tour doit croitre — c’est la trace chifree de la memoire qui s’accumule.
import asynciofrom utils.adk_conversation import ConversationRunnerfrom utils.adk_runtime import build_data_agentasyncdef conversation_trois_tours(): agent = build_data_agent() lignes_profil = []asyncwith ConversationRunner(agent) as conversation: questions = ["Un dataset contient 12 lignes et 3 colonnes. Appelle dataset_profile.","Souviens-toi du dataset precedent : combien de cellules avait-il ? ""Appelle dataset_profile pour verifier.","Toujours pour ce meme dataset : quelle est la densite par colonne ? ""Appelle dataset_profile une derniere fois.", ]for i, question inenumerate(questions, start=1): tour =await conversation.turn(question, timeout_seconds=120) lignes_profil.append( (i, tour.usage_total.prompt_tokens, tour.usage_total.completion_tokens, conversation.usage_total.total_tokens))return lignes_profilprofil =await conversation_trois_tours()print("tour | prompt | completion | cumul conversation")print("-----|--------|------------|------------------")for i, prompt, completion, cumul in profil:print(f" {i} | {prompt:4d} | {completion:3d} | {cumul:5d}")
La colonne promptcroit a chaque tour : le deuxieme et le troisieme appel reçoivent l’historique complet des precedents. C’est le cout marginal de la persistance C1b — un cout reel, maintenant mesurable au lieu d’imaginaire. La colonne completion reste du meme ordre : c’est le contexte qui coute, pas les reponses. La derniere colonne est ce qu’un budget de conversation devra borner (jambe 2 du grain #14687).
Lu dans les sorties commitees (entete tour | prompt | completion | cumul conversation) : les trois lignes lisent 343/69/412, 607/59/1078 et 857/70/2005. Deltas de prompt : +264 puis +250 — chaque tour ajoute un bloc quasi constant, la trace de l’historique qui revient entier. La completion reste plate : 69, 59, 70 (etendue de 11 jetons sur trois tours). Chaque cumul est la somme exacte des tours passes : 412 + 607 + 59 = 1078, puis 1078 + 857 + 70 = 2005. En trois tours, le prompt d’un seul appel est passe de 343 a 857 — 2,5 fois son cout initial, une croissance quasi lineaire sur ce run (+264 puis +250, environ 250 jetons d’historique par tour) ; c’est ce poids croissant qu’un budget de conversation devra borner.
4. Ce que C6 ne dit pas (encore) : le budget natif n’existe pas
La tracons est portee ; le plafond n’est pas natif. La mesure du registre reste vraie : RunConfig ne porte aucun champ de consommation (max_llm_calls borne des appels, pas les jetons). Verifions-le sur le runtime reellement installe.
from google.adk.runners import RunConfigchamps_budget = [ f for f in RunConfig.model_fieldsif"token"in f.lower() or"cost"in f.lower()]print(f"Champs de budget natif dans RunConfig : {champs_budget or'AUCUN'}")
Champs de budget natif dans RunConfig : AUCUN
Lecture du resultat
La sortie dit verbatim : Champs de budget natif dans RunConfig : AUCUN — le filtre a cherche token et cost dans model_fields de RunConfig sans rien trouver — le budget de jetons devra etre un assemblage au-dessus d’ADK (un plafond sur conversation.usage_total qui coupe proprement), pas une option de configuration. La mesure qui precede donne l’echelle du manque : la conversation de la section 3 est montee a 2005 jetons cumules sans qu’aucune option du runtime ne puisse l’arreter en cours de route. Le contraste est net — par appel, le runtime expose TROIS compteurs (prompt, completion, total) ; pour la conversation entiere, ZERO plafond. Toute la jambe 2 du grain #14687 tient dans cet ecart : la section 5 va assembler au-dessus d’ADK le plafond que RunConfig n’offre pas. La tracabilite la rend possible : on ne peut borner que ce qu’on mesure.
5. Le budget porte au-dessus d’ADK : couper proprement
La section 4 a mesure l’absence de budget natif. Le voici, assemble au-dessus d’ADK : un plafond de jetons cumules par conversation (budget_total_tokens), qui refuse un tour avant tout appel LLM quand le plafond est deja atteint, et coupe au premier appel qui le franchit. La coupe rend un verdict explicite — l’exception typee AdkBudgetExceeded, qui porte le plafond, le cumul exact au moment de la coupe et le numero du tour — pas une exception brute du runtime.
Pour que la demonstration soit deterministe quel que soit le comptage reel du provider, le plafond est derive de la mesure : juste au-dessus du cumul du tour 1, le tour 2 doit le franchir des son premier appel.
from utils.adk_conversation import AdkBudgetExceededfrom utils.adk_runtime import build_data_agentasyncdef demonstration_budget(): agent = build_data_agent()asyncwith ConversationRunner(agent) as conversation:await conversation.turn("Un dataset contient 7 lignes et 2 colonnes. Appelle dataset_profile.", timeout_seconds=120, )# Plafond derive de la mesure : le cumul du tour 1 + une marge mince. conversation.budget_total_tokens = conversation.usage_total.total_tokens +30 plafond = conversation.budget_total_tokenstry:await conversation.turn("Un dataset contient 9 lignes et 3 colonnes. Appelle dataset_profile.", timeout_seconds=120, )return plafond, Noneexcept AdkBudgetExceeded as verdict:return plafond, verdictplafond, verdict =await demonstration_budget()if verdict isNone:print(f"Plafond {plafond} non franchi -- relancer la cellule.")else:print(f"Plafond pose apres le tour 1 : {plafond} jetons")print(f"Verdict : {verdict.verdict} (tour {verdict.tour})")print(f"Cumul au moment de la coupe : {verdict.usage_total.total_tokens} jetons")print(f"Message d'origine : {verdict}")
Plafond pose apres le tour 1 : 453 jetons
Verdict : BUDGET_EXCEEDED (tour 2)
Cumul au moment de la coupe : 725 jetons
Message d'origine : BUDGET_EXCEEDED: 725 jetons cumules >= plafond 453 (tour 2)
Lecture du resultat
Le tour 2 n’a jamais produit de reponse : des que son premier appel LLM fait franchir le plafond, la consommation s’arrete et le verdict BUDGET_EXCEEDED porte le plafond, le cumul exact au moment de la coupe (les jetons consommes avant la coupe restent comptes — comptabilite honnete) et le numero du tour coupe. Pas d’exception brute du stream : un verdict type, attrapable, qui laisse la conversation dans un etat comptable coherent, arretee sur un chiffre exact. On ne peut borner que ce qu’on mesure — la tracabilite des sections 2 et 3 est ce qui rend ce plafond possible.
Lu dans les sorties commitees : Plafond pose apres le tour 1 : 453 jetons — le code le construit comme cumul du tour 1 + 30, donc le tour 1 de cette demonstration a consomme 453 - 30 = 423 jetons. Le verdict : BUDGET_EXCEEDED: 725 jetons cumules >= plafond 453 (tour 2) — depassement de 725 - 453 = 272 jetons, 60 % au-dessus du plafond. Ce seul chiffre prouve la granularite de la coupe : elle n’attend pas la fin du tour, elle tombe des que le premier appel franchit la ligne — et le verdict porte les trois nombres qu’il faut pour auditer (plafond 453, cumul 725, tour 2).
6. Exercices
Trois exercices pour vous approprier le contrat. Le notebook doit rester executable meme si vous ne les completez pas — chaque cellule d’exercice est un stub correct (C.1).
Exercice 1 – Cout marginal de la memoire
Ecrivez cout_marginal(profil) qui, a partir du profil de la section 3, rend la liste des delta de prompt entre tours consecutifs. Le premier delta doit etre None (pas de tour precedent).
# Exercice 1 : a completer# Etape 1 : recuperer la colonne prompt du profil de la section 3.# Etape 2 : calculer les differences entre tours consecutifs.# Etape 3 : retourner la liste (le premier element vaut None).def cout_marginal(profil):# TODO etudiantreturnNoneprint("Exercice 1 a completer : cout_marginal(profil)")
Exercice 1 a completer : cout_marginal(profil)
Exercice 2 – Detecteur d’incoherence d’usage
Ecrivez usage_coherent(resultat) qui rend True ssi la somme des usage_turns de resultat egale exactement resultat.usage_total — la propriete qui doit tenir pour que un budget (jambe 2) puisse se fier au cumul.
# Exercice 2 : a completer# Etape 1 : sommer les snapshots de resultat.usage_turns.# Etape 2 : comparer chaque composante a resultat.usage_total.# Etape 3 : retourner le booleen.def usage_coherent(resultat):# TODO etudiantreturnNoneprint("Exercice 2 a completer : usage_coherent(resultat)")
Exercice 2 a completer : usage_coherent(resultat)
Lecture chiffree — vers l’exercice 2 : la coherence snapshots/cumul reste a etablir. Le tour de la section 2 fournit le materiel brut de l’exercice 2 : un cumul de tokens affiche par le runtime et, en amont, des snapshots par appel. L’exercice demande d’ecrire la propriete qui verifie la coherence des deux sur plusieurs formes d’entree — quelles egalites exiger, avec quelle tolerance, et que faire des tours ou un provider ne rapporte rien : autant de choix de semantique qui appartiennent a l’etudiant. La propriete doit tenir pour TOUT tour, pas seulement pour ceux ou tout se passe bien ; le budget de la section 5, qui se fie au cumul, depend de savoir detecter un ecart.
Exercice 3 – Isolement des compteurs
C1 doit rester tenu cote consommation : deux conversations isolees doivent avoir des cumuls independants. Ecrivez compteurs_independants(agent) qui joue la meme question dans deux ConversationRunner distinctes et rend True ssi aucun cumul ne fuit dans l’autre.
# Exercice 3 : a completer# Etape 1 : ouvrir deux ConversationRunner sur le meme agent.# Etape 2 : jouer la meme question dans chacune.# Etape 3 : verifier que les deux usage_total sont strictement positifs# et que chaque cumul ne compte que ses propres tours.def compteurs_independants(agent):# TODO etudiantreturnNoneprint("Exercice 3 a completer : compteurs_independants(agent)")
Exercice 3 a completer : compteurs_independants(agent)
7. Conclusion
Idee clee
Ou la voir
Un tour = plusieurs appels LLM, chacun avec son snapshot
section 2, usage_turns
La memoire C1b a un cout marginal mesurable
section 3, croissance de prompt
Le budget natif n’existe pas : RunConfig sans champ de consommation
section 4
Un plafond de jetons qui coupe proprement, verdict type
section 5, AdkBudgetExceeded
Un compteur qui n’invente rien : provider muet = usage_turns vide
docstring _event_usage
Le contrat C6 est desormais porte en entier (PR #14690) : ce que la conversation consomme est observable depuis le code du depot, appel par appel, ET bornable — un plafond qui coupe proprement sur un verdict explicite. Toujours au-dessus d’ADK, jamais une restauration SK. See #14687, #14058.