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 main | ADK 2.10 |
|---|---|---|
| Une branche par sous-tâche | asyncio.gather | node(f, parallel_worker=True) : la liste en entrée est distribuée, une exécution par élément |
| Plafond de concurrence | Semaphore(n) | max_parallel_workers=n |
| Agent construit à l'exécution (outils propres à chaque périmètre) | une coroutine | await ctx.run_node(agent, node_input=...) (nœud dynamique) |
| Plafond de temps dur | asyncio.wait_for | node(agent, timeout=...) → NodeTimeoutError |
| Budget d'appels et de tokens, atterrissage forcé | la boucle | before_model_callback / after_model_callback |
| Isolation du contexte | contenu construit à la main | include_contents="none" + node_input + sous-branche du travailleur parallèle |
La mise en œuvre
Le graphe tient en quatre nœuds (version_adk.py) :
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 :
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_schemaet des outils, ADK choisit le mécanisme seloncapabilitiesdu 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 outilset_model_responseque l'agent appelle pour conclure (gemini_output_schema_and_toolsdansgoogle/adk/models/_capabilities.py). C'est pour cela que l'atterrissage forcé garde cet outil ; - une instance
Geminipar nom de modèle, réutilisée par tous les agents :run.pyles garde dans un dictionnaire, comme le client unique de la version à la main.
make run IMPL=adkRé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 0Mê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.
ParallelAgentpartage l'état de session entre les branches : « branches writing the same key leave only the value written last », dit sa documentation. Avecoutput_key, deux sous-agents qui écrivent la même clé s'écrasent. Parade :Workflowet des sorties de nœuds, pas un état partagé. asyncio.create_task(ctx.run_node(...)). La documentation derun_nodele 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 :awaitdirect, etparallel_worker=Truepour 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.pycoupe le loggergoogle_adket s'appuie sur le journal de trajectoire ; en production, gardez ces traces mais routez-les à part. - Les accolades dans les instructions. ADK remplace
{nom}dansinstructionpar 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 ?
Le travailleur parallèle rassemble les sorties dans l'ordre des entrées et les passe au nœud suivant : plus besoin de passer par l'état. Des clés distinctes fonctionnent mais réintroduisent un couplage par l'état que le graphe rend inutile. Sérialiser les branches supprime l'écrasement en supprimant le parallélisme.
Dans executer, ctx.run_node lève DynamicNodeFailError. Vous voulez reprendre seulement si la cause est un 503. Que testez-vous ?
Le message enveloppant est générique (« Dynamic node sous_agent failed »). L'exception d'origine est dans e.error, et ADK recopie son code HTTP dans e.status_code. ADK propose RetryConfig sur un nœud, mais rien n'est repris sans configuration.
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
Workflowavec 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=Trueest exigé -
make run IMPL=adkproduitsorties/adk-demo/trajectoire.html