KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
09 · 兼容、可见性与可观测 — keel 龙骨
## 现场:客户端丢了 task id,以为六小时的工作没了
现场:客户端丢了 task id,以为六小时的工作没了
合规脚本重启之后内存里的 task id 全丢了。工程师的第一反应是「任务没了」,于是重新发起一轮核对——六小时的工作重跑了一遍,期间还产生了重复的外部调用。
事后看,任务一直好好地在服务端,只是客户端没有 id 去取它。在 v0.3 里这确实没有办法:没有列表操作,丢了 id 就是丢了工作。v1.0 新增的 ListTasks 正是为这件事。
同一个月还出了第二件事:一次跨组织调用的结果不对,双方各查各的日志,谁都说不清「这次调用到底走到了哪一步」——因为两边的日志没有共同的标识可以对齐。
直觉模型:兼容是两件事,可观测是三件事
兼容拆开看是两条独立的要求:
| 要求 | 不满足时 |
|---|---|
| 对得上:版本、绑定、字段形状一致 | 连不上或静默错位 |
| 分得清:错误能分类,不靠 message 文本 | 排障靠猜 |
可观测在跨组织场景下要能回答三个问题:这次调用属于哪个任务、属于哪次会话、谁发起的。对应到 A2A 就是 taskId、contextId 和主体标识。
精确定义
ListTasks 与游标分页
v1.0 新增 ListTasks,分页模型是游标式(不是页码式):
| 请求参数 | 响应字段 | 说明 |
|---|---|---|
pageSize |
tasks |
每页条数 |
pageToken |
nextPageToken |
游标;为空表示没有更多 |
contextId / status |
— | 过滤条件 |
为什么是游标而不是页码:任务列表在翻页期间会变化(新任务进来、旧任务完成),页码式会漏条或重复条。游标锚定的是位置,不受插入影响。
可见性隔离
ListTasks 与 GetTask 都只返回调用者可见的任务。不可见的任务对外表现和不存在完全一致(TASK_NOT_FOUND)——这是有意的,不能为了「友好」而区分开,否则就变成了枚举他人任务的探针。
错误分类靠 reason
v1.0 要求错误详情里带 google.rpc.ErrorInfo:reason 用 UPPER_SNAKE_CASE,domain 固定为 a2a-protocol.org(来源:规范 v1.0.1 错误处理,检索于 2026-10-05)。
{"code": -32001, "message": "Task not found",
"data": [{"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"reason": "TASK_NOT_FOUND", "domain": "a2a-protocol.org"}]}
分类靠 reason,不靠 message。 message 是给人看的,随时可能被改写(包括被翻译成别的语言),拿它做分支判断的代码迟早失效。
全链路图:找回一次丢掉的调用
现场那个「重跑六小时」的事故,正确的处置顺序全在这张图上:先列、再取、最后才决定要不要重跑。
flowchart TD
subgraph CLIENT["调用方 —— 重启后内存清空"]
A1["① ListTasks pageSize=2"]
A2{"② nextPageToken 为空?"}
A3["③ 收集 task id 去重"]
A4["④ GetTask 取回状态与产物"]
end
subgraph SERVER["A2A Server —— 任务仍在"]
B1["⑤ 按 owner 过滤<br/>只返回调用者可见的任务"]
B2["⑥ 游标锚定位置<br/>返回 tasks + nextPageToken"]
B3["⑦ EventRecorder.record<br/>写入同一 trace id"]
end
A1 --> B1
B1 --> B2
B2 -->|"还有下一页"| A2
A2 -->|"否 继续翻"| A1
B2 -->|"最后一页"| A3
A3 --> A4
A4 --> B1
B1 -->|"owner 不匹配"| E1["-32001 TASK_NOT_FOUND"]
B1 -->|"owner 匹配"| B3
B3 --> OK["⑧ 拿到状态与产物 无需重跑"]
OK --> A4
A3 -.->|"集合为空"| RERUN["⑨ 确认真没有 才重跑"]
三条容易画错的边要特别说明:
- ② 的循环终止条件是
nextPageToken为空,不是「返回条数 < pageSize」。 用后者判断,最后一页刚好填满时你会多翻一次空页,而中间有新任务插入时你会提前停。 - ⑤ 的过滤发生在服务端,客户端拿到的
TASK_NOT_FOUND因此同时意味着「不存在」和「你看不见」。 - ⑦ 的 trace 是跨组织对齐的唯一抓手:双方各自记录,只有共享 trace id 才能在事后对上「这次调用走到了哪一步」。
一次完整运行
python courses/foundation/a2a-protocol-engineering/course/project/examples/08_compat.py
实跑输出:
A. 以 alice 的身份建 3 个任务:
任务状态: TASK_STATE_INPUT_REQUIRED
B. 游标分页(pageSize=2):
翻了 2 页,拿到 3 个: ['task-01', 'task-02', 'task-03']
C. 换成 mallory 看同一个列表:
可见任务数: 0
直接按 id 取: -32001 TASK_NOT_FOUND
D. 错误靠 reason 分类,不靠 message:
未知句柄 -> -32001 TASK_NOT_FOUND
方法拼错 -> -32601 METHOD_NOT_FOUND
E. 一次调用的事件共享同一个 trace:
事件: ['task.read'] | trace 一致: True
逐段读:
- B 是游标分页走完两页拿到 3 条,没有遗漏也没有重复。现场那个「以为工作没了」的场景,正确处置就是这里:先
ListTasks找回 id,再决定要不要重跑。 - C 是可见性隔离:同一个人换了身份就一个都看不见,按 id 直取也报
TASK_NOT_FOUND。注意它和「真不存在」返回完全一样——这正是设计意图。 - D 是错误分类:
TASK_NOT_FOUND和METHOD_NOT_FOUND是两个不同的 reason,调用方可以分别处理。 - E 是 trace 一致:一次调用产生的事件共享同一个 trace id,否则观测数据退化成一堆孤立的点。
失败注入
注入 A:用页码思维实现游标
把游标实现成「跳过前 N 条」(items[offset:])看着能用,但如果在两页之间有新任务插入,第二页会重复一条。反之如果前面的任务被删除,会漏掉一条。
判断标准:在翻第一页和第二页之间插入一个新任务,断言整个遍历过程既不重复也不漏。本教学实现直接按当前列表切片,所以在真实并发下会出问题——这正是它作为教学替身的边界,生产要用稳定的排序键加游标锚点。
注入 B:用 message 做分支
if "not found" in error["message"]:
...
把服务端的 message 改成中文或改个措辞,这个分支立刻失效。正确写法是读 data[0]["reason"]。
生产边界
| 教学实现 | 生产替换 |
|---|---|
| 内存列表切片 | 数据库查询 + 稳定排序键 + 游标锚点 |
| 内存 EventRecorder | OpenTelemetry + Trace Context 跨组织传播 |
| 单一进程内事件 | 双方各自记录,靠共享 trace id 对齐 |
| 无指标 | 任务时延分布、中断态占比、推送失败率、版本分布 |
| 无告警 | 版本不匹配率、认证失败率、终态异常占比 |
练习与验收
练习(有可观察结果):写一个 walk(server, token, page_size):从第一页开始,用 nextPageToken 一路翻到没有下一页,收集全部 task id,并断言没有重复。
验收标准:把 page_size 分别设成 1、2、10 跑三遍,三次收集到的 id 集合必须完全一致,且都没有重复。如果小 page_size 时出现了重复,说明你的游标在有新数据插入时会漂移。
本章检查点
- 现场那个「重跑六小时」的事故,正确的第一步应该是什么?
- 为什么「不可见」要和「不存在」返回同样的错误?区分开会带来什么风险?
- 分类为什么必须靠
reason而不是message?举一个 message 会变的场景。
现在能解释什么
你现在能解释兼容的两条要求(对得上、分得清)和可观测的三要素(taskId、contextId、主体),知道 ListTasks 的游标分页是为了让「丢了 id」不等于「丢了工作」,也知道错误分类必须读 reason。最后一章把这些全部收口:把这套教学 Bridge 提升成可以交付的形态。