Appearance
重试与 Token 放大陷阱:Agent 里的重试会指数烧钱
1. 本节产出
一套 Agent 场景下的重试策略:区分「可安全重试」与「重试即重复付费」的调用、幂等键机制、以及一个重试预算(整个运行内重试次数上限)。并且能算出「重试让这次任务贵了多少」。
2. 前置依赖
3. 为什么 Agent 里的重试比普通服务危险
普通服务重试一次:多一次 HTTP 调用,成本可忽略。
Agent 里重试一次的代价:
轮次 N 的模型调用失败 → 重试
├─ 多花:这次调用的输入 Token(上下文已累积到几万)
├─ 多花:生成的新输出
├─ 多等:30~60s
└─ 连带:整个任务的上下文继续增长关键差异:Agent 的输入上下文随轮次增长,所以越往后重试越贵。第 8 轮重试一次的成本,可能是第 1 轮的 5 倍。
一个真实数字:某任务 10 轮、每轮平均 3000 Token,其中 3 次触发重试:
| 项 | 数值 |
|---|---|
| 无重试的 Token | 3 万 |
| 含重试的实际 Token | 4.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=RetryBudgetTestbash
# 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. 生产避坑
- 写操作工具绝对不能自动重试。这是本节最硬的一条规则:工具可能已执行成功只是响应丢失,重试就是重复业务(重复下单、重复扣款)。正确做法是幂等键 + 失败转人工/审批。
- 必须有重试预算。只设「每次调用最多重试 N 次」是不够的——10 轮每轮重试 1 次就是 10 次。预算让整个运行的成本放大有明确上限,这是唯一能控制总成本的做法。
- 重试次数要按轮次递减。后期重试的性价比很低(任务快结束了,且上下文很大导致重试很贵)。后 1/3 轮次直接降级,能省下最贵的那部分重试。
8. 延伸与锚点
- 思考题:厂商持续故障 30 分钟,重试预算会被每个任务各自消耗一遍,整体仍是一遍遍冲击故障服务。怎么在系统层面止损?(答案在下一课时:熔断)
- 代码锚点:
git checkout ch03-08-retry-trap - 下一课时:03-09 熔断与降级
- 对应课件:L03-08 重试陷阱