财务对账发现两笔一模一样的退款
7 月 2 日上午,财务在群里贴了两行流水:
2026-07-01 22:14:07 RF2026070122140701 SO20260628009 -12,900.00 成功
2026-07-01 22:14:09 RF2026070122140903 SO20260628009 -12,900.00 成功
同一个订单,2 秒内退了两次。金额 1.29 万,不算大,但性质严重——如果是 100 万呢?
这篇记录我们排查这次重复扣款的完整过程,以及之后重做的 Agent 幂等体系。涉及工具调用的副作用治理、重试安全边界和补偿机制三块,是这两周最花精力的事。
排查:一次意图,两次执行
先拉 trace。我们的链路追踪接了 OpenTelemetry GenAI 语义约定,每个 tool call 都有独立 span。
$ otel-cli query --trace-id 4f8a2c91e7d3b015 --format tree
chat claude-sonnet 8.42s
├── tool query_order(orderNo=SO20260628009) 210ms OK
├── tool check_refund_policy(orderNo=...) 180ms OK
└── tool apply_refund(orderNo=..., amount=12900) 1.9s TIMEOUT(2000ms)
└── retry apply_refund(orderNo=..., amount=12900) 1.4s OK
原因一眼可见:第一次调用超时了,框架重试了一次,但第一次其实已经成功了。
这里有个更隐蔽的问题。第一次 apply_refund 显示 TIMEOUT 是我们网关侧的超时,资金系统那边 1.9 秒时其实已经执行完并在写响应,只是网络回程慢了一拍。我们查了资金系统的日志:
[22:14:07.912] refund apply orderNo=SO20260628009 amount=12900.00 status=SUCCESS
[22:14:09.340] refund apply orderNo=SO20260628009 amount=12900.00 status=SUCCESS
WARN duplicate_order_refund_detected, but no idempotency key provided
资金系统本身有防重,但它依赖调用方传幂等键。我们的 Agent 没传,它只能放行。
统计了一下过去 30 天:工具调用总数 1,842 万次,其中重试 12.9 万次(0.70%)。这 12.9 万里,31% 是写操作,也就是约 4 万次写操作重试。这次只是第一次真正造成资金损失。
根因:我们把工具调用当成了函数调用
问题不在重试策略,在于心智模型。
写业务代码时,我们天然认为"方法调用失败 = 没有执行"。这个假设在进程内成立,跨网络不成立。而 Agent 框架把工具调用包装得特别像函数调用——@Tool 注解、自动序列化、自动回调——这种便利性掩盖了它是个 RPC 的事实。
更糟的是,Agent 场景下这个问题被放大了:
- 模型不知道什么是幂等,它可能因为"感觉上一步没成功"而重复调用同一个工具,这在我们的 trace 里出现过 1,200 多次;
- Agent 的"计划 - 执行"循环可能有多个分支,同一个工具在不同分支被调用;
- 多轮对话里,用户说"再试一次",Agent 会从头执行,包括已经成功的步骤。
我们之前的工具定义长这样,完全没有副作用信息:
@Tool(description = "为指定订单申请退款,amount 单位为分")
public RefundResult applyRefund(String orderNo, long amount) {
return refundClient.apply(orderNo, amount);
}
第一步:给工具标注副作用等级
先做分类,才有可能做差异化的重试策略。我们定了四级:
| 等级 | 含义 | 举例 | 默认重试 | 占比 |
|---|---|---|---|---|
| READ | 只读,无副作用 | query_order | 可重试 3 次 | 58% |
| IDEMPOTENT_WRITE | 写,但天然幂等 | update_user_tag | 可重试 2 次 | 11% |
| CONDITIONAL_WRITE | 写,带前置条件,条件冲突即失败 | deduct_stock(if version=X) | 可重试 2 次 | 9% |
| NON_IDEMPOTENT_WRITE | 写,重复执行有实质损害 | apply_refund / send_sms | 不自动重试 | 22% |
注解化:
@Retention(RUNTIME)
@Target(METHOD)
public @interface ToolSpec {
SideEffect effect();
boolean compensable() default false;
String compensator() default "";
int timeoutMs() default 3000;
}
public enum SideEffect {
READ, IDEMPOTENT_WRITE, CONDITIONAL_WRITE, NON_IDEMPOTENT_WRITE
}
@Tool(description = "为指定订单申请退款,amount 单位为分")
@ToolSpec(effect = NON_IDEMPOTENT_WRITE,
compensable = true,
compensator = "refundCompensator",
timeoutMs = 5000)
public RefundResult applyRefund(String orderNo, long amount, ToolContext ctx) {
return refundClient.apply(orderNo, amount, ctx.idempotencyKey());
}
启动时会扫描所有 @Tool,缺 @ToolSpec 的直接 fail-fast。宁可启动失败,也不要让一个未分类的工具上线。这条规则第一周就拦下 7 个工具。
第二步:幂等键怎么生成
这是整个方案里最容易做错的一环。
第一版我们用 sessionId + toolName + 参数哈希。上线第二天就出问题:用户先退了 100 元,确认到账后又退了 50 元,参数不同所以键不同,没问题;但反过来,同一个用户在同一会话里对同一订单重复发起"退款 100 元",第二次被幂等拦掉了——而这次是用户的真实意图(他以为第一次没成功,重新点了一次,其实是想再确认)。
更麻烦的是:模型生成的参数不稳定。同一意图两次调用,amount 可能一次是 12900、一次是 12900.0,或者多一个空格。参数哈希直接失效。
最终方案:幂等键由编排层生成,不依赖模型输出;并按"执行步骤"而非"内容"生成。
public final class IdempotencyKeys {
/**
* 结构:{sessionId}:{turnNo}:{stepNo}:{toolName}
* turnNo = 第几轮用户对话
* stepNo = 本轮内第几次工具调用(含重试,重试不递增)
*/
public static String of(ToolContext ctx) {
return "%s:%d:%d:%s".formatted(
ctx.sessionId(), ctx.turnNo(), ctx.stepNo(), ctx.toolName());
}
}
这样设计有明确的语义:
- 重试(网络超时、5xx)复用同一个键,服务端幂等返回首次结果;
- 模型主动再调一次(stepNo 不同)是新的一次执行,放行,但会触发下面的"疑似重复"告警;
- 用户新的一轮对话(turnNo 不同)键不同,符合预期。
键需要持久化,因为要跨重试。存 Redis,TTL 24 小时:
public <T> T execute(String key, Supplier<T> call, SideEffect effect) {
String lockKey = "idem:" + key;
// 已完成:直接返回缓存的结果
byte[] cached = redis.get(lockKey + ":result");
if (cached != null) {
metrics.counter("tool.idempotent_hit", "tool", toolName).increment();
return codec.decode(cached);
}
// 进行中:说明上一次还没返回,不能重放,抛特定异常让上层等待或人工介入
if (!redis.setNx(lockKey + ":lock", "1", Duration.ofSeconds(30))) {
throw new InFlightException(key);
}
try {
T r = call.get();
redis.setEx(lockKey + ":result", codec.encode(r), Duration.ofHours(24));
return r;
} finally {
redis.del(lockKey + ":lock");
}
}
InFlightException 这个分支是踩坑加的。最初的写法只有"已完成"判断,结果并发重试时两个请求都发现没有结果、都往下执行,还是重复了。7 月 8 日压测重现出来的,加了锁之后 200 并发下零重复。
第三步:重试策略按等级分化
public RetryPolicy policyFor(SideEffect e) {
return switch (e) {
case READ -> RetryPolicy.of(3, backoff(200, 2.0), retryOn(TIMEOUT, 5xx));
case IDEMPOTENT_WRITE, CONDITIONAL_WRITE
-> RetryPolicy.of(2, backoff(500, 2.0), retryOn(5xx)); // 不重试 TIMEOUT
case NON_IDEMPOTENT_WRITE
-> RetryPolicy.of(1, backoff(0, 1.0), retryOn(CONNECT_FAIL));
};
}
NON_IDEMPOTENT_WRITE 只对"连接都没建立"这种确定没执行的错误重试,次数 1 次(即不重试,只是统一走流程)。超时和 5xx 一律不重试,转为状态未知(UNKNOWN)态,交给下面的流程处理。
这里有个重要的认知转变:超时不是失败,是"不知道"。把 UNKNOWN 当 FAILED 处理,是重复执行的根源。
第四步:事务边界与补偿
Agent 里没有分布式事务这件事,很多人理解,但落实到代码还是容易糊。
我们的规则是:一个 Agent 步骤 = 一个本地事务,步骤之间用 Saga 补偿。绝不存在"跨三个工具调用的全局事务"这种设计。
public class AgentSaga {
private final Deque<Compensation> stack = new ArrayDeque<>();
public void run(List<Step> steps) {
for (Step s : steps) {
StepResult r;
try {
r = s.execute();
} catch (Exception ex) {
compensate();
throw new AgentStepFailedException(s.name(), ex);
}
if (r.status() == UNKNOWN) {
// 关键:先查证,再决定是否补偿
StepStatus actual = s.reconcile();
if (actual == FAILED) { compensate(); throw new AgentStepFailedException(s.name()); }
if (actual == UNKNOWN) { escalateToHuman(s); return; } // 查不出来,停手
}
if (s.spec().compensable()) {
stack.push(Compensation.of(s, r));
}
}
}
private void compensate() {
while (!stack.isEmpty()) {
Compensation c = stack.pop();
try {
c.undo();
} catch (Exception ex) {
// 补偿失败不能吞,必须落库进人工队列
compensationLog.markFailed(c, ex);
alerting.page("compensation_failed", c.describe());
}
}
}
}
每个可补偿的工具必须实现 reconcile()——用幂等键去下游查证真实状态。这是整套方案的基石:宁可多一次查询,不要盲补偿。
@Component("refundCompensator")
public class RefundCompensator implements Compensator<RefundResult> {
public StepStatus reconcile(String idemKey) {
RefundRecord r = refundClient.queryByIdemKey(idemKey);
return r == null ? FAILED : SUCCESS;
}
public void undo(RefundResult r) {
// 冲正,而不是删除。资金流水不允许物理删除
refundClient.reverse(r.refundNo(), "AGENT_SAGA_ROLLBACK");
}
}
冲正这个选择也是被教育出来的。第一版我写的是"调用撤销接口",结果资金系统那边撤销接口有 24 小时窗口限制,超时的单子撤不掉。改成冲正(生成一笔反向流水)之后,成功率从 91% 提到 99.2%。
上线后的数据
7 月 20 日全量上线,跑了三周:
| 指标 | 改造前(6 月) | 改造后(7/20-8/10) |
|---|---|---|
| 写操作重复执行 | 约 4.0 万次/月 | 0 |
| 资金类事故 | 1 起(¥12,900) | 0 |
| 幂等命中率(重试被拦) | — | 0.68% |
| Saga 补偿触发次数 | — | 143 次 |
| 补偿成功率 | — | 99.2%(1 起转人工) |
| UNKNOWN 态升级人工 | — | 8 单/月 |
| 写操作 P99 延迟增加 | — | +42ms(Redis 幂等检查) |
写操作 P99 增加 42ms 是完全可以接受的代价。UNKNOWN 态每月 8 单人工,这 8 单我们会逐条复盘,目前看主要是下游对账接口本身超时导致的,正在推动资金系统修。
还有几个没解决的
必须说清楚边界,不然这套方案听着太完美了。
不可补偿的写操作仍然危险。发短信、发邮件、调用第三方物流下单,这些没有 undo。我们现在的做法是把它们挪到整个 Agent 流程的最后一步执行,并且要求前置步骤全部成功。但如果模型在中途改变了计划、已经发过短信了才发现要改,无解。目前只能靠 prompt 约束和人工审核,我还没找到工程上干净的解法。
多 Agent 协作下的幂等键冲突。两个 Agent 同时操作一个订单,sessionId 不同所以键不同,各自都认为是新操作。我们加了基于业务实体(orderNo)的分布式锁,但这引入了新的超时和死锁问题,正在重构。
模型重复调用工具的行为没法根治。每月还有 1,200 多次模型"自作主张"重复调用同一个工具。幂等键能挡住内容完全一致的调用,但模型稍微改个参数(比如 amount 从 12900 改成 129.00 元)就绕过去了。现在靠一个后置的相似度检测告警,准确率大概 70%,误报不少。
先到这
《Agent 执行的幂等性与事务边界》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。