KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
10 · Capstone:把这套 Bridge 提升为可交付形态 — keel 龙骨
前九章各自解决了一个问题。本章把它们合并成一个有边界、可执行、可验收的交付物。
前九章各自解决了一个问题。本章把它们合并成一个有边界、可执行、可验收的交付物。
业务目标与硬约束
为 Acme 的事件响应助手接入平台团队的远端变更分析 Agent。硬约束一句话:本地编排只能看到对方的卡面、任务状态和产物,除此之外的一切都拿不到,也不该去要。
| 约束 | 含义 |
|---|---|
| 不透明优先 | 不得要求对方提供提示词、模型、内部工具;任何设计都不能依赖这些 |
| 凭据不外泄 | 凭据只走 HTTP 头,禁止进入 metadata 或 history |
| 状态可判定 | 中断态必须被显式处理,不得当成终态或当成失败 |
| 长任务可恢复 | 支持 INPUT_REQUIRED、补输入恢复、重启后仍能取回 |
| 幂等 | 同一任务重复推进不产生重复产物、不重复触发下游 |
| 可解释 | 每次调用能被同一个 trace 串起来,跨组织两边都能对齐 |
最小交付范围
| # | 交付物 | 现成的实现 | 你要补的 |
|---|---|---|---|
| 1 | 卡面与发现 | ✅ agent_card 与 WELL_KNOWN_PATH |
真实 HTTPS 发布 + 缓存与过期策略 |
| 2 | 版本协商 | ✅ 逐请求校验 A2A-Version |
多接口并存(0.3 与 1.0 两个 URL) |
| 3 | 委派与取回 | ✅ SendMessage / GetTask |
历史裁剪策略、产物大小上限 |
| 4 | 长任务推进 | ✅ 中断态、补输入、终态幂等 | 持久路径、租约、TTL |
| 5 | 流式与推送 | ✅ 帧生成、SubscribeToTask、配置 CRUD |
真实 SSE、重试、回调白名单与去重 |
| 6 | 认证与信任 | ✅ scope/租户/受众、签名卡 | 真实 OAuth 2.0 加 PKCE、JWKS 轮转 |
| 7 | 兼容与观测 | ✅ 游标分页、可见性隔离、trace | OpenTelemetry、指标与告警 |
分阶段实施
按下面四步走,每一步都要求「测试从红变绿」:
① 发现与版本(第 02、03 章)
卡面发布在 well-known 路径;逐请求校验版本;不兼容时带 supported 拒绝
断言:缺头被拒、0.3 被拒且错误里带 supported、1.0 通过
↓
② 委派与状态(第 04、05 章)
Message 与 Part 校验;两种返回都处理;中断态显式分支;终态幂等
断言:补输入能恢复;终态再推进不改写产物;终态取消报错
↓
③ 取回三选一(第 06、07 章)
轮询、流式、推送按任务时长选择;断线走 SubscribeToTask;回调带签名与去重
断言:重订阅补帧不重跑任务;重复投递不重复处理
↓
④ 信任与观测(第 08、09 章)
凭据三元组绑定;签名卡校验;任务可见性隔离;trace 贯穿
断言:改过 url 的卡验签失败;换身份看不到他人任务;一次调用事件共享 trace
这个顺序不能颠倒:没有②的状态机就无法验证③的补帧,没有④的可见性就无法验证②的隔离。
验收清单
- 卡面在
/.well-known/agent-card.json发布,8 个必填字段齐全 - 缺
A2A-Version与版本不匹配都被拒,且后者返回supported -
SendMessage的两种返回(Message 与 Task)都被客户端处理 - 中断态被显式处理,不误判为终态
- 终态后重复推进不改变状态与产物
- 终态后取消报
TASK_NOT_CANCELABLE,不静默成功 - 任务落盘,换实例(模拟重启)后仍可取回
- 断线后走
SubscribeToTask补帧,而不是重发任务 - 推送带签名、回调 URL 过白名单、重复投递被幂等去重
- 凭据不含在协议消息里;
GetTask带回的 history 中无凭据 - 换身份后看不到他人任务,且返回与「不存在」完全一致
- 分类读
reason而不是message - 一次调用的事件共享同一个 trace
必做的失败注入
能被演示的失败,才说明防线真的在那:
| 注入 | 期望结果 |
|---|---|
缺 A2A-Version 头 |
-32600 VERSION_NOT_SUPPORTED |
声明 0.3 |
-32009,错误里带 supported |
| Part 给 0 个或 2 个内容字段 | 构造即拒绝 |
| 服务端未声明 streaming 却调用 | UNSUPPORTED_OPERATION |
| 服务端未声明 pushNotifications 却登记回调 | PUSH_NOTIFICATION_NOT_SUPPORTED |
| 终态后取消 | -32002 TASK_NOT_CANCELABLE |
卡面 url 被篡改 |
验签失败 |
卡面没有 signatures |
验签失败(不是跳过) |
| 换身份取他人任务 | -32001 TASK_NOT_FOUND |
| 凭据的受众指向别的 Agent | 认证失败 |
生产替换清单(全课汇总)
| 教学实现 | 生产替换 | 章节 |
|---|---|---|
| 内存里的卡面字典 | HTTPS 发布 + 缓存 + ETag | 02 |
只支持 1.0 |
多接口并存,按客户端能力路由 | 03 |
| 进程内函数调用 | JSON-RPC over HTTPS / gRPC / HTTP+JSON | 03 |
| 内存 Part | 真实载荷;大文件走 url |
04 |
| 内存 dict 加整体写 JSON | 任务表 + 事务 + 租约 + TTL | 05 |
| 一次性返回帧列表 | 真实 SSE + 重连退避 + 心跳 | 06 |
| 把投递内容记在列表里 | 真实 webhook + 签名 + 白名单 + 去重 | 07 |
| HMAC bearer 替身 | OAuth 2.0 加 PKCE / Device Code / mTLS | 08 |
| 共享密钥签名卡 | JWS + JCS(RFC 8785)+ JWKS 轮转 | 08 |
| 内存列表切片分页 | 稳定排序键 + 游标锚点 | 09 |
| 内存 EventRecorder | OpenTelemetry + Trace Context 传播 | 09 |
完成后往哪走
做完 Capstone,你能负责任地说自己懂 A2A 的连接这一层了。请把下面这些边界再刻一遍——它们不是 A2A 的职责:
| 你还需要的 | 属于哪一层 |
|---|---|
| 一个 Agent 内部怎么拆子任务、怎么交接 | 多 Agent 协作 |
| 让 Agent 持续运行几十步而不跑偏 | Agent Harness |
| 决定上下文窗口里放什么、怎么压 | Context Engineering |
| 防止提示词注入、危险命令、数据外泄 | Safety Controls |
| 让长时延、可阻塞的任务在生产里稳定运行 | Real-world Execution |
| Agent 怎么接它自己的工具 | MCP |
A2A 解决的是两个自治系统之间那一段。 通道那头的 Agent 聪不聪明、通道内部的编排合不合理,都是另外的问题。
交付自检
用第 01 章那三个失败方案对照你的 Capstone:
- 换一个远端 Agent(不同框架、不同团队),你需要改多少代码?
- 对方改了字段或升了版本,你会在哪一步发现?是报错还是静默错位?
- 你能回答「这次委派查了谁的数据、用的是谁的身份」吗?
三个问题都能给出具体证据(不是「应该没问题」),这门课就真的上完了。
附录 · 把远端 A2A Agent 接进本地编排
边界声明(先读这一段):本附录不是 A2A 协议的正式内容。A2A 管到「怎么发现、怎么委派、
怎么取回」为止;「本地编排要不要委派、委派几次、什么时候放弃」属于多 Agent 协作与
Agent Harness。这里只做一件事:把前九章的契约放进一个编排循环里跑一遍,
让你看见边界在哪一侧被消费、以及编排层最容易用错的地方。
现场:编排层以为任务失败了,其实它在等一句话
本地编排(Coordinator)接好了远端 Agent。第一次委派,远端返回 TASK_STATE_INPUT_REQUIRED,附一句「请提供 service 名称」。
编排层的循环是这样写的:拿到状态后判断有没有产物,没有就再轮询。它没有「补输入」这条路,于是连着轮询了三次,状态一直是 INPUT_REQUIRED,最后按自己的预算判定为超时失败,并把这个失败报告给了用户。
远端那边的任务其实还活着,只是在等一句话。
先预测一下:这次事故里,远端 Agent 的行为有错吗?如果没错,问题出在哪一层?
直觉模型:你是在跟一个黑盒合作,不是在调用一个函数
把三类责任分开:
| 责任 | 属于谁 | 表现 |
|---|---|---|
| 我能派什么活 | 远端(卡面声明) | 技能清单 |
| 这活现在怎么样 | 远端(任务状态) | 状态与产物 |
| 我要不要继续等 | 本地编排 | 预算与放弃策略 |
最关键的一条:远端不会告诉你它内部卡在哪,也不会替你决定等多久。 编排层必须自己有停止条件,而且这个停止条件要能区分「还在跑」「在等我」「已经结束」。
精确定义:一次委派在编排侧的三个动作
| 动作 | 做什么 | 判断依据 |
|---|---|---|
| 派 | 组装 Message 发出去 | 卡面上的技能清单 |
| 跟 | 按状态推进:终态收口、中断态补输入 | TERMINAL_STATES 与 INTERRUPTED_STATES |
| 弃 | 预算耗尽或无进展时停止 | 本地预算(轮询次数、墙钟时间、成本) |
第三条完全在本地,协议里没有任何字段帮你做这件事。
全链路图
flowchart TD
subgraph LOCAL["本地编排 —— 你负责"]
CARD["① 读卡 了解能派什么"] --> DELEGATE["② SendMessage 委派"]
DECIDE{"③ 拿到的是什么"}
SUPPLY["④ 补输入 同一 taskId 再发"]
SETTLE["⑤ 收口 读产物"]
ABORT["⑥ 放弃 预算耗尽"]
end
subgraph REMOTE["远端 Agent —— 不透明"]
RUN["推进任务"]
STATE["返回状态"]
end
DELEGATE --> RUN
RUN --> STATE
STATE --> DECIDE
DECIDE -->|Message 当场答完| SETTLE
DECIDE -->|终态| SETTLE
DECIDE -->|中断态| SUPPLY
DECIDE -->|还在跑 但预算没到| WAIT["继续轮询"]
DECIDE -->|还在跑 且预算耗尽| ABORT
SUPPLY --> RUN
WAIT --> STATE
WAIT -->|同状态重复 判定无进展| ABORT
DECIDE -->|TASK_NOT_FOUND| UNKNOWN["分不清是不存在还是看不见"]
UNKNOWN -.-> ABORT
一次完整运行
python courses/foundation/a2a-protocol-engineering/course/project/examples/09_orchestrator.py
实跑输出:
A. 编排方能看到的(全部):
技能: ['service.status', 'diagnose.incident']
能力: {'streaming': True, 'pushNotifications': True, 'extendedAgentCard': False}
看不到: 对方用什么模型、什么提示词、内部调了哪些工具
B. 委派一次能当场答完的查询:
返回体: ['message'] (Message 意味着没有任务要跟)
C. 委派一个需要先补输入的长任务:
第一步状态: TASK_STATE_INPUT_REQUIRED
轮询结果: ['TASK_STATE_INPUT_REQUIRED', 'TASK_STATE_INPUT_REQUIRED'] -> 无进展(状态重复)
D. 补上缺失参数后重新推进:
终态: TASK_STATE_COMPLETED | 产物: ['artifact-03']
E. 一个不存在的任务 id:
轮询直接撞上: 错误: TASK_NOT_FOUND
—— 「不存在」和「你看不见」在协议里长得一样,编排层必须自己留痕,否则排障时无法区分是对方没建任务,还是自己的凭据换了主体
逐段读:
- A 是不透明性的代码证据:编排方能拿到的全部信息就是卡面上的这些字段。
- B 是「当场答完」的分支:返回体里是
message,没有任务要跟。编排层如果一律按任务处理,这里会 KeyError。 - C 是现场那个事故的改进版:同样的
INPUT_REQUIRED出现两次后,本实现判定为「无进展」而停止——它不再假装任务失败了,而是明确报告「状态重复,需要补输入」。这就是编排层该有的停止条件。 - D 是补输入之后任务正常走到终态。
- E 是最容易误导的一条:
TASK_NOT_FOUND既可能是「对方没建」,也可能是「你换了主体看不见」。协议有意不区分,所以编排层必须自己留痕(记下自己派过哪些 id、用的什么身份)。
失败注入
注入 A:把中断态当失败
把编排层的循环改成「三次拿不到产物就报错」,然后跑一次需要补输入的任务。你会得到现场那个结果:明明还能救活的任务被判死刑。
正确的分支顺序是:先看是不是终态,再看是不是中断态,最后才看预算。
注入 B:无预算地轮询
去掉轮询次数上限,让一个永远停在 WORKING 的任务跑下去。它不会崩,只会一直消耗配额——而且每次轮询看起来都完全正常。
判断标准:连续两次拿到完全相同的非终态状态,就应该判定为无进展,而不是继续等。本实现的 poll_until_settled 就是按这条写的。
生产边界
| 教学实现 | 生产替换 |
|---|---|
| 固定轮询次数 | 次数、墙钟时间、成本多重预算,按任务分级 |
| 脚本化的委派 | 真实模型决定派给谁、派什么(非确定性,需评估集) |
| 无并发委派 | 限流、背压、部分失败时的降级策略 |
| 无跨组织追踪 | Trace Context 传播到远端(若对方接受) |
| 无重试策略 | 区分可重试(超时、限流)与不可重试(权限、不存在) |
练习与验收
练习(有可观察结果):给 poll_until_settled 加一条规则——中断态最多补输入两次,超过就放弃并给出明确原因(而不是继续补)。
验收标准:构造一个每次补输入都仍然回到 INPUT_REQUIRED 的任务,断言循环在第 2 次补输入后停止,且返回的停止原因里带有「补输入次数上限」字样。再跑一次正常的任务(补一次就能完成),断言它不受影响。
本章检查点
- 现场那个事故里,远端有错吗?编排层少做了哪一步判断?
- 「还在跑」「在等我」「已经结束」这三种情况,编排层分别该怎么处理?为什么顺序不能颠倒?
- 拿到
TASK_NOT_FOUND时,编排层为什么必须自己留痕?协议为什么不帮你区分?
现在能解释什么
你现在能说清本地编排和远端 Agent 之间的分工:远端负责「能派什么、现在怎么样」,本地负责「要不要继续、什么时候放弃」。你也亲手看了编排层最容易犯的两个错——把中断态当失败、没有预算地轮询——并且知道前者要靠显式判断状态集合来修,后者要靠「同状态重复即无进展」这条规则来兜。
最后再确认一次边界:让多个 Agent 稳定协作几十步、知道什么时候压缩上下文、什么时候交回给人,是多 Agent 协作与 Context Engineering 的正式内容。 接好 A2A 得到的是一条干净的跨组织通道,通道里面的编排聪不聪明,是另一门课的事。