Administrator
发布于 2026-05-27 / 203 阅读
3

A2A 协议:智能体之间的协作标准

「让运维 Agent 自己去问数据库 Agent」

四月份我们遇到一个需求:客服 Agent 在处理客诉时,需要判断「这个订单的物流是不是真的延误了」。这个判断需要的权限(查物流商内部系统、看历史延误率)我们不打算给客服 Agent。

常规解法是加一个工具。但那意味着把物流系统的权限开放出去,而且物流团队不想让客服团队碰他们的接口。

最后我们用了 A2A:让客服 Agent 把这件事「委派」给物流团队的 Agent。这篇记录 A2A 协议的设计逻辑、我们的 Java 实现,以及用了两个月后的判断。

A2A 解决的是 MCP 不管的那部分

先把分工说清楚,这是最容易搞混的地方。

MCP 解决的是「Agent 怎么用工具」。工具是无状态的、确定性的、不参与决策的——你调 queryOrder 它返回一个订单,仅此而已。

但真实场景里有另一类需求:你需要的是一个能自己做判断的主体,不是一个函数。比如「判断这个物流是不是延误了」,这不是查一次数据库就能回答的,需要看多个数据源、需要行业经验、可能需要反问、可能给出「不确定」的结论。

这就是 A2A 的场景:Agent 之间怎么协作

维度MCPA2A
交互对象Agent → 工具(无状态服务)Agent → Agent(有自主性的主体)
交互模式请求-响应,同步任务(Task),可异步、可长周期
对方能力固定的工具签名用自然语言描述的「技能」
协商无,参数对了就执行有,可以要求澄清、可以拒绝
产出结构化数据工件(Artifact)+ 状态
典型耗时毫秒~秒秒~分钟(可能涉及人工)

最简单的判断标准:如果你需要对方「想一想」,用 A2A;如果只是「做一下」,用 MCP。

它们不是竞争关系。我们的物流 Agent 自己就用 MCP 连着物流系统的各种工具,对客服 Agent 来说它是一个 A2A 对端,对它自己来说它是 MCP 客户端。分层很清楚。

协议核心:Agent Card、Task、Artifact

A2A 的三个核心概念。

Agent Card:能力的自描述

每个 A2A 服务端要提供一个 Agent Card,相当于它的「名片」。这是个约定路径的 JSON:

GET /.well-known/agent-card.json
{
  "name": "logistics-agent",
  "description": "物流异常诊断智能体。可以判断订单是否真的发生延误、延误原因、以及预计到达时间。",
  "url": "https://logistics-agent.internal/a2a",
  "version": "1.4.0",
  "protocolVersion": "0.3.0",
  "capabilities": {
    "streaming": true,
    "pushNotifications": false
  },
  "defaultInputModes": ["text"],
  "defaultOutputModes": ["text", "data"],
  "skills": [
    {
      "id": "delay-diagnosis",
      "name": "延误诊断",
      "description": "判断订单物流是否真实延误。需要提供订单号。可以给出延误原因分类和预计到达时间。",
      "tags": ["物流", "延误", "诊断"],
      "examples": [
        "订单 SO20260512001 的物流是不是延误了",
        "帮我看看这个件为什么还没到"
      ]
    },
    {
      "id": "eta-prediction",
      "name": "到货时间预测",
      "description": "基于历史数据和当前状态预测到货时间。",
      "tags": ["物流", "时效"]
    }
  ]
}

这张卡片的设计我挺喜欢的:它描述的是「能帮你解决什么问题」,不是「有哪些接口」。调用方 Agent 可以根据 descriptionexamples 判断该不该找它,这正是模型擅长的事。

注意 description 里我写了「可以给出延误原因分类」这种模糊表述。这是刻意的——它描述能力边界,不承诺精确输出格式。

Task:异步的、有生命周期的工作单元

A2A 的基本交互单元是 Task,不是 Request。这个区别很重要。

POST /a2a HTTP/1.1
Content-Type: application/json

{
  "jsonrpc": "2.0",
  "id": "req-001",
  "method": "tasks/send",
  "params": {
    "id": "task-7f3a2c91",
    "sessionId": "conv-abc123",
    "message": {
      "role": "user",
      "parts": [
        {"type": "text", "text": "订单 SO20260512001 的物流是不是延误了?"}
      ]
    },
    "metadata": {
      "callerTenant": "T001",
      "priority": "normal"
    }
  }
}

