KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
03 · 版本协商与三种绑定 — keel 龙骨
## 现场:升级之后,任务永远「没完成」
现场:升级之后,任务永远「没完成」
平台团队把变更分析 Agent 从 v0.3 升到 v1.0。升级本身很顺利,服务没报错。
三天后 SRE 团队发现:委派过去的诊断任务永远轮询不到完成。编排层的日志里,任务状态一直是「未完成」,于是它按预算一直轮询到超时。查了很久才定位到一行比较:
if state == "completed": # v0.3 的枚举值
...
而服务端现在返回的是 TASK_STATE_COMPLETED。两边都没报错:字符串比较只是返回 False,编排层以为任务还在跑。
这个事故的教训不是「升级要看 changelog」,而是:协议版本不匹配时,系统必须拒绝,而不是带着错位的语义继续跑。
直觉模型:进门先报暗号,说话可以用三种口音
两件事要分开:
| 是什么 | 放在哪 | |
|---|---|---|
| 版本协商 | 我们说同一代协议吗 | HTTP 头 A2A-Version |
| 协议绑定 | 用哪种载体说 | 卡面上的 protocolBinding |
版本是能不能聊的问题,绑定是用什么口音聊的问题。前者不匹配必须拒绝;后者只要双方都支持,语义完全等价。
精确定义
版本
协议版本用 Major.Minor 表达(如 1.0)。补丁号不参与协商——规范明确它不应出现在请求、响应和 Agent Card 里(来源:a2a-protocol.org v1.0.1 §3.6,检索于 2026-10-05)。客户端在每个请求上带:
A2A-Version: 1.0
注意这是每个请求都带,不是握手一次就完。会话中途换版本是可能发生的(服务端灰度、接口调整),逐请求校验才能发现。
11 个操作与三种绑定的拼写
v1.0 把操作名统一成了 PascalCase,同一批操作在三种绑定下拼写不同(来源:规范 §5.3 Method Mapping Reference 与 §9–§11,检索于 2026-10-05):
| 操作 | JSON-RPC 方法 | gRPC 方法 | HTTP+JSON 端点 |
|---|---|---|---|
| 发送消息 | SendMessage |
SendMessage |
POST /message:send |
| 流式发送 | SendStreamingMessage |
SendStreamingMessage |
POST /message:stream |
| 取任务 | GetTask |
GetTask |
GET /tasks/{id} |
| 列任务 | ListTasks |
ListTasks |
GET /tasks |
| 取消任务 | CancelTask |
CancelTask |
POST /tasks/{id}:cancel |
| 订阅任务 | SubscribeToTask |
SubscribeToTask |
POST /tasks/{id}:subscribe |
| 建推送配置 | CreateTaskPushNotificationConfig |
同名 | POST /tasks/{id}/pushNotificationConfigs |
| 取推送配置 | GetTaskPushNotificationConfig |
同名 | GET /tasks/{id}/pushNotificationConfigs/{configId} |
| 列推送配置 | ListTaskPushNotificationConfigs |
同名 | GET /tasks/{id}/pushNotificationConfigs |
| 删推送配置 | DeleteTaskPushNotificationConfig |
同名 | DELETE /tasks/{id}/pushNotificationConfigs/{configId} |
| 取扩展卡 | GetExtendedAgentCard |
同名 | GET /extendedAgentCard |
三种绑定必须功能等价(规范 §5):同一套操作、语义等价的结果、一致的错误映射、相同的认证方案声明。所以选绑定是运维决策,不是能力取舍——不要因为「gRPC 更快」就以为它能多做一点事。
v0.3 → v1.0 的破坏性变更(最容易踩的部分)
| 变更 | v0.3 | v1.0 | 炸在哪 |
|---|---|---|---|
| 枚举格式 | kebab-case / 小写 | SCREAMING_SNAKE_CASE |
字符串比较静默失败(现场事故) |
| Role | user / agent |
ROLE_USER / ROLE_AGENT |
角色判断失效 |
| Part | TextPart/FilePart/DataPart + kind |
单一 Part,text/raw/url/data 四选一 |
解析代码整体要改 |
| 流式事件 | kind 判别 + final 布尔 |
成员名 statusUpdate / artifactUpdate |
事件分流失效 |
| ID | 复合 ID(如 tasks/{id}) |
简单 UUID / 字面量 | 路径拼接失效 |
| 时间格式 | 秒级示例 | ISO 8601 UTC 毫秒 | 排序出现并列 |
| OAuth | 支持 implicit / password | 移除,新增 Device Code(RFC 8628),授权码流加 PKCE | 老客户端认证失败 |
| 错误 | 自由 data |
google.rpc.Status + ErrorInfo,domain: a2a-protocol.org |
错误处理分支失效 |
最后两行的共同点是:它们不会因为格式不对而崩溃,只会静默走错分支。这正是必须显式协商版本的理由。
全链路图:一个请求进门前的三道闸
关键是顺序:版本校验发生在业务派发之前。版本不对时,请求根本进不到 SendMessage,也就没有机会带着错位的语义跑下去。
flowchart TD
subgraph CLIENT["A2A Client —— 调用方进程"]
A1["① 组装请求<br/>method=SendMessage"]
A2["② 带上 HTTP 头<br/>A2A-Version: 1.0"]
end
subgraph SERVER["A2A Server —— 远端 Agent(不透明)"]
B1{"③ _require_version<br/>头存在吗?"}
B2{"④ 版本 ∈ SUPPORTED_VERSIONS?"}
B3["⑤ _authenticate 凭据校验"]
B4["⑥ dispatch 到 _op_SendMessage"]
B5["⑦ 状态枚举写为 TASK_STATE_*"]
end
A1 --> A2
A2 --> B1
B1 -->|"缺头"| E1["-32600 VERSION_NOT_SUPPORTED<br/>missing A2A-Version header"]
B1 -->|"有头"| B2
B2 -->|"不在支持集合"| E2["-32009 VERSION_NOT_SUPPORTED<br/>data.supported=['1.0']"]
B2 -->|"匹配"| B3
B3 -->|"凭据不足"| E3["-32003 AUTH_REQUIRED"]
B3 -->|"通过"| B4
B4 --> B5
B5 --> OK["⑧ 返回 result"]
E1 -.-> TIP["放行就等于默认一个版本<br/>跨组织没有共识基础"]
E2 -.-> TIP2["带 supported 才能知道该升还是换接口"]
对照图读三条失败路径:
- ③④ 是协议层的拒绝:只回一个 reason,不碰业务。这就是现场事故的修复点。
- ⑥ 之后的失败是业务层的(技能不存在、参数缺失),错误模型相同但 reason 不同。
- 图里没有「宽容降级」这条边——假装兼容会把不兼容性往下游推一层,注入 B 就是它的后果。
一次完整运行
python courses/foundation/a2a-protocol-engineering/course/project/examples/02_version_binding.py
实跑输出:
A. 不带 A2A-Version 头:
-> -32600 missing A2A-Version header | reason=VERSION_NOT_SUPPORTED
B. 声明 A2A-Version: 0.3:
-> -32009 | reason=VERSION_NOT_SUPPORTED | supported=['1.0']
C. 声明 A2A-Version: 1.0:
-> result 里有: ['task']
D. 同一个操作,三种绑定的拼写:
JSONRPC method: SendMessage
GRPC rpc: SendMessage
HTTP+JSON POST /message:send
逐行读:
- A:缺头直接拒绝。 这不是洁癖——放行就等于默认一个版本,而「默认」这个词在跨组织集成里没有共识基础。
- B:版本不对时,错误里带上了
supported。 这一条很关键:客户端拿到supported=['1.0']就知道该升还是该换接口,而不是只知道「失败了」。 - C:版本对了才谈业务。
result里是task,说明这次委派开了一个任务(第 04 章讲为什么有时候返回的是message)。 - D:三种绑定拼写不同、语义相同。 换绑定不需要改业务代码。
失败注入
注入 A:只升级一边
把客户端固定发 A2A-Version: 0.3,服务端只支持 1.0——上面的 B 行就是结果。注意它是在派发操作之前失败的:连 SendMessage 都没进去。这是正确的失败位置,因为一旦进了业务层,语义错位就不可见了。
注入 B:假装兼容
想想如果服务端「宽容一点」:收到 0.3 也照常处理,只是把响应里的枚举写成 v1.0 格式。客户端按 0.3 解析,于是 TASK_STATE_COMPLETED 既不等于 completed,也不是它认识的任何值——任务永远没有完成。
这正是现场那个事故的成因。判断标准可以写成一句话:协议版本不匹配时,唯一安全的响应是拒绝,并告诉对方你支持什么。
生产边界
| 教学实现 | 生产替换 |
|---|---|
| 进程内字典当 HTTP 头 | 真实 HTTP 头,逐请求校验 |
只支持 1.0 |
同一 Agent 多接口并存(0.3 与 1.0 各一个 URL),按客户端能力路由 |
内存里的 supportedInterfaces |
真实卡面 + 缓存 + 灰度切换 |
| JSON-RPC 单绑定 | 按部署选 gRPC(高吞吐内部链路)或 HTTP+JSON(便于终端调试) |
练习与验收
练习(有可观察结果):给 A2AServer 加一个能同时接受 1.0 与 0.3 的版本集合(只改 SUPPORTED_VERSIONS),然后分别用两个版本发请求,断言都成功;再断言响应里的状态枚举始终是 v1.0 形式(TASK_STATE_*)——也就是说,多版本共存指的是「接口能接受旧客户端」,不是「返回旧格式」。
验收标准:用 0.3 发请求时,响应体里不能出现任何 kebab-case 的状态值。如果你的实现为了兼容把返回值也改成旧格式,等于把不兼容性往下游推了一层。
本章检查点
- 现场那个「任务永远没完成」,协议层做对了哪一步就能避免?
- 为什么补丁号不参与版本协商?把
.1也写进头里会有什么后果? - 三种绑定「功能等价」意味着什么?举一个它不承诺的东西。
现在能解释什么
你现在能解释版本和绑定是两件不同的事:版本不匹配必须早失败并带上 supported,绑定只是同一套操作的三种拼写。你也拿到了一份 v0.3 → v1.0 的破坏性变更清单,知道其中最危险的不是会崩的那些,而是静默走错分支的那些(枚举、kind、错误模型)。下一章进入真正的内容层:一次委派到底携带什么、又能取回什么。