Retour au blog

Comment l'agent LLM de la Partie 2 a réellement été construit

Compagnon technique de la Partie 2 de la trilogie SHORA. Pour les lecteurs curieux du pipeline pji-agent qui a produit le taux de navigation de 9 % sur 10,5 millions de pages produit.

Télécharger le PDF
Traduction IA Cet article a été traduit de l'anglais par IA. Les identifiants de code, chemins de fichiers, prompts et noms de modèles sont conservés en anglais à dessein. Lire l'original en anglais.

Compagnon technique de la Partie 2 — ne fait pas partie de la trilogie. Public visé : ingénieurs, chercheurs, communauté open-source. Chaque référence de code ci-dessous est un lien profond vers le dépôt public sous licence MIT, à github.com/crospector/pji-agent.

Pourquoi ce post existe

Le corps principal de la Partie 2 rapporte le résultat d'une expérience d'agent LLM : 10,5 millions de pages produit, 1 056 enseignes françaises, 480 localités, 32 341 $ de dépenses AWS, 9 % de réussite de navigation de parcours. Ce post-là s'adresse à un public d'affaires se demandant si SHORA peut être la couche de mesure déterministe de leur résultat de qualité de données. Le présent post s'adresse à un public technique — ingénieurs, chercheurs, communauté open-source — se demandant comment le pipeline a réellement été construit et comment le reproduire.

Le code open-source vit à github.com/crospector/pji-agent sous licence MIT. Chaque affirmation ci-dessous renvoie à un fichier ou une ligne précis de ce dépôt.

Périmètre et avertissement

Le dépôt publié contient tout ce dont un lecteur a besoin pour reproduire l'expérience décrite dans ce post : l'agent d'extraction, l'agent de parcours, l'échafaudage de prompts, la disposition de capture des preuves, les références au schéma DynamoDB et le runtime Docker. Configurez les ressources AWS requises, fournissez vos propres identifiants, et le pipeline tourne.

Le dépôt ne contient pas le câblage de production qui a fait tourner l'expérience complète de SHORA. Pour protéger des actifs propriétaires sans rapport avec l'agent LLM lui-même, les classes de code et de documentation suivantes sont délibérément omises : le tableau de bord opérationnel et sa logique d'agrégation DynamoDB, la couche d'orchestration de l'essaim qui coordonnait plusieurs flottes d'agents, le système de préflight et d'auto-correction « guardian », l'API du validateur et son maillage de services, l'infrastructure-as-code qui provisionnait les ressources AWS, les fichiers d'état opérationnel et journaux d'audit accumulés pendant l'expérience, les spécifications d'architecture internes et documents de feuille de route, et les bases de code sans rapport (le moteur déterministe, le frontend du tableau de bord, les outils internes) qui partagent le monorepo de SHORA mais n'ont rien à voir avec l'agent. Ce qui est publié, c'est l'agent lui-même et le minimum de fichiers d'infrastructure nécessaires pour le faire tourner, rien de plus. Les références à des chemins internes ci-dessous décrivent l'agent en production ; le dépôt publié contient les mêmes fichiers avec les secrets paramétrés via des variables d'environnement.

Le pipeline en un coup d'œil

L'expérience a tourné sur une architecture à deux machines, toutes deux sur des instances AWS c6i.8xlarge (32 vCPU, 64 Go de RAM, SSD NVMe local), situées en eu-west-3 (Paris) :

  • Machine d'extraction. Explorait le catalogue de 1 056 enseignes françaises. Découvrait les pages de catégories, paginait à travers les listes de produits et extrayait des relevés produit structurés (nom, prix, URL, marque, hiérarchie de catégories, avis). Implémentée dans orchestrator/main.py, avec l'étape d'extraction LLM dans orchestrator/opus_extractor.py.
  • Machine de parcours. Pour chaque produit extrait, exécutait un parcours d'achat complet : naviguer vers la PDP, capturer le prix PDP, cliquer sur « ajouter au panier », naviguer vers le panier, capturer le prix du panier, classifier le résultat. Implémentée dans journey_orchestrator/main.py, avec la logique de test par produit dans journey_orchestrator/journey_tester.py.

Les deux machines faisaient tourner 20 à 40 instances de navigateur Camoufox concurrentes (un fork anti-détection de Firefox), pilotées par Playwright. Le modèle dans le chemin de données était eu.anthropic.claude-opus-4-6-v1 sur Amazon Bedrock, appelé depuis orchestrator/opus_extractor.py (lignes 23–24) :

BEDROCK_MODEL_ID = "eu.anthropic.claude-opus-4-6-v1"
BEDROCK_REGION = "eu-west-3"
MAX_HTML_CHARS = 80_000

