8.2 与 MCP 分工:工具协议 vs 协作协议
上一节我们用 Agent Card 发现对方、用 Task 委派任务,跑通了一次 A2A 往返。但凡是看过 MCP 的人,看到这里大概都会皱眉:MCP 也能让 Agent 用上「别的进程提供的能力」,A2A 又有什么不同? 这是一个必须回答清楚的问题,因为把两者混为一谈,会直接导致架构选错——你会用「工具」的形状去硬套一个本该是「对等 Agent」的东西,或者反过来给一个简单的本地函数套上 Agent Card 的壳,白搭一整套任务状态管理。
本节把 A2A 与 MCP 的边界一次讲透:先用一张对照表区分「协作协议」与「工具协议」,再真跑两端的对象与调用——MCP 侧用
fastmcp起一个内存服务器做tools/list与tools/call,A2A 侧沿用 8.1 的 Agent Card 与 Task——最后给出一张「什么时候用哪个」的决策表、两者的组合形态,以及一组常见误用。
8.2.1 一句话区分:谁连谁
站内 MCP 专题 已经把 MCP 讲得很细,这里只取它对本文有用的定位:MCP 解决的是「一个 Agent 怎么用上工具与数据源」。它把文件、数据库、API、提示词模板统一成服务器暴露的能力,主机(Agent)去发现和调用。它天然是一对多、有主从的:一个大脑,多只手。
A2A 解决的是另一件事:两个各自独立、各自有模型和工具的 Agent,怎么对等地协作。这里没有主从——8.1 里的服务端自己也是一个完整 Agent,它不是「手」,而是「另一个大脑」。
一句话记住:MCP 是 Agent ↔ 工具,A2A 是 Agent ↔ Agent。 前者扩展一个 Agent 的能力边界,后者扩展一个 Agent 的协作边界。
8.2.2 对照表
| 维度 | MCP(工具协议) | A2A(协作协议) |
|---|---|---|
| 解决什么 | Agent 连接工具与数据源 | Agent 之间互相发现与委派 |
| 谁连谁 | 主机 → 服务器,一对多、主从 | 对等,Agent ↔ Agent,可双向 |
| 典型对象 | Tool / Resource / Prompt | Agent Card / Task / Message / Artifact |
| 有无状态 | 基本无状态,一次调用出结果 | 有状态,Task 可轮询、暂停、恢复 |
| 发现机制 | 服务器暴露 tools/list | /.well-known/agent-card.json |
| 执行者是谁 | 确定性代码(函数、查询、API) | 另一个 Agent(可能自己调模型、调工具) |
| 长任务 / 人在环 | 不擅长,一次调用即结束 | input-required 是一等公民 |
| 典型场景 | 读库、调 API、访问文件、用模板 | 委派子任务、跨团队 / 跨语言协作 |
| 与对方的关系 | A2A 的 Agent 内部可以用 MCP 连工具 | A2A 是位于 MCP 之上的编排层 |
最后一行是理解两者关系的钥匙:它们不是竞品,是两层。一个编排 Agent 完全可以在 A2A 里接下一个 Task,然后在自己内部用 MCP 去调一堆工具,最后把结果作为 Artifact 交回去。
换个角度看这张表:MCP 扩展的是**「一个 Agent 能做什么」——工具越多,它的能力边界越大,但它始终是那一个 Agent;A2A 扩展的是「一个系统能拼出什么」——Agent 越多,你能组合出的协作拓扑越丰富,但每个 Agent 仍是独立主体。前者是纵向加深**,后者是横向铺开。想清楚你要的是哪一种,选型就不会错。
8.2.3 真跑对象对照:Tool 描述 vs Agent Card
口说无凭,把两个协议里「描述一个能力」的对象都真跑出来看。先看 MCP 的 Tool 对象(本机 mcp 2.3.0,mcp.types.Tool):
from mcp.types import Tool
tool = Tool(
name="text_stats",
description="对输入文本做确定性统计",
inputSchema={"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"]},
)
print(tool.model_dump_json(indent=2))
本机实测输出:
{
"name": "text_stats",
"title": null,
"description": "对输入文本做确定性统计",
"input_schema": {
"type": "object",
"properties": {
"text": {
"type": "string"
}
},
"required": [
"text"
]
},
"execution": null,
"output_schema": null,
"icons": null,
"annotations": null,
"meta": null
}
再看 8.1 那个 A2A 服务端返回的 Agent Card(同一个「文本统计」能力,换个协议描述):
{"protocolVersion": "0.3.0", "name": "ResearchCopilot-Py", "description": "本地研究助理 Agent,提供一个确定性的文本统计技能。", "url": "http://127.0.0.1:8741/", "version": "0.1.0", "capabilities": {"streaming": false, "pushNotifications": false}, "defaultInputModes": ["text/plain"], "defaultOutputModes": ["application/json"], "skills": [{"id": "text-stats", "name": "文本统计", "description": "对输入文本做确定性统计(字符数/词数/指纹),返回 JSON Artifact。", "tags": ["text", "stats"], "examples": ["统计这段话的字符数与词数"]}]}
把两段 JSON 并排看,差别一目了然:
Tool描述的是「一个函数怎么调」——名字、说明、输入 schema。它是被动的:主机给它参数,它返回结果,仅此而已。Agent Card描述的是「一个主体是谁」——名称、地址、协议版本、能力清单。它是主动的:卡里带url,意味着「你可以来找我聊」;带capabilities,意味着「我支不支持流式、推通知」;带skills,意味着「我会做哪些事」。
一个回答「怎么调」,一个回答「找谁聊」。这个差别决定了后面所有工程决策。还有一个细节值得注意:Tool 的 input_schema 是强约束——参数不对就是调用错误;而 Agent Card 的 skills 只是能力声明,它告诉你「我会做这件事」,但不承诺参数形状,具体怎么协商由 Message 决定。约束的松紧不同,决定了 MCP 适合机器自动编排,A2A 适合留给 Agent 之间自主协商。
8.2.4 同题真跑:同一个能力,两种暴露方式
再看运行时行为的差别。把「文本统计」这一个能力,分别挂到 MCP 与 A2A 上,各跑一遍。MCP 侧用 fastmcp 4.1.0 起一个内存服务器(不占端口、不出网),客户端先 list_tools 再 call_tool:
import asyncio, json
from fastmcp import FastMCP, Client
mcp = FastMCP("research-tools")
@mcp.tool
def text_stats(text: str) -> dict:
"""对输入文本做确定性统计。"""
return {"chars": len(text), "words": len(text.split())}
async def main():
async with Client(mcp) as client:
for t in await client.list_tools():
print("tool:", t.name, "| desc:", t.description)
print("schema:", json.dumps(t.input_schema, ensure_ascii=False))
res = await client.call_tool("text_stats", {"text": "hello a2a mcp"})
print("call:", res.data)
asyncio.run(main())
本机实测输出:
tool: text_stats | desc: 对输入文本做确定性统计。
schema: {"type": "object", "additionalProperties": false, "properties": {"text": {"type": "string"}}, "required": ["text"]}
call: {'chars': 13, 'words': 3}
注意 MCP 的调用是同步语义:call_tool 一返回,结果就在手里了,没有「任务」这个概念,也没有中途状态。再对比 A2A 侧(8.1 的实测响应):
{"jsonrpc": "2.0", "id": 1, "result": {"id": "88f34b6b7cb449e2b3e0216676a53cfd", "contextId": "3d683d92fccc4e08b21297b2603eb2dd", "status": {"state": "completed", "timestamp": "2026-10-07T18:00:00+08:00"}, "history": [{"role": "user", "messageId": "m-1", "parts": [{"kind": "text", "text": "A2A 让 Agent 之间直接对话"}]}], "artifacts": [{"artifactId": "5eebd0d5", "name": "text-stats.json", "parts": [{"kind": "data", "data": {"chars": 18, "words": 4, "fingerprint": "30ec2678a79e", "summary": "共 18 字符、4 个词,指纹 30ec2678a79e"}}]}]}}
同样是「统计一段文本」,MCP 给你一个 dict,A2A 给你一个带 id、contextId、status、history、artifacts 的任务对象。前者是「一次函数调用」,后者是「一次可追踪的委派」。这个差别不是设计冗余——它正是为了让长任务、多轮澄清、失败重试有地方挂。
反过来说,如果你只是要「算个字符数」,那 A2A 的这一整套 Task 就是纯粹的负担:你得生成 id、维护 status、组织 artifacts,而调用方还得轮询。协议的成本必须由它的收益来支付——没有长任务与多轮澄清的需求,就别上 A2A。
8.2.5 组合形态真跑:A2A 里嵌 MCP
真实系统的形态通常是两层叠加。设想一个「研究主管」Agent:它从调用方那里接下一个 A2A Message,然后在自己内部通过 MCP 去调工具,把子步骤做完,最后把结果作为 Task 的 Artifact 交回。下面这段代码把这条链路缩到最小,真跑一次:
import asyncio, json
from fastmcp import FastMCP, Client
mcp = FastMCP("research-tools")
@mcp.tool
def text_stats(text: str) -> dict:
"""对输入文本做确定性统计。"""
return {"chars": len(text), "words": len(text.split())}
async def a2a_message_send(message: dict) -> dict:
"""A2A 服务端处理函数:内部用 MCP 调工具,再包成 Task 返回。"""
text = "".join(p.get("text", "") for p in message["parts"] if p["kind"] == "text")
async with Client(mcp) as client:
res = await client.call_tool("text_stats", {"text": text})
return {"id": "t-1", "contextId": "ctx-1", "status": {"state": "completed"},
"artifacts": [{"artifactId": "a1", "name": "text-stats.json",
"parts": [{"kind": "data", "data": res.data}]}]}
async def main():
msg = {"role": "user", "messageId": "m-1",
"parts": [{"kind": "text", "text": "hello a2a mcp"}]}
task = await a2a_message_send(msg)
print(json.dumps(task, ensure_ascii=False))
asyncio.run(main())
本机实测输出:
{"id": "t-1", "contextId": "ctx-1", "status": {"state": "completed"}, "artifacts": [{"artifactId": "a1", "name": "text-stats.json", "parts": [{"kind": "data", "data": {"chars": 13, "words": 3}}]}]}
一条调用链上同时出现了两个协议:外层是 A2A 的 Message → Task,内层是 MCP 的 call_tool。这其实就是卷一 13.1 supervisor-worker
那个模式的跨进程版:以前 supervisor 和 worker 在同一个进程里用消息总线通信,现在它们可能分属不同服务、不同语言,中间隔的就是 A2A;而 worker 自己去连工具,走的还是 MCP。站内 Agent 框架选型矩阵
把这类「编排层 + 工具层」的分工列为常见拓扑,本节把其中的协议边界补全了。
8.2.6 传输、鉴权与生命周期
两个协议在「怎么跑起来」上也有明显差别:
| 方面 | MCP | A2A |
|---|---|---|
| 传输 | stdio(本地进程)/ HTTP + SSE | HTTP(S),JSON-RPC 2.0 |
| 调用模型 | 请求-响应(可流式) | 请求-响应 + 任务轮询 / 流式订阅 |
| 鉴权 | 传输层(stdio 靠进程边界,HTTP 靠 OAuth 等) | 服务端自定,常配 OAuth 2.0 |
| 生命周期 | 随主机进程存亡 | Task 可跨进程、跨重启存活(配合持久化) |
| 失败语义 | 抛错 / 错误响应 | Task 进 failed 状态,可查可重试 |
| 发现时机 | 连接后 tools/list 动态拉取 | 连接前先拉 Agent Card,静态可缓存 |
| 多轮澄清 | 不支持(一次调用一答) | 原生支持(input-required) |
最值得注意的是生命周期这一行:MCP 的调用通常与主机进程同生共死,而 A2A 的 Task 天生是「可以活得比某一次连接更久」的——这与本卷 6.1 为什么需要持久化执行 讲的 Durable Execution 是同一类问题:任务状态要能落盘、能在进程重启后恢复。
8.2.7 什么时候用哪个
| 你的需求 | 用哪个 | 理由 |
|---|---|---|
| 让 Agent 读一个数据库 / 调一个 REST API | MCP | 确定性函数调用,一次出结果 |
| 给 Agent 加个本地文件读取工具 | MCP | 这是最典型的工具场景,别过度设计 |
| 把「写一份研究报告」整个交给另一个 Agent | A2A | 有状态、多轮、对方可能反问 |
| 需要一个会自己调模型和工具的黑盒 | A2A | 对方是主体,不是函数 |
| 跨团队 / 跨语言复用能力 | 看粒度 | 是函数就用 MCP,是完整 Agent 就用 A2A |
| 编排 Agent 委派给多个专家 Agent,专家各自连工具 | A2A + MCP | 两层各司其职 |
一条判据可以覆盖大多数纠结:问自己「对方是一个函数,还是一个主体?」 如果对方的产出完全由你给的参数决定、且一次调用就结束,那它是工具,用 MCP;如果对方需要自己的上下文、可能跑很久、可能反过来问你,那它是 Agent,用 A2A。用错方向的代价很具体——把 Agent 硬塞进 MCP,你会被迫在工具里模拟状态机与多轮问答;把函数硬套上 Agent Card,你会白白多出一整套任务轮询与状态管理。
8.2.8 一组常见误用
- 「A2A 是 MCP 的替代品」:不是。它们正交,可以同时出现在一条调用链上(见 8.2.5)。
- 「有了 A2A 就不需要 MCP 了」:恰恰相反,A2A 服务端内部连工具,最省事的做法就是 MCP。
- 「把每个微服务都包成 A2A Agent」:若它本质是无状态函数,包成 Agent 只会增加轮询、鉴权、可观测三份成本。
- 「MCP 工具里塞一个会反问的多轮对话」:这是把有状态的东西硬塞进无状态协议,迟早出错。
- 「两个协议各写一套鉴权、各写一套日志」:边界清楚不代表基础设施可以重复,能统一的尽量统一。
- 「因为都有 schema,所以可以自动互转」:MCP 的
input_schema与 A2A 的skills形状不同、语义也不同,机械互转只会丢掉状态语义。
判断有没有踩坑,有一个很实用的自检问题:「如果对方需要先问我一句再继续,我这条链路还成立吗?」 如果答案是否定的,那你很可能把本该是 A2A 的东西做成了 MCP。
8.2.9 这套协议替你做了什么、你又让渡了什么
两个协议共同替你做的:把「能力怎么被发现」「一次交互长什么样」从私下约定变成公开线格式,于是跨进程、跨团队、跨语言的复用才成立。
你让渡的:你要同时维护两套对象模型(工具 schema 与 Agent Card / Task),要判断每个能力该挂在哪一层,还要处理两套鉴权、两套可观测。更隐蔽的代价是认知成本——团队里必须有人能清楚地说出「这个能力是工具还是 Agent」,否则边界会随时间腐化:今天一个 Agent 被降格成工具塞进 MCP,明天一个纯函数被包装成 A2A 服务,架构就散了。
一个可行的做法是把这个判断写进代码评审清单:新增一个能力时,必须回答「它是函数还是主体」「它会不会反问」「它需不需要跨重启存活」这三个问题,答案决定它落在哪一层。把边界判断从「个人经验」变成「团队流程」,是防止协议腐化最便宜的手段。
下一节 8.3 跨语言 Agent 会把「协议的价值」兑现成一次真跑的跨语言协作:用 TypeScript 实现 A2A 端,与 8.1 的 Python 端互相调用。想回看 A2A 的对象模型,见 8.1 A2A 协议 。
阅读导航:上一节:8.1 A2A 协议:Agent Card/Task/消息,起两个本地 Agent 通信 · 下一节:8.3 跨语言 Agent:TS 实现 A2A 端与 Python 协作 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。