跳到主要内容
Cowers://
全部文章
协议与通信

A2A Agent 间通信协议

A2A = Agent-to-Agent Protocol(Agent 间通信协议),Google 提出、现由 Linux Foundation 托管的开放标准,让独立部署的 AI Agent 之间互相委托任务。

A2A:Agent 之间怎么打电话

阅读提示 本笔记面向已经理解 Agent 循环和工具调用、开始接触多 Agent 系统的读者。主线是:A2A 和 MCP 各管什么 → Agent Card 怎么发现 → Task 为什么要有状态机 → 多 Agent 怎么协作 → 什么时候根本不该用它

前置概念见 Agent概念

⚠️ 一个必须先说清的边界:A2A 不是 MCP 的升级版,两者解决的是不同层面的问题。MCP 管「把能力接进我的 Agent」,A2A 管「把活委托给别人的 Agent」。把它们放在一条演进线上理解会导致选型出错,第一节专门讲这个。

📌 本笔记依据 A2A v1.0 规范(specificationv1.0 变更说明)。v0.3 → v1.0 是破坏性升级,遇到与本笔记不一致的旧教程,先确认它写的是哪个版本。

目录


核心概念 A2A = Agent-to-Agent Protocol(Agent 间通信协议),Google 提出、现由 Linux Foundation 托管的开放标准,让独立部署的 AI Agent 之间互相委托任务。

传输层不止一种:规范定义了三种 protocol binding——JSON-RPC 2.0gRPCHTTP+JSON/REST,还允许用 URI 标识自定义绑定。本笔记的报文示例统一用 JSON-RPC 绑定,但不要把「A2A = JSON-RPC」当成协议定义。

三个核心构件:Agent Card(名片,说明我能干什么)、Task(工单,有完整生命周期)、Artifact(产出物)。

⚠️ 本笔记按 A2A v1.0 规范写。v1.0 相对 v0.3 有大量破坏性改动(方法名、Agent Card 结构、TaskState 取值、Part 结构全变了),网上大量教程仍停留在 v0.3,照抄会跑不通。

一、MCP 管插头,A2A 管打电话

两个类比放一起最省事:

MCP = USB-C 接口——模型连工具,像插头插进插座,插上就能用,用完就完。

A2A = 打电话——Agent 连 Agent,像同事之间协作,对方也是个有判断力的主体,可能反问你、可能要花几个小时才回复。

用一个具体场景理解:你是旅行协调员 Agent,接到「帮我规划北京 3 天旅行」。

  • 打电话给气象局同事(天气 Agent):北京明天天气?→ 对方是个独立服务,有自己的模型和逻辑;
  • 打电话给酒店预订部(酒店 Agent):三里屯附近有什么酒店?
  • 你综合两边信息,出一份完整计划。

这里每个「同事」都是独立的 HTTP 服务,不是你能直接调用的函数。

七个维度的对照

维度 MCP A2A
关系 Host 内的 MCP Client ↔ MCP Server A2A Client ↔ A2A Server
类比 USB-C 接口 打电话
通信内容 工具调用 → 直接返回结果 任务提交 → 生命周期管理
时长 短调用,秒级 可长运行,分钟到小时级
发现机制 连接时 tools/list 拿清单 /.well-known/agent-card.json 读名片
长任务 有 Tasks 扩展,但工具调用主流是同步返回 Task 生命周期是协议核心,不是扩展
典型场景 模型调外部 API、读文件 跨团队、跨语言的 Agent 协作

「双方都是 Agent」是常见误解 A2A 里的两个角色叫 A2A ClientA2A Server,不是「两个 Agent」。只有 Server 端必须是一个 A2A Agent;Client 端可以是另一个 Agent,也可以是一个普通的后端服务、CLI 工具、网页前端——任何能读名片、发请求的程序。

说成「点对点、双方对等」会让人误以为必须两边都部署 Agent 才能用 A2A,实际上「普通应用调用一个远程 Agent」是完全合法且常见的用法。

注意第四行和第六行——这两点是 A2A 复杂度的来源。工具调用主流是「问一句答一句」,而另一个 Agent 可能要想很久、可能中途需要补充信息、可能失败重试。为了描述这种不确定的过程,就必须引入任务和状态。