Le modèle recevait le HTML de la page (plafonné à 80 000 caractères), un arbre d'accessibilité et des captures d'écran. L'ancrage d'identité de chaque élément d'UI visible sur la capture était résolu par une couche d'annotation qui dessinait des cadres englobants porteurs de GUID traçables jusqu'aux éléments DOM sous-jacents — de sorte que le modèle n'avait jamais à « trouver » un bouton ou un prix par vision brute. L'identité était donnée ; l'ancrage sémantique (quel cadre est le prix, lequel est l'ajout-au-panier, lequel est le total du panier) restait le travail du modèle. Cette distinction est le cœur architectural de l'argument de la Partie 2.

L'agent d'extraction

Ce qu'on lui demandait de faire

Par page de catégorie sur chaque enseigne, on demandait au modèle de renvoyer un tableau JSON de relevés produit comportant douze champs chacun : category, subcategories, name, reference, url, description, price, promo_price, brand, image_url, reviews_count, reviews_avg et trust_signals. Le prompt (issu de orchestrator/opus_extractor.py lignes 29–62) imposait six « règles critiques » :

  1. N'extraire QUE de VRAIS PRODUITS — des articles réellement achetables
  2. NE JAMAIS extraire : liens de navigation, noms de catégories, bannières, publicités, liens de pied de page, CSS, JS, balises HTML
  3. Si le nom d'un produit ressemble à du code/HTML/CSS/JS, L'IGNORER
  4. Le prix doit être un nombre valide > 0 (si aucun prix visible, mettre à null)
  5. L'URL doit être une URL de page produit valide sur le domaine de cette enseigne
  6. Si la page ne contient aucun vrai produit, renvoyer un tableau vide

Le HTML que recevait le modèle

Un script léger en page (CAPTURE_PAGE_JS dans le même fichier) tentait d'identifier la zone produit de la page rendue et d'en extraire le HTML interne, avec repli sur un clone du body dont les script, style, nav, footer, header, bannières de cookies et fenêtres modales étaient retirés. Le raisonnement : fournir au modèle la charge utile la plus petite et la plus propre possible qui contienne encore chaque fiche produit. En pratique, cette contrainte n'a presque jamais été la contrainte limitante — la contrainte limitante était la capacité du modèle à appliquer des règles sémantiques à un fragment HTML hétérogène, pas la taille du fragment.

Les sélecteurs utilisés pour trouver « la zone produit »

document.querySelector(
  'main, #main, [role="main"], .main-content, #content,
   .products, .product-list, .product-grid,
   [class*="product-list"], [class*="ProductList"]'
);

Cela fonctionnait pour la majorité des enseignes. Quand cela échouait, le repli (body nettoyé) était utilisé. Le modèle ne savait pas, en général, distinguer entre « je lis une page de liste produit qui contient des produits » et « je lis une page de tuiles de catégories dont la navigation de sous-catégories ressemble à des produits pour un modèle de langage ». C'est l'un des modes d'échec que le post principal de la Partie 2 attribue à l'ancrage sémantique : les cadres étaient adressables, mais le modèle ne savait pas toujours quels cadres étaient des produits versus quels cadres étaient des liens vers d'autres pages de catégories.

L'agent de parcours

Une fois que l'extracteur avait produit un catalogue de produits avec des URL valides, l'orchestrateur de parcours (journey_orchestrator/main.py) les parcourait en tourniquet. Pour chaque produit, journey_orchestrator/journey_tester.py exécutait le parcours en huit étapes documenté en tête du fichier :

  1. Naviguer vers l'URL produit (repli : recherche par nom sur le site de l'enseigne)
  2. Capturer le prix PDP
  3. Cliquer sur « Ajouter au panier »
  4. Naviguer vers le panier/la caisse
  5. Capturer le prix du panier
  6. Comparer le prix PDP au prix du panier
  7. Vider le panier
  8. Déterminer le résultat : JOURNEY_COMPLETE, JOURNEY_BLOCKED, PRICE_MISMATCH

Le testeur de parcours tournait en deux modes (définis dans journey_orchestrator/config.py) :

  • Mode lourd. Utilisé à Lille (la localité de référence) et à chaque re-test d'anomalie. Capture complète de preuves par étape : capture d'écran, capture annotée, HTML original, HTML signé (HTML annoté avec le même GUID que les captures), arbre d'accessibilité, vitals (Core Web Vitals), cookies, stockage, correspondance des signatures d'UI vers les ID applicatifs, HAR réseau, journal de console, données de timing, journal de sécurité. Le tout téléversé vers S3 aux côtés d'un enregistrement video.webm et d'un trace.zip Playwright par parcours.
  • Mode léger. Utilisé pour les 479 autres localités françaises. Prix et résultat seulement, écrits dans DynamoDB. Si le mode léger détectait une anomalie, l'orchestrateur re-testait immédiatement le même produit en mode lourd pour capturer les preuves complètes.

