Leçon 6 — Livrer

Le workflow ADK et le tableau de bord

7 min de lecture

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"]
files/analyste/agents.py
    @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écanismeEffetPiège rencontré
parallel_worker=Truele 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ésultatssans 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=Trueobligatoire pour un nœud qui lance des nœuds dynamiquessans 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 ?

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 :

files/run.py
    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 :

files/web/static/app.js
  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"}
Visionneuse de la page source : la citation sur le premier client de Maison Ferlac est surlignée en page 2 du rapport

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=1 a 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.1 par défaut ; l'ouvrir sur un réseau impose une authentification (IAP sur Cloud Run, par exemple), pas un simple changement de HOST.

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 ?

Un développeur propose de calculer les ratios dans app.js « pour alléger le serveur ». Quel argument décide ?

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 :

files/web/static/app.js
      ${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 Workflow ADK avec des nœuds de fonction et parallel_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 run affiche le tableau de bord et la visionneuse ouvre la page source
Suivant