跳到主内容

从主从编排到 A2A:订单 Agent 安全调用售后 Agent 实录 ​

结论先行:跨团队 Agent 协作,别再让一个大脑指挥所有手 ​

我的核心判断是:只要两个 Agent 由不同团队独立部署、独立演进,就不该用一个中心 Orchestrator 去硬编码它们的接口,而应走 A2A 协议的对等协作。在我们最近的自动退款审批改造里,订单 Agent 通过标准的 Agent Card 发现售后 Agent、用 OAuth2 client_credentials 完成机器身份鉴权、再靠任务状态事件流异步拿到审批结论,跨团队联调从平均两周压缩到两天,且售后侧改内部规则完全不需要订单团队发版。

这不是又一个多智能体 Demo,而是一次带着真实资金风险的企业落地。下面把我们的取舍、关键代码与踩过的坑讲清楚。

为什么主从编排在这里是错的 ​

最初的方案很“顺手”:在订单服务里写一个 Orchestrator,函数调用售后系统的 HTTP 接口,拿到风控结果后决定退款。跑通只花了两天,但三个月后维护成本爆炸——售后团队每改一次审批字段,订单侧就要跟着改 DTO、重新回归;更难受的是权限,订单服务拿着一把“万能令牌”直连售后数据库视图,安全评审直接被打回。

本质问题在于:主从模式假设所有 Agent 共享同一个发布节奏和同一份内部模型,这在单体团队里成立,在跨部门协作里不成立。A2A(Agent2Agent)把“谁在什么位置、会什么、怎么鉴权、任务进行到哪一步”全部标准化,让两个 Agent 像两个独立微服务一样对话,而不是一个大脑指挥一堆手。

主从编排 vs A2A 对等协作
1主从多智能体编排
  • 集中控制,链路直观
  • 初期 Demo 上手快
  • Orchestrator 需感知所有子 Agent 的 API 与字段
  • 售后团队改接口要排期联调
  • 故障与权限集中在中心,爆炸半径大
适合单团队、流程稳定的内部场景
2A2A 对等协作
  • Agent Card 自描述能力,发现即对接
  • 鉴权走标准 OAuth2,审计统一
  • 任务事件流异步回调,团队解耦
  • 需要治理卡片版本与令牌生命周期
  • 异步链路可观测性要额外建设
适合跨团队、独立部署的企业级场景

Agent Card:把“能力发现”做成可检索的契约 ​

A2A 的入口是一张部署在对方域名下的 Agent Card,固定路径通常是 /.well-known/agent.json。它不是营销页,而是一份机器可读的能力契约:描述了服务端点、支持的技能、输入模式、鉴权方式与版本号。订单 Agent 启动时拉取并缓存这张卡片,就能知道“退款审批该往哪里发、要带什么字段”。

一份裁剪过的售后 Agent Card 大致长这样:

json
{
  "name": "after-sales-refund-agent",
  "description": "负责退款单的风控复核与审批",
  "version": "1.3.0",
  "url": "https://after-sales.internal.example.com/a2a",
  "capabilities": { "streaming": true, "pushNotifications": true },
  "defaultInputModes": ["text", "data"],
  "skills": [
    {
      "id": "refund-approve",
      "name": "退款审批",
      "description": "根据订单与售后规则返回批准、拒绝或需人工介入",
      "tags": ["refund", "approval"],
      "inputModes": ["data"]
    }
  ],
  "security": {
    "schemes": ["oauth2"],
    "oauth2": {
      "tokenEndpoint": "https://auth.example.com/oauth2/token",
      "scopes": ["refund.approve"]
    }
  }
}

我坚持让售后团队把卡片纳入版本管理并加上 version 字段。原因很现实:订单侧会对卡片做 schema 缓存,一旦对方悄悄改字段,没有版本号你连“对方变了”都感知不到,排查时只会看到莫名其妙的反序列化失败。

OAuth2:机器对机器,别再共用一个员工账号 ​

鉴权是这类场景最容易被“先跑通”糊弄过去的一环。我们早期为了省事,用某个运维同事的个人 PAT 调通了链路,结果那人离职账号一冻结,半夜退款全挂。这个坑我排查了整整一晚上,最后在工单系统里才发现令牌归属人。

正规做法是机器对机器的 client_credentials:订单 Agent 在授权服务器注册自己的 client_id,申请最小 scope,每次调用前换取短时令牌。Python 侧的关键代码如下:

python
import httpx

AUTH = "https://auth.example.com/oauth2/token"

async def fetch_token(client_id: str, client_secret: str) -> str:
    # 仅申请退款审批所需 scope,遵循最小权限原则
    async with httpx.AsyncClient(timeout=5) as c:
        r = await c.post(
            AUTH,
            data={
                "grant_type": "client_credentials",
                "scope": "refund.approve",
            },
            auth=(client_id, client_secret),
        )
        r.raise_for_status()
        return r.json()["access_token"]

