OpenClaw 的 Gateway 对 Agent 请求做了幂等性处理。为什么 Agent 系统特别需要幂等性?工具已经产生副作用时怎么办?

魏远标 Lv7

参考答案

幂等性就是”同一个操作执行一次和执行一百次,效果完全一样”。

Agent 的一次运行可能涉及多个工具调用,花费几十秒到几分钟。在这个过程中,网络断连、客户端重试、消息队列重投都很常见。

如果没有幂等保护,同一个请求可能触发 Agent 重复跑一遍,轻则重复发消息,重则重复执行命令、重复扣费,后果非常严重。

OpenClaw 通过 idempotencyKey + 内存 dedupe Map 来做幂等。每个请求带一个 UUID 作为幂等键,Gateway 先在 dedupe Map 里查一把:有缓存就直接返回,没有才真正执行,执行完再把结果存进去。缓存有 TTL 和最大条目数限制,不会无限膨胀。

工具已经产生副作用时怎么办?这个问题其实没有完美方案。

OpenClaw 的策略是防止重复触发,不做回滚。已经执行的副作用就让它留着,重点保证后续不会再重复执行。

扩展知识

dedupe Map 的实现细节

看代码 src/gateway/server-methods/agent.ts

1
2
3
4
5
6
7
8
9
10
11


typescript

复制代码

const cached = context.dedupe.get(`agent:${idem}`);
if (cached) {
respond(cached.ok, cached.payload, cached.error, { cached: true });
return;
}

dedupe Map 的维护在 src/gateway/server-maintenance.ts,定期清理过期条目,超过 DEDUPE_TTL_MS 就删掉,超过 DEDUPE_MAX 就按时间戳淘汰最老的。

实际占用很小,一个条目就是一个 UUID 加一份响应快照,几万条也就几十 MB。

消息发送的双层防护

消息发送场景更复杂。send.ts 里除了 dedupe Map,还有一个 inflightMap 防止并发发送同一条消息。

两层防护叠加:dedupe 管”历史上发过没有”,inflight 管”当前是不是正在发”。

Chat 方法还有一层额外保护,用 transcriptHasIdempotencyKey() 检查 transcript 中是否已存在某个幂等键,避免重复追加消息到对话历史。三层保护各管一段,组合起来基本堵死了重复执行的可能。

处理已产生副作用的策略

实践中一般从三个层面去缓解:

1)让工具本身尽量设计为幂等的。比如”写入文件”天然幂等,同样的内容写两遍结果一样;”发送消息”就不行,发两遍用户收到两条。

2)对不可幂等的工具,在工具侧做去重。比如发消息前先检查”这条消息是不是已经发过了”,用消息 ID 做幂等键。

3)接受”至多一次”语义。宁可漏执行也不重复执行,对很多业务场景来说,漏发一条通知比重复发十条要好得多。

面试官追问

提问:内存里的 dedupe Map 挂了怎么办?重启后幂等保护不就失效了?

回答:确实,内存 dedupe 在进程重启后就丢了。对于 OpenClaw 这种单实例部署的场景,重启窗口很短,实际风险不大。如果要做到更强的保证,得把幂等状态持久化到 Redis 或数据库里,不过这会引入额外的 IO 开销和复杂度,得根据业务容忍度权衡。

提问:分布式环境下多个 Gateway 实例,幂等怎么保证?

回答:单实例的内存 Map 搞不定多实例场景,因为请求可能落到不同节点。这时候得换成集中式存储,比如 Redis 的 SETNX,用幂等键做 key,设一个和 TTL 一致的过期时间。或者在请求入口加一致性哈希,保证同一个幂等键的请求总是路由到同一个实例。

提问:幂等键用 UUID,那客户端怎么知道该用同一个 UUID 重试?

回答:客户端在第一次发起请求时生成 UUID 作为幂等键,然后本地缓存住。如果需要重试,带上同一个 UUID 就行。消息平台的 Webhook 场景更简单,平台本身每条消息都有唯一 ID,直接拿来当幂等键,天然支持重试去重。

提问:dedupe 缓存的 TTL 设多长合适?

回答:TTL 得跟 Agent 的最大执行时间挂钩。如果一个 Agent 最长跑 5 分钟,TTL 起码设到 10 分钟,留出足够的重试窗口。内存方面靠 DEDUPE_MAX 限制最大条目数,超了就按时间戳淘汰最老的,不用担心撑爆内存。

目录
OpenClaw 的 Gateway 对 Agent 请求做了幂等性处理。为什么 Agent 系统特别需要幂等性?工具已经产生副作用时怎么办?