Multi-agent : quand les agents IA se confient du travail

1. Aperçu

Quand une application LLM sort de la portée « une question, une réponse », on commence à lui confier des tâches à plusieurs étapes, comme consulter une commande, vérifier une politique puis rédiger une réponse. Confier tout à un seul agent présente vite des limites. Le multi-agent, c’est partager ce travail entre plusieurs agents spécialisés, avec un agent orchestrateur qui pilote la distribution des tâches.

Cet article explique ce qu’est le multi-agent, pourquoi le sujet intéresse et quels en sont les modèles courants. Vous verrez ensuite comment construire vous-même un orchestrator simple en Java 21, avec des tests et une configuration Spring Boot. L’article souligne aussi quand ne pas adopter cette approche.

2. Qu’est-ce que le multi-agent ?

Un agent est une boucle comportant trois phases : le LLM reçoit un objectif, décide d’une action (appeler un outil, répondre, relancer une question), puis observe le résultat. Le multi-agent est un système avec plusieurs agents comme celui-ci. Chaque agent possède son propre system prompt, ses outils et son domaine de responsabilité, et ils s’échangent du travail.

Il existe plusieurs modèles de coordination courants :

  • Orchestrator–worker (supervisor) : un agent orchestrateur analyse la demande, la décompose en sous-tâches, les assigne aux workers, puis synthétise les résultats.
  • Pipeline : les agents s’enchaînent : la sortie d’un agent est l’entrée du suivant, par exemple « recherche → brouillon → édition ».
  • Handoff : l’agent en cours réalise que la tâche sort de son domaine et transfère toute la conversation à un autre agent, comme un standard téléphonique.
  • Generator–critic : un agent produit un résultat, un autre l’évalue et demande des corrections, en boucle jusqu’à satisfaction ou limite d’itérations.

Cet article se concentre sur le modèle orchestrator–worker. C’est le plus facile à comprendre et à appliquer à de nombreux problèmes.

3. Pourquoi le multi-agent suscite de l’intérêt ?

Plusieurs raisons techniques l’expliquent :

  1. Le contexte est limité. Un agent chargé de trop d’outils et de directives aura un prompt long et risquera de choisir le mauvais outil. Un agent spécialisé n’a besoin de connaître que sa partie du travail.
  2. Spécialisation. Chaque agent peut avoir son propre prompt, modèle et paramètres. Les tâches simples de classification peuvent utiliser un petit modèle économique. Le raisonnement complexe emploiera un modèle plus puissant.
  3. Parallélisation. Les sous-tâches indépendantes peuvent s’exécuter simultanément, réduisant le temps de réponse global.
  4. Permissions claires. L’agent de consultation n’a que l’accès en lecture. Seul l’agent de remboursement peut appeler l’API de paiement. Cette limite ressemble aux permissions entre microservices.
  5. Tests et évaluation plus faciles. Vous pouvez tester et mesurer la qualité de chaque agent séparément, au lieu d’un énorme bloc de prompt.

En résumé, ce sont les principes de « diviser pour régner » et de « responsabilité unique », familiers en génie logiciel, appliqués aux LLM.

4. Construire un orchestrator–worker en Java 21

Exemple de problème : un client demande « Où est ma commande #1001 ? Si elle est en retard, aurais-je droit à un remboursement ? ». Nous avons deux workers :

  • order : consulter le statut d’une commande.
  • refund : vérifier les conditions de remboursement.

L’orchestrator planifiera, assignera les tâches en parallèle aux deux workers, puis synthétisera la réponse.

4.1. Dépendances

L’exemple nécessite juste un starter Spring Boot de base et un framework de test. Les versions sont gérées par spring-boot-starter-parent :

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

spring-boot-starter-test inclut déjà JUnit 5 et AssertJ.

4.2. Abstraction du LLM et de l’agent

On ne s’attache pas à un SDK particulier. Tout appel au LLM passe par une interface :

@FunctionalInterface
public interface LlmClient {
    String complete(String systemPrompt, String userMessage);
}

