Ohhnews

分类导航

$ cd ..
Baeldung原文

使用Spring AI探索Agent2Agent协议(A2A)

#a2a协议#spring ai#人工智能#智能体通信#ai智能体

[LOADING...]

1. 概述

我们正在越来越多地构建能够独立处理用户完整请求的 AI 智能体。这些智能体不再只是根据大语言模型(LLM)已有的知识来回答问题,而是会对问题进行推理、将其拆解为多个步骤、调用外部工具,甚至执行本地脚本。

随着这些请求复杂度的增加,把全部能力都塞进单个智能体会变得难以管理。自然的解决方案是创建更小的智能体,让每个智能体专门负责一项工作。然而,让这些智能体相互通信本身就是一项挑战,因为它们是以独立服务的形式运行的,并且可能使用不同的语言、框架和 LLM 构建。

Agent2Agent(A2A)协议通过定义一套标准,让智能体能够相互发现并进行通信,从而解决了这个问题。

在本教程中,我们将使用 Spring AI 实现 A2A 协议的客户端和服务器架构,从而进行实践探索。

2. Agent2Agent(A2A)协议入门

在深入实现之前,让我们先仔细看看该协议,以及两个智能体是如何交互的:

[LOADING...]

充当服务器的智能体会对外暴露自身能力,而充当客户端的智能体则消费这些能力。

发现机制通过 Agent Card(代理卡片)实现,这是一份 JSON 文档,服务器会将其发布在约定的 well-known URL。这张卡片会暴露智能体的详细信息,包括它提供的技能列表。客户端智能体会首先获取这张卡片,以了解远程智能体能够做什么,以及如何访问它。

客户端会发送一条 Message(消息),用纯自然语言描述需要完成的工作。远程智能体将该消息转换为 Task(任务),进行处理,然后返回一个或多个携带实际响应内容的 Artifact(产物)

A2A 是一个复杂且庞大的主题。我们可以参考官方规范来了解更多。

3. 我们要构建的项目

为了实际体验该协议,我们将为招聘人员构建一个招聘筛选系统:

[LOADING...]

如图所示,我们的系统由一个充当 A2A 客户端的编排智能体(orchestrator agent)和三个充当 A2A 服务器的专用远程智能体组成

招聘人员将候选人详细信息提交到单个 REST 端点。在后台,编排智能体会将请求拆解,并把每个子任务委派给对应的专用智能体。当所有智能体都响应后,编排智能体会将各个判定结果合并为一份简短的筛选总结。

4. 创建 A2A 服务器

由于创建 A2A 服务器的整体结构完全相同,这里我们只演示技能匹配智能体的实现。

其余两个远程服务器只是在工具和提示词上有所不同。如需查看项目的完整实现,可以参阅本教程对应的仓库。

4.1. 依赖与 LLM 配置

首先,在项目的 pom.xml 文件中添加所需依赖:

$ xml
<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>

这里,我们首先引入 Spring AI 的 OpenAI Starter 依赖,用来与 LLM 交互。此外,我们还引入了来自 Spring AI 社区A2A 服务器自动配置依赖,它负责在启动时提供我们的 Agent Card,并处理 A2A 请求

接下来,在 application.yaml 文件中配置 OpenAI API 密钥聊天模型

$ config
spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        model: gpt-5.5

这里,我们使用 gpt-5.5 模型 ID 指定 OpenAI 的 GPT 5.5 模型。当然,也可以使用其他聊天模型,因为具体的 AI 模型或提供商与本演示无关。

设置了这两个属性后,Spring AI 会自动创建 ChatClient.Builder 类型的 Bean,我们将在下一节中使用它。

4.2. 创建工具并将其注册到 ChatClient

接下来,让我们配置 A2A 服务器实际提供的能力。我们将创建一个 SkillsMatcherTools 类,并定义一个用于评估候选人技能的工具:

$ java
@Tool(
  name = "match-skills",
  description = "Compares a candidate's skills against a job's required skills and returns a fit score"
)
SkillsMatchResult matchSkills(
  @ToolParam(description = "Candidate skills, comma-separated") String candidateSkills,
  @ToolParam(description = "Required job skills, comma-separated") String requiredSkills
) {
    // ... rudimentary implementation
}
record SkillsMatchResult(
    int score,
    Verdict verdict,
    Set<String> matchedSkills,
    Set<String> missingSkills
) {}
enum Verdict {
    STRONG_MATCH,
    PARTIAL_MATCH,
    WEAK_MATCH
}

我们使用 @Tool 注解标注方法,并为它提供明确的 name 和简短的 description。这两个值都能帮助 AI 模型决定是否以及何时调用此工具。类似地,我们也用 @ToolParam 注解描述两个方法参数,以便 LLM 知道我们期望的输入格式。

这里我们特意省略了方法实现,因为它对我们理解 A2A 并不重要。

