Multi-Agent: Wenn KI-Agenten sich gegenseitig Aufgaben zuweisen

1. Übersicht

Wenn LLM-Anwendungen über die Ebene hinausgehen, in der man „eine Frage stellt und eine Antwort erhält”, beginnen wir, mehrstufige Aufgaben an sie zu delegieren – etwa Bestellungen nachverfolgen, Richtlinien überprüfen und dann eine Antwort formulieren. Alles in einen einzigen Agent zu packen wird früher oder später an Grenzen stoßen. Multi-Agent ist der Weg, diese Arbeit unter mehrere spezialisierte Agenten zu verteilen, mit einem koordinierenden Agent in der Mitte, der die Aufgaben vergibt.

Dieser Artikel erklärt, was Multi-Agent ist, warum dieses Thema gerade Aufmerksamkeit bekommt, und welche gängigen Muster es gibt. Danach wirst du selbst einen einfachen Orchestrator mit Java 21 bauen – inklusive Tests und Spring-Boot-Konfiguration. Der Artikel macht auch klar, wann du diesen Ansatz nicht verwenden solltest.

2. Was ist Multi-Agent?

Ein Agent ist eine Schleife aus drei Teilen: Das LLM erhält ein Ziel, entscheidet über eine Aktion (Tool aufrufen, antworten, nachfragen), und beobachtet dann das Ergebnis. Multi-Agent ist ein System mit mehreren solchen Agenten. Jeder Agent hat seinen eigenen System-Prompt, seine Tools und seinen Verantwortungsbereich, und sie delegieren Aufgaben untereinander.

Es gibt mehrere gängige Koordinationsmuster:

  • Orchestrator–Worker (Supervisor): Ein koordinierender Agent analysiert die Anfrage, teilt sie in Unteraufgaben auf, delegiert sie an Worker und fasst dann die Ergebnisse zusammen.
  • Pipeline: Agenten sind hintereinander geschaltet – die Ausgabe des einen ist die Eingabe des nächsten, zum Beispiel „recherchieren → Entwurf schreiben → redigieren”.
  • Handoff: Der Agent, der gerade arbeitet, erkennt, dass eine Aufgabe außerhalb seines Bereichs liegt, und übergibt das gesamte Gespräch an einen anderen Agent – wie eine Telefonvermittlung.
  • Generator–Critic: Ein Agent erstellt ein Ergebnis, ein anderer Agent bewertet es und fordert Änderungen an, bis das Ziel erreicht ist oder die Runden aufgebraucht sind.

Dieser Artikel konzentriert sich auf Orchestrator–Worker. Dies ist das verständlichste Muster und lässt sich auf viele Probleme anwenden.

3. Warum Multi-Agent gerade im Fokus steht

Es gibt mehrere technische Gründe:

  1. Begrenzter Kontext. Ein Agent mit zu vielen Tools und Anweisungen hat einen langen Prompt und neigt dazu, das falsche Tool zu wählen. Ein spezialisierter Agent braucht nur seinen Teil der Aufgabe zu kennen.
  2. Spezialisierung. Jeder Agent kann seinen eigenen Prompt, sein eigenes Modell und seine Parameter haben. Einfache Klassifikation kann mit einem kleineren, günstigeren Modell erfolgen. Komplexes Reasoning nutzt ein stärkeres Modell.
  3. Parallele Ausführung. Unabhängige Unteraufgaben können gleichzeitig laufen und die Gesamtlatenz reduzieren.
  4. Klare Berechtigungen. Der Such-Agent hat nur Lesezugriff. Nur der Refund-Agent darf die Zahlungs-API aufrufen. Diese Grenzen entsprechen der Berechtigungstrennung zwischen Services.
  5. Einfacher zu testen und zu bewerten. Du kannst jeden Agent einzeln testen und seine Qualität messen, statt einen riesigen Prompt als Ganzes zu evaluieren.

Kurz gesagt: Das ist das klassische Prinzip „Teile und herrsche” und „Single Responsibility” – angewendet auf LLMs.