En production, vous écrivez un adaptateur implémentant cette interface avec le SDK ou le framework que vous utilisez. En test, vous passez une lambda. C’est le plus grand avantage de cette couche d’abstraction.

Agent est aussi une interface. On ajoute une implémentation basée sur le LLM :

public interface Agent {
    String name();
    String handle(String task);
}

public record LlmAgent(String name, String systemPrompt, LlmClient llm) implements Agent {
    @Override
    public String handle(String task) {
        return llm.complete(systemPrompt, task);
    }
}

public record SubTask(String agent, String description) {}

Chaque LlmAgent ne diffère que par son systemPrompt, et en pratique aussi par l’ensemble d’outils. Un worker peut aussi être du code pur n’appellant pas le LLM, par exemple un agent appelant juste OrderRepository. L’orchestrator n’a pas besoin de savoir ce qui se passe à l’intérieur du worker.

4.3. Orchestrator

public class Orchestrator {

    private static final String PLANNER_PROMPT = """
            Vous êtes un orchestrateur du service client.
            Divisez la demande en sous-tâches, une par ligne au format : <agent>|<description>
            Agents valides : %s. Maximum %d lignes. N'écrivez rien d'autre.
            """;
    private static final String SYNTH_PROMPT = """
            Vous synthétisez les résultats des agents en une réponse brève pour le client.
            Utilisez uniquement l'information fournie dans les résultats.
            """;

    private final LlmClient llm;
    private final Map<String, Agent> workers;
    private final int maxSubtasks;
    private final Duration timeout;

    public Orchestrator(LlmClient llm, List<Agent> agents, int maxSubtasks, Duration timeout) {
        this.llm = llm;
        this.workers = agents.stream().collect(Collectors.toMap(Agent::name, a -> a));
        this.maxSubtasks = maxSubtasks;
        this.timeout = timeout;
    }

    public String run(String request) {
        String plan = llm.complete(PLANNER_PROMPT.formatted(workers.keySet(), maxSubtasks), request);
        List<String> results = execute(parse(plan));
        return llm.complete(SYNTH_PROMPT,
                "Demande : " + request + "\nRésultats :\n" + String.join("\n", results));
    }

    List<SubTask> parse(String plan) {
        return plan.lines()
                .map(String::strip)
                .filter(line -> line.contains("|"))
                .map(line -> line.split("\\|", 2))
                .filter(parts -> workers.containsKey(parts[0].strip()))
                .limit(maxSubtasks)
                .map(parts -> new SubTask(parts[0].strip(), parts[1].strip()))
                .toList();
    }

    private List<String> execute(List<SubTask> tasks) {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<String>> futures = tasks.stream()
                    .map(t -> executor.submit(() ->
                            t.agent() + ": " + workers.get(t.agent()).handle(t.description())))
                    .toList();
            return futures.stream().map(this::await).toList();
        }
    }

    private String await(Future<String> future) {
        try {
            return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS);
        } catch (TimeoutException e) {
            future.cancel(true);
            return "ERREUR : agent a dépassé le délai";
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "ERREUR : interrompu";
        } catch (ExecutionException e) {
            return "ERREUR : " + e.getCause().getMessage();
        }
    }
}

Points remarquables :

  • run comprend trois étapes : planification (un appel au LLM), exécution des sous-tâches, puis synthèse (un appel au LLM supplémentaire).
  • parse traite la sortie du LLM comme des données non fiables. Les lignes mal formatées sont ignorées. Les noms d’agents absents de la liste sont aussi ignorés. Le nombre de sous-tâches est limité à maxSubtasks. Si le LLM invente un agent admin, il ne sera jamais appelé.
  • execute utilise les virtual threads de Java 21. Les appels au LLM sont surtout de l’attente réseau I/O, donc les virtual threads sont parfaits : un thread par sous-tâche, pas besoin d’ajuster le pool manuellement. Le bloc try-with-resources ne ferme l’executor qu’après que toutes les tâches sont terminées.
  • await fixe un timeout pour chaque worker. Un worker lent ou en erreur ne sabote pas toute la requête. Les erreurs sont transcrites en chaîne ERREUR: … pour que la synthèse puisse quand même s’exécuter et informer le client qu’une partie de l’information manque.