为什么它们不该被放在同一条演进线上 两个协议都能跑在 JSON-RPC 2.0 上,都有「发现能力再调用」的模式,看起来很像。但发起方的心智不同:

  • 调 MCP Tool 时,你知道它会做什么,只是不想自己实现;
  • 调另一个 Agent 时,你不知道它内部怎么做,只知道它承诺能交付什么。

后者更接近委托,前者更接近调用。所以 A2A 把工单、状态和产出物放在协议中心。

但要注意这是倾向而非硬边界:MCP 后来补上了 Elicitation(Server 反过来向用户要信息)和 Tasks(长时任务),两者的能力集在互相靠拢。别把某一个特性当成非此即彼的判据。

二、Agent Card:先看名片再谈合作

A2A 协作的第一步不是发请求,是读对方的名片

GET http://localhost:8001/.well-known/agent-card.json

拿到的是一个 JSON,字段各有分工:

{
  "name": "天气查询 Agent",
  "description": "提供城市天气查询服务,包括温度、天气状况、空气质量、出行建议",
  "version": "1.0.0",
  "supportedInterfaces": [
    {
      "url": "http://localhost:8001/a2a",
      "protocolBinding": "JSONRPC",
      "protocolVersion": "1.0"
    }
  ],
  "capabilities": {
    "streaming": false,
    "pushNotifications": false,
    "extendedAgentCard": false
  },
  "defaultInputModes": ["text/plain"],
  "defaultOutputModes": ["text/plain", "application/json"],
  "skills": [{
    "id": "check_weather",
    "name": "查询实时天气",
    "description": "查询指定城市的实时天气信息,返回温度、天气状况、空气质量和出行建议",
    "tags": ["weather", "temperature", "air-quality", "travel-tip"]
  }]
}
字段 回答什么问题 谁会读它
name / description 我是谁 人和模型
skills[] 我能做什么 调用方据此决定找不找我
supportedInterfaces[] 到哪找我、用哪种绑定、哪个协议版本 客户端据此挑一个自己支持的接口
capabilities 支不支持流式、推送通知、扩展名片 客户端决定用哪种调用方式
defaultInputModes / defaultOutputModes 我收什么、我吐什么(媒体类型) 客户端准备数据格式
securitySchemes 有哪些认证方式可选 客户端准备凭证

顶层必填字段共 八个a2a.proto 里标了 field_behavior = REQUIRED 的那些):namedescriptionsupportedInterfacesversioncapabilitiesdefaultInputModesdefaultOutputModesskills

⚠️ 以为「只要有 name + supportedInterfaces + capabilities 就是一张合法名片」 实际结果: 少了 descriptionversiondefaultInputModesdefaultOutputModesskills 中的任何一个,严格校验的客户端都会判定名片非法。skills 是必填且必须非空——一张不声明任何技能的名片,在协议语义上等于「我什么都不做」,调用方也没有任何依据选中你。

顺带记住嵌套层的必填项: skills[] 内部 idnamedescriptiontags 四个全是必填tags 不是可选的装饰字段);supportedInterfaces[]url 必填。

⚠️ v0.3 的 Agent Card 结构已经作废 错误操作: 照着网上大多数教程写顶层 url + preferredTransport + additionalInterfaces + 顶层 protocolVersion,skill 里只写 name 不写 id

实际结果: 严格按 v1.0 校验的客户端会直接判定名片非法或找不到可用端点,而宽松的客户端可能默默降级到某个默认行为——不报错,但连不上。

原因: v1.0 把「端点 + 传输方式 + 协议版本」三件事收敛进了 supportedInterfaces[] 数组,一个 Agent 可以同时暴露 JSON-RPC、gRPC、REST 三个接口,各自带自己的版本号;顶层 urlpreferredTransportadditionalInterfaces、顶层 protocolVersion 全部移除。同时 skills[].id 从可选变为必填,supportsAuthenticatedExtendedCard 挪进了 capabilities.extendedAgentCard

正确做法: 主端点写在 supportedInterfaces[0].url;客户端选接口时要遍历数组、挑一个自己支持的 protocolBindingprotocolVersion不要假设只有一个接口、也不要假设端点路径固定

skills[] 是调用方选人的主要依据,作用和 MCP 里 Tool 的 description 类似(见 MCP 协议原理)。id 是机器可读的稳定标识,name 是给人看的显示名,description 是给模型判断的自然语言说明,tags 用于按主题检索、Agent 数量多起来时做初筛。

A2A Agent Card 发现与任务生命周期

