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

这个顺序不能颠倒:没有②的状态机就无法验证③的补帧,没有④的可见性就无法验证②的隔离。

验收清单

必做的失败注入

能被演示的失败,才说明防线真的在那:

注入 期望结果
缺 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:

  1. 换一个远端 Agent(不同框架、不同团队),你需要改多少代码?
  2. 对方改了字段或升了版本,你会在哪一步发现?是报错还是静默错位?
  3. 你能回答「这次委派查了谁的数据、用的是谁的身份」吗?

三个问题都能给出具体证据(不是「应该没问题」),这门课就真的上完了。


附录 · 把远端 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
   —— 「不存在」和「你看不见」在协议里长得一样,编排层必须自己留痕,否则排障时无法区分是对方没建任务,还是自己的凭据换了主体

逐段读:

  1. A 是不透明性的代码证据:编排方能拿到的全部信息就是卡面上的这些字段。
  2. B 是「当场答完」的分支:返回体里是 message,没有任务要跟。编排层如果一律按任务处理,这里会 KeyError。
  3. C 是现场那个事故的改进版:同样的 INPUT_REQUIRED 出现两次后,本实现判定为「无进展」而停止——它不再假装任务失败了,而是明确报告「状态重复,需要补输入」。这就是编排层该有的停止条件。
  4. D 是补输入之后任务正常走到终态。
  5. E 是最容易误导的一条:TASK_NOT_FOUND 既可能是「对方没建」,也可能是「你换了主体看不见」。协议有意不区分,所以编排层必须自己留痕(记下自己派过哪些 id、用的什么身份)。

失败注入

注入 A:把中断态当失败

把编排层的循环改成「三次拿不到产物就报错」,然后跑一次需要补输入的任务。你会得到现场那个结果:明明还能救活的任务被判死刑。

正确的分支顺序是:先看是不是终态,再看是不是中断态,最后才看预算。

注入 B:无预算地轮询

去掉轮询次数上限,让一个永远停在 WORKING 的任务跑下去。它不会崩,只会一直消耗配额——而且每次轮询看起来都完全正常。

判断标准:连续两次拿到完全相同的非终态状态,就应该判定为无进展,而不是继续等。本实现的 poll_until_settled 就是按这条写的。

生产边界

教学实现 生产替换
固定轮询次数 次数、墙钟时间、成本多重预算,按任务分级
脚本化的委派 真实模型决定派给谁、派什么(非确定性,需评估集)
无并发委派 限流、背压、部分失败时的降级策略
无跨组织追踪 Trace Context 传播到远端(若对方接受)
无重试策略 区分可重试(超时、限流)与不可重试(权限、不存在)

练习与验收

练习(有可观察结果):给 poll_until_settled 加一条规则——中断态最多补输入两次,超过就放弃并给出明确原因(而不是继续补)。

验收标准:构造一个每次补输入都仍然回到 INPUT_REQUIRED 的任务,断言循环在第 2 次补输入后停止,且返回的停止原因里带有「补输入次数上限」字样。再跑一次正常的任务(补一次就能完成),断言它不受影响。

本章检查点

现在能解释什么

你现在能说清本地编排和远端 Agent 之间的分工:远端负责「能派什么、现在怎么样」,本地负责「要不要继续、什么时候放弃」。你也亲手看了编排层最容易犯的两个错——把中断态当失败、没有预算地轮询——并且知道前者要靠显式判断状态集合来修,后者要靠「同状态重复即无进展」这条规则来兜。

最后再确认一次边界:让多个 Agent 稳定协作几十步、知道什么时候压缩上下文、什么时候交回给人,是多 Agent 协作与 Context Engineering 的正式内容。 接好 A2A 得到的是一条干净的跨组织通道,通道里面的编排聪不聪明,是另一门课的事。

进入 keel 阅读