响应里 Task 有状态:

{
  "jsonrpc": "2.0",
  "id": "req-001",
  "result": {
    "id": "task-7f3a2c91",
    "status": {
      "state": "completed",
      "timestamp": "2026-05-27T10:23:41Z"
    },
    "artifacts": [
      {
        "name": "diagnosis",
        "parts": [
          {
            "type": "data",
            "data": {
              "delayed": true,
              "delayHours": 38,
              "reasonCategory": "WEATHER",
              "confidence": 0.82,
              "evidence": [
                "5月25日 途中遭遇暴雨预警(来源:气象局)",
                "同线路其他 12 个包裹均延误 24-48 小时"
              ]
            }
          }
        ]
      },
      {
        "name": "summary",
        "parts": [
          {"type": "text", "text": "该订单确实延误,原因是天气,预计明天下午到达。"}
        ]
      }
    ]
  }
}

Task 的状态机:submitted → working → (input-required) → completed / failed / canceled

input-required 这个状态是 A2A 最有价值的设计。 它意味着被调用方可以反问:「你给我的信息不够,能不能再提供一下承运商单号?」这在 MCP 里是没有的——工具要么成功要么失败,不能说「我需要更多信息」。

Artifact:结构化 + 非结构化的混合产出

上面那个响应里有两个 artifact:一个是结构化数据(给调用方 Agent 的程序逻辑用),一个是文本(给模型读)。这个设计很实用。

我们的约定是:每个 Task 至少返回一个 data artifact 和一个 text artifact。前者让调用方可以做确定性判断(比如 delayed == true 才触发补偿流程),后者让模型能生成自然语言回复。

Java 实现

Java 侧的 A2A 库还不算成熟,我们评估了几个,最后选了自己实现核心部分(约 800 行)。理由后面说。

服务端

@RestController
@RequestMapping("/a2a")
public class A2aEndpoint {

    private final TaskRegistry tasks;
    private final LogisticsAgent agent;

    @GetMapping("/.well-known/agent-card.json")
    public AgentCard card() {
        return AgentCard.loadFromClasspath("agent-card.json");
    }

    @PostMapping
    public Mono<JsonRpcResponse> handle(@RequestBody JsonRpcRequest req) {
        return switch (req.method()) {
            case "tasks/send"          -> send(req);
            case "tasks/get"           -> get(req);
            case "tasks/cancel"        -> cancel(req);
            case "tasks/sendSubscribe" -> subscribe(req);   // SSE 流式
            default -> Mono.just(JsonRpcResponse.methodNotFound(req.id()));
        };
    }

    private Mono<JsonRpcResponse> send(JsonRpcRequest req) {
        SendParams p = req.paramsAs(SendParams.class);

        // 幂等:同一个 task id 重复提交直接返回已有结果
        if (tasks.exists(p.id())) {
            return Mono.just(JsonRpcResponse.result(req.id(), tasks.get(p.id())));
        }

        Task task = tasks.create(p.id(), p.sessionId());

        // 异步执行,长任务不阻塞 HTTP 连接
        return Mono.fromFuture(
            CompletableFuture.supplyAsync(() -> execute(task, p), taskExecutor))
            .map(t -> JsonRpcResponse.result(req.id(), t));
    }

    private Task execute(Task task, SendParams p) {
        task.markWorking();
        try {
            AgentResult r = agent.handle(p.message(), p.metadata());
            task.complete(r.artifacts());
        } catch (NeedMoreInfoException e) {
            // 关键:可以要求补充信息
            task.requireInput(e.question());
        } catch (Exception e) {
            task.fail(e.getMessage());
        }
        return task;
    }
}

三个必须做的工程点:

  1. 幂等。网络重试会导致同一个 task 被提交两次,不幂等就会重复执行。我们用 task id 做幂等键,重复提交直接返回已存在的结果;
  2. 异步执行。A2A 的 task 可能跑几十秒甚至几分钟(我们的物流诊断平均 8 秒,但涉及人工的能到几小时)。绝不能占着 HTTP 连接;
  3. 状态持久化。Task 状态要落库,否则实例重启就丢了。
