Accueil / Articles / Garde-fous structurels pour les agents d’IA : À l’intérieur du pipeline ResolveFlow

Garde-fous structurels pour les agents d’IA : À l’intérieur du pipeline ResolveFlow

Explique comment un agent basé sur LangGraph assure la séparation entre le raisonnement et l’exécution grâce à des vérifications au niveau du code plutôt qu’à des instructions dans le prompt, y compris une erreur de récupération qui est apparue au cours du processus.

3446 mots

La plupart des démonstrations d’IA agente suivent le même schéma de base : un modèle décide d’une action puis l’exécute immédiatement. Un outil est associé à une instruction, le modèle l’invoque et l’outil s’exécute sans aucune vérification supplémentaire. Cela peut paraître convaincant dans une courte démonstration, mais c’est précisément ce type de configuration qui inquiète ceux qui envisagent de permettre à des systèmes autonomes d’intervenir sur des éléments importants — car souvent, la seule protection entre un diagnostic fiable et une modification nocive d’un système en fonctionnement est une phrase d’avertissement inscrite dans l’instruction.

Le projet décrit ici a été conçu pour éviter cette dépendance à une seule instruction d’avertissement.

ResolveFlow accepte un lien vers une issue GitHub, collecte les preuves pertinentes, classe l’issue dans une catégorie, puis, en fonction de cette catégorie, effectue l’une de trois actions : il met en œuvre une action prédéfinie et incontournable, lance une enquête menée par un LLM en s’appuyant sur les preuves recueillies, ou transmet directement l’issue à un éditeur humain. Plutôt que d’utiliser une boucle simple où un modèle déclenche une appel à outil, il est structuré comme une machine à états LangGraph, conçu autour d’une idée centrale :

Le raisonnement et l’exécution restent séparés en raison de la manière dont le système est conçu, et non à cause d’une convention qu’il serait censé suivre.

Cela signifie qu’il ne s’agit pas simplement d’instruire le modèle à vérifier auprès de quelqu’un en premier. Au lieu de cela, une deuxième requête vers un LLM indépendant examine et critique le diagnostic du premier modèle avant même qu’un humain ne le voie. De plus, la seule fonction présente dans l’ensemble du code autorisée à écrire à nouveau sur GitHub vérifie un indicateur d’approbation explicite au sein de sa propre logique — non pas parce que le schéma prévoit que les appels soient acheminés de cette manière, mais parce que la fonction elle-même refusera de s’exécuter en l’absence de cet indicateur, même si une modification future du code crée un chemin direct qui contourne les étapes habituelles.

Le reste de cette présentation explique comment le système est réellement assemblé, en s’appuyant sur l’implémentation concrète, ainsi qu’un bug apparu pendant le développement. Ce bug offre une leçon utile : un modèle qui fait référence à un document source authentique n’est pas identique à un modèle qui fait référence à une source réellement pertinente pour la question posée.

La structure du pipeline

