幂等键不够用:Agent 客户端对账的 6 种做法
上一篇文章用一套真实的内容发布 API 做了 9 组对照,结论里有三条很反直觉:不带幂等键的重复请求被内容查重收敛;同一个幂等键配不同请求体时服务端返回 200,但第二次内容被静默丢弃;幂等键按账号隔离、资源删除后可复用。那篇文章结尾给了一句“重试前先对账,而不是盲重试”,但没有展开。这篇把客户端侧的对账拆成六种具体实现,每种给出最小骨架和取舍——因为如果服务端只会保证“看起来成功”,Agent 客户端就必须自己想办法确认到底写没写、写的是哪一版。
对账要回答的不是一个问题,而是三个
很多人把对账理解成“查一下有没有重复”,但上一篇文章的 F 组已经说明,真正危险的是“服务端返回了一个 id,但那个 id 对应的内容不是这一版”。所以客户端侧对账至少要确认三件事:
- 这次写入到底有没有新建资源;
- 如果返回了已有资源 id,那个 id 对应的内容是不是我这次提交的内容;
- 如果两个答案都不清楚,我是重试、放弃还是转人工。
这三件事的答案来源不同。第一件可以看服务端返回体里的去重标记(如果它诚实地给了);第二件往往需要再 GET 一次并比对内容指纹;第三件则取决于你的任务状态机。IETF 那份给 AI Agent 用的 HTTP API 草案第 10 节也写明:有副作用的操作应当支持客户端提供的幂等键,服务端还要把键的作用域和有效期写进文档;同一套草案第 17 节把“不安全的撤回重试”直接列进了安全考量。换句话说,官方草案把服务端该做的部分规定得越来越清楚,但客户端侧仍然有一半工作要自己完成——原因很简单:直到今天,很多线上 API 的响应体里根本没有可靠的“是否新建”字段,或者有但不写在文档里。
六种实现逐个过
下面六种不是互相替代的关系,而是从“最轻”到“最重”的递进,实际项目里通常是组合使用。代码是 TypeScript 风格的伪代码,重点是状态流转和判断分支,不是可运行细节。
// 六种对账实现的最小骨架
// 1. 解析响应体去重标记
async function reconcileByDedupFlag(res: any, task: Task) {
if (res.deduplicated) {
// 服务端说“没有新建”:可能是重试命中,也可能是内容被判重复。
// 不能当成功就结束,要回头核对是不是自己这版。
log.warn('[write] not created; reuse existing id', { id: res.id, key: task.idemKey });
return reconcile(task, res.id);
}
return res.id;
}
// 2. 唯一业务键先查再写
async function reconcileByLookup(slug: string, body: any) {
const existing = await getBySlug(slug);
if (existing && existing.title === body.title) return existing.id;
return create(body); // 仍有 race:查完到写入之间可能被别的请求抢先,需配合幂等键
}
// 3. 内容指纹对账
function sha256(s: string) { /* ... */ }
async function reconcileByFingerprint(body: any, task: Task) {
task.fingerprint = sha256(JSON.stringify(body));
const res = await create(body, { 'Idempotency-Key': task.idemKey });
const remote = await getById(res.id);
const remoteFp = sha256(JSON.stringify({ title: remote.title, markdown: remote.markdown }));
if (remoteFp !== task.fingerprint) {
throw new Error('fingerprint mismatch: remote content differs');
}
return res.id;
}
// 4. 条件写(更新已有资源)
async function reconcileByIfMatch(resourceId: string, etag: string, patch: any) {
const res = await update(resourceId, patch, { 'If-Match': etag });
if (res.status === 412) {
// 远端已变化,拉最新版本再决策
const latest = await getById(resourceId);
return reconcileByLookup(latest.slug, patch);
}
return res.id;
}
// 5. 任务状态机 + 本地持久化
async function submitWithState(task: Task, body: any) {
if (!task.idemKey) task.idemKey = crypto.randomUUID();
task.status = 'submitted';
await saveTask(task);
try {
const res = await create(body, { 'Idempotency-Key': task.idemKey });
if (res.deduplicated) {
task.status = 'needs_review';
} else {
task.status = 'confirmed';
}
await saveTask(task);
return res.id;
} catch (e) {
task.status = 'needs_review';
await saveTask(task);
throw e;
}
}
// 6. 人工确认兜底
async function submitWithReview(body: any) {
const draft = await saveDraft(body);
await notifyHuman(`Review draft ${draft.id} before publish`);
return draft.id; // 实际写入由人工或二次确认触发
}
第 1 种是最直接的:服务端返回体里有 deduplicated: true 这类标记,就用它判断“这次没有新建”。上一篇文章的 F 组已经证明,这个标记在响应体里出现却不写在文档契约里,所以只能当提示,不能当保证。工程上的做法是:看到这类标记,一律回查或进入人工确认分支,而不是把返回值直接记成成功。
第 2 种适合有稳定业务唯一键的场景,比如 slug、外部 id、固定标题。先查再写能避免大部分重复,但查询和写之间仍有竞态窗口。如果服务端支持幂等键,最好和键叠加;如果不支持,这种查询可以当作低成本的预筛,不能单独承载并发的可靠性。上一篇文章 A 组和 D 组已经显示,内容查重比对的是标题 + 正文,因此用 slug 先查是同一个维度,可以提前拦截大部分原样重试。
第 3 种是最完整的客户端侧校验:提交前对内容计算 SHA-256 指纹,写入后 GET 回来再算一次并比对。这能发现“服务端返回了旧 id 但内容其实没变”的情况,也能发现服务端静默合并不同内容却返回 200 的情况。代价是多一次 GET 和一份指纹存储,而且依赖远端 GET 能读到自己刚写的内容——如果服务端读写有延迟或最终一致,比对会误报。如果远端不返回内容字段,指纹只能退化成对 id 的信任,失去意义。
第 4 种是针对更新的:如果你维护的资源已经有 ID,并且服务端支持 ETag / If-Match,条件写可以避免把别人的修改覆盖掉。412 响应在 RFC 7232(后来被 RFC 9110 吸收)里是“前提条件失败”,不是程序 bug。对 Agent 来说,这个分支要接回对账流程,拉最新版本后重新判断该做什么,而不是直接重试。
第 5 种是工程上最稳但最重的一种:把任务状态和幂等键一起持久化,提交前先落盘,成功或需要确认都先更新状态再返回。进程重启、网络抖动、连续重试都不会丢键。上一篇文章 I 组反向说明了一件事:如果键不跟任务状态走,进程一重启就换新键,等于没有键。所以这一条是给“绝对不能多写”的场景兜底的。
第 6 种是人工确认:在“生成内容”和“提交写入”之间拆出两步,先存草稿,危险动作由人拍板。上一篇文章清单里也建议把生成和提交拆开,因为一旦提交被静默合并,你连哪一版进去了都无法从响应里判断。这一条的实际价值不在自动化,而在削减不可逆写的爆炸半径。
怎么选:一张表
把它们放在一起比,会看出几个清晰的分界。
| 实现 | 主要成本 | 可靠性 | 适合场景 |
|---|---|---|---|
| 1. 响应去重标记 | 低,只解析字段 | 中,依赖服务端诚实 | 服务端契约完整时 |
| 2. 唯一业务键先查 | 低,多一次查询 | 中低,有竞态 | 唯一标识稳定、并发低 |
| 3. 内容指纹对账 | 中,多一次 GET + 存储 | 高 | 内容一致性命关重要的场景 |
| 4. 条件写 If-Match | 低,extra header | 中高,只在更新时有效 | 已知资源更新 |
| 5. 任务状态机 + 持久化 | 高,需要自建状态存续 | 高 | 高可靠自动写 |
| 6. 人工确认兜底 | 中,引入延迟 | 高但慢 | 不可逆或高风险写 |
这张表是按我自己的工程实践排的,不是哪份规范的规定。六种里只有第 1 种和第 5 种与幂等键关系最紧:第 1 种用服务端给的去重标记,第 5 种保证键不因重启丢失。第 2 到第 4 种则更像是在幂等键“失效”或“不存在”时的补充手段。IETF 的 Idempotency-Key 草案要求同键不同体应返回 422,请求仍在处理中返回 409,并要求服务端生成请求指纹来判断是不是同一个请求。所以如果你的服务端符合草案,第 1 种、第 3 种和第 5 种会好做很多;如果服务端只实现了“内容查重 + 一个不写进文档的 deduplicated 字段”,那第 2 种和第 4 种就要多承担一些责任。
上一篇文章没覆盖的三个变量,会改变排序
上一篇的 9 组对照没有测并发同键、键的时间过期窗口、跨接口复用同键。这三个变量会直接影响上面的选型。并发同键如果服务端没锁,可能两次请求都成功写入,那第 1 种和第 5 种都只能事后发现,因此需要第 3 种指纹对账来兜住。键过期窗口如果很短,任务状态机里持久化的键可能在重试时已经失效,那第 2 种唯一业务键先查就变得更重要。跨接口复用同键则完全是未知数,因为服务端对每个接口的键空间是否独立,客户端从响应里推断不出来,只能由文档承诺。官方草案第 10 节也把“作用域和有效期必须文档化”列为服务端义务,所以遇到没有写清的 API,别猜,直接按最坏情况设计。
把这六种实现放回上一篇文章的语境:不是每一个 Agent 写操作都需要六层铠甲。如果只是内部草稿、可逆写、低风险内容,第 1 种加第 2 种已经足够;一旦进入对外发布、权益转移、不可逆动作,第 3、5、6 种是必须的。与其纠结“服务端支不支持幂等”,不如先问自己:如果这次调用返回了 200,但内容根本没进去,我的客户端下一次动作会不会让问题更难恢复。如果答案是会,那对账就不是可选项,而是你替服务端补上的那半套幂等。
参考来源
评论区
登录后可评论。