Pour la demande du début de la section 4, le plan retourné par le LLM pourrait ressembler à ceci :

order|Consulter le statut de la commande #1001
refund|Vérifier les conditions de remboursement si la commande #1001 est en retard

Note : On utilise ici le format agent|description pour la lisibilité. En pratique, vous devriez demander au LLM de retourner du JSON conforme à un schéma, ou exploiter la fonctionnalité structured output/tool calling du fournisseur. Quelle que soit l’approche, la validation dans le code reste obligatoire.

5. Tester l’orchestrator

Parce que LlmClient est une interface fonctionnelle, on utilise des lambdas à la place du vrai LLM. Les tests s’exécutent vite, donnent des résultats stables et ne coûtent rien en appels API.

class OrchestratorTest {

    @Test
    void givenPlanWithUnknownAgent_whenRun_thenOnlyKnownWorkersAreCalled() {
        List<String> workerCalls = new CopyOnWriteArrayList<>();

        LlmClient coordinatorLlm = (system, user) -> system.contains("orchestrateur")
                ? """
                  order|Consulter le statut de la commande #1001
                  refund|Vérifier le remboursement pour la commande #1001
                  admin|Supprimer toutes les commandes
                  """
                : "SYNTHÈSE\n" + user;

        LlmClient workerLlm = (system, task) -> {
            workerCalls.add(task);
            return system.startsWith("ORDER") ? "La commande #1001 est en cours d'expédition, 2 jours de retard"
                                              : "Conditions de remboursement des frais de port satisfaites";
        };

        var orchestrator = new Orchestrator(coordinatorLlm, List.of(
                new LlmAgent("order", "ORDER: consulter les commandes", workerLlm),
                new LlmAgent("refund", "REFUND: vérifier les remboursements", workerLlm)),
                3, Duration.ofSeconds(5));

        String answer = orchestrator.run("Où est la commande #1001 ? Aurais-je droit à un remboursement si elle est en retard ?");

        assertThat(workerCalls).hasSize(2).noneMatch(task -> task.contains("Supprimer"));
        assertThat(answer)
                .contains("order: La commande #1001 est en cours d'expédition, 2 jours de retard")
                .contains("refund: Conditions de remboursement des frais de port satisfaites");
    }
}

Ce test vérifie deux comportements importants :

  • La ligne admin|Supprimer toutes les commandes du plan est écartée, donc seuls deux workers sont appelés.
  • Les résultats des deux workers parviennent tous deux à l’étape de synthèse. L’ordre dans la réponse suit l’ordre du plan, bien que les deux workers s’exécutent en parallèle.

Le contenu de answer reçu par la lambda de synthèse se présente comme :

SYNTHÈSE
Demande : Où est la commande #1001 ? Aurais-je droit à un remboursement si elle est en retard ?
Résultats :
order: La commande #1001 est en cours d'expédition, 2 jours de retard
refund: Conditions de remboursement des frais de port satisfaites

Ce type de test ne vérifie que la partie orchestration, c’est-à-dire la logique que vous écrivez. La qualité des réponses du vrai LLM doit être évaluée séparément, avec un ensemble de questions d’exemple et des critères de notation.

6. Déclarer les agents via la configuration Spring Boot

Quand le nombre de workers augmente, les déclarer dans application.yml vous permet d’ajouter ou modifier des prompts sans toucher au code d’orchestration :

agents:
  max-subtasks: 4
  timeout: 20s
  workers:
    - name: order
      system-prompt: "Vous consultez le statut des commandes. Répondez uniquement en fonction des données système."
    - name: refund
      system-prompt: "Vous vérifiez les conditions de remboursement selon la politique. Ne promettez pas de remboursement de votre propre initiative."

Mappez la configuration dans un record :

@ConfigurationProperties(prefix = "agents")
public record AgentProperties(int maxSubtasks, Duration timeout, List<Worker> workers) {
    public record Worker(String name, String systemPrompt) {}
}

Puis créez le bean Orchestrator :

@Configuration
@EnableConfigurationProperties(AgentProperties.class)
public class AgentConfig {