CREATE TABLE a2a_task (
    task_id      varchar(64) PRIMARY KEY,
    session_id   varchar(64),
    caller       varchar(32)  NOT NULL,     -- 调用方 Agent 标识
    tenant_id    varchar(16)  NOT NULL,
    state        varchar(20)  NOT NULL,
    input        jsonb        NOT NULL,
    artifacts    jsonb,
    error        text,
    created_at   timestamptz  DEFAULT now(),
    updated_at   timestamptz  DEFAULT now()
);
CREATE INDEX ON a2a_task (session_id, created_at DESC);

客户端

客户端这边我们做成一个 Spring AI 的 Tool,这样调用方 Agent 不需要感知 A2A 的存在:

@Component
public class A2aDelegationTool {

    private final A2aClientRegistry registry;

    @Tool(description = """
        向物流智能体提问物流相关问题。
        适用于:判断物流是否延误、延误原因分析、到货时间预测。
        不适用于:查询订单基本信息(用 queryOrder)、发起物流投诉(转人工)。
        这是一个异步任务,可能需要 10~30 秒,请耐心等待。
        """)
    public DelegationResult askLogistics(
            @ToolParam(description = "要向物流智能体提出的问题,必须包含订单号") String question) {

        AgentCard card = registry.get("logistics-agent");
        if (!card.hasSkillMatching(question)) {
            return DelegationResult.unavailable("物流智能体不支持该类问题");
        }

        Task task = a2aClient.send(card.url(), TaskRequest.builder()
            .sessionId(SessionContext.currentId())
            .text(question)
            .metadata(Map.of("callerTenant", TenantContext.currentId()))
            .build());

        // 轮询或订阅,最长等 60 秒
        Task completed = awaitCompletion(task, Duration.ofSeconds(60));

        return switch (completed.state()) {
            case COMPLETED     -> DelegationResult.of(completed.artifacts());
            case INPUT_REQUIRED -> DelegationResult.needInfo(completed.question());
            case FAILED        -> DelegationResult.unavailable(completed.error());
            default            -> DelegationResult.timeout();
        };
    }
}

关键点是 INPUT_REQUIRED 的处理:调用方 Agent 收到后会转头问用户「物流那边需要承运商单号,能提供一下吗」。这个链路走通的时候我们挺兴奋的——Agent 之间和 Agent 与用户之间用同一套交互范式,不需要为「被反问」写特殊逻辑。

为什么没用现成库

Java 侧现在有几个 A2A 的实现,我们都试过,最后还是自己写了。原因:

  • 协议版本迭代快。我们开始做的时候是 0.2.x,两个月内升到 0.3.0,字段有变化。现成库跟不上;
  • 我们需要的东西不多。完整协议里有推送通知、流式更新、多模态 parts,我们只用到了同步 send、get、cancel 和 SSE 订阅。自己实现 800 行就够了;
  • 和我们现有体系的整合。鉴权、多租户、链路追踪、成本计量都要接进来,用现成库反而要包一层。

这个决定是场景相关的。如果你需要完整的协议支持(尤其是多模态和推送通知),还是用库更合适。我们的场景简单,自己写更快也更可控。

踩的坑

一、超时策略

第一个版本我们设了 30 秒超时,然后发现大量超时——物流诊断平均 8 秒,但 P99 是 47 秒。

正确的做法是要区分两种「慢」:任务还在跑任务卡死了。我们的解法是让被调用方定期更新 task 的 updated_at,客户端看这个时间戳,只要还在更新就继续等。

Task awaitCompletion(Task task, Duration timeout) {
    Instant deadline = Instant.now().plus(timeout);
    Instant lastProgress = Instant.now();

    while (Instant.now().isBefore(deadline)) {
        Task current = client.get(task.id());

        if (current.state().isTerminal()) return current;

        // 有进展就重置「无进展计时」,但不超过总 deadline
        if (current.updatedAt().isAfter(lastProgress)) {
            lastProgress = current.updatedAt();
        } else if (Duration.between(lastProgress, Instant.now())
                       .compareTo(Duration.ofSeconds(45)) > 0) {
            return current.markStalled();      // 45 秒无进展判定卡死
        }
        Thread.sleep(1000);
    }
    return task.markTimeout();
}

改完之后超时率从 18% 降到 0.7%。

二、上下文传递的边界

客服 Agent 派任务给物流 Agent 时,要传多少上下文?传多了浪费 token 且有数据泄露风险,传少了对方答不上来。

