摘要

传统幂等防的是网络抖动和用户双击,请求体逐字节相同。但 Agent 不一样:它会因为读不懂错误而重新规划,会因为上下文被压缩而忘记自己已经执行过,还会在重新生成参数时把一个自由文本字段换个说法。本文讲一套服务端签发的指纹幂等方案,以及为什么两态状态机会让系统陷入"既不能重试又无法确认"的死角。


一、问题从哪来

设想一个把 LLM Agent 接到外部写接口的网关。Agent 通过工具调用走完"预览 → 确认"两步,最后一步是真的会扣钱、真的不可撤销的。

传统 RPC 的重复请求来源很干净:客户端超时重发、负载均衡重试、用户手抖点了两次。这些都是无意识的机械重复,请求体逐字节相同。

Agent 不是。它的重复来源是这些:

  • 循环重规划:工具返回超时,或者返回了一个它读不懂的错误,Agent 的下一轮推理决定"再调一次试试"。
  • 上下文压缩后的失忆:长对话压缩掉了"我已经执行过了"这条信息,Agent 重新规划出同一个动作。
  • 参数漂移:LLM 重新生成一次工具调用参数,某个自由文本字段从"尽快"变成了"请尽快",其他全一样。这在字节层面是两个请求,在业务层面是同一次操作。
  • 人类的模糊指令:用户说"再来一次",指的是"重试刚才失败的",还是"再执行一次"?

所以在 Agent 场景下,幂等要解决的问题变成了:在调用方不可靠、参数不稳定、且它有自主意志的前提下,如何保证一个花钱的动作在物理世界只发生一次。

二、为什么客户端生成的 UUID 方案会塌

标准做法是让客户端生成一个 Idempotency-Key,服务端按这个 key 去重。这个方案的全部有效性,建立在一个假设上:同一个逻辑请求的重试,会带同一个 key。

这个假设在 Agent 面前不成立。

你要么把 key 的生成交给 LLM —— 那它每次推理都会给你一个新的 UUID,幂等直接失效,你等于什么都没做;要么要求 LLM"记住上次那个 key 并原样带回来" —— 这是在把系统的资金安全,押在一个概率模型对一串 32 位十六进制字符的复述准确率上。

也别指望 Agent 框架层帮你兜住。多数 Harness 的 retry 是在 HTTP 层做的,能覆盖"同一次工具调用的传输重试",覆盖不了"下一轮推理重新发起的同一个动作"。而后者恰恰是重复执行的主要来源。

结论:幂等标识必须由服务端签发,并且由服务端强制回传。 这就是指纹。

三、指纹是什么

指纹(fingerprint)是网关在"预览"阶段签发的一次性凭据,"确认"阶段必须携带,校验通过后立即作废。

它有三个属性,缺一不可:

1. 服务端签发。 Agent 无法凭空构造,只能从上一步的响应里原样搬运。这把"生成正确 key"的责任从 LLM 手里拿走了 —— Agent 只需要做它最擅长的事:复制粘贴一个它在上文里见过的字符串。

2. 绑定业务参数。 指纹内含一份参数摘要 param_hash。确认时重新计算并比对,不一致直接拒。这是在防一个很隐蔽的攻击面:拿着大额操作签发的凭据,去确认一个小额操作。

3. 一次性消费。 状态机保证全局只有一次能进入"执行"路径。

签发时用 HMAC 签名而不是写存储:

payload = base64url({
  "n":   nonce,           // 唯一随机数,防重放
  "exp": 1753500000,      // 绝对过期时间戳
  "u":   subject_id,      // 绑定身份
  "s":   scope,           // 绑定动作
  "p":   param_hash       // 绑定参数
})
fingerprint = payload + "." + HMAC_SHA256(secret, payload)

为什么不在签发时就写 Redis?因为预览的调用量比确认大一个数量级以上。绝大多数预览永远不会走到确认 —— 用户看了一眼就走了。给每次预览都写一条带 TTL 的记录,是纯粹的写放大。签名让签发变成一个无状态的纯计算,只有真正进入确认时才产生一次存储写入。

