درس 6 — ADK et mesures

La même chose avec ADK Workflow

8 دقائق قراءة

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

Votre équipe standardise ses agents sur Google ADK. Le premier réflexe, nourri par des tutoriels de 2025, est d'empiler un SequentialAgent qui contient un ParallelAgent. Dans ADK 2.10, ces classes émettent un avertissement explicite : « ParallelAgent is deprecated in favor of Workflow and will be removed in a future version ». SequentialAgent et LoopAgent portent le même avertissement. Cette leçon réécrit le harness sur l'API qui les remplace, google.adk.workflow, en gardant tout ce qui ne dépend pas du framework : corpus, périmètres, schémas, consignes, budget, journal, synthèse et grille.

Le mécanisme

Un Workflow est un graphe de nœuds reliés par des arêtes. Un nœud peut être un agent (Agent), une fonction Python (enveloppée par node()), ou un autre workflow. La sortie d'un nœud devient l'entrée (node_input) du suivant.

flowchart LR
  ST["START<br/>message : demande + catalogue"] --> PL["planificateur<br/>Agent, output_schema=Plan"]
  PL --> VA["valider<br/>fonction : dict → list[dict]"]
  VA --> EX["executer<br/>parallel_worker=True<br/>une exécution par élément"]
  EX -->|"ctx.run_node"| SA["Agent st_i créé à la volée<br/>outils fermés sur son périmètre"]
  EX --> SY["synthetiser<br/>ctx.run_node(Agent synthese)"]

Quatre mécanismes d'ADK font le travail de l'orchestrateur écrit à la main :

BesoinÀ la mainADK 2.10
Une branche par sous-tâcheasyncio.gathernode(f, parallel_worker=True) : la liste en entrée est distribuée, une exécution par élément
Plafond de concurrenceSemaphore(n)max_parallel_workers=n
Agent construit à l'exécution (outils propres à chaque périmètre)une coroutineawait ctx.run_node(agent, node_input=...) (nœud dynamique)
Plafond de temps durasyncio.wait_fornode(agent, timeout=...) → NodeTimeoutError
Budget d'appels et de tokens, atterrissage forcéla bouclebefore_model_callback / after_model_callback
Isolation du contextecontenu construit à la maininclude_contents="none" + node_input + sous-branche du travailleur parallèle

La mise en œuvre

Le graphe tient en quatre nœuds (version_adk.py) :

files/orchestre/version_adk.py (graphe)
workflow = Workflow(name="veille", edges=[(
    START, planificateur, node(valider),
    node(executer, rerun_on_resume=True, parallel_worker=True, max_parallel_workers=cfg.parallelisme),
    node(synthetiser, rerun_on_resume=True),
)])
runner = InMemoryRunner(node=workflow, app_name="veille")

rerun_on_resume=True n'est pas décoratif. Sans lui, le premier ctx.run_node lève : « A node must have rerun_on_resume=True. Reason is that dynamically scheduled nodes might be interrupted, and the workflow wakes-up/re-runs the parent node ». Un nœud qui lance des nœuds dynamiques doit accepter d'être rejoué.

Le sous-agent est construit dans le nœud executer, pour que ses outils ne voient que son périmètre :

files/orchestre/version_adk.py (une branche)
async def executer(ctx: Context, node_input: dict) -> None:
    st = SousTache.model_validate(node_input)
    for essai in range(1, cfg.essais + 1):
        perimetre = Perimetre(docs, st.doc_ids)
        compteur = Compteur(cfg.budget)

        def lire(doc_id: str) -> dict:
            """Lit un document du périmètre."""
            ...  # perimetre.lire + span « outil » dans le journal

        avant, apres = _traceur(journal, lambda s=span: s, cfg.modele, compteur,
                                panne=essai == 1 and st.id in cfg.pannes)
        agent = Agent(
            name=st.id.replace("-", "_"), model=llm(cfg.modele), instruction=CONSIGNE_SOUS_AGENT,
            tools=[chercher, lire], output_schema=Constat, include_contents="none",
            before_model_callback=avant, after_model_callback=apres)
        try:
            sortie = await ctx.run_node(node(agent, timeout=cfg.budget.max_secondes),
                                        node_input=invite_sous_tache(st, perimetre),
                                        run_id=f"{st.id}-essai-{essai}")
            etat["constats"][st.id] = Constat.model_validate(sortie)
            return
        except Exception as e:
            cause = getattr(e, "error", e)       # DynamicNodeFailError enveloppe la vraie cause
            ...

Le budget passe par les callbacks : before_model_callback appelle compteur.avant_appel() (qui lève BudgetDepasse) et, au dernier appel, retire des déclarations d'outils tout sauf set_model_response ; after_model_callback lit usage_metadata et alimente compteur et journal. L'exception levée dans un callback fait échouer le nœud dynamique ; ctx.run_node la remonte enveloppée dans DynamicNodeFailError, dont l'attribut error porte la cause réelle.