图分三块:①读名片;②开一张工单并跟踪它的状态;③协调员对多个 Agent 各开一张单,最后自己合成。协调员本身不含天气和酒店逻辑,这是 A2A 最核心的价值。

Agent Card 的实际意义 它让调用关系不必硬编码。传统写法里,协调员代码中写死了 weather_agent.query(city);有了 Agent Card,协调员可以在运行时读若干张名片,按 skillstags 决定找谁。

但「新增一个 Agent 完全不用改协调员」是有前提的:协调员必须已经实现了动态选人逻辑(从注册表拉名片、按 skill 匹配、动态构造请求),而且新 Agent 的输入输出模式落在协调员已能处理的范围内。如果协调员是照着固定几张名片写死流程的,新增 Agent 照样要改代码——名片只是把「改代码」变成了「改配置 + 确认契约」,不是自动集成。

这是它相比「直接调 HTTP 接口」最主要的增量。如果你的场景里 Agent 就固定那两三个、永远不变,这层发现机制带来的收益接近于零。

三、Task 状态机:为什么不是「调完就返回」?

A2A 里的工作单元叫 Task(任务),v1.0 定义了八个状态。注意 v1.0 起枚举值统一改成了 TASK_STATE_ 前缀的全大写形式,v0.3 的 "working""input-required" 这种小写连字符写法已作废:

状态(v1.0 线上取值) 含义 是否终止态
TASK_STATE_SUBMITTED 已提交,还没开始处理
TASK_STATE_WORKING 处理中
TASK_STATE_INPUT_REQUIRED 停下来了,需要补充信息
TASK_STATE_AUTH_REQUIRED 停下来了,需要先完成认证/授权
TASK_STATE_COMPLETED 完成,有产出物
TASK_STATE_FAILED 失败
TASK_STATE_CANCELED 被取消
TASK_STATE_REJECTED 被执行方拒绝(比如不受理该请求)

正常路径是 SUBMITTED → WORKING → COMPLETED

AUTH_REQUIRED 容易被漏掉,但它在真实集成里很常见:Agent 干到一半发现要代表用户去调一个 OAuth 保护的下游服务,于是挂起任务、让调用方先去拿授权。客户端的状态机如果只写了七个分支,遇到它会掉进 default 分支或直接抛错。

两个「挂起」状态是 A2A 状态机的重点

其余六个状态,用一个普通的异步任务队列也能表达。A2A 真正重的是 INPUT_REQUIREDAUTH_REQUIRED 这两个非终止的挂起态:执行方干到一半停下来,把控制权交回给发起方,等补充完再继续同一个 Task。

比如你让酒店 Agent「订一家北京的酒店」,它可能停下来问:「哪个日期?几个人?预算多少?」

这体现了 A2A 的定位:它面向的是有判断力、可能长时间运行的执行方,不是一次算完就返回的函数。 如果你的下游只会「收参数、算结果、返回」,那你不需要 A2A。

不要把「会不会反问」当成 A2A 与 MCP 的绝对分界 早期资料(包括本笔记的旧版本)常说「工具永远不会反问你,所以 INPUT_REQUIRED 是 A2A 独有」。这个说法现在已经不成立:MCP 规范补上了 Elicitation(Server 在执行中向用户请求补充信息)和 Tasks(长时任务与进度追踪)。

真正稳的判据是责任边界而不是某个特性:你是把一个明确的能力接进自己的 Agent(MCP),还是把一整件事委托给一个自己不掌控内部逻辑的远端主体(A2A)。

taskId 与 contextId 的区别

  • taskId 标识一次委托,一次问答对应一个;
  • contextId 标识一整轮协作,多个相关 Task 共享同一个 contextId。

规划一趟旅行时,「查天气」和「找酒店」是两个 taskId、同一个 contextId。执行方靠 contextId 判断这些请求是否属于同一件事,从而复用上下文。

⚠️ 以为「同一个 contextId = 几个 Agent 之间自动共享上下文」 错误认知: 给天气 Agent 和酒店 Agent 发同一个 contextId,酒店 Agent 就能看到天气 Agent 那边发生了什么。

实际情况: contextId 只是一个关联标识,不是共享存储。每个 Agent Server 各自维护自己的任务与上下文,彼此之间没有任何协议层通道。上面那个例子里,两个 Task 打在两个不同的 Server 上,contextId 相同也只是让协调员事后能把它们归到一组。只有发给同一个 Server 的多个 Task,服务端才有条件按 contextId 复用上下文——而且这是实现方的选择,不是协议保证。