param_hash 该放哪些字段

这是个业务决策,不是技术决策,而且两边都有代价:

  • 放太多 → 用户在确认页改了个无关紧要的字段,指纹失效,被迫重新走一遍预览,体验碎掉。
  • 放太少 → 关键字段可以被替换,幂等变成了一个可以套利的漏洞。

一条可用的划线原则:凡是影响"钱"和"不可逆后果"的字段,必须进指纹;纯展示、纯附言的字段,不进。

进:金额、币种、收款/接收方标识、标的物标识、数量。
不进:自由文本备注、客户端时间戳、UI 埋点字段、纯展示用的冗余字段。

计算前必须做规范化:key 按字典序排序、数字统一格式(1.01.00 必须哈希一致)、剔除 null 字段。否则你会得到一个每次都不一样的 hash,和没做一样。

四、核心:三态,不是两态

这是整套设计里最容易做错、也最值钱的一点。

绝大多数人的第一版实现是两态的:SETNX 抢到了就执行,没抢到就返回"重复请求"。这个实现有一个致命的中间态没有被表达。

看这条时间线:

t0  Agent 发起确认,网关 SETNX 成功
t1  网关向下游发起写请求
t2  下游已经执行成功,正在返回响应
t3  网关进程 OOM 被杀 / 容器被驱逐
t4  Agent 超时,重试
t5  网关 SETNX 失败 → 返回「重复请求,请勿重复提交」

现在问一个问题:这次操作到底成没成?

网关不知道。Agent 不知道。用户不知道。系统进入了最坏的一种状态 —— 既不允许重试,又无法确认结果。钱可能已经扣了,而界面上显示的是"重复提交"。

两态状态机的根本缺陷是:它把"已经开始"和"已经完成"压缩成了同一个状态。而这两者对调用方意味着完全相反的动作。

正确的状态机是四个状态:

        ┌──────────┐
        │   (空)   │  指纹从未被消费
        └────┬─────┘
             │ CAS 抢占成功
             ▼
        ┌──────────┐
        │ IN_FLIGHT│  已开始,结果未知
        └────┬─────┘  → 重试者应等待、查询,而非重发
             │
      ┌──────┴──────┐
      ▼             ▼
 ┌────────┐   ┌──────────┐
 │  DONE  │   │  FAILED  │
 │ +result│   │ +reason  │
 └────────┘   └──────────┘
  重试者拿回      重试者拿回
  原始结果        确定的失败

关键在于终态必须回填结果。重试不再返回"你重复了",而是返回第一次执行的那个结果本身。对调用方来说,重试和首次调用在语义上完全等价 —— 这才是幂等的真正定义,而不是"第二次给你报个错"。

五、实现

抢占(确认入口)

-- KEYS[1] = idem:{fingerprint_nonce}
-- ARGV[1] = param_hash
-- ARGV[2] = owner(本次执行实例的唯一 ID)
-- ARGV[3] = in_flight TTL 秒
local st = redis.call('HGET', KEYS[1], 'state')

-- 首次:抢占成功,进入 IN_FLIGHT
if st == false then
  redis.call('HSET', KEYS[1],
             'state', 'IN_FLIGHT',
             'param_hash', ARGV[1],
             'owner', ARGV[2])
  redis.call('EXPIRE', KEYS[1], ARGV[3])
  return {'ACQUIRED', ''}
end

-- 参数被换过:直接拒,这是异常信号,要告警
if redis.call('HGET', KEYS[1], 'param_hash') ~= ARGV[1] then
  return {'PARAM_MISMATCH', ''}
end

-- 终态:回放原始结果
if st == 'DONE' or st == 'FAILED' then
  return {st, redis.call('HGET', KEYS[1], 'result')}
end

-- 执行中:让调用方等,而不是让它重发
return {'IN_FLIGHT', redis.call('HGET', KEYS[1], 'owner')}

Lua 脚本在 Redis 里是原子执行的,这保证了"读状态 + 写状态"之间不会被其他请求插入。用 SETNX 加后续几条命令拼出来的版本做不到这一点 —— 并发下会出现两个请求都认为自己是第一个。