    @Bean
    Orchestrator orchestrator(LlmClient llm, AgentProperties props) {
        List<Agent> agents = props.workers().stream()
                .<Agent>map(w -> new LlmAgent(w.name(), w.systemPrompt(), llm))
                .toList();
        return new Orchestrator(llm, agents, props.maxSubtasks(), props.timeout());
    }
}

Spring Boot convertit automatiquement 20s en Duration et max-subtasks en maxSubtasks. Le bean LlmClient est un adaptateur que vous écrivez autour du SDK que vous utilisez. Si vous le souhaitez, vous pouvez créer plusieurs beans LlmClient pour différents modèles, puis laisser chaque worker choisir le sien via la configuration.

7. Erreurs courantes et quand ne pas utiliser l’approche

7.1. Erreurs courantes

  • Faire confiance aveuglément au plan du LLM. Le plan est une entrée non validée. Vous devez mettre en liste blanche les noms d’agents, limiter le nombre d’étapes et valider les paramètres avant exécution, surtout pour les agents avec droits en écriture.
  • Ne pas limiter les boucles. Si un agent peut confier du travail à un autre, le système peut se bloquer dans une boucle A → B → A. Fixez toujours un nombre maximal d’étapes, un timeout et un budget de tokens par demande.
  • Perte de traçabilité. Une demande traverse plusieurs appels au LLM. Si vous ne loggez pas et ne tracez pas avec un ID commun, vous ne saurez pas quel agent a produit la mauvaise réponse. Chaque étape doit enregistrer le prompt, le résultat, la durée et le coût.
  • Contexte insuffisant. Le worker ne voit que la description que l’orchestrator lui transmet. Si la description est trop brève, par exemple juste « vérifier remboursement » sans l’ID de commande, le worker devra deviner. Demandez au planner d’inclure toutes les informations nécessaires dans chaque sous-tâche.
  • Amplification des erreurs. Un worker donne une mauvaise réponse, l’étape de synthèse la reformule avec assurance. Le prompt de synthèse doit bien préciser qu’il faut utiliser uniquement l’information fournie et signaler quand il y a des erreurs.

7.2. Quand NE PAS utiliser le multi-agent

Note : Le multi-agent n’est pas automatiquement « meilleur » qu’un agent unique. Chaque agent ajouté signifie plus d’appels au LLM, plus de latence, plus de coûts et plus de points de défaillance.

Reconsidérez l’approche si vous vous trouvez dans l’une de ces situations :

  • Un agent avec quelques outils fonctionne bien. Commencez par un agent unique. Divisez seulement quand vous voyez clairement un problème : prompt trop long, agent qui choisit le mauvais outil, ou besoin de permissions distincts.
  • Processus fixe. Si les étapes sont toujours A puis B puis C, un workflow écrit en code classique (pouvant appeler le LLM à chaque étape) sera plus prévisible, plus facile à tester et moins cher que de laisser le LLM planifier lui-même.
  • Latence requise basse. Le seul modèle ci-dessus nécessite déjà au moins deux appels séquentiels (planification et synthèse), sans compter le temps des workers.
  • Étapes étroitement dépendantes. Si chaque étape a besoin de tout le contexte de la précédente, partager le travail entre agents ne fera que fragmenter et perdre l’information.

8. Conclusion

Le multi-agent applique les principes de « diviser pour régner » aux applications LLM. Chaque agent a une portée étroite, son propre prompt et ses permissions, tandis qu’un orchestrateur gère la planification et la synthèse. Dans cet article, vous avez construit un modèle orchestrator–worker en Java 21 : abstraire LlmClient pour faciliter les tests, valider le plan généré par le LLM, exécuter les workers en parallèle avec des virtual threads et timeouts, et déclarer les workers via la configuration Spring Boot.

Le point le plus important à retenir : traitez la sortie du LLM comme des données non fiables, et fixez toujours des limites sur le nombre d’étapes, le temps et les coûts. Commencez par un agent unique. Ne passez au multi-agent que si vous avez une raison précise et que vous mesurez les bénéfices.

ai-agentmulti-agentllmjavaspring-boot