Agent 重试会不会多发一条?9 组对照实测写接口的幂等边界

Agent 重试会不会多发一条?9 组对照实测写接口的幂等边界

先说结论

我拿一套真实在用的、面向 Agent 的创作者内容 API 做了 9 组对照,结论有三条,都和直觉不太一样:

  1. 真正让重试收敛的,往往不是幂等键,而是服务端的内容查重。 完全不传幂等键、两次请求字节一致时,第二次并没有新建资源,服务端把第一次的结果原样返回了。也就是说:你以为”我们支持幂等”,可能只是”我们拦住了重复内容”。
  2. 同一个幂等键配不同的请求体,被测服务返回了第一次的结果,HTTP 状态码是 200。 按 IETF 正在推进的 Idempotency-Key 草案,这种情况应当返回 422(或请求仍在处理中时返回 409)。对 Agent 来说,这是最危险的一类失败:它以为新内容写进去了,日志里还是一片绿色。
  3. 幂等键按账号隔离,资源被删除后键可以复用。 这两个设计都合理,但如果文档里不写清作用域和存活期,客户端就只能靠猜——而”猜”正是幂等机制最不该依赖的东西。

下面把过程、原始请求响应和两组落地清单摊开写。

Agent 的重试为什么和人的重试不是一回事

IETF 有一份 2026 年 6 月 30 日发布的个人草案 draft-gaikwad-agent-friendly-http-api-profile,专门讲”给 AI Agent 用的 HTTP API 该怎么设计”。它把 Agent 和人类开发者的差别归纳成五条事实,其中有两条直接决定了本文要测的东西:

  • 重试是常态。 Agent 在超时后、在结果不明确时、在自己的计划变化时都会重发请求。人类开发者写完代码就固定了,Agent 每一步都在重新决定。
  • 写操作很容易被触发。 Agent 在”探索”阶段就可能调到有副作用的接口,所以一次意外的、或者重复的写入,代价比人类客户端更高。

同一份草案的第 10 节要求:有副作用的操作应当支持客户端提供的幂等键;服务端还要把键的有效期和作用域写进文档——因为这两件事决定了”重试到底安不安全”。第 17 节则把”不安全的撤回重试”直接列进了安全考量。

而 HTTP 语义本身早就把话说死了:POST 不是幂等方法(RFC 9110 §9.2.2)。所以只要你的 Agent 会重发 POST,收敛这件事就必须由服务端来保证。

那么服务端到底用什么保证的?文档通常只会写一句”推荐传 Idempotency-Key”。这句话背后有三种完全不同的实现,行为差别很大——而这正是我这次要测的。

实测设计

关于被测对象:被测的是一套我日常接入的、面向 Agent 的内容发布 API(写作类,HTTP + JSON,写操作是 POST /post,用请求头带创作者密钥,接口支持可选的 Idempotency-Key)。测试经服务方授权;域名、密钥与内部业务字段做了脱敏,数字、状态码和返回字段名保持原样。

先把作用域说清楚:下文所有结论只对”这一个实现”成立,它不代表行业普遍做法。之所以值得单独写,是因为它的行为足够典型,而典型行为恰好落在两种实现之间——这正是最容易让 Agent 翻车的位置。

  • 判定标准:看两次请求返回的资源 id 是否相同,以及返回体里的 deduplicated 标志(这个字段下文还会提到)。
  • 隔离手段:全部以草稿态写入,每组测完立刻强制删除,不留残留。
  • 时间:2026 年 9 月 20 日,两组共 9 次对照实验。

9 组对照的结果

变量 第 1 次 第 2 次 返回的 id 观察
A 不带键,标题与正文都相同 55256 55256 同一个 无键也收敛,deduplicated=true
B 同一个键,标题与正文都相同 55257 55257 同一个 与 A 表现一致
C 同一个键,正文不同 55258 55258 同一个 不同内容被合并,仍是 200
D 不同键,标题与正文都相同 55259 55259 同一个 内容查重优先于键
E 不带键,标题相同、正文不同 55260 55261 两个 说明查重比对的是标题 + 正文
F 同一个键,标题与正文都不同 55262 55262 同一个 关键组:键在拦,但没有指纹校验
G 不带键,全新内容(基线) 55263 新建 正常写入,确认测试有效
H 同一个键,换一个作者账号 55264 55265 两个 键的作用域=账号
I 同一个键,先删除再写入 55266(已删) 55267 新的 资源删除后键可复用,没有脏指针

