Multi-agent: quando agentes IA delegam tarefas uns aos outros

1. Visão geral

Quando aplicações LLM vão além do nível “faça uma pergunta, receba uma resposta”, começamos a entregar tarefas de múltiplos passos, como rastrear um pedido, verificar políticas e elaborar uma resposta. Colocar tudo em um único agente eventualmente encontrará limitações. Multi-agent é a forma de dividir esse trabalho entre vários agentes especializados, com um agente orquestrador coordenando as tarefas.

Este artigo explica o que é multi-agent, por que o tema está em foco e quais são os modelos mais comuns. Em seguida, você construirá um orchestrator simples com Java 21, incluindo testes e configuração Spring Boot. O artigo também deixa claro quando não usar essa abordagem.

2. O que é multi-agent?

Um agente é um loop com três partes: o LLM recebe um objetivo, decide uma ação (chamar uma ferramenta, responder, perguntar novamente) e então observa o resultado. Multi-agent é um sistema com vários agentes assim. Cada agente tem seu próprio system prompt, ferramentas e escopo de responsabilidade, e eles delegam trabalho uns aos outros.

Existem alguns padrões de coordenação comuns:

  • Orchestrator–worker (supervisor): um agente orquestrador analisa a solicitação, a divide em tarefas, as atribui aos workers e consolida os resultados.
  • Pipeline: os agentes são encadeados, a saída de um agente é a entrada do próximo, por exemplo ‘pesquisar → rascunho → editar’.
  • Handoff: o agente que está processando percebe que o trabalho está fora do seu escopo e transfere toda a conversa para outro agente, como uma central telefônica transferindo uma ligação.
  • Generator–critic: um agente gera um resultado, outro agente avalia e solicita correções, repetindo até atingir o objetivo ou esgotar as iterações.

Este artigo se concentra em orchestrator–worker. Este é o padrão mais fácil de entender e é aplicável a muitos problemas.

3. Por que multi-agent está em foco?

Existem várias razões técnicas:

  1. Context limitado. Um agente com muitas ferramentas e instruções terá prompts longos e tenderá a escolher a ferramenta errada. Um agente especializado só precisa conhecer sua parte do trabalho.
  2. Especialização. Cada agente pode usar seu próprio prompt, modelo e parâmetros. Tarefas simples de classificação podem usar modelos menores e mais baratos. Raciocínios complexos podem usar modelos mais poderosos.
  3. Execução paralela. Tarefas independentes podem rodar simultaneamente, ajudando a reduzir o tempo de resposta total.
  4. Permissões claramente definidas. Um agente de consulta tem acesso apenas de leitura. Apenas o agente de reembolso pode chamar a API de pagamento. Essas fronteiras lembram a segregação de permissões entre serviços.
  5. Mais fácil testar e avaliar. Você pode testar e medir a qualidade de cada agente individualmente, em vez de uma massa monolítica de prompts.

Em resumo, esses são os princípios familiares de ‘dividir para conquistar’ e ‘responsabilidade única’, aplicados a LLMs.

4. Construir orchestrator–worker com Java 21

Problema de exemplo: um cliente pergunta ‘Onde está meu pedido #1001? Se estiver atrasado, tenho direito a reembolso?’. Temos dois workers:

  • order: consulta o status do pedido.
  • refund: verifica as condições de reembolso.

O orchestrator fará um plano, atribuirá o trabalho em paralelo aos dois workers e consolidará a resposta.

4.1. Dependências

O exemplo só precisa de um starter básico do Spring Boot e de dependências de teste. As versões são gerenciadas pelo 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 já vem com JUnit 5 e AssertJ.

4.2. Abstraindo LLM e agente

Não nos prenderemos a um SDK específico. Todas as chamadas de LLM passam por uma interface:

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

Em produção, você escreve um adaptador que implementa essa interface usando o SDK ou framework que está usando. Nos testes, você passa um lambda. Este é o maior benefício dessa camada de abstração.

Agent também é uma interface. Adicionamos uma implementação baseada em 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) {}

Cada LlmAgent difere apenas em seu systemPrompt, e na prática também pode diferir no conjunto de ferramentas. Um worker também pode ser código puro sem chamar LLM, por exemplo um agente que apenas consulta OrderRepository. O orchestrator não precisa saber o que há dentro de um worker.

4.3. Orchestrator

