Agent 工程体系 · 第 40/98 篇。内容以 2026 年 9 月可验证的公开规范和稳定接口为基线;框架版本敏感能力会明确标注,不把实验行为写成通用保证。

Agent2Agent 协议:Agent Card、Task、Message、Artifact 和互操作

在多 Agent 系统中,一个 Agent 通常不会独立完成所有工作:规划 Agent 负责拆解目标,检索 Agent 负责查询资料,代码 Agent 负责修改仓库,审批 Agent 负责风险判断,最终还可能由一个编排 Agent 汇总结果。

如果这些 Agent 只能通过各自框架的内部对象通信,系统就会被实现细节绑定:调用方必须知道对方使用什么框架、有哪些内部工具、如何保存上下文,以及某个任务是否已经完成。A2A(Agent2Agent)协议试图解决的正是这一层问题:让独立实现、不同框架、不同供应商的 Agent 通过统一协议发现彼此、委托任务、传递消息并交换结果,同时不要求公开内部记忆、工具或推理过程。(a2a-protocol.org)

需要先区分三类协议:

  • MCP 主要规范 Agent 与工具、资源和外部 API 的交互;
  • A2A 主要规范 Agent 与另一个 Agent 的交互;
  • AG-UI 主要规范 Agent 后端与面向用户的前端应用之间的事件交互。

因此,一个典型系统可以是:

flowchart LR
    U[用户] <-->|AG-UI 事件流| UI[前端应用]
    UI --> O[编排 Agent]
    O <-->|A2A| R[远程专业 Agent]
    O <-->|MCP| T[工具 / 数据库 / API]
    R <-->|MCP| RT[远程工具]

A2A 不规定 Agent 内部如何调用工具,也不替代 Agent 开发框架;它定义的是跨 Agent 的通信边界。(a2a-protocol.org)


一、A2A 的互操作边界

1. 什么是“互操作”

互操作不是“两个 Agent 能传一段字符串”这么简单。一个可互操作的协议至少需要解决以下问题:

  1. 发现:调用方如何找到远程 Agent;
  2. 能力匹配:调用方如何判断远程 Agent 是否适合当前请求;
  3. 契约协商:双方支持哪些协议绑定、输入类型、输出类型和认证方式;
  4. 请求表达:任务、上下文、消息和附件如何编码;
  5. 生命周期:长任务如何查询、流式观察、暂停、继续或取消;
  6. 结果交付:文本回答与可下载文件如何区分;
  7. 并发关联:多个任务如何归属于同一会话,又如何单独追踪;
  8. 失败处理:鉴权失败、输入不足、拒绝执行和运行时错误如何表达。

A2A 的关键设计是把这些关注点拆成不同对象,而不是把所有信息都塞进一条聊天消息:

Agent Card   描述“我是谁、能做什么、如何调用”
Context      描述一组相关交互
Task         描述一个有生命周期的工作单元
Message      描述一次通信回合
Part         描述消息或产物中的一块内容
Artifact     描述任务产生的可消费结果
Event        描述任务状态或产物的增量变化

其中,TaskMessageArtifact 不是同义词。把它们混为一谈,是实现 A2A 客户端时最常见的错误之一。


二、Agent Card:能力发现与调用契约

1. Agent Card 的职责

Agent Card 是远程 Agent 的自描述 JSON 文档,可以理解为面向机器的“服务名片”。它至少要让客户端回答三个问题:

  • 这个 Agent 是谁,提供什么服务?
  • 客户端应该访问哪个端点、使用什么协议绑定?
  • 调用它需要什么认证,以及它支持哪些技能和数据类型?

A2A 当前定义的 Agent Card 包含身份、服务接口、Agent 能力、技能、输入输出模式和安全要求等信息。客户端使用它来进行发现、能力匹配、请求构造和安全通信。(a2a-protocol.org)

一个经过裁剪的 Agent Card 示例:

{
  "name": "财务报表分析 Agent",
  "description": "读取财务报表并生成结构化分析结果",
  "supportedInterfaces": [
    {
      "url": "https://finance.example.com/a2a",
      "protocolBinding": "JSONRPC",
      "protocolVersion": "1.0"
    }
  ],
  "version": "2026.09.1",
  "capabilities": {
    "streaming": true,
    "pushNotifications": false
  },
  "defaultInputModes": [
    "text/plain",
    "application/pdf",
    "application/json"
  ],
  "defaultOutputModes": [
    "text/markdown",
    "application/json"
  ],
  "skills": [
    {
      "id": "financial-report-analysis",
      "name": "财务报表分析",
      "description": "分析收入、成本、利润和现金流,并给出结构化指标",
      "tags": ["财务", "报表", "分析"],
      "examples": [
        "分析这份季度利润表",
        "比较两个季度的毛利率变化"
      ],
      "inputModes": [
        "text/plain",
        "application/pdf"
      ],
      "outputModes": [
        "application/json",
        "text/markdown"
      ]
    }
  ],
  "securitySchemes": {
    "oauth2": {
      "type": "oauth2",
      "flows": {
        "authorizationCode": {
          "authorizationUrl": "https://auth.example.com/authorize",
          "tokenUrl": "https://auth.example.com/token",
          "scopes": {
            "finance.read": "读取财务分析所需数据"
          }
        }
      }
    }
  },
  "securityRequirements": [
    {
      "oauth2": [
        "finance.read"
      ]
    }
  ]
}