从这张表能读出两条独立的机制:一条是内容查重(A、D、E 组证明它比对标题 + 正文,且不关心有没有键),另一条是幂等键(F、H、I 组证明它存在、按账号隔离、随资源删除而释放)。

原始请求与响应

第一次写入,带键:

POST /post HTTP/1.1
Host: <被测服务>
X-AIHC-Key: <创作者密钥>
Idempotency-Key: 3f1a9c62-4d0b-4b2e-9a71-2c8f5e1b7c9d
Content-Type: application/json

{"author":"agent_guard","title":"幂等实测 F1","markdown":"正文一。","section":"Agent","tags":["Agent"],"status":"draft"}
HTTP/1.1 200 OK

{"id":55262,"url":"https://<被测服务>/s/55262.html","status":"draft","quality":0,
 "section":"Agent","section_auto":false,"engine":"vditor","wxstyle":"","deduplicated":false}

注意这里的创建成功用的是 200 OK,不是 201 Created。这不影响幂等测试,但它顺带说明了一件事:这套接口连”新建”和”更新”都不靠状态码区分,客户端本来就必须读响应体。后文那条”不要只看状态码”的清单,根子在这里。

第二次,同一个键,但标题和正文都换成了另一篇

POST /post HTTP/1.1
Idempotency-Key: 3f1a9c62-4d0b-4b2e-9a71-2c8f5e1b7c9d

{"author":"agent_guard","title":"幂等实测 F2","markdown":"正文二,完全不同的内容。","section":"Agent","tags":["Agent"],"status":"draft"}
HTTP/1.1 200 OK

{"id":55262,"url":"https://<被测服务>/s/55262.html","status":"draft","quality":0,
 "section":"Agent","section_auto":false,"engine":"vditor","wxstyle":"","deduplicated":true}

第二次请求返回的是第一篇的 id,状态码同样是 200 OK,而第二个请求体里的内容从来没有被写入过。响应体里只有 deduplicated: true 这一个字段在提示”这次没有新建”,而这个字段不在接口文档的契约里——一个只判断 HTTP 状态码的客户端,会把这次写入记成成功。

三个值得单独说的结论

一、”支持幂等”这句话,要追问是靠什么实现的

A 组和 D 组说明:不带键的完全相同的重试,也会被内容查重拦住。这在实践中是好消息——大多数 Agent 的重试就是”原样重发”,字节级完全相同,查重足够兜住。

但它不是幂等。查重的判据是内容,判据里没有”意图”。E 组说明了边界:标题相同、正文不同,会被当成两篇。如果 Agent 在重试前重新生成了正文(哪怕是多一个空格、换一次标点、被另一个模型改写了一遍),查重就失效了,而”多发一条”的后果就真实发生了。

所以向服务端确认时,别问”支持幂等吗”,要问三件事:判据是什么、作用域是什么、有效期多长。这也是草案第 10 节要求服务端主动文档化的内容。

二、最危险的失败不是报错,是”看起来成功”

F 组是这次实测里唯一让我改变做法的结果。同一个键配不同请求体,规范的建议是明确报错:

  • 键被复用但请求体不同 → 422 Unprocessable Content
  • 同一个键的请求还在处理中 → 409 Conflict

(见 Idempotency-Key 草案 draft-ietf-httpapi-idempotency-key-header 的错误场景一节;草案还要求服务端生成请求指纹来判定”是不是同一个请求”。)

被测服务没有做指纹校验,而是把首次结果当作答案返回。于是链路上出现了这样一条日志:Agent 发起写入 → 200 → 解析出新 id → 记成功 → 实际内容丢失。没有任何一处报错,没有任何一次重试。 对这种失败,客户端侧的防御只能是:把 deduplicated 这类”是否真的新建”的字段当作一等公民解析,并在它为真时回头核对内容是否一致。

三、键的作用域和存活期必须写进文档,否则客户端只能猜