public class Orchestrator {

    private static final String PLANNER_PROMPT = """
            Você é um coordenador de atendimento ao cliente.
            Divida a solicitação em subtarefas, cada linha no formato: <agent>|<descrição da tarefa>
            Agentes válidos: %s. Máximo %d linhas. Não escreva mais nada.
            """;
    private static final String SYNTH_PROMPT = """
            Você consolida os resultados dos agentes em uma resposta breve para o cliente.
            Use apenas as informações fornecidas nos resultados.
            """;

    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,
                "Solicitação: " + request + "\nResultados:\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 "ERRO: agente excedeu o tempo limite";
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return "ERRO: interrompido";
        } catch (ExecutionException e) {
            return "ERRO: " + e.getCause().getMessage();
        }
    }
}

Pontos a observar:

  • run consiste em três etapas: planejamento (uma chamada ao LLM), execução das subtarefas e consolidação (mais uma chamada ao LLM).
  • parse trata a saída do LLM como dados não confiáveis. Linhas com formato incorreto são ignoradas. Nomes de agentes não na lista também são ignorados. O número de subtarefas é limitado a maxSubtasks. Se o LLM ‘inventar’ um agente admin, ele nunca será chamado.
  • execute usa virtual threads do Java 21. Chamadas de LLM são principalmente esperas por I/O de rede, então virtual threads são perfeitos: uma thread por subtarefa, sem necessidade de ajustar pools manualmente. O bloco try-with-resources fecha o executor apenas após todas as tarefas terem terminado.
  • await define um timeout para cada worker. Um worker lento ou com erro não quebra toda a solicitação. Erros são gravados como strings ERRO: … para que a etapa de consolidação ainda possa rodar e informar ao cliente que parte das informações não está disponível.

Para a solicitação no início da seção 4, o plano retornado pelo LLM pode parecer assim:

order|Consultar o status do pedido #1001
refund|Verificar as condições de reembolso quando o pedido #1001 é entregue com atraso

Nota: Aqui usamos o formato agent|descrição para simplicidade. Na prática, você deve solicitar que o LLM retorne JSON de acordo com um schema, ou use o recurso de structured output/tool calling do seu provedor. De qualquer forma, a validação no lado do código é obrigatória.

5. Testando o orchestrator

Como LlmClient é uma functional interface, usamos lambdas em vez do LLM real. Os testes executam rapidamente, produzem resultados consistentes e não custam chamadas de API.

class OrchestratorTest {

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

        LlmClient coordinatorLlm = (system, user) -> system.contains("coordenador")
                ? """
                  order|Consultar o status do pedido #1001
                  refund|Verificar as condições de reembolso do pedido #1001
                  admin|Deletar todos os pedidos
                  """
                : "CONSOLIDAÇÃO\n" + user;

        LlmClient workerLlm = (system, task) -> {
            workerCalls.add(task);
            return system.startsWith("ORDER") ? "Pedido #1001 em entrega, 2 dias atrasado"
                                              : "Elegível para reembolso de frete";
        };

        var orchestrator = new Orchestrator(coordinatorLlm, List.of(
                new LlmAgent("order", "ORDER: consultar pedido", workerLlm),
                new LlmAgent("refund", "REFUND: verificar reembolso", workerLlm)),
                3, Duration.ofSeconds(5));

        String answer = orchestrator.run("Onde está meu pedido #1001? Se estiver atrasado, tenho direito a reembolso?");

        assertThat(workerCalls).hasSize(2).noneMatch(task -> task.contains("Deletar"));
        assertThat(answer)
                .contains("order: Pedido #1001 em entrega, 2 dias atrasado")
                .contains("refund: Elegível para reembolso de frete");
    }
}

Este teste verifica dois comportamentos importantes:

  • A linha admin|Deletar todos os pedidos no plano é descartada, portanto apenas dois workers são chamados.
  • Os resultados de ambos os workers chegam à etapa de consolidação. A ordem na resposta mantém a ordem do plano, mesmo que os dois workers rodem em paralelo.

O conteúdo da answer que o lambda de consolidação recebe tem esta forma:

CONSOLIDAÇÃO
Solicitação: Onde está meu pedido #1001? Se estiver atrasado, tenho direito a reembolso?
Resultados:
order: Pedido #1001 em entrega, 2 dias atrasado
refund: Elegível para reembolso de frete