落终态

-- KEYS[1] = idem:{fingerprint_nonce}
-- ARGV[1] = owner, ARGV[2] = DONE|FAILED
-- ARGV[3] = result json, ARGV[4] = 终态 TTL
if redis.call('HGET', KEYS[1], 'owner') ~= ARGV[1] then
  return 'NOT_OWNER'   -- key 已过期被别人重新抢占,不能覆盖
end
redis.call('HSET', KEYS[1], 'state', ARGV[2], 'result', ARGV[3])
redis.call('EXPIRE', KEYS[1], ARGV[4])
return 'OK'

owner 校验不是多余的。如果 IN_FLIGHT 的 TTL 到期、key 被清掉、另一个重试抢占成功,那么老的执行流回来写结果时,必须知道自己已经不是持有者了 —— 否则它会用一个陈旧的结果覆盖掉新的执行。

兜底:Redis 不是真相

上面这套是快路径,作用是拦掉绝大多数重复,以及提供可回放的结果。但它不能是唯一防线 —— Redis 会主从切换丢数据,会被误 flush,会在扩容时抖动。

最终真相必须落在数据库的唯一约束上:

CREATE TABLE `write_record` (
  `id`        BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `idem_key`  CHAR(32)        NOT NULL,
  -- ... 业务字段
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_idem` (`idem_key`)
);

插入时撞唯一索引,不要报错给上层,而是 SELECT 回那条已存在的记录原样返回。这条路径平时永远不会被触发,但它是 Redis 全挂之后系统还能守住"不重复执行"这条底线的唯一原因。

顺带回答一个常见争论:Redis 不可用时,涉及资金的接口应该 fail-open 还是 fail-closed? 一个更好的答案是既不放行也不拒绝,而是降级到纯 DB 唯一索引路径 —— 慢一点,但正确性不打折。直接放行等于在故障期间关掉幂等,这是拿钱赌可用性;直接拒绝则把一次缓存故障放大成一次全量业务中断。

六、TTL:最容易埋雷的地方

三个 TTL,三种定法,搞错任何一个幂等都会出现空洞。

指纹有效期(exp):用户从看到预览到点确认的合理时长,通常十几到几十分钟。太短会出现"用户去接了个电话回来就点不了了"。注意它要和下游的预览/报价有效期对齐 —— 指纹还活着但下游报价已经过期,是另一类事故。

IN_FLIGHT TTL必须严格大于「下游最坏处理耗时 + 网关自身超时 + 重试退避总时长」。

这条是重灾区。如果 IN_FLIGHT 的 TTL 只设了十几秒,而下游在极端情况下要更久才返回,那么之后到来的重试会发现 key 已经不存在,于是它会被当成一个全新的首次请求放行 —— 你精心设计的幂等,在最需要它的慢请求场景下,恰好失效了。而慢请求正是重试最密集的场景。宁可设长,不要设短。

终态 TTL:必须 ≥ 调用方最长重试窗口。Agent 的重试窗口比人类长得多,一个卡住的工作流可能十几分钟后才回来问。建议直接对齐对账周期,按天计。存储成本可以忽略。

七、别忘了下游

自己幂等了,不代表链路幂等了。网关到下游之间同样有超时重试,同样会产生重复执行。

指纹的 nonce 应当作为 Idempotency-Key 透传给下游,并且同一个 nonce 在整条链路上保持不变 —— 包括网关自己发起的每一次内部重试。很多人在重试逻辑里顺手重新生成了一个 key,等于在最内层把幂等拆了。

如果下游根本不支持幂等 key(这在不少第三方接口里都很常见),那么你的 IN_FLIGHT 超时后就绝对不能盲目重试,只能走"查询下游执行状态"的对账路径。做不到查询的,就要接受人工介入。这时候把 IN_FLIGHT 的语义设计对,价值就完全体现出来了:它至少告诉你"这一笔需要人来看一眼",而不是让你在两个都错的选项里挑一个。

八、AI Gateway 特有的一件事:错误语义要写给 LLM 看

这是我认为传统网关和 AI Gateway 差别最大的地方。

传统客户端拿到 409 Conflict,代码里 if status == 409 分支怎么写就怎么走,行为完全确定。LLM 拿到一个裸的 409 和一句 “duplicate request”,它的下一步是推理出来的 —— 而模型有很强的倾向去"再试一次看看",尤其是当错误信息看起来像一个临时性故障时。

所以返回体必须是结构化的、语义明确的、并且主动堵死重试念头的:

{
  "status": "already_completed",
  "meaning": "该操作在此前的调用中已经成功执行,本次为重复调用。",
  "instruction": "不要再次调用本工具。直接向用户播报下方结果。",
  "retryable": false,
  "result_ref": "<已生成的业务单据号>",
  "trace_id": "<链路追踪 ID>"
}

对照 IN_FLIGHT 的返回:

{
  "status": "in_progress",
  "meaning": "该操作正在处理中,结果尚未确定。",
  "instruction": "不要重新调用本工具。等待 2 秒后调用状态查询工具确认结果。",
  "retryable": false,
  "poll_with": "<状态查询工具名>",
  "poll_after_ms": 2000
}

两者都是 retryable: false,但给出的下一步动作完全不同。把"不可重试"和"该做什么"同时写清楚,LLM 的行为才稳定。只写前者,它会自己发明后者。

这也呼应了软失败原则:幂等拦截返回的是一个明确、可继续的结果,而不是一个会打断整个工作流的异常。Agent 拿到它之后应该能顺畅地走完剩下的播报动作。

九、七个坑

  1. IN_FLIGHT TTL 短于下游最坏耗时 → 幂等在慢请求下失效,而慢请求正是重试高发区。
  2. 指纹不绑身份 → A 的指纹泄漏后能被 B 消费,幂等漏洞升级成越权漏洞。
  3. 指纹不绑参数 → 同一个凭据换金额、换收款方,套利入口。
  4. 只有两态 → 崩溃后进入"不可重试且不可判定"的死角。
  5. 只在网关做幂等 → 存在绕过网关直连下游的旁路,防线形同虚设。
  6. 把 Redis 当作真相来源 → 一次主从切换就是一批重复执行。真相在 DB 唯一索引。
  7. 给 Agent 返回模糊错误 → 模型自行决定重试,你的幂等被调用方的"自主意志"绕过。

十、怎么测

幂等是那种"平时怎么测都对,出事时全错"的功能。必须构造异常来测:

  • 并发压测:高并发打同一个指纹 → 断言 DB 恰好 1 行,恰好 1 个 ACQUIRED,其余全是 replay 或 in_flight。
  • 崩溃注入:在"下游已返回、结果未落终态"之间 kill -9 → 重试必须能得到一个确定结论(DONE 或需对账),不能是"重复提交"。
  • 缓存清空:执行成功后 FLUSHDB 再重放 → DB 唯一索引必须兜住。
  • 参数篡改:同指纹改金额 → 必须拒,且必须告警(这是安全事件,不是业务错误)。
  • 越权重放:A 的指纹用 B 的身份 → 必须拒。
  • TTL 边界:在 exp 前 1 秒和后 1 秒各打一次 → 一过一拒,边界不能模糊。
  • 乱序:终态回填晚于下一次抢占到达 → NOT_OWNER 保护必须生效。

结语

写完这套之后我最大的体感是:幂等不是一个 SETNX,它是一个必须可判定的状态机。

SETNX 只回答了"是不是第一次";而线上真正会伤到你的问题是"上一次到底成没成"。前者是一个布尔值,后者是一个状态加一份结果 —— 这两者的工程量差了一个数量级,但只有后者能在凌晨三点下游超时的时候救你。

在 Agent 场景下这一点被进一步放大:你的调用方不再是一段行为确定的代码,而是一个会读你的错误信息、然后自己决定下一步的模型。你返回的每一个字段,都是在给它写 prompt。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