正确做法: 跨 Agent 需要共享的信息,由协调员显式放进 Message 的 parts 里传过去,别指望 contextId 替你搬运。

⚠️ 客户端自己生成 taskId 错误操作: 照着「异步任务队列」的习惯,在客户端 uuid.uuid4() 造一个 task_id,连同请求一起发过去。

实际结果: v1.0 规范明确不支持客户端为新任务指定 ID。合规的 Server 会忽略或拒绝这个字段,于是客户端拿着一个服务端根本不认识的 ID 去 GetTask,查到的永远是「任务不存在」。

原因: Task 是服务端的资源,ID 由服务端在收到 Message、决定创建新任务时生成,随首个响应返回给客户端。这样才能保证 ID 在服务端命名空间内唯一。

正确做法:SendMessage不带 taskId;从响应里读出服务端分配的 id 并保存下来,后续 GetTaskCancelTask、多轮追加消息都用它。只有在向已存在的任务追加消息(多轮交互)时,才在 Message 里带上 taskId

contextId 则相反——它用来把多次委托归到同一轮协作,客户端可以在首次请求时自行提供,也可以沿用服务端返回的值。

四、JSON-RPC 绑定下的消息长什么样

下面全部是 JSON-RPC 绑定的写法。同样的语义在 gRPC 和 HTTP+JSON/REST 绑定下形态不同(REST 是 POST /message:send 这样的路径),选哪种由名片里的 protocolBinding 决定。

提交任务用 SendMessage

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "SendMessage",
  "params": {
    "message": {
      "role": "ROLE_USER",
      "messageId": "5c1e...",
      "contextId": "3f2b8c1e-...",
      "parts": [{"text": "查询北京的天气", "mediaType": "text/plain"}]
    }
  }
}

四个容易写错的地方:

  • params 的字段名是 message,对应 SendMessageRequest.message(同级还有可选的 configurationmetadatatenant)。写成 request 或把 Message 的字段直接摊平到 params 下,合规 Server 都会拒收;
  • role 是枚举,取值 ROLE_USER / ROLE_AGENT,不是小写的 "user"
  • messageId 属于 Message,由客户端生成,作用是去重(重发时服务端可据此识别是同一条消息);
  • contextId 也在 Message 里面,不是和 message 平级的参数。

⚠️ 以为「发一次 SendMessage 就必然产生一个 Task」 实际情况: SendMessageResponse 是一个 oneof,载荷要么是 task、要么是 message。规范原文:agent MAY create a new Task…or MAY return a direct Message response for simple interactions。简单问答(比如「你支持哪些语言」)执行方可以不建任务,直接回一条 Message。

写错的后果: 客户端无脑读 response["task"]["id"] 存起来准备轮询,遇到直接返回 Message 的 Agent 就 KeyError;或者拿着不存在的 taskId 反复 GetTask

正确做法: 先判断载荷类型再分支——有 task 就走轮询/订阅路径,只有 message 就当作同步结果直接用。

查询任务进度和结果用 GetTask,参数名是 id,不是 taskId

{"jsonrpc": "2.0", "id": 2, "method": "GetTask", "params": {"id": "task-a1b2c3d4"}}

请求发到名片里 supportedInterfaces[] 中那个 JSON-RPC 接口的 url/rpc 只是某些实现碰巧用的路径,不是协议规定。 把它硬编码进客户端,换一个 Agent 就连不上。

任务完成后,结果放在 Artifact(产出物) 里,而不是直接塞在 response 里:

{
  "id": "task-a1b2c3d4",
  "contextId": "3f2b8c1e-...",
  "status": {
    "state": "TASK_STATE_COMPLETED",
    "timestamp": "2026-08-11T20:30:15Z"
  },
  "history": [
    {
      "role": "ROLE_USER",
      "messageId": "5c1e...",
      "parts": [{"text": "查询北京的天气", "mediaType": "text/plain"}]
    },
    {
      "role": "ROLE_AGENT",
      "messageId": "9a7d...",
      "parts": [{"text": "北京今天晴,25°C", "mediaType": "text/plain"}]
    }
  ],
  "artifacts": [{
    "artifactId": "art-7f3a...",
    "name": "weather_result",
    "parts": [{
      "data": {"city": "北京", "condition": "", "temperature": "25°C"},
      "mediaType": "application/json"
    }]
  }]
}