4. Orchestrator–Worker mit Java 21 bauen

Das Beispielszenario: Ein Kunde fragt „Wo ist meine Bestellung #1001? Wenn sie verspätet ist, kann ich eine Rückerstattung bekommen?“. Wir haben zwei Worker:

  • order: Bestellungsstatus nachschlagen.
  • refund: Rückerstattungsbedingungen prüfen.

Der Orchestrator erstellt einen Plan, delegiert die Aufgaben parallel an beide Worker und fasst dann die Antwort zusammen.

4.1. Abhängigkeiten

Das Beispiel benötigt nur den grundlegenden Spring-Boot-Starter und Test-Abhängigkeiten. Versionen werden über spring-boot-starter-parent verwaltet:

<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 bringt bereits JUnit 5 und AssertJ mit.

4.2. LLM und Agent abstrahieren

Wir binden uns nicht an ein spezifisches SDK. Alle LLM-Aufrufe gehen über ein Interface:

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

In der Produktion schreibst du einen Adapter, der dieses Interface mit deinem SDK oder Framework implementiert. In Tests übergibst du eine Lambda-Funktion. Das ist der größte Vorteil dieser Abstraktion.

Auch Agent ist ein Interface. Wir fügen eine LLM-basierte Implementierung hinzu:

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) {}

Jeder LlmAgent unterscheidet sich hauptsächlich im systemPrompt, und in der Praxis auch in seinen verfügbaren Tools. Ein Worker kann aber auch reiner Code sein, der das LLM nicht aufruft – zum Beispiel ein Agent, der nur ein OrderRepository abfragt. Der Orchestrator muss nicht wissen, was ein Worker intern macht.

4.3. Orchestrator

public class Orchestrator {

    private static final String PLANNER_PROMPT = """
            Du bist ein Kundenservice-Koordinator.
            Teile die Anfrage in Unteraufgaben auf, jede Zeile im Format: <agent>|<Aufgabenbeschreibung>
            Gültige Agenten: %s. Maximal %d Zeilen. Schreib nichts anderes.
            """;
    private static final String SYNTH_PROMPT = """
            Du fasst Ergebnisse von verschiedenen Agenten in eine kurze Antwort für den Kunden zusammen.
            Verwende nur die Informationen in den Ergebnissen.
            """;

    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,
                "Anfrage: " + request + "\nErgebnisse:\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 "FEHLER: Agent hat Zeitüberschreitung";
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "FEHLER: unterbrochen";
        } catch (ExecutionException e) {
            return "FEHLER: " + e.getCause().getMessage();
        }
    }
}

Zu beachten:

  • run umfasst drei Schritte: Planung (ein LLM-Aufruf), Ausführung der Unteraufgaben, dann Zusammenfassung (noch ein LLM-Aufruf).
  • parse behandelt die LLM-Ausgabe als nicht vertrauenswürdig. Falsch formatierte Zeilen werden ignoriert. Agent-Namen, die nicht in der Liste sind, werden ignoriert. Die Anzahl der Unteraufgaben wird bei maxSubtasks begrenzt. Wenn das LLM einen Agent admin „erfindet”, wird er nie aufgerufen.
  • execute nutzt Virtual Threads von Java 21. LLM-Aufrufe sind hauptsächlich Netzwerk-I/O, daher sind Virtual Threads ideal: eine Thread pro Unteraufgabe, keine manuelle Thread-Pool-Tuning erforderlich. Der try-with-resources-Block schließt den Executor erst, wenn alle Tasks beendet sind.
  • await setzt ein Timeout für jeden Worker. Ein langsamer oder fehlerhafter Worker zerstört nicht die gesamte Anfrage. Fehlerergebnisse werden als FEHLER: …-String erfasst, damit der Syntheseschritt trotzdem läuft und dem Kunden mitgeteilt werden kann, dass ein Teil der Information nicht verfügbar war.

Mit der Anfrage vom Anfang von Abschnitt 4 könnte der Plan vom LLM so aussehen:

order|Status der Bestellung #1001 nachschlagen
refund|Rückerstattungsbedingungen prüfen, wenn Bestellung #1001 verspätet ist

