Agent 系统比传统 Web 服务更需要幂等性,原因在于 Agent 的一次运行涉及多步工具调用,耗时几十秒到几分钟,期间网络断连、客户端重试、消息队列重投都是常态。OpenClaw 的 Gateway 通过 idempotencyKey 加双层去重 Map 保证同一请求不会被重复执行。对于工具已产生副作用的情况,框架层没有自动回滚方案,核心策略是防止后续重复触发,并通过工具设计层面的幂等约束来收敛风险。
一、为什么 Agent 系统特别需要幂等性
传统 API 请求通常在几百毫秒内返回,重试窗口极短。Agent 请求完全不同——一次运行可能包含多次 LLM 推理和工具调用,耗时可达数分钟。长执行时间意味着更高的重试概率:
| 重试来源 | 触发原因 | 后果严重度 |
|---|---|---|
| 网络断连 | 客户端 WebSocket 断开,自动重连后重发 | 高:可能重复执行命令 |
| 消息队列重投 | 消息中间件的 at-least-once 语义 | 高:可能重复扣费 |
| 客户端超时重试 | 用户等不及,手动刷新或重发 | 中:可能重复发消息 |
| LLM 不确定性 | 同一输入产出不同工具序列 | 高:幂等键无法覆盖 |
如果没有幂等保护,同一个请求触发 Agent 重复运行一遍,轻则重复发消息,重则重复执行命令、重复扣费。对于涉及资金转移或数据写入的场景,后果尤其严重(来源:besthub.dev OpenClaw 控制面协议解析)。
1.1 Agent 重试与普通 API 重试的区别
普通 API 的幂等设计通常只关注单个 HTTP 请求的重复发送。Agent 系统的复杂之处在于,一次”逻辑请求”内部包含多步操作,且每步操作可能产生不可逆的副作用。
| 对比维度 | 普通 API 请求 | Agent 请求 |
|---|---|---|
| 执行时长 | 毫秒级 | 秒到分钟级 |
| 内部步骤 | 单次处理 | 多步 LLM 推理 + 工具调用 |
| 副作用范围 | 通常单个资源 | 可能涉及多个外部系统 |
| 重试窗口 | 极短 | 很长,重试概率高 |
| 幂等粒度 | 请求级 | 需要请求级 + 工具级 |
二、OpenClaw 的双层去重机制
OpenClaw 把幂等键设为请求 schema 中的必填字段,并在两个层面执行去重(来源:besthub.dev 控制面协议解析)。
2.1 幂等键的生成
Gateway 接收消息时,根据消息来源、会话标识和消息体计算唯一的 idempotencyKey。请求参数中包含的完整字段如下:
{
message: "帮我创建用户张三",
agentId: "main",
sessionKey: "agent:main:cli",
sessionId: "sess_abc123",
model: "claude-sonnet-4",
deliver: true,
channel: "cli",
replyChannel: "cli",
timeout: 300000,
lane: "default",
idempotencyKey: "idem_7f3a2b8c" // 必填字段
}
Gateway 生成幂等键后,先检查去重表中是否已有记录。没有记录才真正执行,执行完毕后将结果写入缓存。已有记录则直接返回缓存的响应,不再重复处理(来源:gitcode.csdn.net OpenClaw 解析)。
2.2 第一层:context.dedupe 跨请求缓存
第一层是一个跨请求的缓存表,存储已完成的结果。请求到达时先查这个表:
// 第一层:跨请求缓存
const dedupeKey = `agent:${idem}`;
const cached = context.dedupe.get(dedupeKey);
if (cached) {
respond(cached.ok, cached.payload, cached.error, { cached: true });
return; // 直接返回缓存结果,不执行
}
2.3 第二层:inflightByContext 并发合并
第二层是按连接维护的 WeakMap,处理同一请求的并发重复发送。两个相同的请求同时到达时,第二个请求不会触发新的执行,而是等待第一个完成:
// 第二层:并发请求合并
const existing = inflight.get(dedupeKey);
if (existing) {
const result = await existing; // 等待正在执行的相同请求
respond(result.ok, result.payload, result.error);
return;
}
// 没有正在执行的相同请求,开始执行
const promise = executeAgent(request);
inflight.set(dedupeKey, promise);
const result = await promise;
context.dedupe.set(dedupeKey, result); // 写入第一层缓存
inflight.delete(dedupeKey);
两层去重写入 accepted 响应的时机相同——立即返回相同的 runId,让重试方收到一致的结果。执行完成后再用最终结果或错误覆盖缓存(来源:besthub.dev 控制面协议解析)。
2.4 去重表的生命周期
| 属性 | 值 | 说明 |
|---|---|---|
| 作用域 | 会话级 | 按 sessionKey 隔离 |
| 时间窗口 | 5 至 30 分钟 | 过期条目自动清除 |
| 最大条目数 | 有限制 | 防止内存无限膨胀 |
| 清理策略 | TTL 过期 + 容量上限 | 双重保障 |
三、两阶段协议与 runId 机制
OpenClaw 的控制面采用两阶段协议,把”接受”和”执行完成”分开,防止 Gateway 线程阻塞:
connect.challenge(nonce) ← 服务端推送 nonce
connect(req, auth, device) → hello-ok(methods, events, snapshot, policy)
agent(message, idempotencyKey) → accepted(runId) ← 立即硬确认
event:agent(streaming, seq) ← 异步推送
agent(final) → ok/error(runId, summary) ← 最终结果
3.1 两阶段协议的工作流程
- WebSocket 连接以
connect帧开始,服务端推送挑战 nonce - 客户端在 10 秒内完成签名认证,超时则连接关闭(code 1008)
- Agent 命令分两步返回:先返回
accepted(runId)表示已接收,再异步推送agent(final)表示执行完成 runId是这次运行的唯一标识,重试时返回相同的runId,让客户端知道这是缓存结果
这种设计把接受和执行解耦。Gateway 收到请求后立即返回 runId,不会因为 Agent 执行慢而阻塞线程。客户端可以用 runId 轮询或订阅事件来获取最终结果(来源:besthub.dev 控制面协议解析)。
3.2 连接安全握手
握手阶段执行三条规则保证连接可信:
| 规则 | 说明 |
|---|---|
| 首帧必须是 connect | 任何其他方法在握手成功前到达,服务端关闭连接 |
| 10 秒握手超时 | 硬编码限制,超时返回 close(1008, “handshake timeout”) |
| 挑战-签名防重放 | 非本地连接需签名随机 nonce,旧签名不可复用 |
认证按固定优先级执行:可信代理 → 限流 → Tailscale 验证 → Token/密码检查。
四、工具已产生副作用时怎么办
幂等去重解决了”整条消息不重复处理”的问题,但 Agent 内部的工具调用仍可能因重试而产生副作用。OpenClaw 采取的策略是在工具设计中强制执行幂等约束,这是对工具开发者的要求,而非框架层的自动保障(来源:gitcode.csdn.net OpenClaw 解析)。
4.1 设计原则一:工具应该”设定状态”而非”触发事件”
// 非幂等:每次调用都发新邮件
api.registerTool({
name: "send_email",
async execute(_id, params) {
return await mailer.send(params.recipient, params.subject, params.body);
}
});
// 幂等:设定状态,已是目标值则跳过
api.registerTool({
name: "ensure_user_state",
parameters: Type.Object({
user_id: Type.String(),
state: Type.Object({ last_notification_sent: Type.String() })
}),
async execute(_id, params) {
const current = await db.getUserState(params.user_id);
if (current.last_notification_sent === params.state.last_notification_sent) {
return { content: [{ type: "text", text: "已处于目标状态,跳过" }] };
}
await db.setUserState(params.user_id, params.state);
return { content: [{ type: "text", text: "状态已更新" }] };
}
});
4.2 设计原则二:关键工具携带去重 Token
对于无法设计为幂等的工具(如扣减库存),要求调用时携带 idempotencyKey 参数,由工具后端自行实现去重逻辑:
api.registerTool({
name: "deduct_inventory",
parameters: Type.Object({
sku: Type.String(),
quantity: Type.Number(),
idempotency_key: Type.String() // 必填去重 Token
}),
async execute(_id, params) {
// 工具后端检查 idempotency_key 是否已处理
const result = await inventoryService.deduct({
sku: params.sku,
quantity: params.quantity,
idempotencyKey: params.idempotency_key
});
return { content: [{ type: "text", text: JSON.stringify(result) }] };
}
});
4.3 设计原则三:返回值明确表示”已处理”或”跳过”
Agent 需要从工具返回值中判断操作是否产生了实际效果,从而调整后续行为:
| 返回状态 | 含义 | Agent 后续行为 |
|---|---|---|
processed |
操作已执行,产生了实际效果 | 继续后续步骤 |
skipped |
操作未执行,因已是目标状态 | 跳过依赖此结果的后续步骤 |
error |
操作失败 | 根据错误类型决定重试或中止 |
4.4 框架层的处理策略
Gateway 检测到重复请求时,直接返回缓存的响应,不再执行。已经执行的副作用保留,不做回滚。重点保证后续不会重复执行。
以一个具体场景说明:用户消息触发 Agent 两次工具调用 create_user("张三") 和 send_welcome_email("张三")。第一次执行成功,用户已创建、邮件已发送。消息因网络问题被重放,Gateway 检测到相同的 idempotencyKey,返回缓存的响应,用户不会收到第二封邮件。
但如果 Agent 在处理同一消息时,因 LLM 的不确定性选择了不同的工具序列,幂等去重就失效了——因为 idempotencyKey 是消息级的,覆盖不了同一消息内部的工具调用差异。这就是为什么工具层面的幂等设计不可或缺(来源:gitcode.csdn.net OpenClaw 解析)。
五、落地时的注意事项
TTL 设置过短会导致正常的长耗时请求被误判为过期。Agent 任务动辄数十秒,去重表 TTL 应至少覆盖最大执行时长,建议设为 30 分钟。
幂等键生成不稳定会破坏去重效果。幂等键应基于消息来源、会话标识和消息体计算,不能包含时间戳等不稳定字段。客户端重发时必须携带相同的幂等键。
工具层面的幂等缺失是框架无法兜底的盲区。idempotencyKey 只保护消息级去重,工具内部的副作用必须由工具开发者负责。对涉及资金、数据写入的工具,强制要求携带去重 Token 是必要的。
常见问题(FAQ)
Q1:幂等键的 TTL 默认多久? 会话级去重表时间窗口通常为 5 至 30 分钟,过期自动清除。
Q2:工具已经产生副作用,Gateway 会回滚吗? 不会。Gateway 不做回滚,只防止后续重复执行,已执行的副作用保留。
Q3:同一消息内 LLM 选了不同工具序列,幂等还有效吗? 消息级幂等失效。工具级幂等需由工具开发者通过去重 Token 自行保障。