Six étapes s’exécutent séquentiellement, et seule l’une d’entre elles a la permission de modifier quoi que ce soit en dehors du pipeline lui-même :

  1. fetch_evidence — appels réels à l’API REST de GitHub, afin de récupérer le texte du sujet, sa série de commentaires et tous les résultats des tests CI.
  2. normalize_evidence — le JSON non traité retourné par ces appels est vérifié et converti en un objet IssueEvidence typé.
  3. classify — une étape légère basée sur des règles, ne nécessitant aucune appel à un LLM.
  • generate_diagnosis — déclenché uniquement lorsque l’étape de classification indique que le problème nécessite une enquête plus approfondie. Il s’agit d’une appel à un LLM combiné à un contexte enrichi par la récupération d’informations, dont la sortie est restreinte à un schéma défini.
  • independent_review — un deuxième appel indépendant à un LLM qui évalue de manière critique la sortie du premier appel, en s’appuyant sur des conditions d’acceptation/refus calculées en code pur plutôt que par un jugement subjectif.
  • await_approval → execute — c’est ici que le processus s’arrête en attendant une décision humaine, suivie, uniquement en cas d’approbation, de l’exécution par le seul nœud autorisé à publier quoi que ce soit sur GitHub.
  • Les sections suivantes détaillent les éléments les plus importants.

    Classification : intentionnellement pas un appel à un LLM

    Avoir un LLM facilement accessible pousse à faire passer toutes les décisions par lui, même celles qui ne nécessitent pas ce type de raisonnement. L’étape de classification détermine dans quelle mesure un problème donné peut emprunter la voie coûteuse et à plus haut risque — diagnostic basé sur un modèle, appels de récupération de données et écritures finales. En raison de ce rôle de contrôle, elle doit être peu coûteuse, rapide et entièrement prévisible en soi :

    def classify(state: GraphState) -> dict:
        if state["evidence"].has_failing_ci:
            return {"classification": "deterministic"}
        elif state["evidence"].is_information_sparse:
            return {"classification": "ai_investigation"}
        else:
            return {"classification": "human_review"}
    

    Cette étape produit trois résultats possibles, chacun exigeant un niveau de confiance différent par la suite :

    1. déterministe — un contrôle CI qui a échoué constitue en soi un signal clair et mécanique. Il n’y a rien à enquêter ; le problème est simplement marqué et transmis à un mainteneur.
  • ai_investigation — utilisé lorsque le problème ne contient pas suffisamment de détails pour agir directement. C’est la branche où les capacités du modèle de langage sont véritablement utiles, car il doit déterminer ce qui est réellement demandé.
  • human_review — réservé à tout ce qui est peu clair ou qui pourrait avoir un impact important en cas de traitement incorrect. Au lieu de laisser le système deviner dans ces cas, il transmet directement le problème à une personne.
  • Il convient de noter que c’est human_review, et non ai_investigation, qui constitue le choix de secours. Chaque fois que le système ne parvient pas à comprendre la situation, il n’essaie pas d’inventer une réponse ingénieuse.

    Dès qu’un problème est acheminé vers la branche ai_investigation, l’étape generate_diagnosis recherche dans un index Pinecone contenant environ 2 750 extraits provenant de problèmes réels et résolus issus de quatre repositories distincts (facebook/react, langchain-ai/langchain, microsoft/terminal et vercel/next.js). Ces extraits sont ensuite envoyés au LLM en tant que contexte de soutien pour l’appel :

    def generate_diagnosis(state: GraphState) -> dict:
        evidence = state["evidence"]
        query = f"{evidence.title}\n\n{evidence.body}"
        snippets = retrieve_evidence(query, k=3)
        snippet_block = "\n\n".join(
            f"[{s['id']}] (relevance: {s['score']:.2f}) {s['text']}" for s in snippets
        )
    
        prompt = (
            f"Issue: {evidence.title}\n{evidence.body}\n\n"
            f"Comments:\n{chr(10).join(evidence.comments) or '(none)'}\n\n"
            f"Evidence snippets (cite by id in square brackets):\n{snippet_block}"
        )
    
        llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
        structured_llm = llm.with_structured_output(Diagnosis)
        diagnosis = structured_llm.invoke([("system", _SYSTEM_PROMPT), ("human", prompt)])
    
        return {
            "diagnosis": diagnosis,
            "retrieved_ids": [s["id"] for s in snippets],
            "retrieved_scores": {s["id"]: s["score"] for s in snippets},
        }
    

    Quelques détails de cette étape de diagnostic méritent une attention plus poussée.

    Tout d’abord, la forme du résultat n’est pas quelque chose qui est extrait a posteriori — elle est définie au préalable. Le diagnostic est représenté par un modèle Pydantic :

    class Diagnosis(BaseModel):
        root_cause: str
        severity: Literal["low", "medium", "high"]
        missing_info: list[str] = Field(default_factory=list)
        recommended_next_steps: list[str]
        citations: list[str] = Field(
            default_factory=list,
            description="IDs of retrieved evidence/doc snippets that support each claim above",
        )
    

    En appelant .with_structured_output(Diagnosis), le modèle est contraint d’adopter cette structure précise lorsqu’il génère sa réponse. Aucune expression régulière n’est utilisée pour extraire une cause racine à partir d’un bloc de texte par la suite — lorsque l’appel réussit, ce qui est retourné est déjà un objet structuré, et non du texte à interpréter.

    Deuxièmement, les citations ne relèvent ni du ton ni de la confiance : elles doivent correspondre à des identifiants réels. L’instruction du système stipule clairement que toute citation doit correspondre à l’un des IDs de snippets fournis ; rien d’inventé, et rien qui ne soit pas étayé par un snippet. Cette contrainte devrait en soi suffire. Ce n’est pas le cas — c’est précisément pourquoi une étape supplémentaire existe dans le processus.

    Revue indépendante : le modèle explique, le code décide

    C’est sans doute le choix architectural le plus important dans l’ensemble du système.

    Le nœud independent_review lance une deuxième requête complètement distincte vers ChatOpenAI, avec son propre prompt et sans aucun contexte partagé avec la requête qui a produit le diagnostic. Sa fonction est de critiquer ce diagnostic.

    Cependant, voici l’élément clé : la décision finale d’approuver ou de faire monter l’affaire n’est jamais laissée au modèle. Elle dépend de trois valeurs booléennes calculées en Python classique, et la sortie du LLM se réduit à des commentaires lus par un humain, sur lesquels aucune logique d’approbation ne repose réellement.

    def independent_review(state: GraphState) -> dict:
        evidence = state["evidence"]
        diagnosis = state["diagnosis"]
        retrieved_ids = set(state.get("retrieved_ids", []))
        retrieved_scores = state.get("retrieved_scores", {})
    
        groundedness_ok = bool(diagnosis.citations) and all(
            citation_id in retrieved_ids
            and retrieved_scores.get(citation_id, 0.0) >= MIN_RELEVANCE_SCORE
            for citation_id in diagnosis.citations
        )
        risk_ok = diagnosis.severity in _ALLOWED_SEVERITIES  # {"low", "medium"}
        permission_ok = True  # comment/label are the only writes available today
    
        llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
        reasoning = llm.invoke(
            [("system", _SYSTEM_PROMPT), ("human", prompt)]
        ).content  # human-readable critique — not what the gate checks
    
        outcome = "approve" if (groundedness_ok and risk_ok and permission_ok) else "escalate_to_human"
    
        return {"review_result": ReviewResult(
            outcome=outcome,
            groundedness_ok=groundedness_ok,
            risk_ok=risk_ok,
            permission_ok=permission_ok,
            reasoning=reasoning,
        )}
    

    C’est le schéma général que toute version fiable de « LLM en tant que juge » doit suivre : le modèle peut s’expliquer, mais c’est le code qui prend la décision.

    Si vous demandez à un modèle d’évaluer le travail d’un autre modèle puis que vous vous contentez de faire confiance au verdict qu’il fournit, vous créez en fait un système dont la fiabilité est limitée par exactement ce que vous essayez de vérifier.

    Dans cette conception, la sortie du LLM constitue une narration utile pour un lecteur humain, mais le mécanisme décisionnel réel ne peut pas être influencé vers un mauvais résultat par des formulations persuasives, car il ne traite pas du tout ce que le modèle a écrit pour prendre sa décision.

    Un autre détail à souligner : la gravité élevée n’apparaît jamais dans _ALLOWED_SEVERITIES. Toute diagnosis marquée comme ayant une gravité élevée est automatiquement transmise à un humain, quel que soit le degré de pertinence de ses références. Être correct et être sûr de pouvoir approuver automatiquement ne sont tout simplement pas la même chose.

    Le bug : « grounded » n’est pas identique à « relevant »

    C’est là que les choses sont devenues véritablement complexes dans la pratique.

    Vérifier initialement la valeur de groundedness_ok se limitait simplement à s’assurer qu’un ID de citation correspondait à quelque chose dans l’ensemble récupéré — un ID réel, et non inventé. Cela semble être une vérification raisonnable sur le papier. Mais ce n’était pas suffisant.

    Lors des tests effectués sur un petit corpus de 40 problèmes, un cas réel de problème React sans contenu a été soumis au pipeline (facebook/react#36932, « experimental_taintUniqueValue throws RangeError for large binary values »). La récupération a fourni trois extraits, tous légitimes et correctement identifiés — mais aucun n’avait de rapport avec ce bug en particulier. En se basant sur ces données insuffisantes, le modèle a néanmoins produit un diagnostic affirmé et précis qui s’est avéré complètement faux : il indiquait un « problème de compatibilité avec l’extension React DevTools ». Chaque citation a réussi le test de fondement sans problème. Le diagnostic lui-même restait inutile.

    La solution a consisté à cesser de considérer « a été récupéré » comme équivalent à « est pertinent ». Le fichier tools/retrieval.py a été mis à jour de manière à ce que chaque extrait renvoie désormais son score de similarité cosinus ainsi que son texte :

    def retrieve_evidence(query: str, k: int = 3) -> list[dict]:
        results = vector_store.similarity_search_with_score(query, k)
        return [
            {"id": doc.metadata["id"], "text": doc.page_content,
             "source": doc.metadata["source"], "score": score}
            for doc, score in results
        ]
    

    J’ai ensuite vérifié à quoi ressemblaient réellement les scores de similarité par rapport à l’index en temps réel, plutôt que d’estimer arbitrairement un seuil. Une requête correspondant étroitement à du contenu réel dans le corpus obtenait un score compris entre 0,53 et 0,63. Une requête concernant quelque chose complètement absent des quatre répertoires pris en compte obtenait un score entre 0,21 et 0,22. Sur la base de cette différence, independent_review.py impose désormais un seuil minimum strict : chaque extrait cité doit atteindre MIN_RELEVANCE_SCORE = 0,35. Ce seuil est délibérément situé plus près du côté élevé de cette différence que du milieu, car permettre à un bon diagnostic d’être mal classé par erreur coûte bien moins cher que de laisser passer un mauvais diagnostic comme étant valide.

    En plus de corriger la logique de notation, le corpus de recherche lui-même devait être élargi. Il a commencé avec 40 articles provenant d’un seul répertoire, générant environ 130 fragments – suffisamment petits pour que, dans la plupart des cas, aucun article réel aléatoire ne dispose initialement de correspondance thématique fiable à retrouver. Ce nombre a ensuite été porté à environ 600 articles issus de quatre répertoires réels, ce qui a donné près de 2 750 fragments.

    Avec l’existence d’un corpus plus important, le même problème taintUniqueValue entraîne désormais le rejet de deux rapports de quasi-dupliques authentiques et permet d’identifier une cause racine précise : String.fromCharCode.apply dépasse la limite de nombre d’arguments imposée par le moteur JavaScript lors du traitement de grands buffers. Il convient de noter que independent_review escalade néanmoins ce cas pour une analyse humaine, car sa gravité est classée comme élevée, et les anomalies de gravité élevée sont systématiquement escaladées, quel que soit le degré de certitude ou de justesse du diagnostic. La justesse du diagnostic ne dispense pas de passer par le contrôle des risques.

    La leçon principale ici est qu’une citation indiquant un ID de fragment réel et récupéré constitue une condition nécessaire, mais pas suffisante. Affirmer « le modèle a cité quelque chose » est une affirmation différente d’affirmer « le modèle a cité quelque chose de vrai et pertinent pour ce bug » ; un système qui ne vérifie que la première affirmation donne l’impression d’être méticuleux alors qu’en réalité il se contente de valider du bruit.

    Étape d’approbation : une pause réelle, et non un état de chargement esthétique

    Chaque étape décrite jusqu’à présent ne propose qu’une action. Rien n’est écrit à GitHub avant un point précis du processus. Toutes les étapes antérieures — classification, diagnostic, revue — ne font que proposer des actions ; rien n’est enregistré sur GitHub avant cette étape unique.

    Le nœud await_approval assemble le commentaire exact (et, le cas échéant, l’étiquette exacte) qui serait publié, en utilisant une logique identique quel que soit le fait que le problème ait été acheminé via la branche déterministe ou provienne d’un diagnostic ai_investigation approuvé. Il appelle ensuite interrupt() de LangGraph :

    def await_approval(state: GraphState) -> dict:
        proposed_action = _build_proposed_action(state)
        approved = interrupt({
            "classification": state["classification"],
            "proposed_action": proposed_action,
        })
        return {"proposed_action": proposed_action, "approved": bool(approved)}
    

    Appeler interrupt() ne se limite pas à afficher un indicateur de « attente d’approbation » pendant que le processus est inactif — il arrête réellement l’exécution du graphe en cours de fonctionnement. Pour reprendre cette exécution ultérieurement, il faut une demande complètement distincte afin de localiser le même thread suspendu et de reprendre à partir de l’endroit où il s’était arrêté, en utilisant une appelation telle que result = await compiled_graph.ainvoke(Command(resume=True), config) avec le même thread_id que celui utilisé lors de l’exécution initiale. Pour que cela fonctionne, l’état du graphe doit être conservé entre deux requêtes HTTP indépendantes, ce qui exclut l’utilisation de la mémoire interne classique pour le stockage.

    Ce n’est qu’une fois ce résumé généré que le nœud execute s’exécute. Deux détails méritent d’être soulignés ici. Premièrement, la vérification des permissions se trouve à l’intérieur du code même du nœud plutôt que d’être exprimée uniquement sous forme de bordure dans le graphe — donc même si une refonte future introduisait par erreur une bordure directe vers execute, cette vérification la détecterait toujours. Deuxièmement, execute publie state["proposed_action"] exactement tel qu’il a été approuvé ; il ne régénère jamais ce commentaire par la suite. Ce que l’humain a validé est précisément ce qui est publié.

    def execute(state: GraphState) -> dict:
        if not state.get("approved"):
            raise PermissionError("execute() called without explicit approval")
    
        evidence = state["evidence"]
        action = state["proposed_action"]
        token = state.get("github_token")
    
        result = {
            "comment": post_comment(evidence.repo, evidence.issue_number,
                                     action["comment"], token=token)
        }
        if action.get("label"):
            try:
                result["label"] = add_label(evidence.repo, evidence.issue_number,
                                             action["label"], token=token)
            except requests.HTTPError as exc:
                result["label_error"] = str(exc)
    
        return {"execution_result": result}
    

    Pourquoi le système de stockage des exécutions en pause est plus important qu’il n’y paraît

    L’implémentation initiale reposait sur SqliteSaver, qui persiste l’état dans un fichier local sur le disque du backend. Cette configuration fonctionne sans problème sur l’ordinateur d’un développeur.

    Cela échoue en production d’une manière facile à manquer : sur un hébergeur de niveau gratuit comme Render, le stockage disque n’est pas persistant entre les redémarrages. Le processus s’arrête après une période d’inactivité et, lors de la prochaine demande entrante, redémarre à l’intérieur d’un conteneur entièrement nouveau.

    Voici à quoi cela ressemble concrètement. Une exécution atteint await_approval et s’arrête, en attendant qu’une personne agisse. Avant que quelqu’un ne clique sur « approuver », l’instance gratuite devient inactive et se met en veille. La demande suivante lance alors un conteneur frais avec une base de données complètement vide. Lorsque Command(resume=...) est exécuté, il n’y a plus rien à reprendre — l’historique du thread suspendu a disparu sans aucun erreur ou avertissement.

    La solution est AsyncPostgresSaver, soutenu par une base de données Postgres réelle (Neon dans cette configuration) qui existe indépendamment du conteneur dans lequel l’application est exécutée :

    async with (
     AsyncPostgresSaver.from_conn_string(DATABASE_URL, serde=get_serde()) as saver,
     AsyncConnectionPool(DATABASE_URL, open=False,
     check=AsyncConnectionPool.check_connection) as pool),
    ):
    

    Même si le conteneur est détruit et reconstruit à partir de zéro, chaque thread en pause survit, tant que DATABASE_URL fait toujours référence à la même base de données. La mécanique d’arrêt et de reprise via interrupt() ne change pas du tout — seul l’endroit où cet état de pause est stocké change.

    Il convient de souligner un point en particulier : les écrits effectués après l’approbation sont réalisés sous l’identité de la personne qui les a approuvées, et non à l’aide d’une clé de déploiement partagée. L’application en ligne permet à n’importe quel utilisateur GitHub de se connecter, et chaque lecture ou écriture pour cette opération utilise alors le token OAuth propre à cette personne. Une requête telle que post_comment(evidence.repo, evidence.issue_number, action["comment"], token=token) utilise le token de l’approbateur, et non celui de la personne qui a déployé l’application.

    Cela revêt de l’importance au-delà de la simple hygiène des authentifications. Cela transforme l’affirmation « c’est la personne qui a approuvé cela qui l’a posté » en quelque chose que GitHub peut lui-même confirmer en consultant l’auteur du commentaire, plutôt qu’en se basant simplement sur une affirmation de l’interface de l’application.

    Cela permet également au système de permissions existant de GitHub d’appliquer des contrôles efficaces sans aucun code supplémentaire : quelqu’un qui se contente de parcourir un répertoire qu’il ne possède pas peut laisser un commentaire, mais l’ajout d’une étiquette nécessite soit une gestion préalable, soit des droits d’écriture sur ce répertoire. execute.py gère cela de manière appropriée — une tentative d’ajout d’étiquette infructueuse est considérée comme un succès partiel plutôt que de faire échouer toute la requête.

    Les appels à l’LLM et au modèle d’embedding s’exécutent toujours à l’aide des clés API du propriétaire de la déploiement, quel que soit celui qui les déclenche ; c’est précisément pourquoi un plafond de fréquence quotidien par utilisateur existe pour limiter ces coûts.

    Il existe une distinction qu’il convient de clarifier : l’évaluation de régression et l’évaluation des capacités testent fondamentalement des choses différentes, et les noter de la même manière est une erreur à laquelle il est facile de tomber. La priorité de routage à l’intérieur de classify(), la logique de contrôle à l’intérieur de independent_review(), ainsi que la vérification des permissions à l’intérieur de execute() ont chacune un comportement correct unique, et le taux de réussite acceptable pour ce type de test est de 100 pour cent, point final. Un mécanisme de sécurité qui est contourné ne serait-ce qu’une seule fois, quel que soit le nombre d’exécutions, constitue une défaillance critique — ce n’est pas un chiffre que l’on peut atténuer en prenant une moyenne.

    Cette norme est complètement différente de l’évaluation de la qualité des diagnostics produits par generate_diagnosis. Ce type d’évaluation est évalué par un modèle, s’améliore réellement avec le temps, et il n’était jamais réaliste d’espérer qu’il atteigne 100 pour cent. Considérer ces deux types de vérifications comme s’ils relevaient de la même échelle est un piège courant : une barrière de sécurité qui « fonctionne en grande partie » ne remplit pas du tout sa fonction de barrière de sécurité.

    Quelle est la réalité actuelle ?

    Mieux vaut sous-estimer cela que de l’exagérer ; voici donc le constat brut des choses plutôt qu’une présentation marketing :

    La collecte des preuves s’effectue via des appels REST réels sur GitHub, puis est transformée en objets structurés. La classification repose sur des règles déterministes, sans implication de modèles de langage grand public. Le diagnostic provient d’un appel réel à OpenAI qui génère des résultats structurés, basés sur une recherche effectuée par Pinecone à partir d’environ 2 750 fragments de données, réimportés chaque semaine. L’examen indépendant nécessite un appel distinct à OpenAI, mais c’est le code lui-même qui impose les contraintes, et non l’opinion du modèle. Le mécanisme d’approbation humaine consiste en une pause réelle générée par interrupt(), reprise via Command(resume=...), et vérifiée caractère par caractère de bout en bout. Le frontend et le backend sont tous deux déployés — respectivement sur Vercel et Render — de manière entièrement asynchrone, avec enregistrement des points de contrôle dans Postgres. GitHub OAuth permet à tout visiteur de se connecter avec son propre compte ; les écritures s’effectuent alors sous ce compte, avec une limite quotidienne. L’ensemble d’évaluation comprend des tests de régression pour les contrôles de sécurité qui r

    Elles exigent un taux de réussite de 100 % et sont notées selon des codes. Une évaluation de la qualité réelle des diagnostics n’a pas encore été mise en place. Aucun test au sens conventionnel n’a encore été écrit.

    Ces deux dernières lacunes ne sont pas cachées — elles figurent dans le plan de développement car ce sont véritablement les éléments les plus importants à développer ensuite, et non parce qu’ils ont été négligés.

    Leçons à retenir pour votre propre projet

    La création d’un agent conçu pour agir sur des systèmes réels met en évidence un certain nombre de principes qui dépassent le cadre de cet outil spécifique pour les problèmes GitHub :

    Conservez le raisonnement et l’exécution sur des chemins de code distincts, et non seulement dans des prompts séparés. Une instruction telle que « demander toujours avant d’agir » n’est après tout qu’un comportement, et les comportements ont tendance à défaillir au moment précis où on a besoin qu’ils fonctionnent. Une fonction qui refuse catégoriquement de s’exécuter à moins qu’un drapeau vérifié ne soit activé constitue une véritable limite, et non une simple suggestion.

    Lorsque vous affirmez qu’une étape d’évaluation est « indépendante », assurez-vous que ce soit littéralement vrai : une appel distinct, sans trace commune, et un verdict généré par du code plutôt que par du texte produit par le modèle lui-même. Le modèle peut expliquer son raisonnement, mais il ne devrait pas être celui qui le juge.

    Ne laissez pas l’expression « le modèle a indiqué quelque chose de réel » remplacer « le modèle a indiqué quelque chose de pertinent ». Mesurez réellement la qualité de la récupération par rapport à des requêtes authentiques avant de fixer un seuil de similarité.

    Si une pause impliquant un intervenant humain est importante pour votre conception, testez explicitement ce qui se passe lorsque le processus redémarre avant que cet intervenant ne réponde. L’état en mémoire et les disques éphémères semblent fonctionner correctement lors des tests locaux, mais échouent précisément là où l’échec a les conséquences les plus graves.

    Finalement, soyez transparent quant à ce qui est véritablement terminé et à ce qui reste provisoire. Un tableau de statut qui admet ses lacunes gagne plus de confiance qu’un README qui suggère discrètement que tout est fait.

    Lectures complémentaires

  • Comprendre les agents IA : objectifs, outils, mémoire et la boucle de l’agent — Une explication adaptée aux débutants sur les différences entre les agents IA et les chatbots, abordant les composants fondamentaux, la boucle de décision, les niveaux d’autonomie et les cas d’usage dans le monde réel.