Skip to content

重试与 Token 放大陷阱:Agent 里的重试会指数烧钱 ​

1. 本节产出 ​

一套 Agent 场景下的重试策略:区分「可安全重试」与「重试即重复付费」的调用、幂等键机制、以及一个重试预算(整个运行内重试次数上限)。并且能算出「重试让这次任务贵了多少」。

2. 前置依赖 ​

3. 为什么 Agent 里的重试比普通服务危险 ​

普通服务重试一次:多一次 HTTP 调用,成本可忽略。

Agent 里重试一次的代价:

轮次 N 的模型调用失败 → 重试
   ├─ 多花:这次调用的输入 Token(上下文已累积到几万)
   ├─ 多花:生成的新输出
   ├─ 多等:30~60s
   └─ 连带:整个任务的上下文继续增长

关键差异:Agent 的输入上下文随轮次增长,所以越往后重试越贵。第 8 轮重试一次的成本,可能是第 1 轮的 5 倍。

一个真实数字:某任务 10 轮、每轮平均 3000 Token,其中 3 次触发重试:

项数值
无重试的 Token3 万
含重试的实际 Token4.7 万
放大倍数1.57×

而这只是 3 次重试。 如果每轮都重试,最坏情况接近 2 倍。

4. 核心原理 ​

4.1 两类调用,重试策略完全不同 ​

调用类型重试是否安全策略
只读工具(查询、检索)✅ 安全可重试 2 次
LLM 生成调用⚠️ 花钱但幂等最多重试 1 次
写操作工具(下单、发消息)❌ 危险禁止自动重试,必须走幂等键
已超时的调用❌ 不确定视服务端是否已完成而定,谨慎

写操作的自动重试是最危险的一类:工具可能已经执行成功了,只是响应丢了。重试 = 重复下单/重复扣款。

4.2 幂等键:写操作的唯一安全重试方式 ​

第一次调用:  生成 idempotencyKey = hash(runId + toolName + args)
              带上 key 调用工具
              工具侧:key 已存在 → 直接返回上次结果(不重复执行)

重试时:      用同一个 key
              → 即使第一次成功了,也不会重复执行

幂等键必须由调用方生成并随工具传递,工具侧要支持。这需要下游系统配合——如果下游不支持幂等,那么写操作就绝对不能自动重试,只能转人工或走审批。

4.3 重试预算(本节核心机制) ​

retryBudget = maxRetriesPerRun(例如 3)

每次要重试前:
   if (usedRetries >= retryBudget) → 不重试,转降级
   usedRetries++

为什么需要预算:没有预算时,10 轮每轮重试 1 次 = 10 次重试。有了预算,整个运行最多 3 次重试,成本放大有明确上限。

这是「总成本可控」和「成本可能翻倍」之间的分界线。

4.4 重试的优先级:越靠后越不值得 ​

第 1 轮失败 → 重试价值高(后面还有很多轮要跑)
第 8 轮失败 → 重试价值低(任务快结束了,不如直接降级)

实现:重试预算按轮次递减。前期允许 2 次,后期只允许 0 次(直接降级)。

5. 代码走查 ​

5.1 重试预算 ​

java
// src/main/java/com/example/harness/guard/RetryBudget.java
public final class RetryBudget {

    private final int total;
    private final AtomicInteger used = new AtomicInteger();

    public RetryBudget(int total) { this.total = total; }

    /** 是否还有预算;轮次越靠后越保守 */
    public boolean tryAcquire(int iteration, int maxIterations) {
        // 后 1/3 的轮次不再重试,直接降级
        if (iteration > maxIterations * 2 / 3) {
            log.info("轮次 {}/{} 已过重试窗口,直接降级", iteration, maxIterations);
            return false;
        }
        if (used.get() >= total) {
            log.warn("重试预算已用尽 {}/{}", used.get(), total);
            return false;
        }
        used.incrementAndGet();
        return true;
    }

    public int used() { return used.get(); }
}

5.2 分类重试执行器 ​

java
// src/main/java/com/example/harness/guard/RetryGuard.java
@Component
public class RetryGuard {

