Ensemble d’outils, boucle, graphe : les trois niveaux à l’origine de la plupart des défaillances des agents en production.
Les défaillances des agents de production proviennent rarement du modèle. Séparez le faisceau d’outils, la condition d’arrêt du boucle et la topologie du graphe LangGraph, chacun ayant un responsable.
Un agent meurt en cours d’exécution, et l’équipe en accuse le modèle. Ils le remplacent par un modèle plus performant, mais l’agent meurt à nouveau. Le prompt est réécrit, sans résultat. Une logique de tentative est mise en place, mais rien ne change. Un ticket est envoyé au fournisseur du LLM, qui indique que le modèle se comporte comme prévu. Tout le monde est perplexe.
Le schéma se répète. Ce qui ressemble à un défaut du modèle est en réalité une faille dans le système qui l’entoure, et ce système se divise en trois couches que les équipes ont rarement tendance à séparer : les outils accessibles à l’agent (le harnais), le cycle qui détermine quand le travail s’achève (la boucle), et la carte des endroits où l’agent peut se rendre (le graphe). Lorsque ces couches se confondent, un bug de boucle ou un problème d’outil est imputé au modèle, et les efforts sont concentrés sur la couche visible tandis que le problème réel reste caché en dessous.
Deux couches là où trois sont nécessaires
La plupart des architectures d’agents de production en 2026 fonctionnent sur deux couches. Elles disposent d’un graphe construit à l’aide de LangGraph, CrewAI ou du SDK OpenAI Agents, ainsi que d’une couche de durabilité comme Temporal, ou alors rien du tout. Ce qui est omis, c’est le mécanisme de contrôle : la couche qui détermine quels outils l’agent peut utiliser, quels URLs il peut appeler, ce qu’il peut retourner et quand il doit s’arrêter.
Les fournisseurs suivent la même tendance. Claude Code a obtenu une configuration sandbox.network.deniedDomains. GitHub a introduit des contrôles de pare-feu au niveau de l’organisation pour son agent Copilot, et VS Code dispose désormais de clés de politique de groupe qui filtrent ce à quoi l’agent peut accéder sur le réseau. Un catalogue public des modèles d’agents liste la politique d’égressus en tant que modèle à part entière. Les recherches vont dans le même sens : une étude publiée sur arXiv en 2026 a maintenu le modèle constant à chaque exécution, n’a remplacé que l’outil d’exécution sur six modèles de pointe, et a constaté une réduction de 41 % des coûts par tâche ainsi que de 44 % de la latence. Seules quelques équipes ont investi dans cette couche technologique.
Rien de tout cela n’existe de manière isolée. Autour des trois couches principales se trouvent un gateway qui dirige les requêtes et applique des budgets, une mémoire chargée des opérations de récupération ainsi qu’un cache sémantique, un suivi de chaque étape, une gouvernance qui impose des quotas et des limites de vitesse, ainsi qu’une évaluation qui autorise ou non le déploiement sur un ensemble de données de référence. Chacun d’eux est utile. Cependant, aucun d’eux n’est généralement la cause d’une panne d’un agent. Ce sont plutôt des problèmes liés à une utilisation confuse des outils, aux limites des boucles et des graphes qui provoquent ces pannes, car l’échec qui en résulte peut facilement être interprété à tort comme une défaillance du modèle.
Comment les trois couches ont été identifiées
Le déclencheur a été un agent de sourcing bloqué dans une boucle de rappels vers la fonction query_suppliers. Le LLM continuait d’indiquer qu’il avait besoin de plus de données, la boucle tournait en rond et la facture liée aux tokens ne cessait de croître. Au lieu de lire des tutoriels, il s’est avéré plus utile de consulter des analyses post-mortem, des documents d’architecture et des rapports d’incidents ; et dans des projets sans lien entre eux, les mêmes trois mots réapparaissaient constamment : harness, loop, graph.
Une formulation fréquemment citée dans la littérature sur l’architecture des agents décrit cette séparation de cette manière : le harness décrit la relation d’un agent avec son environnement ; la loop décrit comment l’exécution d’un seul agent est contrôlée dans le temps ; le graph décrit comment de nombreux agents, outils et points de contrôle sont connectés au sein d’une application globale. Chaque couche repose sur celle qui se trouve en dessous, et chacune n’apparaît que lorsque celle du dessous est suffisamment stable.
Avec ces noms en main, le véritable problème est devenu évident : la condition d’arrêt se trouvait à l’intérieur de l’outil et non dans la boucle. Harness et boucle avaient été confondus. Le modèle n’était pas défectueux, c’étaient les limites des couches qui l’étaient. D’autres rapportent le même schéma. Quelqu’un l’a formulé sans détour : un agent qualifié de peu fiable en raison d’un modèle incorrect a généralement une couche défectueuse à blâmer. Une newsletter a décrit un fondateur qui a supprimé un modèle, l’a remplacé par un prétendument plus performant, pour recevoir à nouveau un agent défaillant, car le problème résidait dans le mécanisme qui l’entourait.
Ces trois couches n’ont pas été inventées ici. Elles ont été nommées par des personnes qui gèrent des agents à grande échelle, et ce nom apparaît dans diverses sources indépendantes. Ce qui suit décrit ce qui se passe lorsque ces noms rencontrent du code réel.
Harness : ce à quoi l’agent peut accéder
Le harness constitue la limite des capacités. Il indique quels outils l’agent dispose, quels sont leurs schémas d’entrée et de sortie, quelles permissions il possède, quelle mémoire il peut lire, quels URLs il peut appeler et quels données il peut consulter.
Imaginez l’agent comme un entrepreneur. Le harness est le badge accroché au cordon : il détermine quelles portes s’ouvrent, quels systèmes sont visibles et ce qui est autorisé une fois à l’intérieur. Il ne dit rien sur l’intelligence de l’agent. Il définit la surface d’action, et la sécurité réside également là. Sans lui, un agent n’est qu’une appel de fonction tenant une clé API, libre d’appeler, de lire et de renvoyer n’importe quoi. Avec lui, l’agent ne peut faire que ce que le harness autorise.
La plupart des projets d’agents ne créent jamais cette couche. Les tutoriels s’arrêtent dès que le modèle a appelé une outil et reçu un résultat. Ils omettent trois situations : un outil qui renvoie un bloc JSON de 10 MB dépassant la limite de la fenêtre de contexte, un outil qui récupère une URL arbitraire sur instruction du texte injecté, et un effet secondaire réessayé deux fois faute de clé d’idempotence. Ces trois cas relèvent de problèmes liés au framework, et c’est en les gérant qu’on transforme une démonstration en un système de production.
Le catalogue de modèles décrit le principe concernant les sorties réseau. Les domaines que les outils d’agent peuvent contacter doivent être limités par des listes d’autorisation et de refus mises en œuvre par le framework, ce qui éloigne le modèle de la zone de confiance réseau. Lorsque le modèle décide s’il doit se connecter, l’injection de prompts peut contrecarrer ce contrôle. Une vérification au niveau du framework transforme l’isolement en une propriété structurelle et non en une propriété probabiliste.
C’est précisément cette distinction qui est importante. Les modèles sont probabilistes et peuvent être trompés, injectés ou confondus. Un système de contrôle est structurel et applique des règles dans le code, et non dans les instructions fournies. Demander poliment au modèle de ne pas appeler des URL arbitraires relève d’une requête ; en faire l’impossibilité constitue une garantie.
Boucle : quand l’agent s’arrête
La boucle est le moteur. L’agent observe le monde, choisit une action, la met en œuvre, vérifie le résultat, puis continue ou s’arrête.
Le cycle observer-agir-vérifier est la partie facile. La partie difficile, c’est la condition d’arrêt, car sans elle, un agent non surveillé consomme indéfiniment des tokens sans échouer de manière évidente.
L’agent de sourcing n’en avait aucun. Il a appelé la fonction query_suppliers, examiné les résultats, décidé qu’il en avait besoin de plus et a appelé à nouveau, encore et encore. Le LLM continuait de dire qu’il nécessitait plus de données, car les modèles de langage ont tendance à effectuer une autre appel d’outil : ils sont utiles, ils essaient et ne veulent pas abandonner. Ce trait est un atout pour les tâches créatives mais un inconvénient pour les tâches de production.
Le catalogue des schémas qualifie cela de boucle infinie. La mise en œuvre consiste à « itérer tant que le modèle indique que le travail n’est pas terminé », sans compteur, horloge ou limite de dépense extérieure capable de l’arrêter. L’hypothèse implicite est que le modèle annoncera la fin du travail. Cela arrive rarement pour les tâches difficiles : le modèle s’égarera vers des questions adjacentes, répétera des actions infructueuses et circulera dans des erreurs sans reconnaître le cycle.
Cela n’est pas hypothétique :
- En juillet 2025, une récursion incontrôlée dans Claude Code a englouti entre 16 000 et 50 000 dollars en cinq heures. Aucun plantage ni erreur n’a eu lieu. Les agents ont simplement exécuté des instructions sans point final.
- Les rapports de bugs publics décrivent des sous-agents qui génèrent des descendants à plus de 50 niveaux de profondeur. Une session a consommé 4 millions de tokens en cinq minutes. Un autre utilisateur a payé 27,60 dollars en dépassement de son forfait lorsque des crédits d’utilisation se sont activés au milieu de la session, sans possibilité d’arrêter l’exécution. Dans le premier cas, la recherche utile s’est achevée après trois ou quatre agents, les 44 autres n’ayant apporté rien de nouveau. Dans le second, les sous-agents ont interprété une instruction concernant la gestion de plusieurs cibles indépendantes comme une autorisation pour créer leurs propres descendants.
- Une étude de juillet 2026 a qualifié ce problème de « Boucles d’agent infinies ». Son outil d’analyse statique, IAL-SCAN, a examiné 6 549 dépôts d’agents LLM et a détecté 68 défaillances dans 47 projets. Ces 68 agents qui ne s’arrêtent jamais constituent moins un simple nombre de bugs qu’une véritable bombe à retardement pour les facturations.
Un guide de conception pour les boucles d’agent bornées formule la même exigence : la limite doit faire partie de la conception dès le déploiement de la boucle, et non être une optimisation ajoutée une fois que des problèmes surviennent. Sinon, la seule règle d’arrêt est que le modèle décide qu’il a terminé, ce qui n’est qu’un espoir et non un mécanisme de contrôle.
Ainsi, la condition d’arrêt doit être mécanique et non sémantique. « Le LLM pense que le travail est terminé » est une affirmation sémantique. « Trois tentatives ont été effectuées » ou « 50 cents ont été dépensés » relèvent de conditions mécaniques. Les conditions mécaniques sont déterministes, testables et stables lors des réessais. Elles ne dépendent pas du jugement du modèle, qui est probabiliste et peut varier lors d’une nouvelle tentative.
Un agent de garde d’une entreprise d’analyse montre quel en est le coût. Il était censé identifier la cause racine d’un temps d’attente excessif d’une passerelle de paiement en moins de deux minutes. Il a examiné les journaux, obtenu 4 200 lignes, les a compressées, a conclu qu’un contexte supplémentaire était nécessaire et a recherché à nouveau. Après douze tentatives, le ticket ne disposait toujours d’aucun résumé, tandis que l’opération avait consommé 380 000 tokens et 47 dollars en frais API. Rien dans le modèle n’a refusé de continuer et rien pendant l’exécution n’a ordonné une interruption. Après que l’entreprise ait introduit des limites d’itération, des outils de détection de stagnation (pour repérer des résultats presque identiques répétés) ainsi qu’un contrôleur de budget de tokens, la proportion de boucles incontrôlées a chuté de 91 %, et le temps moyen pour résoudre un ticket est passé de 4,1 à 1,6 minute. C’est une boucle bornée qui a accompli le travail, et non un modèle plus puissant.
Ce qu’une boucle bornée contient
L’exemple d’analyse montre les éléments constitutifs d’une boucle qui ne peut pas déraper. Chacun d’eux est vérifié par du code en dehors du modèle :
- Une limite d’itérations. Un comptage fixe des passages, de sorte qu’une tâche atteignant cette limite se termine par une erreur explicite et non par une exécution silencieuse.
- Une vérification de stagnation. Une analyse des résultats récents, permettant de considérer plusieurs sorties presque identiques consécutives comme aucun progrès n’ayant été réalisé, même si le nombre d’étapes reste faible.
- Un plafond de dépenses. Un budget en tokens ou en argent que l’exécution ne peut pas dépasser, le mécanisme de contrôle étant intégré au temps d’exécution et non dans la requête.
- Un délai maximal. Une limite de temps écoulé, qui permet de détecter les boucles lentes qui ne déclenchent pas les autres compteurs.
- Limits de récursivité pour les sous-agents. Un plafond de profondeur et un plafond total de création, de sorte qu’une tâche déléguée ne peut pas créer des descendants qui en créent à leur tour.
Aucun de ces mécanismes n’a besoin de la coopération du modèle, et chacun peut être testé individuellement avec un modèle fictif qui ne déclare jamais avoir terminé. Une tentative échouée se terminant bruyamment après trois essais coûte quelques centimes. La même tentative, laissée à la discrétion du modèle, coûte autant que le compteur le permet.
Graphe : où l’agent peut aller
Le graphe représente la topologie : les nœuds qui existent, les arêtes qui les relient, les points où le chemin se divise, les endroits où une personne donne son accord, ainsi que l’état transmis d’un nœud à l’autre. Si le graphe est la carte, alors la boucle est le moteur qui la parcourt et les contraintes déterminent ce qui peut se produire à chaque étape.
Le choix de conception central est l’explicitité. Un cycle ReAct (réfléchir, agir, observer, répéter) est un graphe implicite où un seul nœud s’alimente lui-même, et le LLM agit comme fonction de liaison. Cela convient pour des tâches simples. Dès que la structure devient importante, ce système ne fonctionne plus. Une comparaison entre ReAct et LangGraph résume bien la situation : dans le premier, le modèle choisit la prochaine action à partir du prompt et de l’historique, tandis que dans le second, c’est le développeur qui la choisit grâce au code et à des routages explicites.
La différence devient évidente dès qu’une étape d’approbation humaine est nécessaire. Dans un graphe implicite, il n’y a pas de notion de « entre » : l’agent passe d’une étape à l’autre grâce à son propre raisonnement. Ajouter un point de contrôle signifie briser la boucle, introduire un mécanisme de pause, externaliser l’état et reprendre le fonctionnement sur un signal. Il s’agit donc de modifier un graphe pour qu’il s’adapte à une boucle, mais celle-ci résiste car elle a été conçue pour fonctionner de manière autonome. Dans un graphe explicite, il suffit d’ajouter un nouveau nœud contenant une appel à interrupt(). Le graphe change, tandis que la boucle et le système associé restent inchangés.
Un développeur a mis en œuvre la même politique, le même modèle déterministe et les mêmes trois scénarios à l’aide d’une boucle simple d’appel d’outils ainsi que d’un graphe d’état LangGraph explicite. Le comportement était identique, mais la version basée sur le graphe comptait 97 lignes contre 50, soit environ 94 % de code en plus ; donc après trois étapes, la boucle simple l’emportait à tous les égards. Le développeur a ensuite souligné ce que ce type de comparaison ne pouvait pas révéler. Dans la boucle, le comportement est déduit de l’ordre des instructions et doit être compris en lisant le corps du code. Dans le graphe, les arêtes sont déclarées comme des données, ce qui permet d’examiner, de dessiner et de rejouer la topologie sans ouvrir aucun nœud. La valeur est nulle après trois étapes, élevée après trente, et encore plus élevée lorsque quelqu’un d’autre prend le relais.
C’est l’argument lié à la production. Les démos comprennent trois étapes, et la personne qui les a écrites en est la propriétaire. La production, quant à elle, compte trente étapes et est gérée par celui qui est de garde à 3 heures du matin. À cette échelle, une topologie explicite est indispensable pour survivre, et non une charge supplémentaire.
Un test pratique pour choisir entre ces deux formes consiste à se demander qui sera le propriétaire de l’agent dans six mois. Un agent court et à usage unique, entretenu par son auteur, peut rester un simple cycle. Un agent nécessitant des approbations humaines, plusieurs branches ou un propriétaire de garde différent mérite une représentation graphique explicite, car la topologie devient alors des données pouvant être représentées, examinées et reproduites.
À quoi ressemble la confusion entre les couches
Les modes de défaillance présentés ci-dessous proviennent d’un environnement de développement, qui est un endroit propice à la confusion entre les couches. Ce sont les mêmes qui apparaissent en production.
Le système de gestion qui possède le cycle
Dans la version confuse, la vérification de « est-ce suffisant ? » se trouve à l’intérieur de l’outil, ce qui fait que c’est l’outil qui décide quand la boucle s’arrête. Un outil devrait représenter une capacité, comme « pouvoir interroger des fournisseurs », et non un décideur qui dit « c’est suffisant, arrêtez ». En intégrant la condition d’arrêt à cet endroit, la boucle devient impossible à tester indépendamment, il devient impossible de remplacer l’outil sans réimplémenter la logique d’arrêt, et il n’y a plus de place pour une limite de tentatives, car l’outil ne connaît rien des essais effectués.
La correction est structurelle : il faut extraire la condition d’arrêt de l’outil et la placer dans le nœud du graphe. L’outil renvoie des données, le nœud décide s’il est nécessaire d’effectuer une autre passe, le système fournit la capacité nécessaire, et la boucle se trouve dans le graphe. La version confuse ressemble à ceci en code :
# CONFUSED LAYER — Harness owns Loop logic
async def query_suppliers(item: str, budget: float, quantity: int, attempts: int) -> dict:
"""Tool decides when the loop stops. Capability + control flow in one function."""
results = query_mock_marketplace(item, budget, quantity)
# The tool decides when to stop — couples execution logic with capability
if len(results) > 5 or attempts >= 3:
return {"status": "STOP", "suppliers": results}
return {"status": "CONTINUE", "suppliers": results}
async def research_node(state: AgentState) -> dict:
# The node just calls the tool and forwards whatever the tool decided.
# It has no idea why the loop stopped — that logic is hidden inside the tool.
result = await query_suppliers(
state["brief"].item, state["brief"].budget, state["brief"].quantity,
state.get("attempts", 0) + 1,
)
return {"suppliers": result["suppliers"], "status": result["status"].lower()}
# ✅ CLEAN SEPARATION — Harness is pure capability, Loop lives in the Node
MAX_RESEARCH_ATTEMPTS = 3
async def query_suppliers_impl(item: str, budget: float, quantity: int) -> dict:
"""Pure Harness capability: schema validation, circuit breaker, marketplace call.
The tool has no idea a loop exists. It returns data. It does not decide
when to stop. Circuit breaker + fault injection live here because they are
capability concerns (is the downstream reachable?), not control-flow concerns.
"""
breaker = get_circuit_breaker("query_suppliers")
if not breaker.allow_call():
raise CircuitBreakerOpenError("query_suppliers", breaker.state)
maybe_inject("query_suppliers") # fault injection for chaos tests
try:
results = query_mock_marketplace(item, budget, quantity)
breaker.record_success()
return {"suppliers": results}
except Exception as e:
breaker.record_failure()
raise
async def research_node(state: AgentState) -> dict:
"""Graph Node / Loop: mechanical stop conditions and routing decisions.
The node reads state, calls the tool through the registry, and decides
whether to stop — based on counters in state, NOT on the LLM's judgment
and NOT on the tool's opinion.
"""
brief = state["brief"]
attempts = state.get("attempts", 0) + 1
# Harness call — schema validation, timeout, output cap all happen inside registry.call
query_result = await registry.call("query_suppliers", {
"item": brief.item,
"budget": brief.budget,
"quantity": brief.quantity,
})
suppliers = query_result.get("suppliers", [])
# Mechanical stop condition — enforced OUTSIDE the tool, in code, on state.
# Deterministic. Survives replay. Testable without the tool being live.
if not suppliers and attempts >= MAX_RESEARCH_ATTEMPTS:
logger.info(
"STOP_CONDITION_FIRED path=no_matches item=%s attempts=%d max=%d",
brief.item, attempts, MAX_RESEARCH_ATTEMPTS,
)
return {"suppliers": [], "attempts": attempts, "status": "no_matches"}
if not suppliers:
return {"suppliers": [], "attempts": attempts, "status": "running"}
# Enrich, then mark completed
enriched = []
for s in suppliers:
quote = await registry.call("get_price_quote", {
"supplier_name": s["name"], "item": brief.item, "quantity": brief.quantity,
})
rating = await registry.call("check_seller_rating", {"supplier_name": s["name"]})
enriched.append({**s, "quote": quote, "rating_info": rating})
return {"suppliers": enriched, "attempts": attempts, "status": "completed"}
# The Graph owns WHERE the agent can go — conditional edges, not the node body.
def _should_retry_or_end(state: AgentState) -> str:
"""After Research: loop back if still running, else Score or END."""
if state["status"] == "running":
return "research" # self-loop — the retry
if state["status"] == "no_matches":
return END # mechanical stop → graph terminates
return "score" # success → next node
# Wiring (in build_sourcing_graph):
# graph.add_conditional_edges("research", _should_retry_or_end,
# {"research": "research", "score": "score", END: END})
La boucle qui gère le graphe
De nombreux frameworks fournissent une boucle ReAct où la topologie est implicite : l’agent décide de la prochaine étape à partir de la sortie du modèle, sans qu’aucune carte explicite n’existe. Cela reste valable tant qu’une étape d’approbation humaine doit être insérée entre la décision et la confirmation ; c’est alors que des modifications sont apportées, la boucle s’y opposant.
La solution consiste à concevoir le graphe de manière explicite dès le début, avec des nœuds représentant des fonctions et des arêtes représentant des transitions, certaines étant conditionnelles et d’autres agissant comme des interruptions. La boucle continue de guider l’agent, mais c’est le graphe qui définit les endroits accessibles, et non la boucle. La forme monolithique de ReAct, où la boucle est le graphe et le LLM est la fonction d’arête, ressemble à ceci :
# ❌ CONFUSED LAYER — Implicit topology inside a monolithic ReAct loop
# The loop IS the graph. The LLM is the edge function. There is no "between."
while not agent.is_done():
action = llm.decide_next_step(history)
if action == "query":
data = query_suppliers()
elif action == "approve":
# Retrofitting human approval into an implicit loop requires messy pauses.
# You have to break the loop, externalize state, park the worker, and
# resume on a webhook — the loop fights you because it was designed to
# be self-driving.
pause_execution_and_wait_for_webhook()
elif action == "stop":
break
# ✅ CLEAN SEPARATION — Declarative graph with explicit nodes and human interrupt
from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt
def approve_node(state: AgentState) -> dict:
"""Human checkpoint via interrupt() — a STRUCTURAL pause, not a prompt.
The graph genuinely suspends here. interrupt() pauses execution and waits
for a human to call Command(resume=approval_data). The worker is freed.
If the worker crashes while waiting, Temporal replays the activity, the
graph re-runs from the last checkpoint, and interrupt() fires again —
the human re-approves. No data loss.
"""
selected = state.get("selected_supplier", {})
brief = state.get("brief", {})
approval_request = {
"item": brief.item,
"quantity": brief.quantity,
"supplier": selected.get("name", ""),
"price": selected.get("price", 0),
"total_cost": selected.get("price", 0) * brief.quantity,
}
# interrupt() pauses the graph HERE. The human must call /approve
# with a resume value to continue.
approval = interrupt(approval_request)
approved = approval.get("approved", False)
comment = approval.get("comment", "")
return {
"approval_status": "approved" if approved else "rejected",
"approval_comment": comment,
"rejection_count": state.get("rejection_count", 0) + (0 if approved else 1),
}
def _after_approve(state: AgentState) -> str:
"""After Approve: go to Confirm if approved, retry Research if rejected."""
if state.get("approval_status") == "approved":
return "confirm"
if state.get("rejection_count", 0) + 1 >= 3:
return END
return "research"
def build_sourcing_graph(checkpointer=None):
"""Topology is DECLARED DATA, not statement order in a loop body.
START → Research → (retry) → Score → Decide → Approve → Confirm → END
↓ (rejected, < 3)
Research
↓ (rejected, = 3)
END
"""
graph = StateGraph(AgentState)
graph.add_node("research", research_node)
graph.add_node("score", score_node)
graph.add_node("decide", decide_node)
graph.add_node("approve", approve_node) # explicit checkpoint node
graph.add_node("confirm", confirm_node)
graph.add_edge(START, "research")
graph.add_conditional_edges("research", _should_retry_or_end,
{"research": "research", "score": "score", END: END})
graph.add_edge("score", "decide")
graph.add_edge("decide", "approve")
graph.add_conditional_edges("approve", _after_approve,
{"confirm": "confirm", "research": "research", END: END})
graph.add_edge("confirm", END)
return graph.compile(checkpointer=checkpointer)
Le graphe qui contrôle le harness
Ce cas est plus rare, bien qu’il apparaisse lors des revues de code. Un nœud du graphe appelle directement une API externe, par exemple via une requête HTTP GET vers une extrémité de fournisseur à l’intérieur de la fonction du nœud, de sorte que le graphe contrôle l’appel à l’outil.
C’est incorrect car le graphe est une structure topologique et ne devrait rien savoir sur HTTP, l’authentification, les tentatives de répétition ou les disjoncteurs de circuit. Avec l’appel intégré au nœud, ajouter un disjoncteur de circuit signifie modifier le graphe, changer l’implémentation signifie également modifier le graphe, et tester le graphe nécessite un outil en temps réel.
La solution consiste à rediriger l’appel via le harness. Le nœud demande call_tool("query_suppliers", {...}) et reçoit un résultat validé et typé. Le harness gère la requête HTTP, les tentatives de répétition, les disjoncteurs de circuit ainsi que la validation du schéma. Le graphe ignore l’existence d’HTTP, et le harness ignore l’existence d’un graphe :
# ❌ CONFUSED LAYER — Graph node owns the tool call (Harness concern)
import httpx
def supplier_node(state: AgentState) -> dict:
"""The graph node reaches directly into HTTP, auth, retries, and parsing.
Why that's wrong: the graph is topology. It should not know about HTTP,
authentication, retries, circuit breakers, or schema validation. Those are
harness concerns. When the graph owns the tool call:
- You can't add a circuit breaker without modifying the graph.
- You can't swap the tool implementation without modifying the graph.
- You can't test the graph without the tool being live.
- You can't enforce an egress allowlist — the URL is hardcoded here.
"""
resp = httpx.get(
f"https://supplier-api.example.com/suppliers",
params={"item": state["brief"].item},
headers={"Authorization": f"Bearer {SUPPLIER_API_KEY}"},
timeout=10.0,
)
resp.raise_for_status()
suppliers = resp.json()["suppliers"] # no schema validation, no output cap
return {"suppliers": suppliers}
# ✅ CLEAN SEPARATION — Graph node calls through the harness; harness owns the call
async def supplier_node(state: AgentState) -> dict:
"""The graph node says 'call this tool with these args' and gets back a
validated, typed result. The graph doesn't know there's HTTP involved.
The harness doesn't know there's a graph involved.
"""
query_result = await registry.call("query_suppliers", {
"item": state["brief"].item,
"budget": state["brief"].budget,
"quantity": state["brief"].quantity,
})
suppliers = query_result.get("suppliers", [])
# Mechanical stop condition — the node's job, not the tool's
if state.get("attempts", 0) + 1 >= MAX_RESEARCH_ATTEMPTS and not suppliers:
return {"suppliers": [], "attempts": state.get("attempts", 0) + 1, "status": "no_matches"}
return {"suppliers": suppliers, "attempts": state.get("attempts", 0) + 1, "status": "completed"}
# What the harness does — the graph never sees this:
#
# registry.call("query_suppliers", {...})
# │
# ├─ 1. validate_input(QuerySuppliersInput, args) # Pydantic v2
# ├─ 2. EgressFilter(spec.allowed_egress) # URL allowlist
# ├─ 3. SandboxedHTTPClient(egress_filter) # blocked domains = structural
# ├─ 4. run_with_timeout(fn, kwargs, spec.timeout_seconds)
# ├─ 5. enforce_output_cap(result, spec.max_output_bytes) # 64KB cap
# └─ 6. validate_output(QuerySuppliersOutput, result) # garbage caught here
#
# The graph node gets back a validated dict. It never sees the HTTP call,
# the auth header, the retry, the circuit breaker, or the egress filter.
# Swap httpx for aiohttp, swap the supplier API for a new one, add a circuit
# breaker — the graph doesn't change. The harness doesn't know a graph exists.
Qui possède quelle couche
Chaque couche a besoin d’une infrastructure qui en assure la gestion. Lorsque la propriété n’est pas claire, les couches entrent en conflit.
Temporal gère la sécurité en cas de panne du cycle
Lorsque le moteur tombe en panne au milieu d’un cycle, Temporal s’occupe de la récupération. Il enregistre chaque étape dans un historique d’événements. Après une panne d’un processus, il rejoue le flux de travail sans réexécuter les activités, en insérant à la place leurs résultats enregistrés, de sorte que le cycle redémarre à partir de la dernière étape terminée et non à zéro.
C’est pourquoi une condition d’arrêt doit être déterministe. Une condition telle que « le modèle évalue le travail effectué » est non déterministe, et un rejouement peut aboutir à un verdict différent. Un compteur d’étapes, un plafond de dépenses ou un seuil de qualité dérivé des résultats des outils fonctionnent correctement lors d’un rejouement. Le modèle peut donner son avis, mais la vérification if attempts >= 3: stop doit figurer dans le code.
LangGraph contrôle la topologie du graphe
Dans LangGraph, les nœuds sont des fonctions simples, les arêtes représentent des transitions, et les arêtes conditionnelles sont des fonctions qui retournent le nom du nœud suivant. LangGraph compile tout cela en un graphe exécutable. Comme le graphe est explicite, il peut être consulté, chaque nœud peut être testé individuellement, et un nœud peut être ajouté sans perturber le reste. Un point d’approbation humaine n’est rien d’autre qu’un nœud qui appelle interrupt(), ce qui modifie le graphe sans affecter les boucles ni les mécanismes de liaison.
LangGraph contrôle également la structure de l’état, représentée par un TypedDict transmis entre les nœuds. Chaque nœud lit ce dont il a besoin et écrit ce qu’il produit, de sorte que l’état agit comme un contrat entre eux. Un nœud qui ne écrit jamais dans la variable suppliers empêche le nœud suivant de la lire, et le vérificateur de types le détecte avant l’exécution.
Un système de gestion de outils conçu manuellement gère tout le reste
C’est une couche que ni Temporal ni LangGraph ne fournissent, il faut donc la créer de zéro. Il gère :
- Enregistrement. Chaque outil déclare un nom, un schéma d’entrée Pydantic, un schéma de sortie, un délai d’expiration et indique s’il nécessite une propriété d’idempotence.
- Validation. Les entrées sont validées avant l’exécution d’un outil et les sorties après, afin que toute réponse incorrecte soit détectée avant que l’agent ne la voie.
- Filtrage des sorties. Les outils effectuant des appels HTTP passent par un client isolé disposant d’une liste blanche de URLs, de sorte qu’aucun outil ne peut appeler d’adresse arbitraire. C’est le système de gestion qui décide, et non le modèle.
- Limite des sorties. Les résultats sont tronqués à 64 KB avant d’atteindre la fenêtre de contexte. Un fichier de 10 MB arrive tronqué, accompagné d’un marqueur
_truncated: true. - Disjoncteurs. Chaque outil en possède un, partagé par tous les flux de travail des agents. Cinq échecs consécutifs déclenchent la disjonction, et à partir de là, les agents échouent rapidement au lieu de continuer à solliciter une API inopérante.
- Barrières de concurrence. Des limites de concurrence par outil empêchent qu’un outil lent n’occupe toutes les places disponibles pour les activités des travailleurs.
- Clés d’idempotence. Pour les outils ayant des effets secondaires, le système génère une clé à partir de l’ID du flux de travail et du nom de l’étape. Une activité réessayée reçoit le résultat existant sans création de doublon.
Rien de tout cela ne provient de Temporal ou de LangGraph. Il s’agit de la couche qui rend les appels aux outils sûrs, et c’est celle que la plupart des projets d’agents omettent.
Vérification : « l’agent affirme que ça a fonctionné » contre « ça a fonctionné »
La troisième étape du cycle est la vérification, qui doit avoir lieu en dehors du processus. Il ne s’agit pas que l’agent vérifie sa propre sortie, ni que le LLM évalue ses propres résultats.
Le catalogue de modèles désigne cette correction sous le nom de registre de vérification : les affirmations auto-déclarées telles que « compilation réussie » laissent place à des enregistrements structurés, où chaque vérification correspond à une insertion dans une base de données et chaque ensemble de preuves à une requête SELECT. Sans contrôle, un agent peut croire que les vérifications ont réussi, sauter des étapes sans le mentionner ou prétendre obtenir des résultats d’un outil qu’il n’a jamais exécuté.
L’équipe Honk de Spotify a rencontré ce problème précisément. Leurs agents affirmaient parfois « tests réussis, aucun problème détecté », mais se trompaient. Ils ont intégré des vérificateurs déterministes pour le format, la compilation et les tests dans le cycle, et ont bloqué la création de demandes de pull chaque fois qu’un vérificateur échouait. Le texte produit par l’agent n’était plus un signal de succès ; sa place a été prise par la sortie structurée du vérificateur.
D’autres projets vont plus loin. Un outil open source soit conserve un justificatif indiquant « terminé, tous les tests réussis », soit marque l’affirmation comme non vérifiée, et un ordre de réussite en soi ne met jamais à jour une affirmation sémantique. Une étude de recherche formule la règle ainsi : il faut redériver l’affirmation et ne pas y faire confiance, car le comptage des mots par l’agent ne vaut rien en tant qu’entrée.
Dans la pile décrite ici, la vérification est une activité Temporal distincte, telle que verify_po_exists ou verify_payment_initiated, qui interroge directement la base de données. L’agent affirme avoir créé la commande d’achat. L’activité vérifie alors la base de données des commandes, et si celle-ci ne contient pas la commande, le flux de travail échoue, quel que soit le rapport de l’agent.
Le rapport auto-déclaré est le signal le plus susceptible d’être erroné de manières difficiles à détecter, c’est pourquoi la vérification doit être externe. L’agent appelé create_po a analysé la réponse et affirmé que tout fonctionnait. Pourtant, la réponse aurait pu être un code 200 OK contenant un corps d’erreur, ou la commande aurait pu avoir été enregistrée avant d’être annulée en raison d’un délai de transaction, ou encore l’agent aurait pu lire le mauvais champ et signaler un ID qui n’a jamais existé. L’agent ne connaît que ce que l’outil a renvoyé, sans aucune information sur l’état de la base de données. C’est pourquoi on interroge la base de données.
Les preuves issues de la production confirment cela. Une étude de juillet 2026 a examiné Leni, une analyste commerciale basée sur l’IA, dont les boucles de vérification exécutent, observent, comparent et corrigent en utilisant des modèles spécialisés dans de petites tâches. Le système complet a surpassé son modèle de base de référence de 11 points de pourcentage sur SpreadsheetBench, avec 91,25 % contre 80,25 %. La vérification seule a ajouté 1,5 point de manière modeste, mais ces points se sont concentrés en haut de la fourchette de scores, permettant de traiter des tâches qui échoueraient autrement. La majeure partie de l’amélioration globale provient des structures de soutien, des mécanismes de routage et des modèles spécialisés. La vérification ne résout pas tous les cas ; elle permet plutôt de sauver ceux qui sont borderline, où l’agent prétend avoir terminé la tâche alors que la réalité est différente.
Le principe de séparation
Règle fondamentale : chaque couche doit être testable, remplaçable et compréhensible indépendamment des autres.
- Le graphe peut être vérifié en utilisant des appels de outils simulés, afin de s’assurer que les nœuds appropriés s’exécutent dans le bon ordre.
- Le harness peut être testé en appelant directement les outils et en examinant la validation du schéma, les disjoncteurs électriques et les propriétés d’idempotence.
- La boucle peut être testée avec une activité triviale à l’intérieur d’un flux de travail Temporal, pour confirmer qu’elle survit à une panne.
Changer le graphe n’affecte pas le harness, remplacer l’implémentation d’un outil n’affecte pas non plus le graphe, et modifier la politique de tentative de la boucle n’affecte aucun des deux.
Les avantages sont pratiques. Le graphe de l’agent de sourcing a été modifié à trois reprises : un nœud de notation, une boucle de tentative de réessai tenant compte des rejets et un point de contrôle d’approbation humaine, sans aucune modification du code Harness. Le fournisseur de LLM est passé d’une connexion directe avec OpenAI à une couche de routage, sans toucher au code du graphe. La politique de réessai est passée d’une approche agressive à une approche strictement interdisant les tentatives de réessai pour les activités générant des effets secondaires, sans modifier le graphe ni Harness. La séparation confère plus d’agilité que la pureté : la liberté de modifier une chose sans endommager trois autres.
La même discipline s’avère utile lorsque quelque chose ne fonctionne pas correctement. Une exécution ratée peut être analysée couche par couche en posant trois questions précises. Un outil a-t-il renvoyé quelque chose qu’il n’aurait jamais dû pouvoir renvoyer, ou atteint un endroit où il n’aurait jamais dû pouvoir arriver ? Cela renvoie au système de gestion des outils. L’agent a-t-il continué à dépasser des limites raisonnables ? Cela renvoie à la boucle de traitement. L’agent s’est-il retrouvé dans un nœud qu’il n’aurait pas dû pouvoir atteindre, ou a-t-il sauté un point de contrôle ? Cela renvoie au graphe. Changer de modèle ne résout aucun de ces trois problèmes.
Trois questions pour toute conception d’agent
- Qu’est-ce qu’il peut toucher ? (Système de gestion des outils) Quels outils existent, avec quels schémas, quelle quantité de données peut être émise, où se situent les seuils de protection et quelles clés d’idempotence s’appliquent.
- Quand cela s’arrête-t-il ? (Boucle) Le plafond des étapes, le plafond des coûts, le délai d’expiration, ainsi que la condition d’arrêt mécanique qui ne dépend pas du jugement du LLM.
- Où peut-il aller ? (Graphique) Les nœuds, les arêtes, les branches conditionnelles, les points de contrôle humains et la forme de l’état qui circule entre les nœuds.
Lorsque toute réponse est « le modèle décide », un problème existe. Le LLM donne des conseils tandis que le code prend la décision, le système de gestion l’applique, le graphique dirige le flux et les boucles en définissent les limites.
En bref, il y a trois niveaux, trois responsables et trois questions. Les mélanger fait que les bugs semblent être des problèmes du modèle alors qu’ils ne le sont pas vraiment. Les garder séparés permet de contenir l’instabilité du modèle, d’utiliser en toute sécurité les outils correspondants, et de faire cesser l’agent au moment opportun.
Lectures complémentaires
- Votre graphique d’agent appartient à YAML : compilation d’un LangGraph à 17 agents au démarrage — Comment un petit moteur de exécution compile une spécification YAML en un LangGraph StateGraph : état généré, plugins importlib, routeurs de labels, appels adaptatifs et échanges de nœuds par session.
- Lorsque le tableau de bord est vert et que l’agent a tort : suivi de l’état — Comment les échecs des agents se cachent dans l’état transmis entre les étapes fonctionnelles, et quels éléments de suivi, contraintes de exécution, enregistrements de routage et évaluations il faut capturer.