三个字段各有用途:status 看进度,history 是这个 Task 的消息轨迹,artifacts 是可以拿去用的结构化结果。

⚠️ 把 history 当审计日志用 实际情况: GetTask 带有 historyLength 参数,服务端可以只返回最近 N 条;规范也没有要求 history 必须囊括任务内的全部往来(工具调用、内部推理等本就不在里面)。它是给调用方恢复对话上下文用的,不是审计记录。

正确做法: 需要审计就在自己这侧落库——每次 SendMessage / GetTask 的请求响应、taskId、contextId、时间戳、调用方身份,都记在你可控的存储里。协议返回什么,不由你决定。artifactId 由服务端生成,用于在流式更新中把多次增量拼到同一个产出物上。

⚠️ Part 的结构在 v1.0 变了,且没有类型字段 错误操作: 沿用 {"type": "text", "text": "..."} 或 v0.3 的 {"kind": "text", "text": "..."},用 part["type"] 做分支判断。

实际结果: v1.0 的 Part 没有 type,也没有 kind。取 part["type"] 直接 KeyError;写出去的报文里多一个规范里不存在的字段,严格校验的 Server 会拒收。

原因: v1.0 改成了按成员字段区分——哪个字段存在就是哪种 Part:text(文本)、data(结构化数据)、url(引用外部文件)、raw(内联的 base64 字节)。同时 mimeType 统一更名为 mediaType,嵌套的 file 对象被拿掉了。

正确做法: 判类型用「哪个 key 存在」:

if "text" in part:      ...   # 文本
elif "data" in part:    ...   # 结构化数据
elif "url" in part:     ...   # 外部文件引用
elif "raw" in part:     ...   # 内联字节(base64)

同理,流式事件也去掉了 kind,改为包在 statusUpdate / artifactUpdate 这样的命名对象里,TaskStatusUpdateEventfinal 布尔字段被移除——流关闭本身就代表结束。

五、多 Agent 协作的完整流程

把前面几节串起来,看协调员完整跑一遍(逻辑取自课程 a2a_demo.py 的旅行规划示例):

def travel_coordinator_plan(city: str, days: int = 2, budget: int | None = None):
    """旅行协调员:通过 A2A 调用天气和酒店两个独立 Agent。"""
 
    # 步骤 1:向天气 Agent 开一张工单
    weather_client = A2AClient("http://localhost:8001", weather_card, "weather")
    weather_task = weather_client.submit_task(f"查询{city}的天气")      # → SUBMITTED → WORKING
    weather_task = weather_client.get_task_result(weather_task, {"city": city})  # → COMPLETED
    weather_data = weather_task.artifacts[0]["parts"][0]["data"]
 
    # 步骤 2:向酒店 Agent 开另一张工单
    hotel_client = A2AClient("http://localhost:8002", hotel_card, "hotel")
    hotel_params = {"city": city}
    if budget:
        hotel_params["budget"] = budget
    hotel_task = hotel_client.submit_task(f"查找{city}的酒店")
    hotel_task = hotel_client.get_task_result(hotel_task, hotel_params)
    hotels = hotel_task.artifacts[0]["parts"][0]["data"]
 
    # 步骤 3:协调员自己综合,生成计划
    _synthesize_plan(city, days, weather_data, hotels, budget)

关键在于协调员做了什么、没做什么:

  • 做了:发现 Agent、拆解任务、开工单、取产出物、综合结果;
  • 没做:任何天气或酒店的业务逻辑。

天气 Agent 换实现、换语言、换团队维护,协调员都不用动——前提是 Agent Card 里的 skills 契约、输入输出模式、以及产出物的数据结构都不变。契约不变才是免改的条件,「用了 A2A」本身不是。 这是它和「把三段逻辑写进一个进程」的本质区别。

⚠️ 教学 demo 里的 A2AClient 不是真实实现 错误操作:a2a_demo.py 里的 A2AClient 当成可用的 A2A SDK,照搬进项目。

实际结果: 它的 submit_task 只是打印一条 JSON-RPC 报文然后 time.sleep(0.3)get_task_result 直接调本地字典函数——根本没有 HTTP 请求发出去。放进真实环境后,两个 Agent 之间不会发生任何通信,但日志看起来一切正常。