    public <T> T execute(CallKind kind, RetryBudget budget,
                         int iteration, int maxIterations, Supplier<T> fn) {

        if (kind == CallKind.WRITE_TOOL) {
            // 写操作:绝不自动重试
            return fn.get();
        }

        try {
            return fn.get();
        } catch (Exception first) {
            if (!isTransient(first)) throw first;

            if (!budget.tryAcquire(iteration, maxIterations)) {
                return fallbackFor(kind, first);      // 降级
            }

            sleep(Backoff.nextDelay(1));
            try {
                return fn.get();
            } catch (Exception second) {
                return fallbackFor(kind, second);
            }
        }
    }

    public enum CallKind { LLM, READ_TOOL, WRITE_TOOL }
}

5.3 幂等键 ​

java
// src/main/java/com/example/harness/tool/IdempotencySupport.java
@Component
public class IdempotencySupport {

    /** 同一 runId + 工具 + 参数 → 同一个 key */
    public String keyOf(String runId, String toolName, Map<String, Object> args) {
        String raw = runId + "|" + toolName + "|" + canonically(args);
        return DigestUtils.sha256Hex(raw);
    }

    /** 执行前查:已执行过则直接返回上次结果 */
    public Optional<String> previousResult(String key) {
        return repo.findByKey(key).map(ToolExecutionRecord::result);
    }

    public void record(String key, String result) {
        repo.save(new ToolExecutionRecord(key, result, Instant.now()));
    }
}

5.4 成本放大统计 ​

java
// 记录重试带来的额外开销
public void recordRetry(int iteration, int tokens) {
    meters.counter("agent.retry", "iteration", bucket(iteration)).increment();
    meters.counter("agent.retry.tokens").increment(tokens);
}

// 运行结束时输出
log.info("运行 {} 完成:总 Token {},其中重试消耗 {}(放大 {:.2f}x)",
        runId, total, retryTokens, total / (double) (total - retryTokens));

6. 跑起来 ​

bash
git checkout ch03-08-retry-trap
mvn -q test -Dtest=RetryBudgetTest
bash
# 1. 注入前期限流错误
curl -X POST http://localhost:8080/api/agent/run \
  -d '{"goal":"查订单","fault":"ratelimit-early"}'
# 期望:前几轮重试成功,任务完成,日志显示放大倍数

# 2. 注入全程限流(预算耗尽)
curl -X POST http://localhost:8080/api/agent/run \
  -d '{"goal":"查订单","fault":"ratelimit-all"}'
# 期望:重试预算用尽后转降级,任务仍完成(质量下降)

# 3. 写操作不重试验证(关键)
curl -X POST http://localhost:8080/api/agent/run \
  -d '{"goal":"下单","fault":"write-timeout"}'
# 期望:日志显示「写操作不自动重试」,且订单没有被创建两次

# 4. 幂等验证
# 手动用同一个 runId 重放一次写工具调用
# 期望:第二次直接返回上次结果,不重复执行
检查项通过标准
预算生效重试次数不超过配置上限
后期不重试后 1/3 轮次直接降级
写操作不重试日志明确显示跳过重试
幂等同 key 重复调用不重复执行
放大可见结束时输出放大倍数

第三项是安全红线:写操作被重试一次,可能就是一笔重复业务。

7. 生产避坑 ​

  1. 写操作工具绝对不能自动重试。这是本节最硬的一条规则:工具可能已执行成功只是响应丢失,重试就是重复业务(重复下单、重复扣款)。正确做法是幂等键 + 失败转人工/审批。
  2. 必须有重试预算。只设「每次调用最多重试 N 次」是不够的——10 轮每轮重试 1 次就是 10 次。预算让整个运行的成本放大有明确上限,这是唯一能控制总成本的做法。
  3. 重试次数要按轮次递减。后期重试的性价比很低(任务快结束了,且上下文很大导致重试很贵)。后 1/3 轮次直接降级,能省下最贵的那部分重试。

8. 延伸与锚点 ​

  • 思考题:厂商持续故障 30 分钟,重试预算会被每个任务各自消耗一遍,整体仍是一遍遍冲击故障服务。怎么在系统层面止损?(答案在下一课时:熔断)
  • 代码锚点:git checkout ch03-08-retry-trap
  • 下一课时:03-09 熔断与降级
  • 对应课件:L03-08 重试陷阱