接下来,创建一个 ChatClient Bean,并将工具注册到其中:

$ java
@Bean
ChatClient chatClient(
  ChatClient.Builder chatClientBuilder,
  SkillsMatcherTools skillsMatcherTools
) {
    return chatClientBuilder
      .defaultSystem("""
        You are a skills-matching assistant for recruiters.
        Use the match-skills tool to compare a candidate's skills
        against a job's required skills, then summarize the result.
        """)
      .defaultTools(skillsMatcherTools)
      .build();
}

这里,我们使用 Spring AI 为我们配置的 ChatClient.Builder Bean,以及上面定义的 SkillsMatcherTools Bean,来创建 ChatClient Bean。该类是我们与已配置 LLM 交互的主要入口

此外,我们定义了一个系统提示词,用来确定智能体的角色,然后使用 defaultTools() 方法注册工具类。这使模型在收到匹配的请求时可以调用我们的工具方法。

4.3. 定义 AgentExecutor 来处理 A2A 请求

我们定义的 ChatClient Bean 只能被自己应用内的组件使用。为了让其他智能体向我们发送任务,我们需要定义一个 AgentExecutor Bean,用来处理传入的 A2A 请求:

$ java
@Bean
AgentExecutor agentExecutor(ChatClient chatClient) {
    return new DefaultAgentExecutor(chatClient, (client, requestContext) -> {
        String userMessage = DefaultAgentExecutor.extractTextFromMessage(requestContext.getMessage());
        return client
          .prompt()
          .user(userMessage)
          .call()
          .content();
    });
}

这里,我们使用 DefaultAgentExecutor 类,将 ChatClient Bean 以及一个定义响应方式的处理函数传递给它。在处理函数中,我们提取传入消息的纯文本,并将其作为用户提示词传递给 LLM。来自 chatClient Bean 的响应会自动被封装为 artifact,并返回给调用方智能体

4.4. 使用 AgentCard 描述智能体

最后,我们需要暴露一个能够准确描述智能体的 Agent Card:

$ java
@Bean
AgentCard agentCard(
  @Value("${server.host}") String host,
  @Value("${server.port}") int port
) {
    return new AgentCard.Builder()
      .name("Skills Matcher Agent")
      .description("Evaluates how well a candidate's skills match a job's required skills")
      .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("Compares candidate skills to job requirements and scores the fit")
        .tags(List.of("hiring", "recruiting"))
        .build()))
      .protocolVersion("1.0.1")
      .build();
}

这里,我们创建 AgentCard Bean,并定义 namedescriptionskills 等重要属性。客户端智能体依赖这些属性来判断远程智能体及其提供的能力是否适合给定任务

类似地,我们从运行中应用的主机 host 和端口 port 定义 url 属性,告诉客户端如何访问我们。此外,我们还声明该智能体不支持流式传输,并且双向都只交换纯文本。

值得注意的是,我们上面设置的每个属性都是必填的,构建器会拒绝缺少其中任何一项的卡片。定义好该 Bean 后,自动配置会在 .well-known/agent-card.json 路径提供我们的 Agent Card。## 5. 创建 A2A 客户端

准备好我们的专用 Agent 后,我们将构建 A2A 客户端,即职位筛选编排器,它将实际评估工作委托给这些 Agent。

5.1. 依赖

我们的编排器是一个独立的应用程序,也需要与 LLM 对话。因此,我们需要像在 A2A 服务器中那样,引入聊天模型依赖,并配置 API 密钥和模型属性。

此外,我们还需要将 A2A Java SDK 添加到我们的 pom.xml 中:

$ xml
<dependency>
    <groupId>io.github.a2asdk</groupId>
    <artifactId>a2a-java-sdk-client</artifactId>
    <version>0.3.3.Final</version>
</dependency>

该 SDK 为我们提供了获取 Agent 卡片、打开与远程 Agent 的连接以及向其发送消息所需的类。

需要注意的是,在构建 A2A 服务器时,我们没有显式声明此依赖,因为 server autoconfigure 依赖会以传递方式引入它。但是,对于一个纯粹作为客户端运行的 Agent,我们需要自行添加此依赖。

5.2. 启动时发现远程 Agent

我们的编排器只能将工作委托给它知道的 Agent,因此让我们在 application.yaml 中列出它们的地址:

$ config
remote:
  agents:
    urls:
      - http://localhost:8081
      - http://localhost:8082
      - http://localhost:8083

在这里,我们使用自定义属性配置远程 Agent 的基础 URL。如果 Agent 运行在不同的主机或端口上,我们需要确保更新这些值。

接下来,让我们创建一个 AgentRegistry 组件,在应用程序启动时从这些 URL 获取 Agent 卡片:

$ java
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);
    }
}

在这里,我们在构造函数中遍历每个配置的 URL,并从 .well-known/agent-card.json 路径获取其 Agent 卡片。然后,我们将得到的 AgentCard 实例存储在按 Agent 名称作为键的内存映射中。

