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 :

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.

import sys
sys.path.insert(0, '..')

import warnings

warnings.filterwarnings(
    "ignore",
    message=(
        r"\[EXPERIMENTAL\] feature "
        r"FeatureName\.JSON_SCHEMA_FOR_FUNC_DECL is enabled\."
    ),
    category=UserWarning,
    module=r"google\.adk\.models\.llm_request",
)

from config.providers import get_settings, get_provider_config, get_litellm_model

settings = get_settings()
provider = get_provider_config(settings)
print(f"Provider actif : {provider.provider.value}")
print(f"Modele : {get_litellm_model(provider)}")
print(f"Endpoint externe : {bool(provider.base_url)}")
Provider actif : openrouter
Modele : openrouter/openai/gpt-4.1
Endpoint externe : True

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, AdkUsage

resultat = 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 in enumerate(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 asyncio
from utils.adk_conversation import ConversationRunner
from utils.adk_runtime import build_data_agent

async def conversation_trois_tours():
    agent = build_data_agent()
    lignes_profil = []
    async with 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 in enumerate(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_profil

profil = 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}")
tour | prompt | completion | cumul conversation
-----|--------|------------|------------------
  1  |   343  |     69    |    412
  2  |   607  |     59    |   1078
  3  |   857  |     70    |   2005

Lecture du resultat

La colonne prompt croit 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 RunConfig

champs_budget = [
    f for f in RunConfig.model_fields
    if "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 AdkBudgetExceeded
from utils.adk_runtime import build_data_agent

async def demonstration_budget():
    agent = build_data_agent()
    async with 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_tokens
        try:
            await conversation.turn(
                "Un dataset contient 9 lignes et 3 colonnes. Appelle dataset_profile.",
                timeout_seconds=120,
            )
            return plafond, None
        except AdkBudgetExceeded as verdict:
            return plafond, verdict

plafond, verdict = await demonstration_budget()
if verdict is None:
    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 etudiant
    return None

print("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 etudiant
    return None

print("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 etudiant
    return None

print("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.

Retour au sommet