Deux détails d'API vérifiés dans le code installé :

  • avec output_schema et des outils, ADK choisit le mécanisme selon capabilities du modèle. Sur Vertex AI, il déclare le schéma de réponse à côté des outils ; avec une clé Gemini API (notre cas) comme avec le faux modèle des tests, il ajoute un outil set_model_response que l'agent appelle pour conclure (gemini_output_schema_and_tools dans google/adk/models/_capabilities.py). C'est pour cela que l'atterrissage forcé garde cet outil ;
  • une instance Gemini par nom de modèle, réutilisée par tous les agents : run.py les garde dans un dictionnaire, comme le client unique de la version à la main.
make run IMPL=adk

Résultat obtenu (2026-10-15)

$ make run IMPL=adk
WARNING orchestre.version_adk : st-2 essai 1 : PanneInjectee: 503 UNAVAILABLE (panne injectée)
adk-demo  36.4 s (plan 7.4 / sous-agents 6.1 / synthèse 22.9)  21 appels  36733 in  7803 out  0.0377 $
          qualité 85.0/100 (faits 12/12, contradictions 2/4, citations 25/25)  échecs -  arbitrages suspects 0
Trajectoire ADK du 2026-10-15 en thème sombre : même structure, mais des sous-agents à 3 à 6 appels

Même structure, même reprise. Une différence saute aux yeux sur la frise : les sous-agents ADK font 2 à 6 appels là où la version à la main en fait 2, d'où 21 appels et 36 733 tokens d'entrée contre 11 et 17 275. La consigne est identique ; ce qui change, c'est la requête que construit le framework : ADK ajoute ses propres instructions et l'outil set_model_response, et le modèle groupe moins ses lectures dans ce contexte. Nous ne l'avons pas corrigé : c'est exactement le genre d'écart qu'une comparaison doit montrer.

Ce qui casse en production

  • Les classes dépréciées. ParallelAgent partage l'état de session entre les branches : « branches writing the same key leave only the value written last », dit sa documentation. Avec output_key, deux sous-agents qui écrivent la même clé s'écrasent. Parade : Workflow et des sorties de nœuds, pas un état partagé.
  • asyncio.create_task(ctx.run_node(...)). La documentation de run_node le dit : une tâche lancée ainsi n'est pas supervisée, ses erreurs sont avalées et elle n'est pas annulée si le parent est interrompu. Parade : await direct, et parallel_worker=True pour le fan-out.
  • Les journaux d'erreurs d'ADK. Un nœud en échec écrit sa trace complète au niveau ERROR, même quand votre code rattrape l'exception pour la reprendre. run.py coupe le logger google_adk et s'appuie sur le journal de trajectoire ; en production, gardez ces traces mais routez-les à part.
  • Les accolades dans les instructions. ADK remplace {nom} dans instruction par la valeur de l'état de session. Une consigne qui contient un exemple JSON avec accolades lève une erreur ou se vide. Les consignes de ce projet sont formatées avant d'être passées à l'agent et ne contiennent plus d'accolades.
  • Un modèle instancié par agent. Créer un Gemini(...) par sous-agent multiplie les clients HTTP ; un client temporaire non conservé se ferme et les appels suivants échouent. Parade : une instance par nom de modèle.

La règle de décision

Sur ADK 2.10, construisez les pipelines multi-agents avec google.adk.workflow : Workflow, node(..., parallel_worker=True) pour le fan-out, ctx.run_node pour les agents construits à l'exécution, timeout pour le temps, callbacks pour le budget. N'utilisez plus SequentialAgent, ParallelAgent ni LoopAgent dans du code neuf. Gardez hors du framework tout ce qui définit la qualité (périmètres, schémas, consignes, contrôles, grille) : c'est ce qui permet de comparer, de tester sans réseau et de changer de framework.

Pratique

Vous migrez un ParallelAgent dont les trois sous-agents écrivent leur résultat avec output_key="resultat". Après migration vers Workflow, que faites-vous de output_key ?

Dans executer, ctx.run_node lève DynamicNodeFailError. Vous voulez reprendre seulement si la cause est un 503. Que testez-vous ?

Exercice. Remplacez la boucle de reprise manuelle de executer par la reprise native d'ADK : node(agent, timeout=..., retry_config=RetryConfig(max_attempts=2, exceptions=["PanneInjectee"])). Qu'est-ce qui change dans le journal de trajectoire ?

Voir la solution
from google.adk.workflow import RetryConfig

sortie = await ctx.run_node(
    node(agent, timeout=cfg.budget.max_secondes,
         retry_config=RetryConfig(max_attempts=2, initial_delay=1, exceptions=["PanneInjectee"])),
    node_input=invite_sous_tache(st, perimetre))

La reprise devient invisible pour votre code : un seul span « st-2 (essai 1) » couvre les deux tentatives, et le compteur de budget n'est pas réinitialisé entre elles (il est créé hors du nœud). Il faut donc soit créer le compteur dans un callback de début d'agent, soit accepter un budget commun aux tentatives. C'est l'argument pour garder la boucle explicite quand la trajectoire doit montrer chaque tentative ; RetryConfig reste le bon choix pour des erreurs transitoires sur des nœuds sans état.

  • Je sais construire un Workflow avec un travailleur parallèle et des nœuds dynamiques
  • Je sais poser un budget et un atterrissage forcé avec les callbacks de modèle
  • Je sais pourquoi rerun_on_resume=True est exigé
  • make run IMPL=adk produit sorties/adk-demo/trajectoire.html
التالي