La conception à deux modes était une optimisation de coût : la capture complète de preuves sur 480 localités × des millions de parcours était prohibitive. Le mode lourd existait pour les cas où les preuves comptaient (la référence de Lille comme vérité-terrain, et toute anomalie digne d'être documentée).

Détection anti-bot

Camoufox contournait environ 92 % des WAF et défenses anti-bot rencontrés. Les 8 % restants étaient détectés via la liste de signatures dans journey_tester.py lignes 62–71 :

ANTIBOT_SIGNATURES = [
    "captcha", "recaptcha", "hcaptcha", "challenge-platform",
    "cloudflare", "cf-browser-verification", "ray id",
    "attention required", "checking your browser",
    "access denied", "403 forbidden", "just a moment",
    "datadome", "incapsula", "imperva", "perimeterx",
    "bot detection", "security check", "verify you are human",
    "please enable cookies", "please turn javascript on",
]

Quand une signature était détectée, l'orchestrateur réessayait avec une session de navigateur fraîche, une sortie IP fraîche (via rotation de proxy résidentiel quand configurée) et un backoff. Les défis anti-bot qui survivaient aux nouvelles tentatives n'étaient jamais comptés dans le résultat du parcours — ils étaient journalisés comme échecs d'infrastructure et le parcours était remis en file. Le taux de navigation de 9 % rapporté en Partie 2 est calculé après ces nouvelles tentatives d'infrastructure, sur les parcours où l'agent a atteint les pages qu'on lui demandait de lire.

Classification des résultats

Le testeur de parcours émettait l'un de trois résultats par produit :

RésultatSignification
JOURNEY_COMPLETEL'agent a navigué le site de bout en bout et atteint un ajout-au-panier qui a fonctionné avec le prix PDP intact.
JOURNEY_BLOCKEDL'agent a navigué le site de bout en bout et a conclu que le site lui-même bloquait l'acheteur : un indicateur explicite de rupture de stock, un ajout-au-panier non fonctionnel, une erreur serveur pendant la caisse, une impasse d'UI, une page de produit discontinué, ou toute autre friction côté site qui aurait aussi arrêté un vrai acheteur. JOURNEY_BLOCKED est un signal de friction d'expérience client que l'agent a attrapé, pas un signal d'échec de l'agent.
PRICE_MISMATCHL'agent a navigué le site de bout en bout et observé un prix de panier strictement supérieur au prix PDP.

Un mur de connexion rencontré après que le panier ait été atteint était classé JOURNEY_COMPLETE (le parcours a fait son travail ; le flux de caisse de l'enseigne exigeait alors une authentification, ce qui est hors du périmètre de l'agent).

Le 9 % que rapporte la Partie 2 est le taux auquel l'agent émettait une classification quelconque — c'est-à-dire qu'il naviguait le site de bout en bout et atteignait l'un des trois états terminaux ci-dessus (JOURNEY_COMPLETE, JOURNEY_BLOCKED ou PRICE_MISMATCH). Les 91 % restants sont le taux auquel l'agent échouait à naviguer le site de bout en bout — la chaîne multi-étapes se rompait quelque part en chemin et l'agent n'atteignait jamais de conclusion. Ces 91 % sont ce que la Partie 2 §« Pourquoi l'architecture a buté sur un plafond » attribue à des erreurs d'ancrage sémantique se composant le long de la chaîne, à des taux d'erreur par étape qui s'effondrent multiplicativement, et au fait que le modèle n'apprenait pas de ses échecs précédents.

Disposition des preuves dans S3

Par parcours lourd (un par produit de Lille, plus chaque re-test d'anomalie dans l'une des 480 localités), l'agent écrivait un préfixe S3 correspondant à la disposition ci-dessous (la disposition des preuves est implémentée dans journey_orchestrator/evidence_collector.py et téléversée via journey_orchestrator/s3_uploader.py) :

v2/journeys/{retailer}/{location}/{date}/{journey_id}/
├── manifest.json
├── journey.json
├── report.md
├── checksums.sha256
├── video.webm
├── trace.zip
├── logs/
│   ├── journey.log
│   ├── errors.json
│   ├── api_calls.json
│   └── agent_transcript.json
└── steps/{NN}/
    ├── screenshot.png
    ├── screenshot_annotated.png
    ├── original.html
    ├── signed.html
    ├── vitals.json
    ├── cookies.json
    ├── storage.json
    ├── accessibility.json
    ├── state.json
    ├── summary.json
    └── logs/
        ├── network.har
        ├── network.json
        ├── console.json
        ├── timing.json
        ├── coverage.json
        └── security.json

Cette disposition était conçue pour que n'importe quel parcours unique puisse être rejoué et audité de manière déterministe à partir de ses seules preuves archivées, sans avoir besoin d'accéder au système de production qui l'a produit. La référence de Lille plus l'archive d'anomalies constituaient ensemble l'enregistrement reproductible de l'expérience.

Stockage et validation

Les relevés de prix par produit étaient écrits dans une conception DynamoDB à table unique, implémentée dans journey_orchestrator/dynamo_writer.py, avec le modèle d'entités suivant :

EntitéPKSKRôle
EnseigneRETAILER#{slug}METADATADéfinitions d'enseigne (URL, nom)
LocalitéLOCATION#{slug}METADATA480 localités françaises
ProduitPRODUCT#{retailer}#{id}PRICE#{location}Produit extrait + prix par localité
ParcoursJOURNEY#{retailer}#{location}{timestamp}#{journey_id}Résultats des tests de parcours
Tableau de bordDASHBOARD#STATSGLOBALCompteurs agrégés

Chaque parcours était validé indépendamment contre quatre points d'accès API exposés par un service de validation interne. Les appels client vivent dans journey_orchestrator/validator.py ; le service lui-même n'est pas dans le dépôt open-source (voir l'avertissement de périmètre en tête de ce post). Son rôle était de vérifier que la disposition des preuves S3 était cohérente en interne (les affirmations du manifeste correspondaient au contenu de l'archive, les sommes de contrôle vérifiées, le nombre d'étapes concordait avec les totaux déclarés) avant qu'un parcours soit promu d'« exécuté » à « validé ».

Le contexte des benchmarks tiers

Le 9 % rapporté en Partie 2 est cohérent avec la littérature publiée sur les agents web. Pour les lecteurs qui veulent vérifier l'affirmation du plafond architectural face à des sources faisant autorité :

  • WebArenaZhou et al., ICLR 2024. Le benchmark académique canonique, 812 tâches web à long horizon. À la publication, le meilleur agent basé sur GPT-4 atteignait 14,41 % contre une référence humaine de 78,24 %. Le classement en direct montre actuellement l'état de l'art à 74,3 % (Deepseek v3.2, fév. 2026), toujours sous la référence humaine.
  • VisualWebArena / Online-Mind2WebAn Illusion of Progress? Assessing the Current State of Web Agents, Tao et al., COLM 2025. Introduit un benchmark réaliste de 300 tâches sur 136 sites web réels ; la frontière 2025 chute à 61 %, contre 88,7 % pour l'humain sur VisualWebArena.
  • WAREXarxiv:2510.03285, 2025. Met sous contrainte les agents publiés sur WebArena, REAL et WebVoyager dans des conditions proches de la production ; documente une « dégradation sévère en conditions réalistes, exposant des lacunes fondamentales de robustesse ».
  • OSWorldXie et al., NeurIPS 2024. Benchmark d'usage d'ordinateur ; le papier original rapporte le meilleur modèle à 12,24 % contre 72,36 % pour l'humain.
  • Fragilité des tests record-replaySimilarity-based Web Element Localization for Robust Test Automation, Stocco et al., ACM TOSEM 2023. Étude empirique de 1 065 ruptures de tests sur 453 versions d'applications web ; les localisateurs fragiles ont causé 73,6 % des échecs. Why do Record/Replay Tests of Web Applications Break?, Hammoudi et al., 2016 en est l'étude antérieure canonique.

Les cinq familles de preuves ci-dessus sont la base tierce de l'argument de la Partie 2 §« Pourquoi l'architecture a buté sur un plafond ». Les lecteurs qui reproduisent le pipeline pji-agent sur une charge comparable devraient s'attendre à des chiffres cohérents avec cette littérature, pas avec l'état de l'art sur des benchmarks soignés.

Une note sur l'échelle

L'expérience complète décrite dans ce post a tourné sur 10,5 millions de pages produit, 1 056 enseignes françaises et 480 localités, accumulant 32 341 $ de dépenses AWS sur sept mois. Un lecteur curieux intéressé par l'architecture, l'échafaudage de prompts ou la boucle de l'orchestrateur de parcours n'a pas besoin de faire tourner l'expérience à cette échelle — le code, les prompts et la disposition des preuves sont tous lisibles dans le dépôt. Voir le README du dépôt pour les détails d'installation.

Partie 2 de la trilogie SHORA : 32 341 $ de dépenses AWS, 10,5 millions de pages produit, 9 % de fiabilité. Compagnon financier : compagnon financier de la Partie 2. Dépôt open-source : github.com/crospector/pji-agent.