Hinweis: Hier verwenden wir das Format agent|Beschreibung zur Lesbarkeit. In der Praxis solltest du das LLM JSON basierend auf einem Schema zurückgeben lassen oder die Structured Output/Tool Calling Funktion deines Anbieters nutzen. Egal welcher Weg – die Code-seitige Validierung bleibt obligatorisch.

5. Orchestrator testen

Da LlmClient ein Functional Interface ist, verwenden wir eine Lambda statt des echten LLM. Tests laufen schnell, sind stabil und sparen API-Aufrufe.

class OrchestratorTest {

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

        LlmClient coordinatorLlm = (system, user) -> system.contains("Koordinator")
                ? """
                  order|Status von Bestellung #1001 nachschlagen
                  refund|Rückerstattungsbedingungen für Bestellung #1001 prüfen
                  admin|Alle Bestellungen löschen
                  """
                : "ZUSAMMENFASSUNG\n" + user;

        LlmClient workerLlm = (system, task) -> {
            workerCalls.add(task);
            return system.startsWith("ORDER") ? "Bestellung #1001 wird gerade versandt, 2 Tage verspätet"
                                              : "Berechtigt für Versandgebührenrückerstattung";
        };

        var orchestrator = new Orchestrator(coordinatorLlm, List.of(
                new LlmAgent("order", "ORDER: Bestellungen nachschlagen", workerLlm),
                new LlmAgent("refund", "REFUND: Rückerstattungen prüfen", workerLlm)),
                3, Duration.ofSeconds(5));

        String answer = orchestrator.run("Wo ist meine Bestellung #1001? Wenn sie verspätet ist, bekomme ich eine Rückerstattung?");

        assertThat(workerCalls).hasSize(2).noneMatch(task -> task.contains("löschen"));
        assertThat(answer)
                .contains("order: Bestellung #1001 wird gerade versandt, 2 Tage verspätet")
                .contains("refund: Berechtigt für Versandgebührenrückerstattung");
    }
}

Dieser Test prüft zwei wichtige Verhalten:

  • Die Zeile admin|Alle Bestellungen löschen im Plan wird gefiltert, daher werden nur zwei Worker aufgerufen.
  • Ergebnisse von beiden Workern erreichen den Syntheseschritt. Die Reihenfolge in der Antwort bleibt wie im Plan, obwohl beide Worker parallel laufen.

Der Inhalt von answer, den die Synthesislambda erhält, sieht so aus:

ZUSAMMENFASSUNG
Anfrage: Wo ist meine Bestellung #1001? Wenn sie verspätet ist, bekomme ich eine Rückerstattung?
Ergebnisse:
order: Bestellung #1001 wird gerade versandt, 2 Tage verspätet
refund: Berechtigt für Versandgebührenrückerstattung

Dieser Test-Typ prüft nur die Orchestrierungslogik – also den Code, den du schreibst. Die Qualität der tatsächlichen LLM-Antworten muss separat mit einem Satz von Beispielfragen und Bewertungskriterien evaluiert werden.

6. Agenten über Spring-Boot-Konfiguration deklarieren

Wenn die Anzahl der Worker wächst, hilft dir die Deklaration in application.yml, Prompts hinzuzufügen oder zu ändern, ohne den Orchestrator-Code anzupassen:

agents:
  max-subtasks: 4
  timeout: 20s
  workers:
    - name: order
      system-prompt: "Du schaust Bestellungsstatus nach. Antworte nur basierend auf Systemdaten."
    - name: refund
      system-prompt: "Du prüfst Rückerstattungsbedingungen nach Richtlinie. Verspreche nicht eigenmächtig Rückerstattungen."

Konfigurationen in einen Record abbilden:

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

Dann den Orchestrator-Bean erstellen:

@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 konvertiert automatisch 20s zu Duration und max-subtasks zu maxSubtasks. Der LlmClient-Bean ist ein Adapter, den du um dein SDK schreibst. Wenn du möchtest, kannst du mehrere LlmClient-Beans für unterschiedliche Modelle erstellen und jeden Worker ein Modell per Konfiguration wählen lassen.

