A2A 任务幂等与恢复:重复消息、断流与停滞任务
网络会断、客户端会重试、进程会重启。协议不会替您吸收这些故障:它给了 messageId、contextId 和两条恢复通道,把"恰好一次"的效果留给实现。这里给的是把这三样东西用起来的模式,和能重复运行的恢复探针。
状态与操作的含义见Task 对象页 与Task 运行时指南 ;重启与重试的发布检查清单见生产就绪检查 第 5 节。
恢复决策树
连接中断或响应超时后,客户端先判断任务去到了哪里,再决定动作:
流中断 / 响应超时
├─ 任务已到终态(COMPLETED / FAILED / CANCELED / REJECTED)?
│ └─ 是:GetTask 取终态与产出物,结束
├─ 订阅通道可用(capabilities.streaming)?
│ └─ 是:SubscribeToTask 重连,从事件流继续
│ └─ 服务端不支持从任意点续传时,以收到的事件为准,
│ 重复事件按序号去重(见下)
├─ 只剩轮询?
│ └─ GetTask 按退避轮询,直到终态或停滞判定
└─ 停滞判定:超过业务上限仍停在 SUBMITTED / WORKING
├─ 客户端:CancelTask,或在新任务里重发请求(同 contextId)
└─ 服务端:把任务转入 FAILED 并写明原因,不要永远 WORKING不要把"流结束"当成"任务结束":连接断开只说明您不再看得到它,任务本身可能仍在服务端推进。
1. 重复消息:messageId 是幂等键
messageId 由发送方生成,这是协议给幂等留的钩子:客户端超时重发时用同一个 messageId,服务端看到已处理过的 ID 就返回当时的结果,而不是重跑一遍工作。
| 场景 | 客户端行为 | 服务端期望 |
|---|---|---|
| 超时后重发同一条消息 | 原样重发,messageId 不变 | 识别重复,返回首次处理结果 |
| 对话中继续(同一任务) | 新 messageId + taskId(或 contextId) | 作为新输入推进任务 |
| 任务结束后补充需求 | 同 contextId、不带 taskId 的新消息 | 创建新任务(终态任务不可重启) |
| 重复向终态任务发消息 | 同 messageId 重发 | 幂等返回结果,或按实现定义处理 |
实现要点:
- 服务端把
(taskId 或 contextId, messageId)作为幂等键,存储首次处理的结果指针(任务 ID、响应或错误); - 幂等窗口覆盖"客户端放弃之前"的整个周期,不只覆盖几秒;
- 业务副作用(外部调用、写库)发生在幂等键登记之后,避免"回执未落库、副作用已发生"的缝隙;
- 需要跨消息的幂等时用
contextId:同一上下文的新任务可以携带上游业务键,由您的实现定义去重范围。
协议不保证业务层面的恰好一次——referenceTaskIds、contextId 只提供关联信息,去重策略由实现定义。这一点生产就绪检查
第 5 节同样列为发布边界。
2. 重复投递:接收侧的三个去重点
恢复路径上有三个位置会遇到"同一事件第二次到达":
| 位置 | 去重依据 |
|---|---|
| 流事件(重连后) | 事件序号 / TaskArtifactUpdateEvent 的 lastChunk 分界 |
| 推送通知 | 任务 ID + 状态;流式通知按事件序号(见推送接收方加固 第 4 节) |
| 产出物分块 | artifactId + append / lastChunk,重复块覆盖或跳过 |
lastChunk 为 true 之前,同一 artifactId 的分块都可能是重传;客户端缓存按 artifactId 聚合,不假设块数。
3. 重启恢复:给已接收任务一个结局
服务端重启后,已接收的任务只有两种可接受的结局:按产品契约继续,或以明确结果失败。实现模式:
启动 → 加载持久化的任务记录
├─ 幂等键登记过的任务 → 重放登记结果,不重跑副作用
├─ 已在执行的任务 → 恢复执行,或转 FAILED(原因写"服务重启")
└─ 无法恢复的 → FAILED + 可读原因;绝不停回 WORKING 等一个不会来的执行器把"重启后任务去哪了"写进产品契约,客户端才能决定重试还是放弃;这一点在生产就绪检查 第 5 节的检查表里对应第 3 项。
4. 恢复探针
两条探针分别验证"重复消息不产生重复工作"和"断流后终态不丢"。它们以Task 运行时指南 第 4 节的轨迹校验器为基座,只列出恢复相关的断言:
#!/usr/bin/env bash
set -euo pipefail
: "${A2A_BASE_URL:?Set A2A_BASE_URL}"
: "${A2A_VALID_TOKEN:?Set A2A_VALID_TOKEN}"
send() { # body -> response
curl --silent -H "Authorization: Bearer $A2A_VALID_TOKEN" \
-H 'Content-Type: application/json' -d "$1" "$A2A_BASE_URL"
}
# Probe 1 — duplicate message is idempotent:
# send the same message twice with one messageId; the observable task
# artifacts must not double. Pick a task whose artifact count is stable.
MSG_ID="probe-$$-$(date +%s)"
BODY="{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"message/send\",\"params\":{\"message\":{\"role\":\"role_user\",\"parts\":[{\"kind\":\"text\",\"text\":\"count words in: one two three\"}],\"messageId\":\"$MSG_ID\",\"contextId\":\"probe-ctx-$MSG_ID\"}}}"
first="$(send "$BODY")"
second="$(send "$BODY")"
task_id="$(printf '%s' "$first" | jq -r '.result.taskId // empty')"
[[ -n "$task_id" ]] || { printf 'FAIL first send did not create a task\n' >&2; exit 1; }
first_count="$(printf '%s' "$first" | jq '[.result.artifacts[]?] | length')"
second_count="$(printf '%s' "$second" | jq '[.result.artifacts[]?] | length')"
[[ "$first_count" == "$second_count" ]] \
|| { printf 'FAIL duplicate message produced extra artifacts (%s vs %s)\n' "$first_count" "$second_count" >&2; exit 1; }
printf 'OK duplicate message is idempotent (taskId=%s, artifacts=%s)\n' "$task_id" "$first_count"
# Probe 2 — terminal state survives a dropped stream:
# 1. start a streaming task, read the first event, then kill the connection
# 2. SubscribeToTask (or GetTask when streaming is unavailable)
# 3. assert the task reaches a terminal state with the same taskId探针 2 需要中断一条流式连接(curl --max-time 1 读首个事件后退出),再按恢复决策树重连;完整的事件顺序断言交给轨迹校验器,这里只断言终态可达且任务 ID 不变。两个探针都可以重复运行:它们自己就是幂等的。
5. 观测:让停滞可见
- 停滞告警:
WORKING超过业务 P99 时长仍无事件的任务数;命中时给出任务年龄分布与最近事件; - 终态分布:COMPLETED / FAILED / CANCELED / REJECTED 比例,FAILED 按原因聚类;
- 重复率:命中幂等键的消息占比——它同时反映网络质量与客户端重试策略;
- 恢复指标:重连后成功取回终态的比例、重启后任务被终结(而非悬挂)的数量。
字段命名与生产就绪检查 第 4 节的日志约定保持一致,任务 ID 只做关联、不做授权。
相关页面
- Task 运行时指南 :状态规则、事件流与轨迹校验器
- Task 对象页
:状态机与
contextId语义 - Message 对象页
:
messageId、referenceTaskIds与消息归属规则 - 推送接收方加固 :通知侧的重复投递处理
版本与验证说明
对齐 A2A v1.0.1。message/send、role_user、taskId、contextId、lastChunk 等名称以数据结构
为准;探针 1 在示例端点上验证过幂等行为分类,探针 2 的重连断言依赖站点的流式实现,属于部署期检查。停滞阈值、幂等窗口时长是部署参数,本文只约束"必须存在且可观测"。