Este tipo de teste verifica apenas a lógica de orquestração, ou seja, o código que você escreveu. A qualidade das respostas do LLM real deve ser avaliada separadamente, com um conjunto de perguntas de exemplo e critérios de pontuação.

6. Declarando agentes via configuração Spring Boot

Conforme o número de workers aumenta, declará-los em application.yml permite que você adicione ou modifique prompts sem alterar o código de orquestração:

agents:
  max-subtasks: 4
  timeout: 20s
  workers:
    - name: order
      system-prompt: "Você consulta o status do pedido. Responda apenas com base nos dados do sistema."
    - name: refund
      system-prompt: "Você verifica as condições de reembolso de acordo com a política. Não prometa reembolsos por conta própria."

Mapeie a configuração para um record:

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

Em seguida, crie um 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 automaticamente converte 20s em Duration e max-subtasks em maxSubtasks. O bean LlmClient é um adaptador que você escreve em torno do SDK que está usando. Se desejar, você pode criar vários beans LlmClient para diferentes modelos, permitindo que cada worker escolha seu modelo através da configuração.

7. Erros comuns e quando não usar

7.1. Erros comuns

  • Confiar cegamente no plano do LLM. O plano é uma entrada não validada. Você deve fazer whitelist de nomes de agentes, limitar o número de etapas e validar parâmetros antes de executar, especialmente para agentes com permissões de escrita.
  • Não limitar loops. Se um agente for permitido delegar para outro agente, o sistema pode cair em um loop A → B → A. Sempre defina um número máximo de etapas, timeouts e orçamento de tokens para cada solicitação.
  • Perder a rastreabilidade. Uma solicitação passa por várias chamadas de LLM. Se você não registrar e rastrear com um ID de solicitação comum, não saberá qual agente causou a resposta incorreta. Cada etapa deve registrar o prompt, resultado, tempo e custo.
  • Passar contexto insuficiente. O worker só vê a parte description que o orchestrator passa. Se a descrição for muito breve, como apenas ‘verificar reembolso’ sem o número do pedido, o worker terá que adivinhar. Peça ao planejador para incluir todas as informações necessárias em cada subtarefa.
  • Erros são amplificados. Um worker responde incorretamente e a etapa de consolidação reformula a resposta com confiança. O prompt de consolidação precisa deixar claro que apenas informações nos resultados devem ser usadas e que erros devem ser relatados.

7.2. Quando NÃO usar multi-agent

Nota: Multi-agent não é automaticamente ‘melhor’ que single agent. Cada agente adicionado é mais uma chamada de LLM, mais latência, mais custo e mais um ponto de falha.

Reconsidere se você se enquadrar em um dos seguintes cenários:

  • Um agente com algumas ferramentas já funciona bem. Comece com um único agente. Divida apenas quando você perceber claramente um problema, como prompt muito longo, agente escolhendo a ferramenta errada ou necessidade de controle de acesso.
  • Fluxo de processamento fixo. Se os passos são sempre A depois B depois C, um workflow escrito em código comum (que pode chamar LLM em cada etapa) será mais previsível, mais fácil de testar e mais barato do que deixar o LLM fazer seu próprio planejamento.
  • Requisitos de baixa latência. Apenas o modelo acima já requer pelo menos duas chamadas sequenciais (planejamento e consolidação), sem contar o tempo dos workers.
  • Etapas altamente dependentes. Se cada etapa precisa de todo o contexto da etapa anterior, dividir entre múltiplos agentes apenas fragmentará as informações e causará perda de contexto.

8. Conclusão

Multi-agent aplica o princípio de ‘dividir para conquistar’ a aplicações com LLM. Cada agente tem seu próprio escopo estreito, prompt e permissões, enquanto um orchestrator cuida do planejamento e consolidação. Neste artigo, você construiu um modelo orchestrator–worker com Java 21: abstraiu LlmClient para facilitar testes, validou planos gerados pelo LLM, executou workers em paralelo com virtual threads e timeouts, e declarou workers através de configuração do Spring Boot.

A coisa mais importante a lembrar é tratar a saída do LLM como dados não validados e sempre definir limites no número de etapas, tempo e custo. Comece com um único agente. Mude para multi-agent apenas quando você tiver uma razão específica e conseguir medir o benefício.

ai-agentmulti-agentllmjavaspring-boot