1. Visión General
Estamos construyendo cada vez más agentes de IA que pueden manejar la solicitud completa de un usuario por sí mismos. En lugar de simplemente responder preguntas sobre lo que ya sabe un Modelo de Lenguaje Grande (LLM), estos agentes razonan sobre un problema, lo descomponen en pasos, llaman a herramientas externas e incluso ejecutan scripts locales.
A medida que estas solicitudes crecen en complejidad, empacar todas las capacidades en un solo agente se vuelve incontrolable. La solución natural es crear agentes más pequeños, cada uno especializado en una tarea. Sin embargo, lograr que estos agentes se comuniquen entre sí es un desafío por sí mismo, ya que operan como servicios independientes construidos con diferentes lenguajes, frameworks y LLMs.
El Protocolo Agent2Agent (A2A) aborda este problema definiendo un estándar para que los agentes se descubran entre sí y se comuniquen.
En este tutorial, exploraremos de manera práctica el protocolo A2A implementando su arquitectura de cliente y servidor usando Spring AI.
2. Protocolo Agent2Agent (A2A) 101
Antes de sumergirnos en la implementación, echemos un vistazo más cercano al protocolo y a la forma en que interactúan dos agentes:
Un agente que actúa como servidor expone sus capacidades al mundo exterior, mientras que un agente que actúa como cliente las consume.
El descubrimiento ocurre a través de una Tarjeta de Agente (Agent Card), que es un documento JSON que un servidor publica en una URL bien conocida. Esta tarjeta expone los detalles del agente, incluida la lista de habilidades que ofrece. Un agente cliente recupera primero esta tarjeta para aprender qué puede hacer un agente remoto y dónde alcanzarlo.
El cliente envía un Mensaje que describe el trabajo a realizar en lenguaje natural sencillo. El agente remoto convierte ese mensaje en una Tarea, la procesa y devuelve uno o más Artefactos que llevan el contenido real de la respuesta.
A2A es un tema complejo y vasto. Podemos consultar la especificación oficial para aprender más.
3. El Proyecto que Estamos Construyendo
Para ver el protocolo en acción, construiremos un sistema de selección de candidatos para reclutadores:
Como vemos, nuestro sistema está compuesto por un agente orquestador que actúa como cliente A2A y se comunica con tres agentes remotos especializados que actúan como servidores A2A.
Un reclutador envía los detalles de un candidato a un único punto final REST. Tras bambalinas, el agente orquestador descompondrá la solicitud y delegará cada tarea a un agente especializado. Una vez que cada agente responda, el orquestador combinará los veredictos individuales en un breve resumen de selección.
4. Creando un Servidor A2A
Como crear servidores A2A sigue exactamente la misma estructura, solo recorreremos la implementación del agente de coincidencia de habilidades (skills matcher).
Los dos servidores remotos restantes difieren únicamente en sus herramientas y prompts. Para ver la implementación completa del proyecto, podemos consultar el repositorio que respalda este tutorial.
4.1. Dependencias y Configuración del LLM
Comencemos añadiendo las dependencias necesarias al archivo pom.xml de nuestro proyecto:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
<version>2.0.0</version>
</dependency>
<dependency>
<groupId>org.springaicommunity</groupId>
<artifactId>spring-ai-a2a-server-autoconfigure</artifactId>
<version>0.3.0</version>
</dependency>
Aquí, primero importamos la dependencia de inicio de Spring AI para OpenAI, que utilizaremos para interactuar con un LLM. Además, importamos la dependencia de autoconfiguración del servidor A2A de la comunidad de Spring AI, que se encarga de servir nuestra tarjeta de agente al inicio y manejar las solicitudes A2A.
A continuación, configuremos nuestra clave API de OpenAI y el modelo de chat en el archivo application.yaml:
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
model: gpt-5.5
Aquí, especificamos el modelo GPT 5.5 de OpenAI utilizando la ID de modelo gpt-5.5. Alternativamente, podemos usar un modelo de chat diferente, ya que el modelo de IA o el proveedor específico son irrelevantes para esta demostración.
Con estas dos propiedades establecidas, Spring AI crea automáticamente un bean de tipo ChatClient.Builder, que utilizaremos en la sección siguiente.
4.2. Creación de una Herramienta y Registro con ChatClient
A continuación, configuremos la capacidad real que ofrece nuestro servidor A2A. Crearemos una clase SkillsMatcherTools y definiremos una herramienta que evalúe las habilidades de un candidato:
@Tool(
name = "match-skills",
description = "Compara las habilidades de un candidato con las habilidades requeridas de un puesto y devuelve una puntuación de ajuste"
)
SkillsMatchResult matchSkills(
@ToolParam(description = "Habilidades del candidato, separadas por comas") String candidateSkills,
@ToolParam(description = "Habilidades requeridas del puesto, separadas por comas") String requiredSkills
) {
// ... implementación rudimentaria
}
record SkillsMatchResult(
int score,
Verdict verdict,
Set<String> matchedSkills,
Set<String> missingSkills
) {}
enum Verdict {
STRONG_MATCH,
PARTIAL_MATCH,
WEAK_MATCH
}
Anotamos nuestro método con la anotación @Tool y le damos un name explícito junto con una breve description. Ambos valores ayudan al modelo de IA a decidir si y cuándo invocar esta herramienta. De manera similar, describimos ambos parámetros del método con la anotación @ToolParam para que el LLM conozca el formato que esperamos para las entradas.
Hemos omitido deliberadamente la implementación del método aquí, ya que no es importante para nuestro entendimiento de A2A.
A continuación, creemos un bean ChatClient y registremos nuestra herramienta con él:
@Bean
ChatClient chatClient(
ChatClient.Builder chatClientBuilder,
SkillsMatcherTools skillsMatcherTools
) {
return chatClientBuilder
.defaultSystem("""
Eres un asistente de coincidencia de habilidades para reclutadores.
Usa la herramienta match-skills para comparar las habilidades de un candidato
con las habilidades requeridas del puesto, luego resume el resultado.
""")
.defaultTools(skillsMatcherTools)
.build();
}
Aquí, usamos el bean ChatClient.Builder que Spring AI configura para nosotros, junto con el bean SkillsMatcherTools que definimos anteriormente, para crear un bean ChatClient. Esta clase actúa como nuestro punto de entrada principal para interactuar con el LLM configurado.
Además, definimos un prompt de sistema que describe el rol del agente, y luego registramos nuestra clase de herramienta usando el método defaultTools(). Esto permite al modelo llamar a nuestro método de herramienta cuando recibe una solicitud coincidente.
4.3. Definición de un AgentExecutor para Manejar Solicitudes A2A
El bean ChatClient que hemos definido solo puede ser utilizado por componentes dentro de nuestra propia aplicación. Para permitir que otros agentes nos envíen tareas, necesitamos definir un bean AgentExecutor que maneje las solicitudes A2A entrantes:
@Bean
AgentExecutor agentExecutor(ChatClient chatClient) {
return new DefaultAgentExecutor(chatClient, (client, requestContext) -> {
String userMessage = DefaultAgentExecutor.extractTextFromMessage(requestContext.getMessage());
return client
.prompt()
.user(userMessage)
.call()
.content();
});
}
Aquí, usamos la clase DefaultAgentExecutor, pasándole nuestro bean ChatClient junto con una función manejadora que define cómo queremos responder. En la función manejadora, extraemos el texto plano del mensaje entrante y lo pasamos a nuestro LLM como un prompt de usuario. La respuesta de nuestro bean chatClient se envuelve automáticamente en un artefacto y se envía de vuelta al agente que llama.
4.4. Descripción de Nuestro Agente con una AgentCard
Finalmente, necesitamos exponer una Tarjeta de Agente que describa con precisión a nuestro agente:
@Bean
AgentCard agentCard(
@Value("${server.host}") String host,
@Value("${server.port}") int port
) {
return new AgentCard.Builder()
.name("Skills Matcher Agent")
.description("Evalúa qué tan bien coinciden las habilidades de un candidato con las habilidades requeridas del puesto")
.url(String.format("http://%s:%d/", host, port))
.version("1.0.0")
.capabilities(new AgentCapabilities
.Builder()
.streaming(false)
.build())
.defaultInputModes(List.of("text"))
.defaultOutputModes(List.of("text"))
.skills(List.of(new AgentSkill.Builder()
.id("skills_matching")
.name("Skills Matching")
.description("Compara las habilidades del candidato con los requisitos del puesto y puntúa el ajuste")
.tags(List.of("hiring", "recruiting"))
.build()))
.protocolVersion("1.0.1")
.build();
}
Aquí, creamos un bean AgentCard y definimos las propiedades importantes de name, description y skills. Los agentes cliente confían en estas propiedades para decidir si un agente remoto y las capacidades que ofrece son adecuados para una tarea dada.
De manera similar, definimos la propiedad url a partir del host y el port de nuestra aplicación en ejecución, indicando a los clientes dónde encontrarnos. Además, declaramos que nuestro agente no admite streaming y que intercambia texto plano en ambas direcciones.
Cabe destacar que cada propiedad que establecemos arriba es obligatoria, y el constructor rechaza una tarjeta con cualquiera de ellas faltante. Con este bean definido, la autoconfiguración sirve nuestra tarjeta de agente en la ruta .well-known/agent-card.json.
5. Creando un Cliente A2A
Con nuestros agentes especializados listos, construiremos nuestro cliente A2A, es decir, el orquestador de selección de candidatos que delega la evaluación real a ellos.
5.1. Dependencias
Nuestro orquestador es una aplicación separada que también conversa con un LLM. Por esta razón, necesitaremos importar una dependencia de modelo de chat y configurar la clave API y las propiedades del modelo, al igual que lo hicimos en nuestro servidor A2A.
Además de eso, necesitamos añadir el SDK Java de A2A a nuestro pom.xml:
<dependency>
<groupId>io.github.a2asdk</groupId>
<artifactId>a2a-java-sdk-client</artifactId>
<version>0.3.3.Final</version>
</dependency>
Este SDK nos proporciona las clases que necesitamos para obtener tarjetas de agente, abrir conexiones con agentes remotos y enviarles mensajes.
Cabe señalar que no declaramos explícitamente esta dependencia al construir nuestro servidor A2A, ya que la dependencia de autoconfiguración del servidor la incluye transitivamente. Sin embargo, para un agente que actúa exclusivamente como cliente, necesitaremos añadirla nosotros mismos.
5.2. Descubrir Agentes Remotos en el Inicio
Nuestro orquestador solo puede delegar trabajo a agentes que conoce, así que vamos a listar sus direcciones en el application.yaml:
remote:
agents:
urls:
- http://localhost:8081
- http://localhost:8082
- http://localhost:8083
Aquí, configuramos las URLs base de nuestros agentes remotos usando una propiedad personalizada. Debemos asegurarnos de actualizar estos valores si los agentes se ejecutan en un host o puerto diferente.
A continuación, creemos un componente AgentRegistry que obtenga las tarjetas de agente de estas URLs cuando la aplicación se inicie:
private final Map<String, AgentCard> agentCards = new HashMap<>();
AgentRegistry(@Value("${remote.agents.urls}") List<String> agentUrls) {
for (String url : agentUrls) {
String path = new URI(url).getPath();
AgentCard card = A2A.getAgentCard(url, path + ".well-known/agent-card.json", null);
agentCards.put(card.name(), card);
}
}
Aquí, iteramos sobre cada URL configurada dentro del constructor y recuperamos su tarjeta de agente desde la ruta .well-known/agent-card.json. Luego, almacenamos las instancias resultantes de AgentCard en un mapa en memoria claveado por el nombre del agente.
Para exponer las tarjetas de agente recuperadas a otros componentes, agreguemos un par de métodos auxiliares a esta clase:
AgentCard get(String agentName) {
return agentCards.get(agentName);
}
String describeAgents() {
return agentCards
.values()
.stream()
.map(card -> "- " + card.name() + ": " + card.description())
.collect(Collectors.joining("\n"));
}
El método get() devuelve la tarjeta de un agente específico por su nombre. Mientras tanto, el método describeAgents() muestra el resumen formateado de todos los agentes remotos disponibles con sus descripciones. Usaremos estos métodos auxiliares en las secciones siguientes para definir componentes adicionales.
5.3. Comunicándose con Agentes Remotos
Nuestro cliente A2A ahora es capaz de descubrir agentes remotos al inicio. A continuación, creemos un componente RemoteAgentClient que realmente se comunique con estos agentes:
String sendMessage(String agentName, String task) {
AgentCard agentCard = agentRegistry.get(agentName);
CompletableFuture<String> response = new CompletableFuture<>();
BiConsumer<ClientEvent, AgentCard> responseConsumer = (event, card) -> {
TaskEvent taskEvent = (TaskEvent) event;
response.complete(taskEvent.getTask()
.getArtifacts()
.stream()
.map(Artifact::parts)
.map(this::extractText)
.collect(Collectors.joining("\n")));
};
Client client = Client.builder(agentCard)
.clientConfig(new ClientConfig.Builder()
.setAcceptedOutputModes(List.of("text"))
.build())
.withTransport(JSONRPCTransport.class, new JSONRPCTransportConfig())
.addConsumers(List.of(responseConsumer))
.streamingErrorHandler(response::completeExceptionally)
.build();
Message message = A2A.toUserMessage(task);
client.sendMessage(message);
return response.get(60, TimeUnit.SECONDS);
}
private String extractText(List<Part<?>> parts) {
return parts
.stream()
.filter(TextPart.class::isInstance)
.map(TextPart.class::cast)
.map(TextPart::getText)
.collect(Collectors.joining("\n"));
}
Aquí, comenzamos recuperando la tarjeta del agente objetivo desde nuestro registro, ya que el SDK la utiliza para construir un cliente.
Dado que el SDK devuelve respuestas de forma asíncrona, registramos un consumidor que recibe la tarea completada. Luego, extrae el texto de sus artefactos y lo pasa a nuestro CompletableFuture response. A continuación, convertimos la task dada en un mensaje A2A, lo enviamos al agente remoto y esperamos a que response se complete.
A continuación, exponemos esta capacidad como una herramienta que el LLM de nuestro orquestador puede llamar:
@Tool(
name = "send-message-to-agent",
description = "Envia una tarea a un agente remoto y devuelve su respuesta."
)
String sendMessageToAgent(
@ToolParam(description = "Nombre del agente remoto") String agentName,
@ToolParam(description = "La tarea a realizar") String task
) {
return remoteAgentClient.sendMessage(agentName, task);
}
Esta única herramienta es todo lo que el orquestador necesita para alcanzar todos nuestros agentes configurados. El LLM decidirá qué agente contactar y qué preguntarle, simplemente rellenando los parámetros agentName y task.
5.4. Construyendo el ChatClient del Orquestador
Con nuestra lógica de descubrimiento y comunicación en su lugar, creemos el bean ChatClient para nuestro agente orquestador:
@Bean
ChatClient chatClient(
ChatClient.Builder chatClientBuilder,
AgentRegistry agentRegistry,
RemoteAgentTools remoteAgentTools
) {
return chatClientBuilder
.defaultSystem("""
Eres un orquestador de selección de candidatos para reclutadores.
No evalúas candidatos tú mismo. En su lugar, delegas
a los siguientes agentes remotos:
%s
Una vez que todos los agentes hayan respondido, combina sus respuestas en un breve resumen de selección.
""".formatted(agentRegistry.describeAgents()))
.defaultTools(remoteAgentTools)
.build();
}
En nuestro prompt de sistema, instruimos explícitamente al modelo a no evaluar candidatos por sí mismo. En su lugar, inyectamos la lista de agentes descubiertos usando el método describeAgents() y le pedimos que delegue las evaluaciones individuales a ellos.
Luego, registramos nuestra herramienta, que da al modelo la capacidad de llegar realmente a estos agentes.
5.5. Exponiendo una API REST
Finalmente, utilicemos el ChatClient del orquestador que hemos definido y exponamos un punto final REST para aceptar solicitudes de selección de candidatos:
@PostMapping("/screenings")
ScreeningResponse screenCandidate(@RequestBody ScreeningRequest screeningRequest) {
String verdict = chatClient
.prompt()
.user(screeningRequest.toString())
.call()
.content();
return new ScreeningResponse(verdict);
}
record ScreeningRequest(
String name,
String email,
String jobTitle,
String requiredSkills,
String candidateSkills,
int expectedSalary
) {}
record ScreeningResponse(
String verdict
) {}
Nuestro punto final acepta un registro ScreeningRequest que contiene todo lo que nuestros agentes necesitan, es decir, la identidad del candidato, los detalles del puesto y el salario esperado.
Simplemente pasamos la representación en cadena del registro al modelo como un prompt de usuario y devolvemos el resumen resultante como un ScreeningResponse. De esta manera, nuestro cliente orquestador recibe todos los detalles del candidato y distribuye los datos relevantes a cada uno de nuestros agentes especializados.
6. Probando Nuestra Implementación
Con nuestra arquitectura implementada, iniciemos todos nuestros agentes y probemos el flujo de selección de candidatos.
Usaremos la CLI HTTPie para invocar nuestro punto final de selección:
http POST :8080/screenings \
name="John Doe" \
email="/cdn-cgi/l/email-protection" \
jobTitle="Backend Developer" \
requiredSkills="Java, Spring Boot, AWS, Kafka" \
candidateSkills="Java, Spring Boot, Azure, Kafka" \
expectedSalary:=110000 \
| jq -r '.verdict'
Aquí, enviamos datos de ejemplo para un candidato y pipe la respuesta a través de jq para imprimir el verdict como texto legible.
Veamos lo que obtenemos como respuesta:
Screening summary for John Doe (Backend Developer):
- Salary: Expected salary of $110,000 is within budget.
- Background check: Clear; no relevant flags found.
- Skills match: Strong match, 75% fit. Only missing AWS experience, though Azure experience may be transferable.
Overall: John Doe appears to be a good candidate to proceed with, with follow-up recommended on AWS/cloud experience.
Como vemos, el orquestador delegó la solicitud a los tres agentes remotos y consolidó sus veredictos individuales en un único resumen.
7. Conclusión
En este artículo, hemos comprendido qué es el protocolo Agent2Agent (A2A) y lo hemos implementado prácticamente usando Spring AI.
Comenzamos construyendo un servidor A2A, exponiendo su capacidad a través de una tarjeta de agente. Luego, construimos un cliente A2A que actúa como orquestador, descubriendo dinámicamente agentes remotos y delegando tareas a ellos. Finalmente, probamos el flujo completo de nuestra implementación y confirmamos que nuestro orquestador combina las respuestas de todos los agentes especializados en un único resumen de selección.
Como siempre, todos los ejemplos de código utilizados en este artículo están disponibles sobre GitHub.
Newsletter Semanal de Java
Cada viernes recibe lo más nuevo del ecosistema Java: frameworks, herramientas y mejores prácticas.
Sin spam. Cancela cuando quieras.