上例中的字段含义如下:

  • supportedInterfaces:声明可调用的接口。列表有序时,通常第一项是首选接口;
  • protocolBinding:声明接口使用的绑定方式,例如 JSONRPCGRPCHTTP+JSON
  • protocolVersion:声明该接口实现的 A2A 协议版本;
  • capabilities:声明是否支持流式输出、推送通知等能力;
  • defaultInputModesdefaultOutputModes:Agent 默认接受和产生的 MIME 类型;
  • skills:对能力的更细粒度描述;
  • securitySchemessecurityRequirements:描述认证方案及其使用要求。

AgentSkill 的作用不是替代完整业务契约,而是提供能力发现和路由所需的描述。它适合回答“这个 Agent 大致擅长什么”,但不能自动保证某个输入一定成功。

2. 能力描述不是能力保证

假设某 Agent Card 写着:

{
  "id": "sql-analysis",
  "name": "SQL 分析",
  "description": "分析 SQL 查询并给出优化建议",
  "examples": [
    "优化这条 PostgreSQL 查询"
  ]
}

这只能说明该 Agent 宣称支持 SQL 分析,不能推出以下结论:

  • 它一定支持 MySQL;
  • 它一定可以访问用户提供的数据库;
  • 它一定返回可执行的 SQL;
  • 它一定支持大文件;
  • 它一定能在规定时限内完成;
  • 它一定具备某种准确率。

因此,路由器应把 Agent Card 当作候选过滤和初始匹配依据,而不是结果正确性的证明。

可以将能力匹配形式化为:

C(a,r)=Iskill(a,r)Iinput(a,r)Ioutput(a,r)Isecurity(a,r)Itenant(a,r)C(a, r) = I_{\text{skill}}(a,r) \land I_{\text{input}}(a,r) \land I_{\text{output}}(a,r) \land I_{\text{security}}(a,r) \land I_{\text{tenant}}(a,r)

其中:

  • aa 是候选 Agent;
  • rr 是当前请求;
  • IskillI_{\text{skill}} 表示是否存在匹配技能;
  • IinputI_{\text{input}} 表示请求输入类型是否被支持;
  • IoutputI_{\text{output}} 表示客户端接受的输出类型是否有交集;
  • IsecurityI_{\text{security}} 表示认证条件是否满足;
  • ItenantI_{\text{tenant}} 表示租户或路由约束是否满足。

只有当 C(a,r)=1C(a,r)=1 时,Agent 才进入下一步的质量、成本、延迟和负载排序。

一个只按关键词选 Agent 的错误实现是:

def choose_agent(query, agents):
    for agent in agents:
        if "报表" in agent["description"]:
            return agent

它没有检查输入格式、输出模式、认证和租户,可能把一个只支持图片输入的 Agent 选给 PDF 请求,也可能把无权访问财务数据的 Agent 选出来。

更可靠的候选筛选至少应包含结构化条件:

def supports(agent, required_skill, input_mode, output_mode):
    for skill in agent.get("skills", []):
        if skill["id"] != required_skill:
            continue

        input_modes = skill.get(
            "inputModes",
            agent.get("defaultInputModes", [])
        )
        output_modes = skill.get(
            "outputModes",
            agent.get("defaultOutputModes", [])
        )

        return (
            input_mode in input_modes
            and output_mode in output_modes
        )

    return False

这段代码只完成硬约束过滤,没有声称解决语义匹配、质量评估或动态回退。生产系统还需要将成功率、超时率、任务类型和租户隔离纳入选择过程。

3. Agent Card 的发现方式

公开服务通常可以通过标准的 Well-Known URI 发布 Agent Card:

GET https://finance.example.com/.well-known/agent-card.json

A2A 文档将其定义为一种适合公开 Agent 或特定域内广泛发现的方式。企业内部也可以使用集中式注册表,或者通过私有配置直接指定 Agent Card 地址。(a2a-protocol.org)

三种方式的边界不同:

方式 适用场景 主要风险
Well-Known URI 公网 Agent、跨组织发现 卡片伪造、缓存过期、暴露过多信息
注册表 企业内部、多租户、统一治理 注册表成为中心依赖
直接配置 私有网络、固定拓扑 缺少动态发现和自动更新

Agent Card 本身不是信任根。生产环境应验证 HTTPS、服务身份、认证配置以及卡片签名。A2A v1.0 还引入了基于 JWS 的 Agent Card 签名验证能力,并使用 JSON Canonicalization Scheme 等相关规范支持稳定签名。(a2a-protocol.org)

缓存 Agent Card 时不能只按 URL 永久保存。至少需要考虑:

缓存键 = URL + 租户 + 环境 + 协议版本

否则同一个网关地址背后的不同租户可能被错误地复用同一份能力描述。卡片更新后,路由器还应重新验证技能、协议版本和安全要求,而不是只更新显示名称。