原因: 那是为了课堂能直接运行而写的内存模拟版,文件开头也写明了「本文件是教学模拟版,不是完整 A2A SDK」。

正确做法: 生产环境优先用官方 SDK(a2a-sdk),别手写协议层。真要自己实现,请求地址必须从 Agent Card 的 supportedInterfaces[] 里选出来,而不是拼一个写死的 /rpc;发请求用什么 HTTP 库(httpxaiohttprequests)是实现细节,不是协议要求。此外还要补上模拟版完全没有的部分:认证、超时、重试、任务轮询或流式订阅、INPUT_REQUIRED / AUTH_REQUIRED 挂起态的处理。demo 的价值在于看清报文长什么样、状态怎么流转,不在于代码可复用。

六、A2A vs LangGraph 多 Agent:什么时候值得跨进程?

多 Agent 不一定要上 A2A。同一件事在 LangGraph 里也能做,代价完全不同:

维度 LangGraph 多 Agent A2A 多 Agent
进程 同一个进程内 不同进程 / 不同机器
通信 内存共享、函数调用 HTTP + JSON-RPC
延迟 微秒级 毫秒到秒级,含网络和序列化
部署 单体应用,一起发布 微服务,各自独立发布
发现 代码里硬编码 Agent Card 动态发现
调试 单进程断点,直观 跨服务,要看双方日志
适合 一个团队、一套技术栈 跨团队、跨语言、需独立扩容

选择规则 同进程能解决就别上 HTTP。 具体判断:

  1. 所有 Agent 都是你自己写的 Python,且一起发布 → LangGraph,别加网络这层不确定性。
  2. 某个 Agent 由别的团队维护、或用别的语言写 → A2A,进程边界本来就存在。
  3. 某个 Agent 需要独立扩容(比如它调用重型模型、要单独配 GPU)→ A2A。
  4. 需要对外开放能力给第三方调用 → A2A,Agent Card 就是对外契约。

只有 2、3、4 成立时,A2A 引入的任务状态管理、超时重试、认证授权才换来了对应的收益。

七、认知陷阱

⚠️ 把 A2A 当成 MCP 的升级版 错误操作: 看到 A2A 也能跑 JSON-RPC、也有能力发现机制,认为它「更新更强」,于是用它来连数据库、连文件系统这类工具。

实际结果: 为一个「查一下就返回」的操作背上了整套任务生命周期——要跟踪服务端分配的 taskId、要维护状态、要轮询结果、要处理两个挂起态和四个终止态。代码量翻几倍,行为却和一次同步调用没区别。

原因: 两者不在一条演进线上。MCP 面向的是把某个能力接进你自己的 Agent,A2A 面向的是把一整件事委托给你不掌控内部逻辑的远端主体

正确做法: 先问「我是在给自己的 Agent 装一个能力,还是在把活派给别人」。派给别人、且对方内部有自己的模型和判断 → A2A;接一个能力进来 → MCP 或直接函数调用。

注意别用「会不会反问」来判断——MCP 现在也有 Elicitation 和 Tasks,这条老判据已经失效(见第三节)。

⚠️ Agent Card 里堆一大堆 skills 错误操作: 一个 Agent 的名片里列十几个 skill,从查天气到订机票到写邮件全包。

实际结果: 调用方(尤其是让模型自己选的时候)无法判断该不该找这个 Agent。多个 Agent 的 skill 描述互相重叠,选择行为变得不可预测,且不会报错——只是有时候找错人。

原因: skills(尤其是 descriptiontags)是调用方选人的主要依据。能力边界越模糊,选择就越靠猜。这和 MCP 里工具描述含糊导致选错工具,是同一个失效模式。

正确做法: 一个 Agent 对应一个清晰的职责域,skill 描述里写明能做什么和不能做什么。能力太杂就拆成两个 Agent——它们本来就是独立部署的,拆分成本很低。

⚠️ 用 A2A 的松耦合掩盖设计问题 错误操作: 因为「微服务架构更先进」,把本来就在一个团队、一套代码库里的三个功能拆成三个 A2A Agent。

实际结果: 一次用户请求变成三次 HTTP 往返;任何一个服务挂了整条链路失败;调试要同时看三份日志;改一个字段要协调三次发布。开发速度显著下降,而系统能力没有任何增加。

原因: A2A 解决的是组织边界问题(跨团队、跨语言、独立扩容),不是代码组织问题。同一个团队内的模块划分,用函数和类就够了。

