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 规范(specification、v1.0 变更说明)。v0.3 → v1.0 是破坏性升级,遇到与本笔记不一致的旧教程,先确认它写的是哪个版本。
目录
- 一、MCP 管插头,A2A 管打电话
- 二、Agent Card:先看名片再谈合作
- 三、Task 状态机:为什么不是「调完就返回」?
- 四、JSON-RPC 绑定下的消息长什么样
- 五、多 Agent 协作的完整流程
- 六、A2A vs LangGraph 多 Agent:什么时候值得跨进程?
- 七、认知陷阱
- 速查表
- 复习重点
核心概念 A2A = Agent-to-Agent Protocol(Agent 间通信协议),Google 提出、现由 Linux Foundation 托管的开放标准,让独立部署的 AI Agent 之间互相委托任务。
传输层不止一种:规范定义了三种 protocol binding——JSON-RPC 2.0、gRPC、HTTP+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 Client 和 A2A 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 的那些):name、description、supportedInterfaces、version、capabilities、defaultInputModes、defaultOutputModes、skills。
⚠️ 以为「只要有 name + supportedInterfaces + capabilities 就是一张合法名片」 实际结果: 少了
description、version、defaultInputModes、defaultOutputModes、skills中的任何一个,严格校验的客户端都会判定名片非法。skills是必填且必须非空——一张不声明任何技能的名片,在协议语义上等于「我什么都不做」,调用方也没有任何依据选中你。顺带记住嵌套层的必填项:
skills[]内部id、name、description、tags四个全是必填(tags不是可选的装饰字段);supportedInterfaces[]里url必填。
⚠️ v0.3 的 Agent Card 结构已经作废 错误操作: 照着网上大多数教程写顶层
url+preferredTransport+additionalInterfaces+ 顶层protocolVersion,skill 里只写name不写id。实际结果: 严格按 v1.0 校验的客户端会直接判定名片非法或找不到可用端点,而宽松的客户端可能默默降级到某个默认行为——不报错,但连不上。
原因: v1.0 把「端点 + 传输方式 + 协议版本」三件事收敛进了
supportedInterfaces[]数组,一个 Agent 可以同时暴露 JSON-RPC、gRPC、REST 三个接口,各自带自己的版本号;顶层url、preferredTransport、additionalInterfaces、顶层protocolVersion全部移除。同时skills[].id从可选变为必填,supportsAuthenticatedExtendedCard挪进了capabilities.extendedAgentCard。正确做法: 主端点写在
supportedInterfaces[0].url;客户端选接口时要遍历数组、挑一个自己支持的protocolBinding和protocolVersion,不要假设只有一个接口、也不要假设端点路径固定。
skills[] 是调用方选人的主要依据,作用和 MCP 里 Tool 的 description 类似(见 MCP 协议原理)。id 是机器可读的稳定标识,name 是给人看的显示名,description 是给模型判断的自然语言说明,tags 用于按主题检索、Agent 数量多起来时做初筛。
图分三块:①读名片;②开一张工单并跟踪它的状态;③协调员对多个 Agent 各开一张单,最后自己合成。协调员本身不含天气和酒店逻辑,这是 A2A 最核心的价值。
Agent Card 的实际意义 它让调用关系不必硬编码。传统写法里,协调员代码中写死了
weather_agent.query(city);有了 Agent Card,协调员可以在运行时读若干张名片,按skills和tags决定找谁。但「新增一个 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_REQUIRED 和 AUTH_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并保存下来,后续GetTask、CancelTask、多轮追加消息都用它。只有在向已存在的任务追加消息(多轮交互)时,才在 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(同级还有可选的configuration、metadata、tenant)。写成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 newTask…or MAY return a directMessageresponse 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这样的命名对象里,TaskStatusUpdateEvent的final布尔字段被移除——流关闭本身就代表结束。
五、多 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 库(httpx、aiohttp、requests)是实现细节,不是协议要求。此外还要补上模拟版完全没有的部分:认证、超时、重试、任务轮询或流式订阅、INPUT_REQUIRED/AUTH_REQUIRED挂起态的处理。demo 的价值在于看清报文长什么样、状态怎么流转,不在于代码可复用。
六、A2A vs LangGraph 多 Agent:什么时候值得跨进程?
多 Agent 不一定要上 A2A。同一件事在 LangGraph 里也能做,代价完全不同:
| 维度 | LangGraph 多 Agent | A2A 多 Agent |
|---|---|---|
| 进程 | 同一个进程内 | 不同进程 / 不同机器 |
| 通信 | 内存共享、函数调用 | HTTP + JSON-RPC |
| 延迟 | 微秒级 | 毫秒到秒级,含网络和序列化 |
| 部署 | 单体应用,一起发布 | 微服务,各自独立发布 |
| 发现 | 代码里硬编码 | Agent Card 动态发现 |
| 调试 | 单进程断点,直观 | 跨服务,要看双方日志 |
| 适合 | 一个团队、一套技术栈 | 跨团队、跨语言、需独立扩容 |
选择规则 同进程能解决就别上 HTTP。 具体判断:
- 所有 Agent 都是你自己写的 Python,且一起发布 → LangGraph,别加网络这层不确定性。
- 某个 Agent 由别的团队维护、或用别的语言写 → A2A,进程边界本来就存在。
- 某个 Agent 需要独立扩容(比如它调用重型模型、要单独配 GPU)→ A2A。
- 需要对外开放能力给第三方调用 → 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(尤其是description和tags)是调用方选人的主要依据。能力边界越模糊,选择就越靠猜。这和 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 个:name、description、supportedInterfaces、version、capabilities、defaultInputModes、defaultOutputModes、skills |
| 名片关键字段 | skills[](内部 id/name/description/tags 全必填)——调用方选人的主要依据 |
| 核心方法(JSON-RPC) | SendMessage、SendStreamingMessage、GetTask、ListTasks、CancelTask |
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 的分界 | 存在组织边界(跨团队/语言/扩容)才值得跨进程 |
复习重点
复习重点
- 一句话说清 A2A:把一件事委托给独立部署的远端 Agent——先读名片确认对方能干什么、走哪个接口,再开一张有生命周期的工单。
- 最容易搞错的定位:A2A 不是 MCP 的升级版,判据是「委托整件事 vs 接一个能力」。不要用「会不会反问」来分——MCP 有 Elicitation 和 Tasks,这条老判据已经失效。
- 最容易写错的六个 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截断,不能当审计日志。- 最关键的机制:Agent Card 让调用关系不必硬编码。但「新增 Agent 零改动」的前提是协调员已实现动态选人逻辑且契约不变,不是用了 A2A 就自动成立。
- 最值得记住的架构结论:协调员不含任何下游业务逻辑,只负责发现、派单、合成。契约不变时,下游换语言换团队,协调员不动。
- 最容易踩的实现坑:教学 demo 的
A2AClient是内存模拟,不发 HTTP,照搬进项目后两个 Agent 之间不会有任何通信,且日志看起来正常。生产用官方a2a-sdk。- 选型规则:同进程能解决就别上 HTTP。只有跨团队、跨语言、需独立扩容或要对外开放时,A2A 的成本才换来收益。
一句话总结 A2A 的价值不在于「多 Agent 能协作」——进程内早就能做到了——而在于让协作跨越组织边界:对方是别人写的、别的语言、别的机器上跑的,你只认那张名片。没有这层边界,它带来的只有网络延迟和状态管理成本。
工具层的协议见 MCP 协议原理;Agent 自身的循环和组件分层见 Agent概念、Agent架构设计-lang系框架。