三、Message:一次通信回合,而不是任务本身

1. Message 的结构

Message 表示客户端与远程 Agent 之间的一次通信回合。它包含:

  • messageId:由消息创建者生成的唯一标识;
  • roleuseragent
  • parts:一个或多个内容块;
  • contextId:可选的上下文标识;
  • taskId:可选的任务标识;
  • referenceTaskIds:可选的相关任务引用;
  • metadataextensions:附加信息。

服务端消息必须带有 contextId;客户端消息中的 contextIdtaskId 可以省略,但如果同时提供,两者必须指向同一个任务上下文。(a2a-protocol.org)

一个客户端消息可以是:

{
  "messageId": "msg-20260901-0001",
  "role": "user",
  "contextId": "ctx-finance-001",
  "parts": [
    {
      "text": "分析这份季度财务报表,并输出收入、毛利率和现金流变化。",
      "mediaType": "text/plain"
    },
    {
      "url": "https://files.example.com/reports/q3.pdf",
      "filename": "q3.pdf",
      "mediaType": "application/pdf"
    },
    {
      "data": {
        "period": "2026-Q3",
        "requiredMetrics": [
          "revenue",
          "gross_margin",
          "operating_cash_flow"
        ]
      },
      "mediaType": "application/json"
    }
  ]
}

这里的三个 Part 分别表达指令、文件和结构化约束。它们不是三个独立消息,而是同一个通信回合中的三个内容块。

2. Part:统一承载文本、文件和结构化数据

Part 使用 one-of 语义,内容必须是以下类型之一:

  • text:文本;
  • raw:内嵌二进制,JSON 表示时通常是 Base64;
  • url:外部文件地址;
  • data:结构化 JSON 值。

同时还可以包含 mediaTypefilenamemetadata。(a2a-protocol.org)

rawurl 的选择会直接影响系统行为:

raw:
  请求自包含
  不依赖外部下载
  但会扩大请求体,增加 Base64 编码开销

url:
  请求体较小
  便于大文件交换
  但需要处理访问权限、过期时间和 SSRF 风险

如果使用 URL,不能假设远程 Agent 能访问调用方的内网地址。一个常见失败路径是:

客户端上传文件到内网对象存储
    ↓
将 http://minio.internal/report.pdf 放入 Part.url
    ↓
远程 Agent 尝试下载
    ↓
DNS、网络策略或认证失败
    ↓
任务进入 failed 或 input-required

更稳妥的做法是使用短期签名 URL、明确的下载权限和可验证的文件摘要。摘要可以放在 Part.metadata 中,但摘要校验属于应用层约定,不是 A2A 自动完成的内容完整性保证。

3. Message 与 Task 的区别

可以用下面的判定过程理解两者:

收到 Message
   ↓
是否能立即给出自包含结果?
   ├─ 是 → 返回 Message
   └─ 否
       ↓
是否需要可追踪的状态、异步执行或继续输入?
       ├─ 是 → 创建 Task
       └─ 否 → 返回拒绝或错误

A2A 允许远程 Agent 返回无状态 Message,也允许返回有状态 Task。短小、即时、无需继续操作的交互适合返回 Message;需要跟踪、长时间执行或等待输入的工作适合创建 Task。(a2a-protocol.org)

例如:

请求:“你支持 PostgreSQL 吗?”
响应:Message

因为它不需要创建可追踪的工作单元。

而:

请求:“读取 2 GB 的销售数据,计算异常交易,并生成 Excel 报告。”
响应:Task

因为它可能持续很久,可能生成文件,可能需要报告状态,还可能要求用户补充筛选条件。

这里有一个重要边界:Message 不是 Task 的简写,Task 也不是 Message 的包装器。

  • Message 表示通信;
  • Task 表示工作;
  • Task 的状态消息仍然可能是 Message;
  • Task 的最终产物则通过 Artifact 表示。

四、Context:把多个任务放进同一条交互链

contextId 用于把相关的 Task 和独立 Message 归入同一上下文。它不是 taskId 的别名。

可以把三种标识理解为:

messageId:这一条消息是谁
taskId:这一项工作是谁
contextId:这些交互属于哪条连续业务链

例如旅行规划场景:

contextId = ctx-trip-001

taskId = task-flight-001   预订航班
taskId = task-hotel-001    根据航班日期预订酒店
taskId = task-activity-001 根据目的地推荐活动

这些任务可以并行存在,但属于同一上下文。A2A 文档明确支持在同一个 contextId 下创建并行任务,并由客户端分别追踪每个任务。(a2a-protocol.org)

一个错误的设计是把 contextId 当作全局会话锁:

同一个 contextId 只能有一个 active task

这会阻止天然可以并行的工作。正确的约束应当是:

同一个 contextId 可以包含多个 Task;
每个 Task 仍然拥有独立状态和结果;
只有存在业务依赖时,才在编排层建立先后关系。

例如“先取得航班日期,再预订酒店”存在数据依赖;“根据同一目的地推荐活动”可能不依赖酒店结果,可以并行。


五、Task:有状态、可观察的工作单元

1. Task 的核心结构

