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 |
删除配置 |
两个要点:
- 配置挂在任务上,不是挂在客户端会话上。任务没了,配置也随之失效。
- 推送载荷使用
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
逐段读:
- A 说明配置要挂在已存在的任务上——先有任务才有回调。
- E 是投递记录:本项目不真的发 HTTP,而是把「本该发什么」记下来。注意它记了
configId,这是去重的依据。 - F 是配置可被删除:任务结束后应当清理回调,避免给一个已经不关心的端点继续发。
- 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 而不说原因,排障时你会再花一小时。
本章检查点
- 推送和轮询、流式的本质区别是什么?它的代价是什么?
- 为什么推送载荷要和 SSE 帧用同一套格式?这对客户端意味着什么?
- 一个可被任意填写的 webhook 地址会带来哪两类风险?
现在能解释什么
你现在能解释推送是为「调用方无法保持在线」准备的第三条取回路径,也知道它的成本是你暴露了一个端点——所以 URL 白名单、来源校验和幂等去重三件事一件都不能少。到这里,发现、委派、取回这条主线已经完整了。下一章转向另一条主线:你怎么证明对面是谁,以及它凭什么相信你。