《AI Agent 高级编程》8.1 A2A 协议:Agent Card/Task/消息,起两个本地 Agent 通信

前七章讲的是单个 Agent 怎么用框架写得更好,可真实系统往往有多个 Agent。它们怎么互相发现、怎么把活儿交出去、怎么拿回结果?这正是 A2A(Agent2Agent)协议要解决的。本节先讲清 Agent Card、Task、Message、Artifact 四个核心对象与任务状态机,再用 Python 标准库起一个 A2A 服务端、由客户端发现并委派任务,把一次真实的任务往返贴出来。

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 CardAgent 的「名片」,供别人发现能力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 协作协议 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距