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 才能知道该升还是换接口"]

对照图读三条失败路径:

一次完整运行

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

逐行读:

  1. A:缺头直接拒绝。 这不是洁癖——放行就等于默认一个版本,而「默认」这个词在跨组织集成里没有共识基础。
  2. B:版本不对时,错误里带上了 supported。 这一条很关键:客户端拿到 supported=['1.0'] 就知道该升还是该换接口,而不是只知道「失败了」。
  3. C:版本对了才谈业务。 result 里是 task,说明这次委派开了一个任务(第 04 章讲为什么有时候返回的是 message)。
  4. 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 的状态值。如果你的实现为了兼容把返回值也改成旧格式,等于把不兼容性往下游推了一层。

本章检查点

现在能解释什么

你现在能解释版本和绑定是两件不同的事:版本不匹配必须早失败并带上 supported,绑定只是同一套操作的三种拼写。你也拿到了一份 v0.3 → v1.0 的破坏性变更清单,知道其中最危险的不是会崩的那些,而是静默走错分支的那些(枚举、kind、错误模型)。下一章进入真正的内容层:一次委派到底携带什么、又能取回什么。

进入 keel 阅读