Task 是一个有唯一 ID 和生命周期的工作单元。典型任务包含:

  • id:任务标识;
  • contextId:所属上下文;
  • status:当前状态及状态消息;
  • artifacts:已经生成的任务产物;
  • history:与任务相关的消息历史;
  • metadata:应用层附加信息。

状态本身不是简单的布尔值。A2A 当前定义的状态包括:

submitted       已提交并被确认
working         正在处理
completed       成功完成,终态
failed          执行失败,终态
canceled        已取消,终态
input-required  需要客户端补充输入,中断态
rejected        Agent 决定不执行,终态
auth-required   需要认证,中断态

其中 completedfailedcanceledrejected 是终态;input-requiredauth-required 是中断态,通常意味着客户端可以补充信息或完成认证后继续交互。(a2a-protocol.org)

2. 状态转换

一个典型任务状态图如下:

stateDiagram-v2
    state "input-required" as input_required
    state "auth-required" as auth_required
    [*] --> submitted
    submitted --> working
    submitted --> rejected
    working --> completed
    working --> failed
    working --> canceled
    working --> input_required
    working --> auth_required
    input_required --> working
    auth_required --> working
    completed --> [*]
    failed --> [*]
    canceled --> [*]
    rejected --> [*]

状态转换的因果关系是:

  1. 客户端发送 Message;
  2. Agent 接受请求后创建 Task,进入 submitted
  3. 开始执行后进入 working
  4. 如果缺少参数,进入 input-required
  5. 如果缺少凭证,进入 auth-required
  6. 客户端补充输入或认证后,创建后续交互,使任务继续工作;
  7. 最终进入某个终态。

需要注意,终态 Task 不会重启。如果用户要求修改已完成的结果,客户端应在同一 contextId 中发起新的交互,创建新的 Task,而不是把原 Task 从 completed 改回 working。这种不可变性让每个工作单元都能被稳定追踪和审计。(a2a-protocol.org)

3. input-required 不是失败

下面两个结果语义完全不同:

{
  "state": "input-required",
  "message": {
    "role": "agent",
    "parts": [
      {
        "text": "请提供要分析的会计期间。"
      }
    ]
  }
}

这表示 Agent 具备执行能力,只是当前信息不足。

{
  "state": "failed",
  "message": {
    "role": "agent",
    "parts": [
      {
        "text": "数据解析失败:文件不是有效的 PDF。"
      }
    ]
  }
}

这表示本次执行已经失败,客户端不能简单地把同一个 Task 当作仍在等待输入。

在编排层,二者的处理策略也不同:

def handle_task(task):
    state = task["status"]["state"]

    if state == "input-required":
        return "暂停编排,向上游请求补充参数"

    if state == "auth-required":
        return "进入认证流程,不要重试业务请求"

    if state == "failed":
        return "记录失败原因,按错误类型决定回退或终止"

    if state == "completed":
        return "读取 artifacts,继续后续工作"

    return "继续观察任务"

4. Task 的幂等与重复提交

A2A 的 messageId 能标识消息,但它不自动等价于所有服务实现中的幂等键。尤其在网络超时场景下,客户端无法判断请求是:

请求未到达服务端
请求已到达但响应丢失
请求已成功创建 Task,但客户端未收到 taskId

如果客户端直接重发,可能创建两个相同的任务。

因此,关联主题中提到的幂等键应作为服务契约的一部分单独设计:

{
  "message": {
    "messageId": "msg-20260901-0001",
    "role": "user",
    "parts": [
      {
        "text": "生成 2026 年第三季度财务分析报告"
      }
    ],
    "metadata": {
      "idempotencyKey": "finance-report-tenant-a-2026-q3-v1"
    }
  }
}

服务端可以维护:

(idempotencyKey, agent, tenant) → original response/task

重复请求时返回原始 Task,而不是再次执行。

这里要区分三层语义:

  • messageId:消息身份;
  • idempotencyKey:请求重试去重身份;
  • taskId:服务端创建的工作身份。

如果应用要求严格幂等,不能只依赖消息 ID;如果服务端不声明幂等行为,客户端也不能假定重复调用安全。


六、Artifact:任务产生的可消费结果

1. Artifact 与 Message 的区别

Artifact 是任务产生的具体结果,例如:

  • PDF 报告;
  • 生成的图片;
  • Excel 文件;
  • 结构化 JSON;
  • 编译产物;
  • 数据分析结果。

Artifact 至少包含 artifactId 和一个或多个 Part,还可以包含名称、描述、元数据和扩展信息。一个 Artifact 不是“Agent 最后说的一句话”,而是可以被保存、下载、传给下游系统或在后续任务中引用的交付物。(a2a-protocol.org)

例如:

{
  "artifactId": "artifact-report-001",
  "name": "financial-analysis.json",
  "description": "2026 年第三季度财务指标分析",
  "parts": [
    {
      "data": {
        "period": "2026-Q3",
        "revenue": 128000000,
        "grossMargin": 0.42,
        "operatingCashFlow": 17000000
      },
      "mediaType": "application/json",
      "filename": "financial-analysis.json"
    }
  ]
}

消息适合表达:

“我已经发现毛利率下降,下面是详细结果。”

Artifact 适合承载:

{
  "period": "2026-Q3",
  "grossMargin": 0.42,
  "operatingCashFlow": 17000000
}

前者是交流内容,后者是可供程序继续消费的结果。

2. 一个 Artifact 可以包含多个 Part

多 Part 设计允许一个结果同时包含不同模态:

{
  "artifactId": "artifact-dashboard-001",
  "name": "quarterly-dashboard",
  "parts": [
    {
      "data": {
        "revenue": 128000000,
        "grossMargin": 0.42
      },
      "mediaType": "application/json"
    },
    {
      "url": "https://files.example.com/dashboard.png?signature=...",
      "filename": "dashboard.png",
      "mediaType": "image/png"
    },
    {
      "text": "图表展示收入增长与毛利率变化。",
      "mediaType": "text/plain"
    }
  ]
}

这使 Agent 可以把机器可读数据、可视化文件和说明文字作为同一项交付物发送,而不必强行选择一种表现形式。

3. Artifact 更新与增量传输

当 Artifact 较大或持续生成时,A2A 可以通过 TaskArtifactUpdateEvent 增量发送。事件包含:

  • taskId
  • contextId
  • artifact
  • append
  • lastChunk

如果 append=true,客户端应把当前内容追加到同一 artifactId 的已有内容后;如果 lastChunk=true,表示该 Artifact 的最后一个分块已经到达。(a2a-protocol.org)

客户端重组逻辑可以表示为:

artifacts = {}

def apply_artifact_update(event):
    artifact = event["artifact"]
    artifact_id = artifact["artifactId"]
    parts = artifact["parts"]

    if event.get("append") and artifact_id in artifacts:
        artifacts[artifact_id]["parts"].extend(parts)
    else:
        artifacts[artifact_id] = artifact

    if event.get("lastChunk"):
        artifacts[artifact_id]["complete"] = True

真正实现时还要处理:

  • 重复事件;
  • 事件乱序;
  • SSE 连接中断后的重复分块;
  • 同一个 artifactId 的并发更新;
  • lastChunk 丢失;
  • 客户端进程重启后的恢复。

因此,增量事件需要配合持久化游标、任务查询和版本校验,而不能只依赖内存中的列表。

4. Artifact 版本不是协议自动维护的

后续任务可能基于旧 Artifact 生成新版本。例如:

artifactId = artifact-report-v1
name       = financial-report.pdf

用户要求:把报告改成中文
↓
新 Task
↓
artifactId = artifact-report-v2
name       = financial-report.pdf

A2A 文档建议在修订时保持一致的 artifact-name,但 Artifact 版本之间的父子关系并不由协议自动维护;客户端应自行记录哪个版本被接受为当前版本。(a2a-protocol.org)

推荐的客户端记录形式:

{
  "logicalName": "financial-report.pdf",
  "versions": [
    {
      "artifactId": "artifact-report-v1",
      "taskId": "task-report-001",
      "accepted": false
    },
    {
      "artifactId": "artifact-report-v2",
      "taskId": "task-report-002",
      "accepted": true,
      "derivedFrom": "artifact-report-v1"
    }
  ]
}

derivedFrom 是应用层字段,不应误认为 A2A 核心字段。这样设计的好处是:服务端只负责生成结果,客户端负责决定哪个结果满足业务验收条件。


七、一次完整的 A2A 交互

下面用“生成季度财务分析报告”说明从发现到交付的完整过程。

1. 发现 Agent Card

curl --fail \
  --header 'Accept: application/json' \
  'https://finance.example.com/.well-known/agent-card.json'

预期结果是一个 JSON Agent Card。客户端需要检查:

1. supportedInterfaces 是否包含可用的 A2A 版本;
2. protocolBinding 是否是客户端支持的绑定;
3. skills 是否包含目标技能;
4. inputModes 是否支持 application/pdf;
5. outputModes 是否包含 application/json;
6. 是否具备所需认证条件;
7. capabilities 是否支持客户端计划使用的流式或推送模式。

如果客户端只支持 JSON-RPC,而 Agent Card 仅提供 gRPC 接口,不能因为 URL 可访问就直接发送 JSON-RPC 请求。

2. 发送初始 Message

下面是 JSON-RPC 风格的请求示例:

{
  "jsonrpc": "2.0",
  "id": "req-001",
  "method": "SendMessage",
  "params": {
    "message": {
      "messageId": "msg-001",
      "role": "user",
      "parts": [
        {
          "text": "分析这份季度财务报表,输出收入、毛利率和经营现金流。",
          "mediaType": "text/plain"
        },
        {
          "url": "https://files.example.com/q3.pdf?signature=...",
          "filename": "q3.pdf",
          "mediaType": "application/pdf"
        }
      ]
    },
    "configuration": {
      "acceptedOutputModes": [
        "application/json",
        "text/markdown"
      ],
      "blocking": false
    }
  }
}

A2A 的服务定义包含发送消息、发送流式消息、查询任务、取消任务和订阅任务等操作;发送消息时,服务端可能立即返回 Message,也可能创建或更新 Task。(a2a-protocol.org)

