De nombreuses mains, un seul stylo : sept règles pour orchestrer des agents IA
Sept règles pour orchestrer des agents IA : quand un flux de travail simple l’emporte, pourquoi l’orchestrateur ne dispose d’aucun outil, le retour du sous-agent en mode texte et les conditions d’arrêt écrites.
Sources: Many Hands, One Pen: Orchestrating Agents Without Losing the Plot
Imaginez la marine américaine en 1942, confrontée à un problème de coordination qui dit beaucoup sur les logiciels modernes. Les navires de guerre disposaient désormais de radars, de sonars, de interceptions radio et d’observateurs humains, et chaque rapport arrivait simultanément sur le pont, dans un ordre aléatoire. Un capitaine tentant de mener une bataille était submergé par des signaux non traités.
La réponse de la marine fut une pièce sans fenêtres appelée Centre d’information de combat. Chaque rapport provenant d’un capteur était indiqué sur un même graphique, selon un format convenu. Les opérateurs qui consultaient les instruments ne donnaient pas d’ordres ; leur tâche consistait à maintenir l’exactitude du graphique, tandis qu’un seul officier décidait de la prochaine manœuvre du navire. Les informations affluaient de manière diffuse, les décisions se concentraient progressivement, et le format standard des rapports permettait de comparer les données entre elles.
Les systèmes multi-agents continuent de redécouvrir cette pièce. La phase où « ajouter un autre agent » était la solution à tout est révolue, et l’année 2026 a été principalement consacrée à mesurer le prix payé pour cette solution. Le modèle qui persiste est celui retenu par la Marine : travailler en parallèle, présenter des rapports selon un format discipliné, prendre des décisions en un seul endroit et définir clairement ce qui constitue une tâche terminée.
Un cadre utile : la plupart des problèmes multi-agents ne sont en réalité pas des problèmes d’agents. Ce sont des problèmes de contexte et de flux de contrôle déguisés en problèmes d’agents. Une exécution échoue généralement parce que deux agents prennent des décisions incompatibles qu’aucun ne peut voir, parce que les informations perdent leur sens en franchissant une frontière, ou parce que personne n’a noté à quoi ressemble une tâche terminée.
Sept pratiques suivent, chacune tirée d’échecs survenus dans des systèmes déjà en service. Ensemble, elles vous aident à déterminer quand un flux de travail simple est préférable à un agent, quand un orchestrateur doit financer ses tokens, comment répartir le travail selon les limites contextuelles, pourquoi l’orchestrateur doit se tenir à l’écart des outils, comment fournir aux sous-agents un contrat de retour que vous pouvez valider, ainsi que comment mettre fin à une exécution de manière intentionnelle, et non au gré du modèle.
Privilégiez les flux de travail simples ; l’autonomie doit être gagnée
Qu’est-ce qui ne va pas ? Prenons un processus de remboursement avec quatre issues, chacune déjà couverte par une règle écrite. Une équipe l’encadre dans un boucle autonome, avec six outils et une chaîne d’objectifs. Soudain, le ticket du mardi suit un parcours différent de celui du lundi, et chaque exécution coûte entre dix et cinquante fois plus que le code ancien. La précision reste stable, car aucune partie du flux n’est suffisamment ambiguë pour nécessiter de l’intelligence artificielle.
La solution. Dessinez d’abord le diagramme de flux. Tout ce que vous pouvez planifier à l’avance relève d’un flux de contrôle ordinaire, de sorte que du code simple peut gérer chaque branche que vous pouvez nommer. Appeliez le modèle uniquement à l’étape où les entrées sont réellement sujettes à interprétation. Un agent ne devrait obtenir l’autorité sur une branche que lorsque vous avez démontré qu’elle ne peut pas être décrite par écrit.
REFUND_LIMIT = 50.00
def llm_step(prompt: str) -> str:
"""Called only where the written rules run out."""
return model.complete(prompt).strip().lower()
def handle_refund(ticket):
if ticket.days_since_purchase > 30:
return deny(ticket, reason="outside window")
if ticket.amount <= REFUND_LIMIT:
return approve(ticket) # a rule, not a judgement call
intent = llm_step(
f"Classify as fraud, defect, or remorse:\n{ticket.body}"
)
if intent == "fraud":
return escalate(ticket, queue="risk")
if intent == "defect":
return approve(ticket)
return route_to_human(ticket)
Le problème. On peut se retrouver pris dans un enchevêtrement de conditions imbriquées qui codent discrètement des règles de jugement que le modèle ne peut en réalité pas appliquer. Lorsqu’on écrit une cinquième condition pour du texte libre, c’est précisément cette ambiguïté que le modèle est conçu pour résoudre.
Évitez l’orchestrateur lorsque la solution est déjà connue
Ce qui ne va pas. Un pipeline de rapport en quatre étapes est enfermé dans une boucle d’orchestrateur limitée à quinze itérations. Chaque itération consacre un appel de planification à la décision de la prochaine action, et chaque appel aboutit à la même réponse déjà déterminée : récupérer, extraire, résumer, formater. Ce qui revient à quinze appels de raisonnement, chacun traitant quelques milliers de tokens, pour redécouvrir une liste que l’on pourrait taper en cinq lignes.
La solution. Utilisez une règle rapide avant de coder quoi que ce soit. Lorsque la liste complète des sous-tâches est connue avant le début de l’exécution, exécutez-la sous forme d’une boucle simple avec un affectation fixe et conservez les tokens pour un autre usage. L’orchestration pilotée par des modèles convient aux cas où la décomposition elle-même est inconnue, ou doit évoluer en fonction des résultats des étapes précédentes.
# Before: up to 15 planning calls to rediscover a fixed list
def run_orchestrated(doc):
state = {"doc": doc}
for _ in range(15):
decision = orchestrator.plan(state) # 1 LLM call per pass
if decision.action == "finalize":
break
state = WORKERS[decision.worker](state)
return state
# After: 0 planning calls, same four workers, same result
PIPELINE = [fetch, extract, summarize, format_report]
def run_static(doc):
state = {"doc": doc}
for step in PIPELINE: # 0 LLM calls here
state = step(state)
return state
Le problème. Ce conseil a un revers à la médaille. Un pipeline qui crée un cas particulier nouveau chaque mois, ou où la sortie de l’étape deux décide réellement si l’étape trois s’exécute, constitue un véritable planification. Supprimer le planificateur ne fait que déplacer cette logique de décision dans un fichier de configuration que personne ne prendra en charge.
Divisez les agents en fonction du contexte, et non du titre du poste
Ce qui ne va pas. La version organigramme du modèle multi-agent semble ordonnée : planificateur, implémenteur, testeur, réviseur, travaillant sur une fonctionnalité après l’autre. En pratique, chaque transfert de tâches introduit des hypothèses que personne n’a écrites. Le testeur vérifie un comportement que l’implémenteur n’avait jamais envisagé, et le réviseur s’oppose à un choix fait délibérément par le planificateur. Vous payez quatre fois le prix d’une dégradation progressive, alors qu’un seul agent assurant toutes ces tâches aurait obtenu de meilleurs résultats.
La solution. Coupez le long des lignes de contexte. Avant d’ajouter un deuxième agent, posez-vous une question : cette sous-tâche peut-elle être terminée dans une fenêtre de contexte qui n’a rien en commun avec celle de sa voisine ? Si la réponse est non, les deux appartiennent au même agent, c’est précisément pourquoi l’agent chargé de développer une fonctionnalité doit également écrire les tests correspondants.
from dataclasses import dataclass
@dataclass
class Subtask:
name: str
needs: set[str] # facts this subtask must read
produces: set[str] # facts this subtask decides
def should_split(a: Subtask, b: Subtask) -> bool:
"""Split only when neither side needs what the other decides."""
shared = (a.needs & b.produces) | (b.needs & a.produces)
return not shared
implement = Subtask("implement", {"spec"}, {"api_shape", "error_semantics"})
test = Subtask("test", {"spec", "api_shape", "error_semantics"}, {"cases"})
assert not should_split(implement, test) # one agent writes both
Le problème. Les sous-tâches véritablement isolées sont plus rares que ne le suggère un organigramme, donc le test indiquera souvent « un agent », et c’est la bonne réponse, pas un manque d’imagination. Diviser les tâches s’avère utile pour des travaux à grande échelle, comme cinq agents qui lisent cinq sources non liées entre elles, car aucune décision prise par l’un ne modifie ce dont un autre a besoin.
Permettre à l’orchestrateur de produire une décision sans jamais appeler d’outil
Ce qui ne va pas. Un orchestrateur dispose d’un outil de recherche pour les urgences. Bientôt, il cesse de distribuer les tâches et commence à répondre aux sous-tâches par lui-même. Les résultats bruts des outils s’accumulent dans sa fenêtre, ce qui détériore sa planification au moment même où la durée du traitement augmente et où la planification devient cruciale. La traçabilité est également affectée, car il n’est pas clair quel agent a produit quelle découverte.
La solution. Toute planification inutile doit être évitée, c’est pourquoi il ne faut pas fournir d’outils à l’orchestrateur. Celui-ci ne génère qu’une décision concise et structurée : déléguer, replanifier ou finaliser, accompagnée du travailleur à utiliser et du texte de la tâche. Une boucle circulaire lit cette décision, exécute le travailleur désigné et stocke le résultat dans un état partagé, prêt pour la décision suivante.
from typing import Literal
from pydantic import BaseModel
class OrchestratorDecision(BaseModel):
next_action: Literal["delegate", "replan", "finalize"]
target_worker: str | None = None
task_description: str | None = None
reasoning: str
def orchestrator_step(state):
d = decide(state) # model has zero tools attached
if d.next_action == "finalize":
return finalize(state, d.reasoning)
if d.next_action == "replan":
return state.reset_plan(d.reasoning)
worker = WORKERS[d.target_worker] # workers own every tool
result = worker.run(d.task_description)
return state.record(d.target_worker, result)
Le problème. Sans outils, un orchestrateur ne voit que ce que les travailleurs lui renvoient. Un travailleur qui ne fournit qu’un bref résumé le laisse dans l’impossibilité de planifier. Il convient plutôt de proposer une vue en lecture seule de l’état partagé plutôt qu’un accès direct aux outils, tout en veillant à ce que les renseignements fournis par les travailleurs soient suffisamment structurés pour permettre une planification efficace.
Imposer à chaque sous-agent un contrat de retour structuré
Ce qui ne va pas. Prenons l’exemple d’un sous-agent de recherche qui renvoie un journal de 2 500 mots décrivant toutes ses tentatives, y compris les chemins échoués. Multipliez cela par sept travailleurs, et environ 20 000 tokens arrivent dans la fenêtre de l’agent principal avant même que toute synthèse ne commence. La délégation devait protéger cette fenêtre, mais en réalité, l’exploration est effectuée deux fois, et l’agent principal suit les instructions de manière encore plus imparfaite au moment crucial.
La solution. Considérez le retour comme une frontière API, avec un schéma de réponse pour chaque sous-agent. Son message de fin est un JSON contenant des champs nommés : conclusions, sources, questions non résolues et un statut d’achèvement. Vérifiez-le dès son arrivée, et renvoyez tout élément mal formaté ou trop long pour une tentative ultérieure, afin qu’il n’atteigne jamais l’agent parent.
from pydantic import BaseModel, Field, ValidationError
class SubagentResult(BaseModel):
findings: list[str] = Field(max_length=5) # hard cap, one sentence each
sources: list[str]
open_questions: list[str] = []
completion_status: str # complete | partial | blocked
def parse_or_retry(worker, task, attempts=2):
for _ in range(attempts):
raw = worker.run(task, response_format=SubagentResult)
try:
return SubagentResult.model_validate_json(raw)
except ValidationError as err:
task = f"{task}\n\nRejected: {err}\nReturn only JSON in the schema."
raise RuntimeError(f"{worker.name} returned no valid result")
Le problème. Les schémas fixent un minimum, rien de plus. Un travailleur peut encore insérer un paragraphe de 400 mots dans une seule chaîne de résultats et fournir du contenu superflu présenté sous forme de JSON. Il faut limiter à la fois le nombre d’éléments et la longueur de chaque champ, et faire en sorte que le texte de rejet indique le champ qui a dépassé la limite, afin que la tentative de correction sache ce qu’il faut corriger.
Paralléliser les lectures et canaliser les écritures via un seul agent
Ce qui peut mal se passer. Deux agents travaillent sur une même fonctionnalité en même temps et chacun signale un succès. L’un a généré un arrière-plan de style Super Mario tandis que l’autre a créé un sprite de Flappy Bird, et tous deux réussissent les tests conçus pour leur propre représentation du design. La fusion ne peut pas résoudre des choix dont aucun des agents n’était au courant, si bien qu’un résultat, ou les deux, est rejeté.
La solution. La lecture peut se faire en parallèle ; l’écriture, non. Limitez chaque travailleur à une liste autorisée d’outils en lecture seule, ce qui permet à la phase de collecte de s’élargir autant que nécessaire, et ne donnez jamais à un travailleur d’outil d’écriture. L’artefact est ensuite généré lors d’une seule appelation sans condition qui reçoit les résultats de tous les travailleurs en même temps.
import asyncio
READ_TOOLS = ["search_repo", "read_file", "fetch_docs"]
async def gather_context(subtasks):
workers = [Agent(name=t.name, tools=READ_TOOLS) for t in subtasks]
return await asyncio.gather(
*(w.run(t.prompt) for w, t in zip(workers, subtasks))
)
async def build_feature(spec, subtasks):
findings = await gather_context(subtasks) # wide, parallel, read-only
writer = Agent(name="writer", tools=["write_file", "apply_patch"])
return await writer.run(spec, context=findings) # one writer, one pass
Le problème. Ce seul écrivain constitue un goulot d’étranglement et doit gérer en même temps les résultats de tous les travailleurs, ce qui entraîne une épuisement du budget de tokens bien avant atteinte d’une limite logique. Si l’artefact se divise réellement de manière propre, comme c’est le cas pour trois fichiers ne partageant aucune interface, attribuez à chaque fichier son propre écrivain et maintenez la même discipline au sein de chacun d’eux.
Écrivez les conditions de terminaison
Ce qui ne va pas. Une exécution se termine, aucun erreur n’apparaît, mais le travail reste inachevé. Une analyse de 1 600 traces multi-agents sur sept frameworks a montré que 12,4 % des échecs étaient dus au fait que les agents ignoraient leurs conditions de cessation, tandis que 6,2 % s’arrêtaient prématurément. Ainsi, près d’un échec sur cinq se manifeste par une sortie ordonnée accompagnée d’un résultat inachevé.
La solution. Transformez la ligne d’arrivée en quelque chose de concret plutôt qu’en une simple sensation. Définissez pour chaque tâche une liste de contrôle explicite pour son achèvement, et ajoutez une case de validation, évaluée par rapport à l’objectif initial, sur le seul chemin menant à l’état final. Selon cette même étude, la mise en place de ce système de vérification aurait augmenté le taux de succès de ChatDev de 9,4 %, tandis que la vérification des objectifs de haut niveau l’aurait accru de 15,6 %.
CHECKLIST = [
"every requested section exists",
"each claim cites a retrieved source",
"open questions are listed, or explicitly none",
]
def verify_complete(objective, checklist, output) -> bool:
for item in checklist:
verdict = judge(f"Objective: {objective}\nCheck: {item}\n\n{output}")
if not verdict.passed:
log.info("termination blocked by: %s", item)
return False
return judge(f"Does this satisfy the objective?\n{objective}\n\n{output}").passed
def finish(state):
if verify_complete(state.objective, CHECKLIST, state.draft):
return state.done()
return state.keep_working(reason="checklist not satisfied")
Le problème. Laissez le modèle de production évaluer sa propre liste de contrôle, et il acceptera son travail sans vérification. Préférez autant que possible des critères mécaniques, tels que la présence d’un fichier, le remplissage d’un champ ou la résolution d’une citation. Un contrôle qui ne s’ouvre jamais constitue lui-même une sorte de panne ; limitez donc le nombre d’essais et générez une erreur une fois qu’ils ont été épuisés.
Où commencer
Si vous ne changez qu’une chose, choisissez la solution la moins coûteuse : définissez par écrit ce que signifie « terminé ». Une liste de contrôle associée à une étape de vérification avant d’atteindre l’état final ne prend qu’un après-midi, fonctionne aussi bien pour un seul agent que pour toute une flotte, et élimine toute une catégorie de pannes silencieuses. Faites-en la tâche de cette semaine.
Ensuite vient l’élimination. Examinez votre orchestrateur et vérifiez si ses choix se réduisent à une liste fixe et à une fonction d’agrégation. Si c’est le cas, retirez-le : la consommation de tokens et la latence diminuent, et les résultats restent stables. Comme il est facile de revenir en arrière, c’est un premier choix sûr.
Évitez d’utiliser des outils complexes tant que ce n’est pas nécessaire. Les contrats de retour en mode saisie s’avèrent utiles à partir de cinq travailleurs environ, et la liste d’autorisation en lecture seule est importante lorsque deux agents écrivent réellement simultanément. L’exception concerne l’orchestrateur sans outils : presque gratuit au début, mais difficile à intégrer par la suite.
Les configurations qui résisteront en 2026 ne compteront pas le plus grand nombre d’agents. Dans celles-ci, les liaisons entre les agents constituent la partie la plus petite et la plus explicite du design. Moins de personnes, des contrats plus solides, un seul outil.
Lectures complémentaires
- Agents de codage parallèles avec des worktrees Git, le chef d’orchestre et Antigravity 2.0 — Comment un agent coordinateur, des worktrees Git par agent et des pistes basées sur les problèmes permettent de développer une douzaine de fonctionnalités SRE en environ cinq heures, ainsi que des leçons sur les questions HITL et les bonnes pratiques de fusion.
- RAG agentic avec Agno Teams : réécriture des queries, boucle de critique et Qdrant — Une équipe coordinateur réécrit les queries, itère la récupération d’informations avec un critique et ne répond que à partir de contextes approuvés, en utilisant des recherches hybrides dense, BM25 et ColBERT dans Qdrant.