为了将获取到的 Agent 卡片暴露给其他组件,让我们向这个类添加几个辅助方法:

$ java
AgentCard get(String agentName) {
    return agentCards.get(agentName);
}
String describeAgents() {
    return agentCards
      .values()
      .stream()
      .map(card -> "- " + card.name() + ": " + card.description())
      .collect(Collectors.joining("\n"));
}

get() 方法按名称返回特定 Agent 的卡片。同时,describeAgents() 方法渲染所有可用远程 Agent 的格式化摘要及其描述。我们将在接下来的章节中使用这些辅助方法来定义其他组件。

5.3. 与远程 Agent 通信

我们的 A2A 客户端现在已经能够在启动时发现远程 Agent。接下来,让我们创建一个 RemoteAgentClient 组件,实际与这些 Agent 通信:

$ java
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"));
}

在这里,我们首先从注册表获取目标 Agent 的卡片,因为 SDK 使用它来构建客户端。

由于 SDK 会异步返回响应,我们注册一个消费者来接收已完成的任务。然后,它从任务产物中提取文本,并将其传递给我们的 CompletableFuture response。接着,我们将给定的 task 转换为 A2A 消息,发送给远程 Agent,并等待 response 完成。

接下来,让我们将此能力作为工具暴露出来,供我们编排器的 LLM 调用:

$ java
@Tool(
  name = "send-message-to-agent",
  description = "Sends a task to a remote agent and returns its response."
)
String sendMessageToAgent(
  @ToolParam(description = "Name of the remote agent") String agentName,
  @ToolParam(description = "The task to perform") String task
) {
    return remoteAgentClient.sendMessage(agentName, task);
}

这一个工具就足以让编排器触达所有已配置的 Agent。LLM 只需填写 agentNametask 参数,就能决定联系哪个 Agent、询问什么内容。

5.4. 构建编排器的 ChatClient

在发现和通信逻辑就绪后,让我们为编排器 Agent 创建 ChatClient Bean:

$ java
@Bean
ChatClient chatClient(
  ChatClient.Builder chatClientBuilder,
  AgentRegistry agentRegistry,
  RemoteAgentTools remoteAgentTools
) {
    return chatClientBuilder
      .defaultSystem("""
        You are a job-screening orchestrator for recruiters.
        You do not evaluate candidates yourself. Instead, you delegate
        to the following remote agents:
        %s
        Once all agents have responded, combine their responses into a short screening summary.
        """.formatted(agentRegistry.describeAgents()))
      .defaultTools(remoteAgentTools)
      .build();
}

在系统提示中,我们明确指示模型不要自行评估候选人。相反,我们使用 describeAgents() 方法注入发现的 Agent 列表,并要求它将各个评估任务委托给这些 Agent

然后,我们注册工具,使模型具备实际触达这些 Agent 的能力。

5.5. 暴露 REST API

最后,让我们使用已定义的编排器 ChatClient,并暴露一个 REST API 端点来接收候选人筛选请求:

$ java
@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
) {}

我们的端点接受一个 ScreeningRequest 记录,其中包含我们的 Agent 所需的全部信息,即候选人身份、职位详情和期望薪资。

我们只需将记录的字符串表示作为用户提示传递给模型,并将生成的摘要作为 ScreeningResponse 返回。这样,我们的编排器客户端就能收到完整的候选人详情,并将相关数据分发给每个专用 Agent

6. 测试我们的实现

实现完架构后,让我们启动所有 Agent 并测试职位筛选流程。

我们将使用 HTTPie CLI 来调用我们的筛选端点:

$ bash
http POST :8080/screenings \
  name="John Doe" \
  email="john.doe@baeldung.com" \
  jobTitle="Backend Developer" \
  requiredSkills="Java, Spring Boot, AWS, Kafka" \
  candidateSkills="Java, Spring Boot, Azure, Kafka" \
  expectedSalary:=110000 \
  | jq -r '.verdict'

在这里,我们为候选人提交示例数据,并将响应通过 管道 传递给 jq,以可读文本形式打印 verdict

让我们看看会得到什么响应:

$ bash
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.

正如我们所见,编排器将请求分派给了全部三个远程 Agent,并将它们的各自结论整合为一份摘要

7. 结论

在本文中,我们了解了 Agent2Agent (A2A) 协议是什么,并使用 Spring AI 实际实现了该协议。

我们首先构建了一个 A2A 服务器,通过 Agent 卡片暴露其能力。接着,我们构建了一个充当编排器的 A2A 客户端,动态发现远程 Agent 并将任务委托给它们。最后,我们测试了实现的完整流程,并确认编排器能将所有专用 Agent 的响应合并为一份筛选摘要。

与往常一样,本文使用的所有代码示例都可在 GitHub 上获取。