blocking=false 时,客户端期望请求在任务创建后尽快返回,而不是等待任务进入终态。具体字段和绑定方式应以目标接口在 Agent Card 中声明的协议版本为准。

3. 返回 Task

{
  "jsonrpc": "2.0",
  "id": "req-001",
  "result": {
    "task": {
      "id": "task-report-001",
      "contextId": "ctx-finance-001",
      "status": {
        "state": "submitted",
        "timestamp": "2026-09-01T02:00:00Z"
      },
      "artifacts": [],
      "history": [
        {
          "messageId": "msg-001",
          "contextId": "ctx-finance-001",
          "taskId": "task-report-001",
          "role": "user",
          "parts": [
            {
              "text": "分析这份季度财务报表,输出收入、毛利率和经营现金流。"
            }
          ]
        }
      ]
    }
  }
}

客户端此时不能把 submitted 当作成功完成。它只表示请求已被接受并创建了工作单元。

4. 观察任务

客户端可以轮询:

{
  "jsonrpc": "2.0",
  "id": "req-002",
  "method": "GetTask",
  "params": {
    "id": "task-report-001",
    "historyLength": 10
  }
}

服务端可能依次返回:

submitted
    ↓
working
    ↓
input-required

例如:

{
  "jsonrpc": "2.0",
  "id": "req-002",
  "result": {
    "task": {
      "id": "task-report-001",
      "contextId": "ctx-finance-001",
      "status": {
        "state": "input-required",
        "message": {
          "messageId": "msg-agent-001",
          "contextId": "ctx-finance-001",
          "taskId": "task-report-001",
          "role": "agent",
          "parts": [
            {
              "text": "报表中包含多个实体,请指定要分析的公司。"
            }
          ]
        }
      }
    }
  }
}

客户端补充输入时,不应修改旧 Task 的状态,而应发送同一 contextId 下的新 Message:

{
  "jsonrpc": "2.0",
  "id": "req-003",
  "method": "SendMessage",
  "params": {
    "message": {
      "messageId": "msg-user-002",
      "contextId": "ctx-finance-001",
      "taskId": "task-report-001",
      "role": "user",
      "parts": [
        {
          "text": "分析杭州子公司,使用人民币作为金额单位。"
        }
      ]
    }
  }
}

如果客户端同时提供 contextIdtaskId,两者必须一致;如果只提供 taskId,服务端可以从任务推导上下文。(a2a-protocol.org)

5. 返回 Artifact

任务完成后,客户端可能得到:

{
  "jsonrpc": "2.0",
  "id": "req-003",
  "result": {
    "task": {
      "id": "task-report-001",
      "contextId": "ctx-finance-001",
      "status": {
        "state": "completed"
      },
      "artifacts": [
        {
          "artifactId": "artifact-report-001",
          "name": "financial-analysis.json",
          "description": "杭州子公司 2026 年第三季度财务分析",
          "parts": [
            {
              "data": {
                "currency": "CNY",
                "revenue": 128000000,
                "grossMargin": 0.42,
                "operatingCashFlow": 17000000
              },
              "mediaType": "application/json",
              "filename": "financial-analysis.json"
            },
            {
              "text": "收入保持增长,但经营现金流低于利润增长速度。",
              "mediaType": "text/markdown"
            }
          ]
        }
      ]
    }
  }
}

客户端应以 Artifact 为后续系统的主要输入,而不是从 Agent 的自然语言摘要中重新解析数字。


八、轮询、SSE 与推送通知

1. 轮询

轮询适用于:

  • 任务数量少;
  • 更新频率低;
  • 客户端不能维持长连接;
  • 任务状态查询成本可接受。

基本流程是:

SendMessage
    ↓
获得 taskId
    ↓
定时 GetTask(taskId)
    ↓
观察状态和 artifacts
    ↓
到达终态后停止

轮询间隔不应固定为极短时间,否则大量客户端会把任务查询接口打成新的瓶颈。实际间隔可采用指数退避,并在状态变化或预计完成时间附近调整。

2. SSE 流式交互

当任务会产生增量文本、长文档或持续状态更新时,可以使用 SendStreamingMessage。Agent Card 必须声明 capabilities.streaming=true,服务端通过 text/event-stream 返回事件。流中的事件可能包括 Task、状态更新和 Artifact 更新。(a2a-protocol.org)

逻辑时序如下:

sequenceDiagram
    participant C as A2A Client
    participant S as Remote Agent

    C->>S: SendStreamingMessage
    S-->>C: Task(submitted)
    S-->>C: statusUpdate(working)
    S-->>C: artifactUpdate(chunk 1, append=false)
    S-->>C: artifactUpdate(chunk 2, append=true)
    S-->>C: statusUpdate(completed)
    S-->>C: 关闭 SSE

连接中断时,不能直接认为任务失败。任务可能仍在远程执行。客户端应:

  1. 记录最后观察到的 taskId
  2. 判断本地是否收到终态;
  3. 如果没有终态,重新订阅或调用 GetTask
  4. 去重已经处理过的 Artifact 更新;
  5. 最终以服务端任务状态为准。

