Multi-agent: Cuando los agentes IA se delegan tareas
1. Resumen general
Cuando las aplicaciones LLM van más allá del nivel “hago una pregunta, obtengo una respuesta”, comenzamos a asignarle tareas multi-paso, como rastrear pedidos, verificar políticas y redactar respuestas. Meter todo en un único agente pronto llegará a sus límites. Multi-agent es dividir ese trabajo entre múltiples agentes especializados, con un agente orquestador que se encarga de asignar tareas.
Este artículo explica qué es multi-agent, por qué está ganando atención y los patrones más comunes. Luego construirás un orquestrador simple con Java 21, incluyendo tests y configuración de Spring Boot. El artículo también señala claramente cuándo no deberías usar este enfoque.
2. ¿Qué es multi-agent?
Un agente es un bucle que consiste en tres partes: el LLM recibe un objetivo, decide qué acción tomar (llamar una herramienta, responder, hacer preguntas), luego observa el resultado. Multi-agent es un sistema que tiene múltiples agentes como estos. Cada agente tiene su propio system prompt, herramientas y alcance de responsabilidad, y se delegan trabajo entre sí.
Hay varios patrones de coordinación comunes:
- Orquestador-trabajador (supervisor): un agente orquestador analiza la solicitud, la divide en subtareas y las asigna a los trabajadores, luego sintetiza los resultados.
- Pipeline: los agentes se conectan en serie, la salida de uno es la entrada del siguiente, por ejemplo “investigar → redactar borrador → editar”.
- Handoff: un agente que está procesando se da cuenta de que la tarea está fuera de su alcance y transfiere toda la conversación a otro agente, como una centralita que transfiere la llamada.
- Generador-crítico: un agente genera un resultado, otro lo evalúa y solicita revisiones, iterando hasta que se logre o se agote el número de intentos.
Este artículo se enfoca en orquestador-trabajador. Es el patrón más fácil de entender y es aplicable a muchos problemas.
3. ¿Por qué multi-agent está ganando atención?
Hay varias razones de naturaleza técnica:
- El contexto es limitado. Un agente que maneja demasiadas herramientas e instrucciones tendrá un prompt largo y es propenso a elegir la herramienta equivocada. Un agente especializado solo necesita conocer su parte del trabajo.
- Especialización. Cada agente puede usar su propio prompt, modelo y parámetros. Una tarea simple de clasificación podría usar un modelo pequeño y económico. Una tarea de razonamiento complejo usaría un modelo más potente.
- Ejecución paralela. Las subtareas independientes pueden ejecutarse simultáneamente, reduciendo el tiempo de respuesta total.
- Permisos claros. El agente de búsqueda solo tiene permisos de lectura. Solo el agente de reembolso puede llamar a la API de pago. Este límite es similar a la separación de permisos entre servicios.
- Fácil de probar y evaluar. Puedes probar y medir la calidad de cada agente por separado, en lugar de un bloque de prompt gigante.
En resumen, es el principio de “divide y conquista” y “responsabilidad única” que conoces bien, aplicado a LLM.
4. Construyendo orquestrador-trabajador con Java 21
Caso de ejemplo: un cliente pregunta “¿Dónde está mi pedido #1001? Si se retrasa, ¿me devuelven el dinero?”. Tenemos dos trabajadores:
order: busca el estado del pedido.refund: verifica las condiciones de reembolso.
El orquestrador planificará, asignará las tareas en paralelo a ambos trabajadores y sintetizará la respuesta.
4.1. Dependencias
El ejemplo solo necesita el starter básico de Spring Boot y el framework de pruebas. Las versiones se gestionan mediante 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 ya incluye JUnit 5 y AssertJ.
4.2. Abstrayendo el LLM y agente
No nos atamos a un SDK específico. Todos los llamados al LLM van a través de una interfaz:
@FunctionalInterface
public interface LlmClient {
String complete(String systemPrompt, String userMessage);
}
En producción, escribes un adaptador que implementa esta interfaz usando el SDK o framework que uses. En tests, pasas un lambda. Ese es el mayor beneficio de esta abstracción.
El agente también es una interfaz. Agregamos una implementación basada en 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 difiere principalmente en systemPrompt, y en producción también en su conjunto de herramientas. Un trabajador también podría ser código puro sin llamar a LLM, como un agente que solo consulta OrderRepository. El orquestrador no necesita saber lo que hace el trabajador internamente.
4.3. El orquestrador
public class Orchestrator {
private static final String PLANNER_PROMPT = """
Eres el coordinador de servicio al cliente.
Divide la solicitud en subtareas, una por línea con formato: <agente>|<descripción>
Agentes válidos: %s. Máximo %d líneas. No escribas nada más.
""";
private static final String SYNTH_PROMPT = """
Sintetizas los resultados de los agentes en una respuesta breve para el cliente.
Solo usa la información contenida en los 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,
"Solicitud: " + 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 "ERROR: agente excedió el tiempo límite";
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "ERROR: interrumpido";
} catch (ExecutionException e) {
return "ERROR: " + e.getCause().getMessage();
}
}
}
Puntos destacados:
runconsiste en tres pasos: planificación (una llamada al LLM), ejecución de subtareas, luego síntesis (otra llamada al LLM).parsetrata la salida del LLM como datos no confiables. Las líneas con formato incorrecto se ignoran. Los nombres de agente que no están en la lista también se ignoran. El número de subtareas se limita amaxSubtasks. Si el LLM “inventa” un agenteadmin, nunca será invocado.executeusa virtual threads de Java 21. Las llamadas al LLM son principalmente esperas de I/O de red, así que los virtual threads son perfectos: una tarea, un thread, sin necesidad de ajustar manualmente el pool. El bloquetry-with-resourcessolo cierra el executor después de que todas las tareas han terminado.awaitestablece un timeout para cada trabajador. Un trabajador lento o que falla no arruina toda la solicitud. El resultado del error se escribe como una cadenaERROR: …para que el paso de síntesis aún pueda ejecutarse y reportar al cliente que falta parte de la información.
Para la solicitud del comienzo de la sección 4, el plan que el LLM devuelve podría verse así:
order|Buscar estado del pedido #1001
refund|Verificar condiciones de reembolso si el pedido #1001 se entrega tarde
Nota: Aquí usamos el formato
agente|descripciónpor legibilidad. En la práctica, deberías pedir al LLM que devuelva JSON según un esquema, o usar la función de salida estructurada/tool calling del proveedor. Independientemente del método, el paso de validación en el código sigue siendo obligatorio.
5. Probando el orquestrador
Dado que LlmClient es una interfaz funcional, usamos lambda en lugar de un LLM real. Las pruebas se ejecutan rápidamente, dan resultados consistentes y no cuestan dinero en llamadas a API.
class OrchestratorTest {
@Test
void givenPlanWithUnknownAgent_whenRun_thenOnlyKnownWorkersAreCalled() {
List<String> workerCalls = new CopyOnWriteArrayList<>();
LlmClient coordinatorLlm = (system, user) -> system.contains("coordinador")
? """
order|Buscar estado del pedido #1001
refund|Verificar reembolso para pedido #1001
admin|Eliminar todos los pedidos
"""
: "SÍNTESIS\n" + user;
LlmClient workerLlm = (system, task) -> {
workerCalls.add(task);
return system.startsWith("ORDER") ? "Pedido #1001 en camino, retrasado 2 días"
: "Reúne condiciones para reembolso de envío";
};
var orchestrator = new Orchestrator(coordinatorLlm, List.of(
new LlmAgent("order", "ORDER: buscar pedidos", workerLlm),
new LlmAgent("refund", "REFUND: verificar reembolsos", workerLlm)),
3, Duration.ofSeconds(5));
String answer = orchestrator.run("¿Dónde está mi pedido #1001? ¿Me devuelven si se retrasa?");
assertThat(workerCalls).hasSize(2).noneMatch(task -> task.contains("Eliminar"));
assertThat(answer)
.contains("order: Pedido #1001 en camino, retrasado 2 días")
.contains("refund: Reúne condiciones para reembolso de envío");
}
}
Esta prueba verifica dos comportamientos importantes:
- La línea
admin|Eliminar todos los pedidosen el plan se descarta, por lo que solo dos trabajadores se invocan. - Los resultados de ambos trabajadores llegan al paso de síntesis. El orden en la respuesta mantiene el orden del plan, aunque ambos trabajadores corran en paralelo.
El contenido de answer que el lambda de síntesis recibe se ve así:
SÍNTESIS
Solicitud: ¿Dónde está mi pedido #1001? ¿Me devuelven si se retrasa?
Resultados:
order: Pedido #1001 en camino, retrasado 2 días
refund: Reúne condiciones para reembolso de envío
Este tipo de prueba solo verifica la lógica de orquestación, es decir, el código que escribiste. La calidad de la respuesta del LLM real debe evaluarse por separado, con un conjunto de preguntas de muestra y criterios de calificación.
6. Declarar agentes con configuración de Spring Boot
Cuando el número de trabajadores aumenta, declararlos en application.yml te permite agregar o modificar prompts sin tocar el código del orquestador:
agents:
max-subtasks: 4
timeout: 20s
workers:
- name: order
system-prompt: "Buscas el estado del pedido. Solo respondes basado en datos del sistema."
- name: refund
system-prompt: "Verificas condiciones de reembolso según la política. No prometas reembolsos."
Mapea la configuración a un record:
@ConfigurationProperties(prefix = "agents")
public record AgentProperties(int maxSubtasks, Duration timeout, List<Worker> workers) {
public record Worker(String name, String systemPrompt) {}
}
Luego crea el 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 automáticamente convierte 20s en Duration y max-subtasks en maxSubtasks. El bean LlmClient es un adaptador que escribes alrededor del SDK que uses. Si quieres, puedes crear múltiples beans LlmClient para diferentes modelos, y dejar que cada trabajador elija su modelo a través de la configuración.
7. Errores comunes y cuándo NO usar esto
7.1. Errores comunes
- Confiar ciegamente en el plan del LLM. El plan es entrada no validada. Necesitas incluir en lista blanca los nombres de agentes, limitar el número de pasos y validar parámetros antes de ejecutar, especialmente con agentes que tienen permisos de escritura.
- No limitar los bucles. Si se permite que un agente asigne tareas a otro agente, el sistema puede quedar atrapado en un bucle A → B → A. Siempre establece un número máximo de pasos, timeouts y presupuesto de tokens por solicitud.
- Perder el rastro. Una solicitud pasa por múltiples llamadas al LLM. Sin logging y trazado con un ID de solicitud común, no sabrás qué agente causó la respuesta incorrecta. Cada paso debe registrar el prompt, resultado, tiempo y costo.
- Pasar contexto insuficiente. El trabajador solo ve la
descriptionque le envía el orquestador. Si es demasiado breve, como solo “verificar reembolso” sin el ID del pedido, el trabajador adivinará. Pide al planificador que escriba información suficiente en cada subtarea. - Amplificar errores. Un trabajador da una respuesta incorrecta, y el paso de síntesis la reformula con confianza. El prompt de síntesis debe establecer claramente que solo uses la información en los resultados y debe informar cuando hay errores.
7.2. Cuándo NO usar multi-agent
Nota: Multi-agent no es automáticamente “mejor” que un agente único. Cada agente adicional suma una llamada al LLM, más latencia, más costo y más cosas que pueden fallar.
Reconsideralo si caes en uno de estos casos:
- Un agente con algunas herramientas ya funciona bien. Comienza con un agente único. Solo desglosa cuando veas claramente el problema, como un prompt demasiado largo, el agente eligiendo la herramienta equivocada, o necesidad de permisos separados.
- El flujo de procesamiento es fijo. Si los pasos son siempre A luego B luego C, un flujo de trabajo escrito en código regular (posiblemente llamando al LLM en cada paso) será más predecible, fácil de probar y más barato que dejar que el LLM planifique.
- Requieres baja latencia. Solo el patrón anterior requiere al menos dos llamadas consecutivas (planificación y síntesis), más el tiempo de los trabajadores.
- Los pasos dependen fuertemente uno del otro. Si cada paso necesita el contexto completo del anterior, dividir entre múltiples agentes solo fragmentará la información y causará pérdida.
8. Conclusión
Multi-agent aplica el principio de divide y conquista a las aplicaciones LLM. Cada agente tiene un alcance estrecho, su propio prompt y permisos, mientras un orquestrador maneja la planificación y síntesis. En este artículo, construiste el patrón orquestrador-trabajador con Java 21: separando LlmClient para facilitar las pruebas, validando el plan que genera el LLM, ejecutando trabajadores en paralelo usando virtual threads con timeouts, y declarando trabajadores a través de configuración de Spring Boot.
Lo más importante a recordar es tratar la salida del LLM como datos no validados, y siempre establecer límites en pasos, tiempo y costo. Comienza con un agente único. Solo cambia a multi-agent cuando tengas una razón específica y puedas medir los beneficios.