Arrêter les outils de manière conditionnelle : les artefacts l’emportent sur return_direct dans LangGraph
Arrêt/continuation par appel à partir des artefacts de l’outil — middleware ReAct et create_agent construits manuellement — lorsque return_direct statique ne peut pas se décider.
Lorsque return_direct statique n’est pas l’outil adapté
Tout ceux qui ont déployé un agent appelant des outils LangGraph ont déjà rencontré la configuration return_direct=True : cela permet d’éviter d’envoyer le résultat de l’outil via le modèle et de terminer la boucle. Tout semble parfait jusqu’au moment où la décision d’arrêt doit dépendre du résultat de cette invocation, et non du outil qui a été enregistré.
Cette explication aborde ce problème, construit manuellement une boucle ReAct minimale, puis recrée le même comportement à l’aide de create_agent et de middleware. La réponse courte : oui, cela fonctionne — mais l’approche basée sur le middleware peut échouer pour des raisons subtiles liées à l’ordre des messages, et non parce que le framework supprime silencieusement les mises à jour.
Version des pins pour plus de clarté : « legacy » désigne langgraph==0.6.6 (la dernière ligne avant que create_react_agent ne soit remplacé par create_agent) ; « current » désigne langchain==1.4.2 utilisant langgraph==1.2.11. Chaque agent ReAct est un cycle modèle ↔ outils ; l’attention se porte ici sur le lien allant des outils vers le modèle — et sur le moment où ce lien doit disparaître pour une donnée requête.
La exigence qui a brisé le cycle par défaut
L’intégration d’un outil de recherche semblait ordinaire : le modèle appelle search(query), lit les résultats, répond ou continue. Deux propriétés ont rendu ce cycle de base inadapté.
Lorsqu’une recherche aboutit, l’outil renvoie une grande page JSON. Reinsérer ce bloc dans le contexte pour un autre traitement par le modèle est coûteux et généralement inutile — si la recherche a déjà répondu à la question, une deuxième appel consiste principalement en une reformulation au prix fort.
Lorsqu’il y a échec, les défaillances se divisent en deux catégories : un véritable cul-de-sac (il n’y a rien à correspondre – réessayer est inutile) par rapport à un temps d’attente temporaire ou une erreur 503 (réessayer est pertinent). Ainsi, la règle varie selon l’appel : succès → arrêter, échec réessayable → continuer, échec fatal → arrêter – la décision est prise en fonction du contenu transmis, et non selon le type statique de l’outil.
Pourquoi return_direct ne peut pas le dire
Dans langgraph.prebuilt.chat_agent_executor de la version legacy fixée, le routage se présente comme suit :
should_return_direct = {t.name for t in tool_classes if t.return_direct}
...
def route_tool_responses(state):
for m in reversed(_get_state_value(state, "messages")):
if not isinstance(m, ToolMessage):
break
if m.name in should_return_direct:
return END
...
return entrypoint
should_return_direct est calculé une seule fois à partir de l’attribut .return_direct de l’outil au moment de la construction du graphe. Ce flag indique que « cet outil termine toujours le cycle ». Il ne dispose d’aucun mode spécifique par appel. Il s’agit d’une inadéquation de catégorie, et non d’un défaut du flag.
Les sujets du forum expriment la même préoccupation : les outils volumineux obligent à des appels de modèles supplémentaires inutiles, et les mainteneurs suggèrent souvent de connecter manuellement tool_node → END. Des discussions distinctes portant sur les mises à jour de Command ainsi que sur return_direct (y compris langgraph#5496) fournissent un contexte complémentaire ; l’argument principal ne nécessite pas de bug — les flags statiques ne peuvent tout simplement pas gérer des résultats dynamiques.
La structure du flux de contrôle
En termes simples :
Tool call
├── success or unfixable failure → stop, use the tool's result
└── fixable failure → let the model decide
Deux destinations, choisies à chaque appel. Le reste de ce texte implémente cette structure deux fois — une fois manuellement, une fois à l’aide de middleware.
Canaux séparés : content et artifact
Le @tool de LangChain sépare déjà ce que le modèle voit de ce que le code d’application reçoit via response_format="content_and_artifact". L’outil renvoie (content, artifact). ToolMessage.content est envoyé au modèle ; l’artifact reste dans le message pour l’orchestration et n’entre jamais dans le flux du LLM.
@tool(response_format="content_and_artifact")
def search(query: str):
return "the content the LLM sees", {"stop": True, "debug": "extra stuff"}
node = ToolNode([search])
result = node.invoke(state)
msg = result["messages"][0]
# msg.content -> "the content the LLM sees"
# msg.artifact -> {"stop": True, "debug": "extra stuff"}
Le schéma souhaité :
tool result
│
┌──────────┴──────────┐
↓ ↓
content artifact
│ │
↓ ↓
model router
│
continue / stop
contre ce que return_direct réduit en une seule réponse statique :
return_direct content_and_artifact
│ │
└── tool content → model
definition artifact → routing metadata
→ routing
Un indicateur fixe tentant de répondre à la fois à « qu’est-ce que voit l’utilisateur ? » et « le cycle doit-il s’arrêter ? » — ou bien deux canaux, chacun répondant à une question.
ReAct traditionnel construit manuellement
Au lieu d’inventer une boucle, réduisez create_react_agent à son noyau : conservez les noms des nœuds et le cycle, supprimez les points d’ancrage pour les prompts, les formats de réponse structurés, la résolution dynamique du modèle, le suivi des étapes restantes, les points de contrôle, les interruptions et l’envoi parallèle via Send.
Il ne reste que trois nœuds :
agent— appelez le modèle ; s’il existe destool_calls, continuez, sinon terminez.tools— un simpleToolNode; ajoutez les résultats desToolMessage.finalize— pas d’appel au modèle ; enveloppez le texte final choisi par l’outil sous forme deAIMessagetel quel.
Deux routeurs :
should_continueaprèsagent: appels aux outils →tools, sinonEND.
route_after_tools après tools : examiner l’artefact et soit retourner à agent, soit passer à finalize (en remplaçant la vérification initiale statique de return_direct). finalize représente un compromis délibéré : il évite une appel à l’LLM et affiche exactement ce que le outil a produit, mais le outil doit générer du texte présentable et le modèle ne peut pas fusionner ce résultat avec d’autres éléments de preuve. Lorsque « la sortie du outil est déjà la réponse », ce compromis s’avère avantageux.
Résultats de recherche en tant que métadonnées, et non commandes de graphe
Le outil de recherche définit artifact["stop"] en fonction de ce qui s’est produit lors de cet appel. stop est une métadonnée de l’application, et non un champ réservé à LangChain. Il est essentiel de noter que le outil rapporte un résultat ; l’orchestrateur l’interprète. Cela permet à la routage de rester compatible avec des politiques que le outil ne voit jamais.
@tool(response_format="content_and_artifact")
def search(query: str) -> tuple[str, dict]:
"""Search a knowledge base for information about the query."""
outcome = force_outcome or rng.choices(
list(resolved_weights), weights=list(resolved_weights.values())
)[0]
if outcome == "retryable":
return rng.choice(_RETRYABLE_MESSAGES), {"stop": False} if outcome == "fatal":
return rng.choice(_FATAL_MESSAGES), {"stop": True} query_lower = query.lower()
for topic, page in _INDEX.items():
if topic in query_lower or query_lower in topic:
return page, {"stop": True}
return "Nothing in the index overlaps with this query.", {"stop": True}
Trois cas possibles :
- Succès avec du contenu réel →
stop=True(une autre tentative par le modèle se limiterait à reformuler). - Echec pouvant être réessayé →
stop=False(donner au modèle une autre chance). - Echec fatal →
stop=True(la boucle gaspille des tokens sur la même réponse nulle).
stop=False ne signifie pas « réessayer maintenant » — il empêche simplement une arrêt immédiat. Le modèle peut toujours choisir de relancer la recherche, d’essayer autre chose ou de répondre. Le routage se réduit alors à une vérification en une ligne :
def route_after_tools(self, state: AgentState) -> str:
last_message = state["messages"][-1]
if (
isinstance(last_message, ToolMessage)
and isinstance(last_message.artifact, dict)
and last_message.artifact.get("stop")
):
return "finalize"
return "agent"
Un mécanisme qui impose "success" | "retryable" | "fatal" rend les chemins déterministes avec un véritable modèle Groq : les cas de succès et d’échec fatal suivent le parcours tools → finalize → END sans nouvelle tentative par le modèle ; les cas réessayables retournent à agent.
Limite : lorsque le routage dépend également des étapes restantes, du nombre d’essais précédents ou des flags d’authentification, l’artefact seul est insuffisant — le routeur doit prendre en compte un état du graphe plus complet. content_and_artifact s’avère utile lorsque c’est le résultat de cet outil qui détermine l’étape suivante.
Au-delà de la recherche
Tout outil dont le résultat est plus détaillé que simplement « OK »/« Échec » convient : un outil write_record peut définir la valeur de already_applied ; un poller peut mettre à jour progress pour une interface utilisateur que le modèle ne décrit jamais. L’artefact n’est que des données brutes — utilisables depuis une connexion conditionnelle, un middleware ou une interface utilisateur qui ne touche jamais au graphe. return_direct est une décision de routage intégrée dans la définition ; il ne dispose d’aucun mode permettant de « conserver des informations pour décider plus tard ».
Même principe avec create_agent
Pins : Python 3.12, langchain==1.4.2 / langgraph==1.2.11, langchain-groq==1.1.3. create_agent remplace le graphe manuel par des connexions déclaratives ainsi que du middleware.
Premier réflexe : utiliser wrap_tool_call et retourner Command(goto=END) lorsque la valeur de stop est définie.
class StopOnArtifact(AgentMiddleware):
def wrap_tool_call(self, request, handler):
result = handler(request)
if isinstance(result, ToolMessage):
stop = isinstance(result.artifact, dict) and result.artifact.get("stop")
if stop:
relay = AIMessage(content=str(result.content))
return Command(goto=END, update={"messages": [result, relay]})
return Command(goto="model", update={"messages": [result]})
return result
Dans la version testée, ce chemin ne fonctionne en court-circuit que lorsque END est déjà accessible via la connexion assurée par return_direct. Le middleware peut déterminer que stop=True, tandis que la boucle continue de renvoyer les données au modèle jusqu’à ce que celui-ci donne une réponse sans utiliser d’outils. Cela correspondait à #5496 sur les versions actuelles — jusqu’à ce que deux variantes scriptées montrent le contraire :
A: update={"messages": [result]} -> stops correctly
B: update={"messages": [result, relay]} -> loops back to the model
La version A fonctionne. La version B ajoute un relais AIMessage sans tool_calls dans la même mise à jour. La vérification de sortie parcourt les éléments en sens inverse jusqu’au dernier AIMessage pour évaluer return_direct ; elle trouve le relais, ne détecte aucune appel de outil et continue à boucler. La Command a été appliquée — l’ordre des messages a caché le message d’appel de outil original à la vérification de sortie. Il ne s’agit pas d’une mise à jour manquante, et ce n’est pas #5496.
Même après avoir corrigé cela, l’approche utilisée dans la version finale fait appel à before_model : elle n’exige absolument pas return_direct.
class StopOnArtifact(AgentMiddleware):
@hook_config(can_jump_to=["end"])
def before_model(self, state, runtime):
last = state["messages"][-1]
if isinstance(last, ToolMessage) and isinstance(last.artifact, dict) and last.artifact.get("stop"):
relay = AIMessage(content=str(last.content))
return {"jump_to": "end", "messages": [relay]}
return None
before_model s’exécute juste avant chaque appel au modèle — lors des itérations ultérieures, c’est immédiatement après les outils. @hook_config(can_jump_to=["end"]) permet de sauter vers END indépendamment de tout indicateur d’outil. Retourner {"jump_to": "end", ...} constitue une simple mise à jour d’état lue par les arêtes du graphe. Un seul crochet détecte à la fois l’artefact et crée le relais AIMessage — la tâche étant alors divisée entre route_after_tools et finalize.
Les résultats forcés correspondent au graphe construit manuellement : succès ou court-circuit fatal avec le contenu exact de l’outil ; les tentatives répétées rouvrent la phase du modèle.
En résumé
content_and_artifact n’a pas été conçu comme une primitive de routage. Il sépare les publics — le contenu visible par le modèle et les métadonnées réservées uniquement à l’application — et cette même séparation permet de déterminer clairement « devrions-nous arrêter ? » sans demander au modèle de raisonner sur le flux de contrôle. return_direct, quant à lui, mélange la présentation et l’arrêt en une seule flag statique, ce qui entraîne des erreurs précisément lorsque ces deux éléments doivent être différents à chaque appel.
Si un cas d’usage nécessite une interruption conditionnelle, il faut séparer le résultat obtenu par l’outil de la décision de routage : exposer les métadonnées à côté de la réponse et laisser l’orchestrateur prendre la décision. content_and_artifact offre déjà ce mécanisme.
Notes de conception que les équipes oublient après le premier test réussi
La cessation conditionnelle semble résolue une fois que les trois résultats obligatoires sont atteints. La production introduit de la concurrence : deux appels d’outils par tour de modèle, ou un lot de recherches où seul un résultat devrait être finalisé. Il faut déterminer si quelconque artefact de cessation interrompt prématurément toute l’étape, si tous doivent être en accord, ou s’une hiérarchie de priorités s’applique. Encodez cette politique dans le routeur, et non dans des connaissances empiriques.
L’observabilité doit afficher l’artefact à côté de ToolMessage sans enregistrer les secrets contenus dans content. Lorsqu’une cessation est déclenchée, notez quelle règle a été appliquée — succès, erreur fatale ou surcharge de politique — afin que le support technique puisse expliquer pourquoi l’assistant n’a pas « réfléchi plus longtemps ». Associez cela à un suivi des tokens : l’objectif principal de finaliser en cas de succès est de réduire le nombre d’appels au modèle ; les tableaux de bord doivent démontrer ces économies.
Faites attention lors du transfert de modèles entre les versions mineures de LangGraph. Les noms des points d’ancrage du middleware, la capacité d’accès aux Command et les vérifications de sortie directe ont changé entre la version 0.6 et la série 1.x. Conservez un test de caractérisation qui force une réponse « succès », « reprise possible » ou « erreur fatale » à chaque mise à jour. Si un point d’ancrage entre soudainement en boucle indéfiniment, suspectez d’abord la structure de la liste de messages avant de signaler des bugs du framework — les messages relais constituent une source fréquente de problèmes.
Finalement, résistez à l’idée d’insérer des flags de contrôle dans le content « juste cette fois ». Dès que le modèle détecte stop=true dans le texte, il peut décrire le flux de contrôle ou afficher des codes internes aux utilisateurs. L’existence d’artefacts permet à l’orchestration de prendre des décisions tout en maintenant le canal destiné aux utilisateurs propre.
Mappage du modèle sur les frameworks voisins
Cette même séparation entre contenu et commandes se retrouve en dehors de LangGraph. Tout environnement d’exécution d’agent qui fusionne la sortie standard des outils dans le seul canal de messages finit par inventer des marqueurs ad hoc, des enveloppes JSON ou des métadonnées secondaires. Préférez un canal secondaire officiel lorsque la plateforme en propose un ; créez une enveloppe documentée lorsqu’elle n’en existe pas ; ne comptez jamais sur le modèle pour ignorer les tokens de contrôle dissimulés dans le texte.
Si une équipe doit prendre en charge à la fois les graphes legacy create_react_agent et les nouvelles applications create_agent, il convient de maintenir le contrat des artefacts du outil identique et de ne modifier que l’implémentation du routage. Cela isole les changements de version aux tests d’orchestration. Lorsque le middleware s’enrichit — vérifications d’authentification, plafonds de dépenses, suppression des données personnelles — il faut exécuter ces hooks avant d’interpréter la commande stop, afin qu’un refus de politique ne soit pas confondu avec un court-circuit réussi. L’ordre des hooks fait partie du comportement public de l’agent, même s’il peut sembler relatif à des aspects techniques internes.
Dossier destiné aux futurs lecteurs expliquant pourquoi finalize (ou le saut before_model) existe : il s’agit d’un choix délibéré du produit permettant que le texte généré soit visible par l’utilisateur sans étape de mise en forme supplémentaire. Si le produit souhaite ultérieurement un style de résumé oral, il suffit de réintroduire un nœud de modèle sur le chemin d’arrêt, plutôt que de surcharger l’outil pour qu’il génère deux tons en même temps. En séparant « calcul du résultat » et « narration du résultat », on permet aux outils d’être réutilisés tant pour la voix que pour le chat ou les clients API.
Intuition pratique concernant l’arrêt et la poursuite
Imaginez un outil de paiement qui renvoie parfois une facture complétée, parfois un message indiquant « timeout du processeur de paiement », et parfois « carte refusée définitivement ». Ces trois cas correspondent respectivement à une fin de traitement réussie, à une continuation permettant des tentatives supplémentaires, et à une fin de traitement fatale. Le content de la facture peut être du HTML prêt à être affiché au client ; l’artefact contient { "stop": true, "reason": "completed" }. En cas de timeout, un bref message d’explication est placé dans content pour le modèle, tandis que l’artefact contient { "stop": false, "reason": "transient" }. Le refus définitif met fin au cycle avec un message adapté aux utilisateurs et { "stop": true, "reason": "fatal" }, afin que l’agent ne sollicite pas indéfiniment le processeur. Ce même schéma peut ensuite être généralisé à la recherche, à la création de tickets ou à l’export de documents, sans avoir à réécrire le routeur — seuls les mappings de l’outil doivent être modifiés.
Habitudes de vérification complémentaires
Gardez le harnais à résultat forcé dans CI avec un modèle de chat fictif qui émet des appels d’outils prédéterminés. Les exécutions réelles de Groq servent à vérifier occasionnellement la fiabilité du processus de bout en bout, et non à chaque mise à jour. Vérifiez les séquences exactes de chemins : quels nœuds ont été exécutés, s’un deuxième appel de modèle a eu lieu, et que le contenu final est identique au contenu généré par les outils sur les chemins de fin. Lorsque quelqu’un « simplifie » le middleware et réintroduit return_direct, le harnais doit échouer de manière visible. Stockez des transcriptions d’ référence à côté du harnais afin que les échecs puissent être comparés. L’arrêt conditionnel constitue un contrat de comportement ; les tests permettent à ce contrat de rester valide malgré les refacteurs au fil des versions de LangGraph, ainsi que face aux ingénieurs qui ne se contentent que de parcourir rapidement les notes de conception originales.
Si le produit a ultérieurement besoin que le modèle fusionne les résultats des étapes précédentes, même en cas de succès, ajoutez un nœud d’affinage optionnel après l’étape de finalisation plutôt que de supprimer le court-circuit. Les flags fonctionnels sont préférables aux réécritures : stop_mode=hard|polish|never permet aux expériences de se poursuivre sans altérer les contrats liés aux artefacts. Mesurez la consommation de tokens pour chaque mode sur le même ensemble de requêtes avant de choisir une valeur par défaut.
Contrat pour l’utilisateur visant à adopter ce modèle
Copiez d’abord le schéma de l’artefact ainsi que les tests du routeur avant de copier le texte principal. La valeur de l’essai réside dans la séparation des préoccupations, et non dans l’anecdote relative à l’outil de recherche. Si votre domaine utilise des étiquettes d’échec différentes, associez-les aux mêmes trois catégories et gardez le routeur simple. Évitez d’ajouter une quatrième catégorie tant qu’un incident réel ne l’exige pas. En cas de doute, préférez continuer à utiliser le modèle plutôt que d’arrêter brutalement en cas d’erreurs ambiguës — des court-circuits silencieux qui masquent des échecs partiels sont pires qu’une appel supplémentaire au modèle peu coûteux qui explique l’incertitude à l’utilisateur.
Fournissez le kit d’intégration avec l’article afin que les lecteurs puissent tester eux-mêmes les cas limites sur leur propre environnement avant de faire confiance à ce modèle dans le trafic en production.