8.1 A2A 协议:Agent Card/Task/消息,起两个本地 Agent 通信
前面七章反复在回答一个问题:单个 Agent 怎么用框架写得更好。可真实系统很少只有一个 Agent。检索一个、写作一个、审核一个,再叠上别的团队、别的语言实现的服务——它们之间怎么互相发现、怎么把活儿交出去、怎么拿回结果?前面几章讲的都是「一个进程内怎么编排」,一旦跨进程、跨团队、跨语言,就需要一个线协议。A2A(Agent2Agent)就是为这件事设计的。
本节把「单 Agent 内部编排」推进到「Agent 之间协作」:先厘清 A2A 的 Agent Card / Task / Message / Artifact 四个核心对象,再用 Python 标准库起一个 A2A 服务端、用客户端发现并委派任务,把一次真实的任务往返(含 Agent Card JSON 与 Task 响应)贴出来。
8.1.1 为什么「把对方当工具」不够用
卷一 minagent 的工具注册表 里,工具的形状是「函数名 + JSON schema + 返回字符串」,同步、无状态、一次调用出结果。那么能不能把另一个 Agent 也降格成一个工具?在简单场景下能,但很快就会撞上三堵墙:
- 长任务:对方的活儿可能要跑几分钟,甚至停下来等人工审批——它不是一次函数调用,而是一个可轮询、可取消、可恢复的工作单元。
- 会话上下文:它有自己的提示词、模型、记忆和工具,需要连续的上下文,而不是每轮都被当无状态函数重新喂一遍。
- 双向消息:它可能反过来回问你(信息不够、需要授权),这意味着通信是双向的,不是单向的 request/response。
A2A 把这三点都建模进了协议:Agent 是对等的、有状态的、可能长时间的。理解了这一点,下面四个对象就顺理成章。
8.1.2 A2A 的四个核心对象
| 对象 | 作用 | 关键字段 |
|---|---|---|
| Agent Card | Agent 的「名片」,供别人发现能力 | name / description / url / version / capabilities / skills |
| Task | 一次委派的工作单元,有状态、可轮询 | id / contextId / status / history / artifacts |
| Message | 双方往来的一条消息 | role(user/agent)/ parts / messageId |
| Artifact | 任务的产出物 | artifactId / name / parts |
两点值得强调:其一,Agent Card 是「发现」的入口,规范约定它挂在 /.well-known/agent-card.json,任何客户端先拉这张卡,才知道对方会什么、地址在哪、支持什么输入输出模式。其二,Message 的 parts 是类型化的——kind 可以是 text、file 或 data,所以「发一段文字」和「发一个结构化对象」走的是同一个信封,这正是跨语言能对上的关键。
8.1.3 服务端:暴露 Agent Card 与任务端点
下面用 Python 标准库(http.server,零第三方依赖)起一个最小 A2A 服务端。它做两件事:GET /.well-known/agent-card.json 返回名片,POST / 处理 JSON-RPC 的 message/send。技能本身是确定性的文本统计,不含任何模型调用:
"""A2A 最小服务端(Python 标准库):暴露 Agent Card 与 message/send 任务端点。"""
import json
import uuid
import hashlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
PORT = 8741
AGENT_CARD = {
"protocolVersion": "0.3.0",
"name": "ResearchCopilot-Py",
"description": "本地研究助理 Agent,提供一个确定性的文本统计技能。",
"url": f"http://127.0.0.1:{PORT}/",
"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": ["统计这段话的字符数与词数"],
}
],
}
def run_skill(text: str) -> dict:
"""确定性的技能实现:不含任何随机与外部调用。"""
words = text.split()
fingerprint = hashlib.sha256(text.encode("utf-8")).hexdigest()[:12]
return {
"chars": len(text),
"words": len(words),
"fingerprint": fingerprint,
"summary": f"共 {len(text)} 字符、{len(words)} 个词,指纹 {fingerprint}",
}
def handle_message_send(params: dict) -> dict:
"""把 A2A 的 Message 转成内部技能调用,再包成 Task 返回。"""
message = params["message"]
parts = message.get("parts", [])
text = "".join(p.get("text", "") for p in parts if p.get("kind") == "text")
task_id = message.get("taskId") or uuid.uuid4().hex
result = run_skill(text)
return {
"id": task_id,
"contextId": message.get("contextId", uuid.uuid4().hex),
"status": {"state": "completed", "timestamp": "2026-10-07T18:00:00+08:00"},
"history": [message],
"artifacts": [
{
"artifactId": uuid.uuid4().hex[:8],
"name": "text-stats.json",
"parts": [{"kind": "data", "data": result}],
}
],
}
class Handler(BaseHTTPRequestHandler):
def _json(self, code: int, payload: dict):
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
if self.path == "/.well-known/agent-card.json":
self._json(200, AGENT_CARD)
else:
self._json(404, {"error": "not found"})
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
req = json.loads(self.rfile.read(length) or b"{}")
if req.get("method") != "message/send":
self._json(200, {"jsonrpc": "2.0", "id": req.get("id"),
"error": {"code": -32601, "message": "method not found"}})
return
task = handle_message_send(req["params"])
self._json(200, {"jsonrpc": "2.0", "id": req.get("id"), "result": task})
def log_message(self, *args):
pass
if __name__ == "__main__":
server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)
print(f"Agent Card: http://127.0.0.1:{PORT}/.well-known/agent-card.json")
server.serve_forever()
这里有一个刻意的设计:服务端不认识调用方是谁。它只认 Message 和 Task 这两个形状,至于调用方是 Python、TypeScript 还是别的什么,它不关心——这正是 8.3 跨语言能成立的前提。
8.1.4 真跑:发现 + 委派
客户端只做两步:先按约定路径拉 Agent Card 完成发现,再往卡里给的 url 发一条 JSON-RPC 的 message/send 完成委派:
"""A2A 客户端:先取 Agent Card 发现技能,再发起一次 message/send 任务。"""
import json
import urllib.request
BASE = "http://127.0.0.1:8741"
def get_json(url: str) -> dict:
with urllib.request.urlopen(url, timeout=5) as resp:
return json.loads(resp.read().decode("utf-8"))
def post_json(url: str, payload: dict) -> dict:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(url, data=data,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=5) as resp:
return json.loads(resp.read().decode("utf-8"))
card = get_json(f"{BASE}/.well-known/agent-card.json")
print("发现 Agent:", card["name"], "| 协议", card["protocolVersion"])
print("技能:", [s["id"] for s in card["skills"]])
rpc = {
"jsonrpc": "2.0",
"id": 1,
"method": "message/send",
"params": {
"message": {
"role": "user",
"messageId": "m-1",
"parts": [{"kind": "text", "text": "A2A 让 Agent 之间直接对话"}],
}
},
}
task = post_json(card["url"], rpc)["result"]
print("任务状态:", task["status"]["state"])
print("Artifact:", json.dumps(task["artifacts"][0]["parts"][0]["data"],
ensure_ascii=False))
本机实测(服务端与客户端都是本地进程,无任何外网与模型调用)。先看服务端返回的 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": ["统计这段话的字符数与词数"]}]}
再看客户端拿到的任务往返结果:
发现 Agent: ResearchCopilot-Py | 协议 0.3.0
技能: ['text-stats']
任务状态: completed
Artifact: {"chars": 18, "words": 4, "fingerprint": "30ec2678a79e", "summary": "共 18 字符、4 个词,指纹 30ec2678a79e"}
一次完整的 A2A 往返就完成了:发现 → 构造 Message → 提交 Task → 读 Artifact。注意响应里 history 保留了原始 Message、artifacts 承载结构化产出——调用方拿到的不是一个字符串,而是一个可追溯的任务对象。
还有一点值得对照:这里的「技能」是本地确定性实现,所以输出可复现、可断言。真实系统里 run_skill 的位置会换成一次模型调用或一段编排逻辑,但协议层完全不变——服务端对调用方暴露的始终是 Agent Card 与 Task,至于内部是假模型还是真模型,属于实现细节。这正是协议分层的价值:把「稳定的接口」和「易变的实现」隔离开。
8.1.5 Task 的状态机
Task 不是「调完就完」,它有自己的生命周期。规范里 status.state 的取值是一组固定枚举,理解它才能正确处理长任务与人在环:
state | 含义 | 调用方该做什么 |
|---|---|---|
submitted | 已受理,尚未开始 | 轮询 tasks/get |
working | 执行中 | 轮询或订阅 message/stream |
input-required | 缺信息,在等调用方补 | 回一条 Message 补齐后再轮询 |
auth-required | 需要额外授权 | 走鉴权后再继续 |
completed | 成功结束 | 读 artifacts |
failed | 失败 | 读 status.message 里的错误 |
canceled / rejected | 被取消 / 被拒绝 | 结束或改派 |
input-required 是 A2A 与「工具调用」最本质的分野:工具要么返回要么抛错,而 Agent 可以反过来向你提问,这让「多轮澄清」成了协议的一等公民,而不是调用方自己拼出来的 hack。本文的最小服务端只覆盖了 completed 这一条路径;一旦你要接真实的长任务,working 的轮询与 input-required 的回填就是必须补上的两段调用端逻辑。
8.1.6 这套协议替你做了什么、你又让渡了什么
A2A 替你做的:把「怎么发现对方」「怎么描述能力」「怎么表达一次有状态的委派」「怎么表达长任务与人在环」都定成了线格式。你不再需要为「A 调 B」私下约定一套 JSON,也不用把 B 硬塞进工具的形状里。
你让渡的:你得接受对方的对象模型(Task / Message / Artifact 的形状),得为长任务实现轮询或流式的调用端逻辑,得自己处理鉴权、重试与幂等——协议只规定了「怎么说话」,没规定「说错了怎么办」。而且,A2A 解决的是 Agent 之间的协作;至于 Agent 怎么连工具,那是另一个协议的事。
下一节 8.2 与 MCP 分工 就把这两个协议摆到一张表上,讲清「工具协议」与「协作协议」的边界。想先回顾上一章的收尾,见 7.3 Computer Use 的安全与成本 。
一句话先记在这里:A2A 不是 MCP 的替代品,而是它上面的一层——下一节会用一个真跑的「A2A 里嵌 MCP」链路把这句话坐实。
阅读导航:上一节:7.3 安全与成本:不可逆动作沙箱与人工确认 · 下一节:8.2 与 MCP 分工:工具协议 vs 协作协议 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。