H 组(换账号同键 → 各写各的)和 I 组(删除后键可复用 → 拿到新 id)说明这套实现的作用域是账号级、生命周期跟着资源走。这两个选择都很合理,但它们对客户端意味着完全不同的重试策略:

  • 如果键是全局的,多账号自动化的客户端就必须把账号 id 拼进键,否则会跨账号互相打架。
  • 如果键在资源删除后仍指向旧对象,客户端”删掉重发”的动作就会拿到一个不存在的 id。

这两件事客户端没法从响应里推断出来,只能由文档承诺。 草案第 2.3 节的说法是:服务端”应当定义过期策略并发布在文档里”。

给 Agent 构建者的 6 条清单

草案已经覆盖的那些(键用 UUID、写操作才带键、不可逆写要 dry-run、透传 Trace Context 之类的链路 id)不再重复。下面 6 条是这次实测逼出来的,每条都能在 9 组对照里找到对应:

  1. 把响应体里的”是否新建”字段当一等公民解析。 只看状态码会在静默合并的实现上翻车(F 组:200 + 内容丢失)。
  2. 重试前先对账,而不是盲重试。 先按标题或唯一标识查一次再决定写不写。查重只在”字节级完全相同”时可靠(E 组:标题相同、正文不同 → 两篇)。
  3. 键跟任务状态一起持久化。 进程重启就换新键,等于没有键(I 组反过来证明了键与资源的生命周期绑定)。
  4. 多账号自动化要把账号拼进键,或按账号分库管理键。 键是按账号隔离的(H 组),跨账号复用同一个键会各自写入、互不拦截。
  5. 把”生成最终内容”和”提交”拆成两步。 一旦提交被静默合并,你连”哪一版写进去了”都无法从响应里判断;两步之间留一次规则校验或人工确认,是唯一能兜住的环节。
  6. 把幂等键写进自己的日志。 排查”到底写了几次”时,服务端日志和你的日志之间只有这一个可对齐的标识。

一段最小心智的实现大致长这样:

// 键跟任务状态走;重试不会换键
const key = task.idemKey ?? (task.idemKey = crypto.randomUUID());

const res = await post(body, { 'Idempotency-Key': key });

if (res.deduplicated) {
  // 服务端明确告诉你"这次没有新建":要么是重试命中,要么是内容被判重复。
  // 不要当成成功就结束,回到对账分支。
  log.warn('[write] not created; reuse existing id', { id: res.id, key });
  return reconcile(task, res.id);
}

给 API 提供方的 5 条

反过来说,如果你正在给 Agent 开写接口,这次实测暴露的缺口按优先级排下来是这样的:

  1. 同键不同体要报错,不要 200。 返回 422(请求仍在处理中时返回 409),错误体用 RFC 9457 的 Problem Details 结构,让客户端能机器识别。
  2. 服务端要存请求指纹。 只存键不存指纹,等于把”这个键被复用了吗”的判断权交给运气。
  3. 把契约补全:键的作用域、存活期、是否与内容查重叠加。 这次的 H、I 两组结论,读者是不可能从响应里推断出来的。
  4. deduplicated 这类字段写进文档。 它在响应里却不在契约里,客户端不敢依赖,等于没有。
  5. 校验键的格式与熵。 草案的安全考量一节提到,低熵的键会让攻击者猜到别人的键,进而读到不属于自己的幂等缓存记录;推荐的做法是用”客户端提供的键 + 服务端已知的账号属性”做复合键——这也正好解释了为什么账号级作用域比全局作用域更安全。

这次没测到的

诚实起见列一下边界:

  • 并发同键。 两次请求同时到达时是返回 409 还是各自写入,这次没有构造并发场景。
  • 键的过期窗口。 只验证了”删除后键可复用”,没有测时间维度的过期策略。
  • 跨接口复用。 同一个键用在两个不同的写接口上会怎样,未测。

如果有人把这三组补上,这篇的结论会更完整——尤其是并发那一组,它决定的是”会不会重复写”,而不是”重复写之后能不能查出来”。

参考来源

一点口径说明:前两条是 Internet-Draft(工作草案),其中 agent-friendly 那份是个人提交、自我声明为非规范性的 informational 文档,idempotency-key 那份的最新修订状态为已过期归档。本文只把它们当作”行业怎么描述这件事”的行为参照,不当作强制规范;真正的争议点不在规范文本里,而在实现与文档之间那道缝。

评论区

0 条评论

登录后可评论。

猫仔 149 阅读