正确做法: 先在 LangGraph 里用进程内多 Agent 跑通,等真正出现「另一个团队要维护它」或「它需要独立扩容」的压力时,再把那个节点拆出去。接口契约想清楚的话,迁移是局部改动。


速查表

概念 结论
A2A 全称 Agent-to-Agent Protocol,Google 提出,现由 Linux Foundation 托管
解决的问题 把一整件事委托给远端 Agent,不是给自己接工具
两个角色 A2A Client 与 A2A Server;Client 不必是 Agent
传输绑定 JSON-RPC 2.0 / gRPC / HTTP+JSON REST,三选一,可自定义
端点从哪来 supportedInterfaces[].url不是写死的 /rpc
名片路径 /.well-known/agent-card.json(v1.0 注册的标准路径)
名片必填 8 个namedescriptionsupportedInterfacesversioncapabilitiesdefaultInputModesdefaultOutputModesskills
名片关键字段 skills[](内部 id/name/description/tags 全必填)——调用方选人的主要依据
核心方法(JSON-RPC) SendMessageSendStreamingMessageGetTaskListTasksCancelTask
SendMessage 参数名 params.message,不是 params.request
SendMessage 返回什么 oneof:task message——简单交互可以不建任务
GetTask 参数名 id,不是 taskId
正常状态流 TASK_STATE_SUBMITTED → WORKING → COMPLETED
状态总数 8 个;终止态 4 个(COMPLETED/FAILED/CANCELED/REJECTED
两个挂起态 INPUT_REQUIRED(要信息)、AUTH_REQUIRED(要授权)
taskId 谁生成 服务端;客户端不能为新任务指定 ID
taskId vs contextId 一次委托 vs 一整轮协作
Part 怎么判类型 看哪个成员字段存在:text / data / url / raw;无 kind、无 type
媒体类型字段 mediaType(v0.3 的 mimeType 已更名)
结果放哪 artifacts[].parts[]artifactId 由服务端生成
与 MCP 的分界 委托整件事 → A2A;接一个能力 → MCP。别用「会不会反问」判断
与 LangGraph 的分界 存在组织边界(跨团队/语言/扩容)才值得跨进程

复习重点

复习重点

  1. 一句话说清 A2A:把一件事委托给独立部署的远端 Agent——先读名片确认对方能干什么、走哪个接口,再开一张有生命周期的工单。
  2. 最容易搞错的定位:A2A 不是 MCP 的升级版,判据是「委托整件事 vs 接一个能力」。不要用「会不会反问」来分——MCP 有 Elicitation 和 Tasks,这条老判据已经失效。
  3. 最容易写错的六个 v1.0 细节:① 名片用 supportedInterfaces[],没有顶层 url,且顶层必填字段有 8 个(含 description/version/skills);② skills[]id/name/description/tags 全必填;③ taskId 由服务端生成;④ Part 没有 type/kind,按成员字段判类型,mimeType 改叫 mediaType;⑤ SendMessage 的参数名是 params.message,不是 params.request;⑥ 响应是 task message 的 oneof,不一定产生 Task。 另有两个别想当然的字段:contextId 只做关联标识,不跨 Server 共享上下文history 可被 historyLength 截断,不能当审计日志
  4. 最关键的机制:Agent Card 让调用关系不必硬编码。但「新增 Agent 零改动」的前提是协调员已实现动态选人逻辑且契约不变,不是用了 A2A 就自动成立。
  5. 最值得记住的架构结论:协调员不含任何下游业务逻辑,只负责发现、派单、合成。契约不变时,下游换语言换团队,协调员不动。
  6. 最容易踩的实现坑:教学 demo 的 A2AClient 是内存模拟,不发 HTTP,照搬进项目后两个 Agent 之间不会有任何通信,且日志看起来正常。生产用官方 a2a-sdk
  7. 选型规则:同进程能解决就别上 HTTP。只有跨团队、跨语言、需独立扩容或要对外开放时,A2A 的成本才换来收益。

一句话总结 A2A 的价值不在于「多 Agent 能协作」——进程内早就能做到了——而在于让协作跨越组织边界:对方是别人写的、别的语言、别的机器上跑的,你只认那张名片。没有这层边界,它带来的只有网络延迟和状态管理成本。

工具层的协议见 MCP 协议原理;Agent 自身的循环和组件分层见 Agent概念Agent架构设计-lang系框架