KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

07 · 推送通知:客户端不在线时怎么办 — keel 龙骨

## 现场:一个跑六小时的任务,和一台合上盖的笔记本

现场:一个跑六小时的任务,和一台合上盖的笔记本

合规团队的分析 Agent 受理了一个跨系统核对任务,预计六小时。调用方是某个工程师笔记本上的一个脚本,他合上电脑回家了。

第二天早上回来看,任务早就跑完了——但脚本早断了连接,什么都不知道。它只能重新 GetTask 一次才知道结果,而如果它没记住 task id,连这一步都做不了。

轮询和流式都隐含同一个前提:调用方在整个任务期间保持在线并保持连接。这个前提在长任务上不成立。推送通知(Push Notification)就是为这个场景准备的:调用方留一个 webhook 地址,服务端在关键节点主动打过去。

直觉模型:留个电话号码,而不是一直守在窗口前

谁保持在线 适合
轮询 客户端 秒级到分钟级
流式 双方(连接要维持) 分钟级,要实时反馈
推送 服务端(回调时客户端只要在就行) 小时级、断连场景

代价是清楚的:你把自己的一个端点暴露给了对方。所以推送的重点不在「怎么发」,而在「谁可以让我发、我怎么确认这通电话是它打的」。

精确定义

v1.0 的推送配置是四个操作一组(规范 §3.1.7–3.1.10,检索于 2026-10-05):

操作 作用
CreateTaskPushNotificationConfig 为某个任务登记一个回调
GetTaskPushNotificationConfig 取回某条配置
ListTaskPushNotificationConfigs 列出该任务的全部配置
DeleteTaskPushNotificationConfig 删除配置

两个要点:

  1. 配置挂在任务上,不是挂在客户端会话上。任务没了,配置也随之失效。
  2. 推送载荷使用 StreamResponse 格式(v1.0 澄清)——也就是说,webhook 里收到的对象和你在 SSE 里看到的帧是同一套形状。这是刻意的:客户端只需要写一套解析逻辑。

另外,服务端必须先在卡面上声明 capabilities.pushNotifications,否则调用应被拒绝。

全链路图

flowchart TD
    subgraph CLIENT["Client Agent"]
        CREATE["① CreateTaskPushNotificationConfig 登记回调"]
        HOOK["④ webhook 端点接收"]
        VERIFY["⑤ 校验来源与去重"]
    end
    subgraph SERVER["Remote Agent"]
        EXEC["② 任务推进"]
        EMIT["③ 产生更新 走 StreamResponse 格式"]
    end
    CREATE --> EXEC
    EXEC --> EMIT
    EMIT -->|HTTP POST| HOOK
    HOOK --> VERIFY
    CREATE -->|服务端未声明 pushNotifications| UNSUP["错误 PUSH_NOTIFICATION_NOT_SUPPORTED"]
    EMIT -->|网络失败 需重试与去重| RETRY["⑥ 重投 客户端必须幂等"]
    RETRY -.-> HOOK
    UNSUP -.-> CLIENT

一次完整运行

python courses/foundation/a2a-protocol-engineering/course/project/examples/06_push.py

实跑输出:

A. 先有任务: TASK_STATE_INPUT_REQUIRED
B. 登记回调: push-01
C. 该任务的回调数量: 1
D. 任务推进到: TASK_STATE_COMPLETED
E. 服务端投递了: [{'url': 'https://orch.acme.internal/hooks/a2a', 'configId': 'push-01', 'taskId': 'task-push-01', 'state': 'TASK_STATE_COMPLETED'}]
F. 删除后剩余: 0
G. 服务端没声明 pushNotifications: PUSH_NOTIFICATION_NOT_SUPPORTED

逐段读:

  1. A 说明配置要挂在已存在的任务上——先有任务才有回调。
  2. E 是投递记录:本项目不真的发 HTTP,而是把「本该发什么」记下来。注意它记了 configId,这是去重的依据。
  3. F 是配置可被删除:任务结束后应当清理回调,避免给一个已经不关心的端点继续发。
  4. G 是能力未声明时的拒绝,和上一章 streaming 的处理一致。

失败注入

注入 A:回调地址是谁都能填的

如果不做限制,攻击者可以提交一个内部地址(如 http://169.254.169.254/latest/meta-data),让你的服务端去访问它。这就是典型的 SSRF。

判断标准:回调 URL 必须过白名单或域名校验,并且不接受内网地址、不接受非 HTTPS(生产环境)。

注入 B:重投导致重复处理

网络抖动时服务端会重投。如果客户端收到推送就触发一次「升级告警」,重复投递就会重复升级。

正确的处理是按幂等键去重:本实现里 configId + taskId + state 三元组可以充当去重键。更稳妥的做法是服务端在推送里带一个唯一 eventId,客户端记下处理过的 id。

注入 C:谁都能打到我的 webhook

webhook 是一个公开端点。必须校验请求来源(签名头、mTLS 或共享密钥),否则任何人都能伪造一条「任务已完成」来驱动你的下游动作。

生产边界

教学实现 生产替换
把投递内容记在列表里 真实 HTTP POST,带超时、重试与退避
无签名 请求签名(HMAC)或 mTLS,客户端必须校验
无 URL 校验 域名白名单 + 禁止内网地址 + 强制 HTTPS
无去重 eventId 幂等键 + 客户端去重表
无投递状态 投递成功与失败要可观测,失败要有重试队列

练习与验收

练习(有可观察结果):给推送投递加一层校验——写一个 accept(delivery, allowed_hosts) 函数,只有 url 的 host 在 allowed_hosts 里、且协议是 https 时才接受,其余一律拒绝并返回拒绝原因。

验收标准:喂给它 http://169.254.169.254/... 和 https://orch.acme.internal/... 两条,断言前者被拒、后者通过;再断言拒绝原因里写清是哪一条规则不满足。如果只返回 False 而不说原因,排障时你会再花一小时。

本章检查点

现在能解释什么

你现在能解释推送是为「调用方无法保持在线」准备的第三条取回路径,也知道它的成本是你暴露了一个端点——所以 URL 白名单、来源校验和幂等去重三件事一件都不能少。到这里,发现、委派、取回这条主线已经完整了。下一章转向另一条主线:你怎么证明对面是谁,以及它凭什么相信你。

进入 keel 阅读