Administrator
发布于 2026-07-05 / 2811 阅读
14

Agent 执行的幂等性与事务边界

财务对账发现两笔一模一样的退款

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 执行的幂等性与事务边界》这块我前前后后踩了不止一次。今天先写这些,后面想到新的再补。

参考