7. Häufige Fehler und wann man es nicht verwenden sollte

7.1. Häufige Fehler

  • Blindes Vertrauen in den LLM-Plan. Der Plan ist unkontrollierte Eingabe. Du musst Agent-Namen auf eine Whitelist beschränken, die Anzahl der Schritte begrenzen und Parameter validieren, bevor du sie ausführst – besonders bei Agenten mit Schreibrechten.
  • Keine Schleifen-Limits. Wenn Agenten sich gegenseitig Aufgaben geben dürfen, kann das System in einer Schleife A → B → A steckenbleiben. Immer die maximale Anzahl von Schritten, Timeouts und ein Token-Budget pro Anfrage setzen.
  • Mangelnde Nachverfolgung. Eine Anfrage durchläuft viele LLM-Aufrufe. Ohne Logging und Tracing mit einer gemeinsamen Request-ID weißt du nicht, welcher Agent die falsche Antwort erzeugt hat. Jeder Schritt sollte den Prompt, das Ergebnis, die Zeit und die Kosten protokollieren.
  • Unzureichender Kontext. Der Worker sieht nur die description, die der Orchestrator ihm gibt. Ist die Beschreibung zu knapp – etwa nur „Rückerstattung prüfen” ohne Bestellnummer – rät der Worker. Lass den Planner genug Kontext in jede Unteraufgabe schreiben.
  • Fehlerverstärkung. Ein Worker gibt eine falsche Antwort, die Syntheseschicht präsentiert sie dann selbstbewusst. Der Synthesisprompt muss klar vorgeben: Verwende nur die Informationen in den Ergebnissen und melde Fehler.

7.2. Wann man Multi-Agent NICHT verwenden sollte

Hinweis: Multi-Agent ist nicht automatisch „besser” als Single-Agent. Jeder zusätzliche Agent bedeutet mehr LLM-Aufrufe, mehr Latenz, höhere Kosten und mehr Fehlerquellen.

Überdenk es, wenn eines dieser Szenarien zutrifft:

  • Ein Agent mit ein paar Tools funktioniert gut. Beginne mit Single-Agent. Teile nur auf, wenn du ein echtes Problem siehst – etwa zu langer Prompt, falsches Tool-Picking oder du brauchst Zugriffskontrolle.
  • Fester Prozessfluss. Wenn die Schritte immer A dann B dann C sind, ist ein klassischer Workflow (mit optionalen LLM-Aufrufen an jedem Punkt) vorhersehbarer, leichter zu testen und billiger als eine freie Planung durch das LLM.
  • Anforderung für niedrige Latenz. Allein das Modell hier erfordert mindestens zwei sequenzielle Aufrufe (Planung und Synthese), plus die Zeit der Worker selbst.
  • Enge gegenseitige Abhängigkeiten der Schritte. Wenn jeder Schritt den gesamten Kontext des vorherigen braucht, führt die Aufteilung auf mehrere Agenten nur zu verlorenen Informationen und schlechterer Qualität.

8. Fazit

Multi-Agent wendet „Teile und herrsche” auf LLM-Anwendungen an. Jeder Agent hat einen engen Bereich, seinen Prompt und Berechtigungen, während ein Orchestrator Planung und Synthese übernimmt. In diesem Artikel hast du ein Orchestrator-Worker-Muster mit Java 21 gebaut: LlmClient isoliert für einfaches Testen, LLM-Pläne validiert, Worker mit Virtual Threads und Timeouts parallel ausgeführt, und Worker über Spring-Boot-Konfiguration deklariert.

Das Wichtigste zum Merken: Behandle LLM-Ausgaben als unkontrollierte Eingabe, und setze immer Limits für Schritte, Zeiten und Kosten. Fang mit einem einzelnen Agent an. Wechsle zu Multi-Agent nur, wenn du einen konkreten Grund hast und den Nutzen gemessen hast.

ai-agentmulti-agentllmjavaspring-boot