A2A 支持通过 SubscribeToTask 重新订阅仍在执行的任务。(a2a-protocol.org)

3. 推送通知

对于执行时间很长、客户端可能断开连接的任务,可以配置 Webhook 推送通知。服务端在重要状态变化时向客户端提供的 HTTPS 地址发送通知,通知载荷使用与流式操作相同的 StreamResponse 形式;客户端收到通知后,通常仍应调用 GetTask 获取完整任务。(a2a-protocol.org)

推送通知并不等于“直接信任通知内容”。典型安全流程是:

远程 Agent
    ↓ HTTPS POST
客户端 Push Notification Service
    ↓ 验证来源、签名、租户、taskId
客户端任务协调器
    ↓ GetTask
获取完整任务和 Artifact

服务端不应盲目向任意客户端提供的 URL 发起请求,否则可能形成 SSRF 或 DDoS 放大器;Webhook 接收端也必须验证来源和消息关联关系。(a2a-protocol.org)


九、A2A 与 AG-UI 的组合

A2A 解决的是 Agent-to-Agent;AG-UI 解决的是 Agent-to-User Interface。

AG-UI 是事件驱动协议,Agent 后端可以发出标准事件,前端据此渲染文本流、状态变化、结构化消息、生成式 UI 和人工介入流程;其传输可以使用 SSE、WebSocket 或 Webhook 等方式。(github.com)

一个包含远程 Agent 的前端系统可以这样组织:

用户在前端提交问题
    ↓
AG-UI 输入事件
    ↓
前端连接的编排 Agent
    ↓
A2A SendMessage / SendStreamingMessage
    ↓
远程 Agent 返回 Task、状态和 Artifact
    ↓
编排 Agent 将结果转换为 AG-UI 事件
    ↓
前端更新界面

关键是不要把 A2A 的对象直接当成 AG-UI 的对象:

A2A AG-UI
Task 后端工作状态,可映射为状态事件
Message Agent 间通信内容,可映射为文本或结构化事件
Artifact 可映射为文件、数据表、图表或下载结果
input-required 可映射为前端表单或人工确认事件
auth-required 可映射为登录或授权流程

例如,A2A 远程 Agent 返回:

{
  "state": "input-required",
  "message": {
    "parts": [
      {
        "text": "请指定分析公司。"
      }
    ]
  }
}

编排 Agent 不应把它当作普通文本直接显示后结束流程,而应转换成前端可交互的输入请求。这样,协议状态才真正影响用户界面行为。


十、失败路径与诊断方法

1. Agent Card 不匹配

表现:

客户端发送 application/pdf
远程 Agent 只声明支持 text/plain

诊断:

记录 Agent Card 版本
记录选中的 skill.id
记录输入 Part.mediaType
记录 acceptedOutputModes

修复:

  • 将 Agent Card 能力描述改准确;
  • 在路由前执行硬约束过滤;
  • 对未声明的输入类型默认拒绝,而不是猜测支持。

2. Message 与 Task 关联错误

表现:

message.contextId = ctx-a
message.taskId    = task-b
task-b.contextId  = ctx-b

这会破坏任务关联,可能导致远程 Agent 无法判断消息属于哪条交互链。

修复:

def validate_message_reference(message, task_index):
    task_id = message.get("taskId")
    context_id = message.get("contextId")

    if task_id and task_id in task_index:
        actual_context = task_index[task_id]["contextId"]

        if context_id and context_id != actual_context:
            raise ValueError("taskId 与 contextId 不一致")

3. 把 completed 当作有 Artifact

一个任务可以完成但没有 Artifact,例如 Agent 只完成了一个控制动作,或者返回结果直接放在 Agent Message 中。因此客户端不能只写:

artifact = task["artifacts"][0]

应先检查:

artifacts = task.get("artifacts", [])

if state == "completed" and not artifacts:
    # 可能是 Message-only 结果,也可能是服务实现错误
    inspect_history_or_status_message(task)

协议允许 Message 作为即时响应,因此“完成但没有 Artifact”不必然是协议错误;是否必须产生 Artifact,应由具体 Agent Card、技能契约或业务 API 约定决定。

4. SSE 重连后重复产物

表现:

第一次连接收到 chunk 1、chunk 2
连接断开
重连后再次收到 chunk 2、chunk 3
最终文件重复

诊断必须同时记录:

taskId
artifactId
append
lastChunk
客户端连接实例
收到事件的本地序号

解决方式可以是:

  • 按事件或分块 ID 去重;
  • 重新订阅后先调用 GetTask 获取完整快照;
  • 使用 Artifact 内容摘要校验;
  • 对追加写入设置事务边界。

5. 认证失败不是普通重试

auth-required 表示需要补充认证,不能按网络错误无限重试。错误重试策略应至少区分:

网络超时       → 有限重试,要求幂等
429 / 过载     → 退避后重试
401 / 403      → 刷新或补充凭证,不能盲目重试
input-required → 请求业务输入
rejected       → 通常更换 Agent 或终止
failed         → 根据错误码判断是否可重试

