KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
02 · Agent Card:一张卡为什么能撑起整个发现机制 — keel 龙骨
## 现场:能力清单写在 wiki 里,第八周就过期了
现场:能力清单写在 wiki 里,第八周就过期了
平台团队把变更分析 Agent 的能力清单写在了内部 wiki 上:能做什么、怎么连、要什么凭据。SRE 团队照着写客户端,跑通了。
第八周,平台团队给 Agent 加了 gRPC 端点,把 JSON-RPC 的旧地址标记为即将下线,然后忘了改 wiki。SRE 的客户端照旧连旧地址,某天开始收到 404,助手回答「变更分析不可用」。值班工程师查了半小时才发现:服务端没坏,是客户端在按一张过期的清单找人。
这类问题的根源不是谁疏忽,而是把「能力声明」放错了地方:放在人读的文档里,它一定会被程序当作真相来用。 A2A 的解法是把这份声明变成机器读的、由服务端自己发布的、带版本的 JSON——Agent Card。
直觉模型:名片、接线说明与门禁规则,三合一
一张 Agent Card 同时承担三件事:
| 它在回答的问题 | 对应字段 |
|---|---|
| 你是谁、能干什么 | name description skills provider version |
| 怎么连你、用哪种协议 | supportedInterfaces |
| 凭什么让你干活 | securitySchemes securityRequirements |
把它想成一张门禁卡:正面写着身份和能力,背面写着闸机在哪、刷卡规则是什么。少了任何一面,对面都进不来。
精确定义:14 个顶层字段,8 个必填
按规范 v1.0.1 的 AgentCard 定义(检索于 2026-10-05),顶层字段共 14 个:
| 字段 | 必填 | 说明 |
|---|---|---|
name |
是 | 人类可读名称 |
description |
是 | 用途说明,给人和模型读 |
supportedInterfaces |
是 | 有序接口数组,第一项为首选 |
version |
是 | Agent 自己的版本(不是协议版本) |
capabilities |
是 | 能力开关:streaming pushNotifications extendedAgentCard |
defaultInputModes |
是 | 默认接受的媒体类型 |
defaultOutputModes |
是 | 默认产出的媒体类型 |
skills |
是 | 能力单元列表 |
provider |
否 | 提供方信息 |
documentationUrl |
否 | 更多文档的链接 |
securitySchemes |
否 | 认证方案定义 |
securityRequirements |
否 | 调用所需的认证要求 |
signatures |
否 | JWS 签名数组(第 08 章) |
iconUrl |
否 | 图标 |
每个 supportedInterfaces 条目(AgentInterface)自己带四个字段:
| 字段 | 必填 | 说明 |
|---|---|---|
url |
是 | 生产环境应是绝对 HTTPS 地址 |
protocolBinding |
是 | JSONRPC / GRPC / HTTP+JSON(开放字符串,允许厂商扩展) |
protocolVersion |
是 | 该接口暴露的 A2A 版本,如 1.0 |
tenant |
否 | 多租户时的路由标识 |
v1.0 相对 v0.3 的三处结构变化
这三处是迁移时最先炸的地方(来源:a2a-protocol.org What's New in v1.0,检索于 2026-10-05):
| v0.3.0 | v1.0 | 后果 |
|---|---|---|
顶层 url + preferredTransport + additionalInterfaces |
合并为有序 supportedInterfaces[] |
老客户端读不到地址 |
顶层 protocolVersion |
下沉到每个 AgentInterface |
一个 Agent 可以同时暴露 0.3 和 1.0 两种接口 |
supportsAuthenticatedExtendedCard |
移到 capabilities.extendedAgentCard |
老客户端拿不到扩展卡 |
第三行值得多想一层:协议版本从「整个 Agent 的属性」变成了「每个接口的属性」。这是 A2A 能做平滑迁移的技术前提——同一个 Agent 可以在不同 URL 上同时提供新旧两个版本,让客户端按自己的能力挑。
另外,发现路径本身也变过:v0.3.0 起是 /.well-known/agent-card.json,更早是 /.well-known/agent.json。
全链路图:一次发现走过五跳
先看形状再读细节:取卡是一次跨网络的读,校验和选接口全在客户端本地完成——服务端不参与「你该用哪个接口」这件事,它只负责如实地把有序数组发出来。
flowchart TD
subgraph CLIENT["A2A Client —— 调用方进程"]
C1["① GET /.well-known/agent-card.json<br/>agentcard.WELL_KNOWN_PATH"]
C2{"② validate_card<br/>8 个必填字段齐全?"}
C3["③ compatible_interfaces<br/>版本 ∩ 绑定 求交集"]
C4{"④ 交集为空?"}
C5["⑤ choose_interface<br/>取 supportedInterfaces 第一项"]
end
subgraph SERVER["A2A Server —— 远端 Agent(不透明)"]
S1["发布点 /.well-known/agent-card.json<br/>server.agent_card"]
S2["supportedInterfaces[0]<br/>JSONRPC 1.0 @ .../a2a/incident-bridge"]
end
C1 --> S1
S1 -->|"卡面 JSON"| C2
C2 -->|"缺 skills 等"| FAIL1["早失败 报出具体缺失字段名"]
C2 -->|"结构合法"| C3
S2 --> C3
C3 --> C4
C4 -->|"是"| FAIL2["no compatible A2A interface<br/>不降级 不硬聊"]
C4 -->|"否"| C5
C5 --> DONE["⑥ 带着 url 与 A2A-Version 发后续请求<br/>server.dispatch"]
FAIL1 -.-> C1
FAIL2 -.-> NOTE["卡面过期也会走到这里<br/>取卡时间要自己记"]
图里两个失败点值得对照:
- ② 的失败是结构性的:缺哪个字段,函数就报哪个字段名。它防的是「卡写错了还照样用」。
- ④ 的失败是能力性的:版本或绑定对不上。这里必须早失败,不能退回别的协议硬聊——第 03 章会讲为什么。
注意 FAIL1 -.-> C1 那条虚线:校验失败后正确的动作是重新取卡并重验,而不是改本地缓存里的字段把洞填上。
一次完整运行
python courses/foundation/a2a-protocol-engineering/course/project/examples/01_discovery.py
实跑输出(节选与卡面直接相关的部分):
H. 卡面字段: ['capabilities', 'defaultInputModes', 'defaultOutputModes', 'description', 'documentationUrl', 'name', 'provider', 'securityRequirements', 'securitySchemes', 'skills', 'supportedInterfaces', 'version']
I. 拿掉 skills 再校验 -> ['skills']
J. 首选接口的完整条目: {'url': 'https://agents.acme.internal/a2a/incident-bridge', 'protocolBinding': 'JSONRPC', 'protocolVersion': '1.0', 'tenant': 'acme'}
逐行读:
- H 列出的是本项目实际发布的字段。规范的 14 个里,
iconUrl和signatures没发——前者是可选装饰,后者只在签名时才出现(第 08 章)。可选字段不发是合法的,必填字段少一个就不合法。 - I 是结构校验:把
skills置空之后,校验函数指名道姓说缺skills。注意它只查结构——结构合法不等于内容可信,这是第 08 章签名要解决的问题。 - J 是接口的完整条目:地址、绑定、协议版本、租户四件事绑在一起。租户在这里出现,意味着同一个 URL 后面可以按租户路由到不同 Agent——这是 v1.0 给 SaaS 场景留的位置。
失败注入
注入 A:客户端只要 gRPC
choose_interface(card, versions=("1.0",), binding="GRPC")
实跑输出:
E. 只想走 gRPC -> no compatible A2A interface
服务端没声明 gRPC,客户端就必须早失败,不能退回 HTTP 硬聊。原因在第 03 章会展开:三种绑定是功能等价的,但「等价」指的是同一套操作语义,不是说你可以临时换一条路。
注入 B:拿一张过期的卡
把卡里的 supportedInterfaces[0].url 改成旧地址再发消息——服务端会返回连接层错误,客户端如果只做了「拿到卡就发消息」,此时根本分不清是网络问题还是清单过期。
判断标准很实用:每次取卡都要能看到卡上的 version 与当前时间。卡没有「最后更新时间」这个字段,但你应当在自己的客户端里记录「这张卡是什么时候取的、当时的 version 是什么」,排障时才说得清是不是用了旧清单。
生产边界
| 教学实现 | 生产替换 |
|---|---|
| 函数调用直接返回卡面字典 | HTTPS 服务在 /.well-known/agent-card.json 上发布,带缓存与 ETag |
| 每次都全量读卡 | 本地缓存 + 过期策略 + 失败时重新取卡 |
单一 tenant |
多租户路由,卡面按租户不同 |
| 未签名卡 | JWS 签名 + 规范化(第 08 章) |
| 客户端硬编码一个地址 | 注册中心或目录服务,但卡本身仍是权威来源 |
练习与验收
练习(有可观察结果):给卡加第二个接口(protocolBinding 用 GRPC,protocolVersion 用 1.0,url 指向 gRPC 地址),然后用 compatible_interfaces(card, versions=("1.0",)) 断言返回 2 条;再用 choose_interface(card, versions=("1.0",)) 断言拿到的是第一条(数组顺序即优先级)。
验收标准:把两个接口的顺序调换,断言首选随之变化。如果你的实现固定返回某个绑定(比如「能用 gRPC 就用 gRPC」),说明你替服务端做了它没做的决定——优先级由卡面顺序表达,客户端不该自作主张。
本章检查点
- 现场那个 wiki 事故,如果能力声明是机器读的、由服务端发布的,故障会在哪一步提前暴露?
protocolVersion从顶层下沉到每个接口,为什么是平滑迁移的前提?- 「结构合法」和「内容可信」分别由什么保证?只做前者会留下什么洞?
现在能解释什么
你现在能读懂一张 Agent Card:知道 8 个必填字段各自回答什么问题,知道 supportedInterfaces 是有序的、第一项即首选,知道协议版本现在挂在接口上而不是卡上。你也知道发现机制的全部依赖只有一个约定路径——不需要注册中心,但代价是卡必须是最新的、并且最终必须可验签。下一章讲连上之前的那一步:版本怎么协商,三种绑定怎么选。