我们第一版是把整个对话历史传过去,被安全团队拦下了——客服对话里有用户的手机号、地址,物流 Agent 不需要这些,也不应该有权限看。

现在的规矩是:只传任务必需的字段,且经过脱敏

public class ContextSanitizer {

    public Map<String, Object> forDelegation(String targetAgent,
                                              ConversationContext ctx) {
        Set<String> allowed = allowedFields.get(targetAgent);   // 白名单

        Map<String, Object> out = new HashMap<>();
        out.put("orderNo", ctx.fact("order_no"));
        out.put("userId", mask(ctx.fact("user_id")));      // 脱敏
        out.put("question", ctx.lastUserMessage());
        // 手机号、地址、支付信息一律不传
        return out;
    }
}

白名单是按「目标 Agent」配置的,每个 Agent 能收到什么字段是显式声明的。这个配置在安全评审时过了一遍。

三、循环委派

Agent A 派给 B,B 又派回给 A,无限循环。我们在测试环境就遇到了,一个任务跑了 40 分钟才被超时终止。

解法是在请求头里带上调用链:

public void checkNoCycle(String callerChain, String selfName) {
    List<String> chain = Arrays.asList(callerChain.split(">"));
    if (chain.contains(selfName)) {
        throw new DelegationCycleException(
            "检测到循环委派:" + callerChain + ">" + selfName);
    }
    if (chain.size() >= 3) {
        throw new DelegationDepthException("委派层级超过 3 层");
    }
}

// 请求头
X-A2A-Delegation-Chain: customer-service-agent>order-agent

深度限制设 3 层。实际用下来,超过 2 层的委派我们就没见成功过——越深的链路越容易在某个环节断掉。

四、责任归属

这是个非技术问题,但很重要:委派出去的任务出错了,算谁的?

我们的约定是:Agent Card 的 skill description 里必须写明能力边界和免责范围,调用方对最终用户负责。物流 Agent 说「没延误」但实际延误了,导致客服给了错误答复——对外是客服 Agent 的责任,内部再看是物流 Agent 的判断错误。

这个责任模型定下来之后,调用方会做二次校验(我们的客服 Agent 对物流 Agent 的 confidence < 0.7 的结论会转人工),而不是无脑采信。

两个月的数据

指标数值说明
日均委派任务3,420占客服对话的 13%
平均耗时6.8sP99 是 41s
超时率0.7%优化前 18%
input-required 比例4.1%需要反问用户
委派后任务完成率91.3%相比自己判断的 78.2%
循环委派0加了链路检查后

最关键的一行是「委派后任务完成率 91.3% vs 自己判断 78.2%」。这个对比来自我们 A/B 测试的两组数据——同样的客诉,一组让客服 Agent 自己判断物流是否延误(用它能拿到的有限数据),一组委派给物流 Agent。

差异主要来自物流 Agent 能拿到客服 Agent 拿不到的数据(物流商内部状态、同线路其他包裹的情况)。这是 A2A 最实在的价值:让专业的事交给有权限、有数据、有领域知识的 Agent 去做。

我的判断

用了两个月,说说我对 A2A 的看法。

一、协议设计里最有价值的是 input-required 状态。它承认了一个事实:复杂任务的信息往往是不完备的,需要在执行过程中反补。这是 Agent 协作和函数调用的本质区别。光这一条就值得用 A2A 而不是简单地互相调 HTTP 接口。

二、现在(2026 年中)还处在很早期的阶段。 协议版本 0.3.0,还在快速迭代。Java 侧的实现都不成熟。我们的建议是:如果你只有一个明确的点对点协作需求,A2A 有点重,直接定义内部接口更快。真正需要它是当你有多个团队各自维护 Agent,且协作关系会变化时。

三、最大的风险是它让系统变得难以理解。 一次用户请求可能穿过 3 个 Agent,每个 Agent 内部又有多次 LLM 调用和工具调用。出问题时排查链路很长。我们现在强制要求全链路 traceId 贯通,并且每个委派点都记日志。这个成本不小,但必须有。

四、跨组织的 A2A 我暂时不看好。 协议本身支持(Agent Card 是公开可发现的),但实际操作中,跨公司的 Agent 协作涉及责任、计费、数据合规,这些都不是协议能解决的。我们目前只在公司内部跨团队用。

留个问题

关于《A2A 协议:智能体之间的协作标准》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。

参考