A2A 将认证要求声明在 Agent Card 中,实际凭证通常通过 HTTP Header 等协议外层机制传输,而不是嵌入普通 Message 文本。(a2a-protocol.org)


十一、生产系统中的路由、回退和评测

Agent Card 只提供候选能力描述,真正的动态选择还需要运行时指标。可以把候选 Agent 的选择写成:

argmaxaAr(wqQ(a,r)wlL(a)wcCost(a)wfF(a))\operatorname*{argmax}_{a \in A_r} \left( w_q Q(a,r) - w_l L(a) - w_c Cost(a) - w_f F(a) \right)

其中:

  • ArA_r:通过硬约束过滤后的候选集合;
  • Q(a,r)Q(a,r):Agent 对请求 rr 的质量评分;
  • L(a)L(a):延迟或预计完成时间;
  • Cost(a)Cost(a):调用成本;
  • F(a)F(a):近期失败或超时惩罚;
  • wq,wl,wc,wfw_q,w_l,w_c,w_f:业务权重。

推导顺序不能反过来:

先检查协议和安全硬约束
    ↓
再检查技能和输入输出模式
    ↓
再按质量、延迟、成本和负载排序
    ↓
执行调用
    ↓
根据 Task 状态和错误类型决定回退

错误的做法是先选择“历史成功率最高”的 Agent,再发现它没有权限访问当前租户数据。质量排序不能覆盖协议不兼容和安全不满足。

回退也要区分阶段:

Agent Card 获取失败
    → 使用缓存卡片,但必须检查 TTL 和版本

发送请求超时
    → 只有确认幂等时才切换到备用 Agent

Task 已进入 working
    → 不能随意在另一个 Agent 上重复提交,可能产生双重副作用

Task rejected
    → 可以尝试能力更匹配的候选 Agent

Task failed
    → 先解析错误类别,再决定重试、修复输入或回退

评测时不要只测最终文本是否“看起来正确”,还应验证协议行为:

1. Agent Card 是否能被解析;
2. unsupported input 是否被拒绝;
3. Message 的 role 和 messageId 是否正确;
4. contextId / taskId 是否保持一致;
5. Task 是否遵循合法状态转换;
6. input-required 是否能继续交互;
7. completed 是否停止后续 Task 更新;
8. Artifact 是否可完整重组;
9. SSE 重连是否不会重复结果;
10. 重试是否不会重复产生副作用;
11. auth-required 是否进入授权流程;
12. 多租户路由是否不会串租户。

这些测试比单次“调用成功”更接近真实互操作能力。


十二、版本与实现边界

A2A 的协议版本会影响字段名称、方法名、事件结构和错误处理。当前官方 v1.0 变更说明列出了若干需要特别注意的兼容点,包括 Part 类型统一、流事件判别方式、Agent Card 结构、枚举值、分页、字段名称和标准化错误处理。(a2a-protocol.org)

因此,客户端不能仅通过 URL 推断协议版本,应读取 Agent Card 中接口声明的 protocolVersion,再选择对应的序列化和方法调用逻辑。

还需要区分三种层次:

规范保证

例如:

  • Message 有角色和内容 Parts;
  • Task 有生命周期状态;
  • Artifact 是任务输出;
  • Agent Card 描述能力和接口;
  • SSE 与推送通知有相应的协议模型。

常见实现

例如:

  • 使用 Redis 保存 Task 状态;
  • 使用对象存储保存 Artifact;
  • 使用消息队列执行长任务;
  • 使用 API Gateway 完成 OAuth 校验;
  • 使用 OpenTelemetry 记录跨 Agent trace。

这些做法有助于实现协议,但并不是 A2A 规范强制的内部架构。

应用层建议

例如:

  • 使用 idempotencyKey 避免重复副作用;
  • 给 Artifact 增加内容摘要;
  • 为技能定义输入 JSON Schema;
  • 对 Agent Card 设置缓存 TTL;
  • 记录 Artifact 版本链;
  • 为回退策略建立离线评测集。

这些约定必须写入你自己的 Agent API 契约,不能假设对端天然理解。


结语

A2A 的核心不是“让 Agent 互相聊天”,而是把跨 Agent 协作拆成可发现、可追踪、可恢复的协议对象:

Agent Card → 发现能力与调用边界
Message    → 表达一次通信回合
Part       → 承载文本、文件或结构化数据
Task       → 追踪有生命周期的工作
Artifact   → 交付可消费的具体结果
Context    → 组织相关交互与并行任务
Event      → 传递状态和产物的增量变化

真正的互操作要求调用方不仅能解析 JSON,还要正确处理任务状态、上下文关联、Artifact 重组、流式重连、认证中断、幂等重试和版本协商。

当 A2A 与 MCP、AG-UI 配合使用时,系统边界会更清晰:MCP 让单个 Agent 获得工具,A2A 让 Agent 之间交换工作,AG-UI 让这些工作以可交互的方式呈现给用户。协议的价值不在于隐藏复杂性,而在于把复杂性放在可验证、可观察和可演进的边界内。


系列导航与关联阅读

官方资料

本文依据 Agent、模型、协议与框架官方资料重新梳理;正文、示例与生产清单由 WR BLOG 编写。