令牌绝不能写进 Agent Card 或代码仓库,我们放在集中密钥管理里,按环境注入。另一个关键动作是在售后侧做 scope 与资源的绑定校验:持有 refund.approve 只能调审批任务,碰不到工单查询接口。这样即使订单 Agent 被攻破,爆炸半径也被限制在单一能力上。

任务状态事件流:异步才是真实业务的常态 ​

退款审批不是毫秒级返回的纯函数——它可能要等风控评分、等人工主管复核。A2A 用任务对象建模这种长生命周期交互,订单侧发起 tasks/send 后,通过 SSE 订阅 working、input-required、completed、failed 等状态,而不是傻等一个 HTTP 响应。

退款审批 A2A 调用链路
1
拉取 Agent Card
订单 Agent 从售后域名 /.well-known/agent.json 获取端点、能力与鉴权方式
2
获取令牌
用 client_credentials 向授权服务器换取受限 scope 的访问令牌
3
发起任务
POST tasks/send,携带订单号、金额、退款原因等结构化参数
4
订阅事件
通过 SSE 接收 working / input-required / completed 状态
5
落库与通知
审批结果回写订单库并触发用户通知

发起任务时我们带上业务幂等键,避免重试造成重复退款:

python
import json, uuid

async def submit_refund(token: str, endpoint: str, order: dict) -> str:
    payload = {
        "jsonrpc": "2.0",
        "id": str(uuid.uuid4()),
        "method": "tasks/send",
        "params": {
            "id": f"refund-{order['order_no']}",  # 业务幂等 ID
            "sessionId": str(uuid.uuid4()),
            "message": {
                "role": "user",
                "parts": [
                    {"kind": "text", "text": "请审批该订单退款"},
                    {"kind": "data", "data": {
                        "orderNo": order["order_no"],
                        "amount": order["amount"],
                        "reason": order["reason"],
                    }},
                ],
            },
        },
    }
    headers = {"Authorization": f"Bearer {token}"}
    async with httpx.AsyncClient(timeout=10) as c:
        r = await c.post(endpoint, json=payload, headers=headers)
        r.raise_for_status()
        return r.json()["result"]["id"]

拿到 task id 后,事件处理循环按状态推进本地状态机:working 只更新进度、input-required 转人工补件、completed 才真正执行资金退款并通知用户。资金动作必须只由终态事件触发一次,这是我们在代码评审里反复强调的红线。

落地分层:让协议之外的东西有人管 ​

A2A 规范只解决“怎么对话”,企业落地还要补齐身份、治理与观测。我们把整体能力划成四层,权责清晰后跨团队扯皮明显少了。

A2A 企业落地分层
1
身份与授权层
OAuth2 授权服务器client 注册与 scope 最小化令牌审计
2
能力发现层
Agent Card版本与环境标识能力安全清单
3
任务协作层
tasks/send 与 querySSE 状态事件流幂等与任务续接
4
治理观测层
链路追踪配额熔断敏感字段脱敏

一个容易被忽视的点是可观测性:调用链跨了两个独立服务,光看订单侧日志根本不知道卡在哪。我们要求两边在事件里透传同一组 trace 标识,并把每次状态变迁、令牌申请、scope 校验结果都打进审计日志。事后复盘某次“退款审批超时”,正是靠这些日志定位到售后风控下游的征信接口抖动,而不是 A2A 层本身的问题。

避坑总结:三条用故障换来的经验 ​

  • 令牌身份必须独立且短命。 现象:员工离职或密钥轮换后批量任务 401。修正:为每个 Agent 注册独立 client,使用 client_credentials 换取短时令牌,密钥进集中管理并定期轮换,禁止共用个人 PAT。
  • Agent Card 必须带版本并做缓存失效。 现象:对方更新字段后本地反序列化静默失败,退款参数丢失。修正:卡片加 version,客户端缓存时记录 ETag,启动与定时双刷新,发现不兼容版本直接熔断并告警,而不是带病调用。
  • 资金类副作用只认幂等终态。 现象:网络重试导致同一退款执行两次。修正:用业务订单号生成稳定 task id,售后侧做幂等去重,订单侧只在收到首个 completed 事件且本地未处理时执行退款,并以数据库唯一约束兜底。
  • 权限按 scope 收窄,拒绝万能令牌。 现象:一个 Agent 失陷即可横向读取售后全量数据。修正:一能力一 scope,资源服务器强制校验,敏感字段在进入对方上下文前脱敏。

从紧耦合的主从编排转向 A2A 对等协作,表面是换了一套调用协议,真正换来的是团队边界的清晰和系统演进的自由度。协议会迭代,卡片会增字段,但“能力自描述、身份最小化、任务可观测”这三条原则值得长期坚持。这篇踩坑记录首发于开发者日记,希望能让正在做多智能体企业落地的你,少熬几个我熬过夜。


<!-- 内容间隙推广位:正文之后内联渲染,不遮挡阅读;变现开关关闭时零渲染零占位 -->
<MonetizeSlot position="inline" domain="essays" />

<!-- 免责声明:文字与 data/site.config.yaml 的 disclaimer 保持完全一致,不得删改 -->
::: warning 免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。
:::