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["⑨ 确认真没有 才重跑"]

三条容易画错的边要特别说明:

一次完整运行

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

逐段读:

  1. B 是游标分页走完两页拿到 3 条,没有遗漏也没有重复。现场那个「以为工作没了」的场景,正确处置就是这里:先 ListTasks 找回 id,再决定要不要重跑。
  2. C 是可见性隔离:同一个人换了身份就一个都看不见,按 id 直取也报 TASK_NOT_FOUND。注意它和「真不存在」返回完全一样——这正是设计意图。
  3. D 是错误分类:TASK_NOT_FOUND 和 METHOD_NOT_FOUND 是两个不同的 reason,调用方可以分别处理。
  4. 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 时出现了重复,说明你的游标在有新数据插入时会漂移。

本章检查点

现在能解释什么

你现在能解释兼容的两条要求(对得上、分得清)和可观测的三要素(taskId、contextId、主体),知道 ListTasks 的游标分页是为了让「丢了 id」不等于「丢了工作」,也知道错误分类必须读 reason。最后一章把这些全部收口:把这套教学 Bridge 提升成可以交付的形态。

进入 keel 阅读