Le workflow ADK et le tableau de bord
Trois sociétés analysées l'une après l'autre prennent 114 secondes sur Vertex AI ; en parallèle, 61 à 76 secondes avec la clé d'API. Lancées en parallèle sur Vertex AI le 15 octobre, elles ont aussi produit une erreur 429 et une analyse perdue. L'orchestration n'est pas un détail : elle décide du temps d'attente du comité, de la facture, et de ce qui se passe quand l'API refuse un appel. Cette leçon assemble les étapes dans un workflow ADK 2.10 et expose le résultat dans un tableau de bord où chaque chiffre mène à sa page.
Le mécanisme : un graphe de nœuds
ADK 2.10 déprécie SequentialAgent, ParallelAgent et LoopAgent au profit de google.adk.workflow. Un Workflow est un graphe : des fonctions Python et des agents deviennent des nœuds, les arêtes transmettent la sortie d'un nœud en entrée du suivant.
flowchart LR
S(("START")) --> C["charger : FunctionNode"]
C -- "liste de 3 documents" --> A["analyser_societe : parallel_worker"]
A -- "liste de 3 analyses" --> R["rediger_memo"]
A -. "ctx.run_node" .-> E["extracteur"]
A -. "ctx.run_node" .-> L["lecteur_risques"]
R -. "ctx.run_node" .-> W["redacteur"]
@node(rerun_on_resume=True, parallel_worker=True, max_parallel_workers=PARALLELISME)
async def analyser_societe(ctx: Context, node_input: dict[str, Any]) -> dict[str, Any]:
...
brut = ExtractionBrute.model_validate(
await ctx.run_node(agents["extracteur"], node_input=message(consigne)))
...
return Workflow(name="analyste_financier", edges=[(START, charger, analyser_societe, rediger_memo)])Trois mécanismes à connaître :
| Mécanisme | Effet | Piège rencontré |
|---|---|---|
parallel_worker=True | le nœud reçoit une liste et s'exécute une fois par élément, en parallèle ; la sortie est la liste des résultats | sans max_parallel_workers, tout part en même temps |
ctx.run_node(agent, node_input=...) | exécute un agent comme nœud enfant et renvoie sa sortie (un dict si l'agent a un output_schema) | toujours await directement, jamais dans asyncio.create_task |
rerun_on_resume=True | obligatoire pour un nœud qui lance des nœuds dynamiques | sans lui : ValueError: A node must have rerun_on_resume=True |
Le workflow se lance avec un InMemoryRunner(node=workflow). Chaque nœud émet un événement dont l'attribut output porte sa sortie ; run.py garde celle qui contient le mémo et additionne usage_metadata pour compter les jetons.
Vous voulez analyser 40 rapports d'un coup sur Vertex AI. Quel réglage changez-vous en premier ?
parallel_worker lance tout ce qu'on lui donne : 40 rapports, c'est jusqu'à 80 appels simultanés, et le quota répond 429. Une boucle for réglerait le problème en perdant tout le parallélisme. La bonne réponse borne la concurrence et absorbe les refus transitoires.
Réessayer au bon niveau
Le premier réflexe a été de poser un RetryConfig sur le nœud analyser_societe. Sur Vertex AI, le 429 est remonté sans être réessayé : l'erreur naît dans l'agent enfant lancé par ctx.run_node, pas dans le nœud lui-même, et l'échec a fait tomber tout le workflow. Le réessai a été déplacé là où l'erreur se produit, dans le client HTTP du modèle :
llm = Gemini(model=modele, retry_options=types.HttpRetryOptions(
attempts=6, initial_delay=5, max_delay=60, http_status_codes=[429, 503]))
runner = InMemoryRunner(node=construire_workflow(llm), app_name="analyste")
...
finally:
await runner.close()
await llm.api_client.aio.aclose()Une seule instance Gemini est partagée par les trois agents, et son client est fermé explicitement. Sans cette fermeture, Python affiche à la sortie une trace « Event loop is closed » : le client HTTP asynchrone est collecté après la fermeture de la boucle.
Le tableau de bord
L'API FastAPI (web/app.py) est en lecture seule : /api/analyse sert le JSON de l'analyse, /rapports/ sert les PDF, /health répond aux sondes (/healthz est réservé sur Cloud Run). StaticFiles refuse les chemins qui sortent du dossier : /rapports/../.env.example renvoie 404, ce que vérifie un test.
Le JavaScript (app.js) n'effectue aucun calcul financier : il affiche les chaînes formatées par Python. Un clic sur une barre, un ratio, une alerte ou une source du mémo ouvre la page du PDF dans une visionneuse pdf.js et surligne la citation :
for (const it of (await p.getTextContent()).items) {
const n = norme(it.str);
const recouvre = cible.includes(n) || n.includes(cible.slice(0, 15)) || n.includes(cible.slice(-15));
if (n.length < 6 || !recouvre) continue;
const [x1, y1, x2, y2] = vp.convertToViewportRectangle(
[it.transform[4], it.transform[5] - 2, it.transform[4] + it.width, it.transform[5] + it.height]);
ctx.fillRect(Math.min(x1, x2), Math.min(y1, y2), Math.abs(x2 - x1), Math.abs(y2 - y1));
}Résultat obtenu (2026-10-15)
$ make run
INFO analyste tableau de bord sur http://localhost:8000 (analyse : sorties/analyse.json)
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
$ curl -s localhost:8000/health
{"statut":"ok"}Les captures de cette formation sont produites par scripts/captures.mjs (Playwright, Chromium), sur l'analyse réelle du jour.
Ce qui casse en production
- Le 429 qui fait tomber tout le lot. Symptôme :
Root node analyste_financier failed, aucune sortie, même pour les sociétés terminées. Parade : réessais HTTP sur le modèle, concurrence bornée (PARALLELISME=1a suffi sur Vertex AI), et à terme une sauvegarde par société pour reprendre sans tout relancer. - Le surlignage qui rate. pdf.js découpe le texte en fragments qui ne suivent pas toujours les lignes ; une citation qui commence au milieu d'un fragment n'est surlignée qu'en partie. La page reste affichée en entier et la citation est rappelée au-dessus : le surlignage aide, il ne prouve rien. La preuve est la vérification côté Python.
- Le CDN indisponible. Chart.js et pdf.js viennent de cdnjs. Dans un réseau d'entreprise filtré, le tableau de bord s'affiche sans graphiques. Parade : servir les deux fichiers depuis
web/static/. - Les données exposées. Un rapport de société non cotée est confidentiel. Ce serveur écoute sur
127.0.0.1par défaut ; l'ouvrir sur un réseau impose une authentification (IAP sur Cloud Run, par exemple), pas un simple changement deHOST.
Règle de décision
Utilisez un Workflow ADK dès que des étapes déterministes s'intercalent entre des agents : les contrôles deviennent des nœuds visibles, testables avec un faux modèle. Un seul agent avec des outils suffit si le modèle doit choisir l'ordre des étapes ; ici, l'ordre est fixe. Surveillez la durée par société et le nombre de 429 : ce sont les deux premiers signaux d'un quota trop juste.
Pratique
L'analyse de Solvéa échoue sur une erreur réseau après que Ferlac et Helvane ont réussi. Avec le code actuel, que perd-on et comment l'éviter ?
run.py n'écrit analyse.json qu'à la fin, quand le mémo existe. ADK prévoit la reprise d'un workflow à partir des événements de session, mais avec InMemoryRunner la session disparaît avec le processus. Une sauvegarde par société est la parade la plus simple.
Un développeur propose de calculer les ratios dans app.js « pour alléger le serveur ». Quel argument décide ?
La précision est la même (IEEE 754 des deux côtés). Le vrai risque est la divergence : une règle d'arrondi différente, une définition du ROE différente, et le mémo vérifié ne correspond plus à ce que voit le comité.
Exercice. Affichez dans la carte de chaque société un avertissement quand le rapport contenait des pages scannées (pages_scannees) et, en rouge, quand un état n'a pas pu être lu (etats_non_lus).
Voir la solution
Dans cartes() de app.js, après la ligne du secteur :
${s.pages_scannees?.length ? `<div class="secteur">Page(s) scannée(s) ${s.pages_scannees.join(', ')}, lue(s) visuellement</div>` : ''}
${s.etats_non_lus?.length ? `<div class="secteur" style="color: var(--haute)">▲ Non lu : ${esc(s.etats_non_lus.join(', '))}</div>` : ''}Le symbole et le texte accompagnent la couleur : l'information ne repose pas sur le rouge seul. Pour la tester, lancez une analyse avec SCANS=ignorer et ouvrez-la : python run.py servir sorties/analyse.json.
- Je sais construire un
WorkflowADK avec des nœuds de fonction etparallel_worker - Je sais lancer un agent dans un nœud avec
ctx.run_node - Je sais où placer les réessais pour qu'un 429 soit réellement absorbé
-
make runaffiche le tableau de bord et la visionneuse ouvre la page source