Appearance
迭代上限与死循环检测:Agent 一夜烧掉几万元的故事
1. 本节产出
三层循环防护:硬迭代上限、重复动作检测、无进展检测。三者组合能把「死循环烧钱」从「一夜几万元」降到「几分钟内自动停止」。
2. 前置依赖
- 03-01 ReAct 范式:已有基础循环
- 03-05 状态机与持久化
3. 为什么死循环是 Agent 最贵的故障
一个真实事故复盘:
周五 22:00 用户提交一个调研任务,Agent 开始运行
周五 22:03 任务卡在某个查不到的信息上,开始反复重试
周五 22:10 达到 10 次迭代上限?——没有,因为设了 50 次
周六 08:00 运维发现账单异常:一夜烧掉 3.7 万元三个教训:
| 问题 | 说明 |
|---|---|
| 迭代上限设得太大 | 50 次意味着最坏情况烧 50 轮的钱 |
| 没有重复检测 | 同一个工具用同样参数调了 30 次,每次都失败 |
| 没有成本上限 | 只限制了轮次,没限制钱 |
死循环的可怕之处:它不报错、不崩溃,只是安静地烧钱。 等发现时已经是第二天。
4. 核心原理
4.1 三层防护
第一层:硬迭代上限
iteration > maxIterations → 停止
作用:无论如何都有个尽头
第二层:重复动作检测
同一个 (toolName + args) 在最近 K 轮内出现过 → 判定重复
作用:抓住「反复重试同一个失败操作」
第三层:无进展检测
最近 K 轮的 Observation 高度相似(或都为失败) → 判定无进展
作用:抓住「换着花样但毫无进展」三层各抓不同类型的循环:
| 循环类型 | 被哪层抓住 |
|---|---|
| 无限调用但每次不同 | 第一层 |
| 反复调用同一个工具同一参数 | 第二层 |
| 换个工具但结果都一样(都失败) | 第三层 |
4.2 重复检测怎么实现
动作指纹 = hash(toolName + canonicalJson(args))
维护最近 K 个指纹的窗口
新动作的指纹已在窗口中 → 计数 +1
同一指纹出现 >= 2 次 → 警告
同一指纹出现 >= 3 次 → 停止为什么允许出现 2 次:有时重试一次是合理的(比如第一次超时)。3 次就基本可以确定是循环。
4.3 无进展检测
收集最近 3 轮的 Observation
如果满足以下任一:
a) 全部是失败/超时/NOT_FOUND
b) 两两相似度 > 0.9(内容几乎一样)
→ 判定无进展,停止或强制结束实现相似度可以用简单的文本哈希,也可以用向量——用哈希更便宜(不需要额外模型调用)。
4.4 更根本的一道防线:成本上限
if (tokenUsed > budgetTokens || costEstimate > budgetYuan) {
强制结束,返回已完成部分
}这是最直接的防线:不管循环不循环,钱花到上限就停。建议按任务类型设置预算,比如:
| 任务类型 | 迭代上限 | Token 预算 | 总超时 |
|---|---|---|---|
| 简单查询 | 5 | 2 万 | 60s |
| 标准任务 | 10 | 8 万 | 180s |
| 复杂调研 | 20 | 30 万 | 600s |
这套「三限额」表是本节最实用的产出,可直接用于项目配置。
5. 代码走查
5.1 循环检测器
java
// src/main/java/com/example/harness/guard/LoopDetector.java
@Component
public class LoopDetector {
private static final int WINDOW = 5;
private static final int MAX_REPEAT = 3;
/** 返回停止理由,null 表示未检测到循环 */
public String check(List<StepRecord> history) {
if (history.size() < 3) return null;
// 1. 重复动作检测
Map<String, Long> recent = history.stream()
.skip(Math.max(0, history.size() - WINDOW))
.filter(s -> s.action() != null)
.map(s -> fingerprint(s.action()))
.collect(Collectors.groupingBy(f -> f, Collectors.counting()));
Optional<Long> maxRepeat = recent.values().stream().max(Long::compare);
if (maxRepeat.isPresent() && maxRepeat.get() >= MAX_REPEAT) {
return "检测到重复动作:同一工具与参数已调用 %d 次".formatted(maxRepeat.get());
}
// 2. 无进展检测:最近 3 轮 Observation 都失败
List<String> last3 = history.stream()
.skip(Math.max(0, history.size() - 3))
.map(StepRecord::observation)
.toList();
if (last3.size() == 3 && last3.stream().allMatch(this::isFailure)) {
return "连续 3 步未取得进展(工具均返回失败)";
}
// 3. 无进展检测:最近 3 轮 Observation 内容高度相似
if (last3.size() == 3 && allSimilar(last3)) {
return "连续 3 步返回高度相似的结果,判定无进展";
}
return null;
}
private String fingerprint(Action a) {
return DigestUtils.sha256Hex(a.toolName() + "|" + canonical(a.args()));
}
private boolean isFailure(String obs) {
return obs == null || obs.startsWith("ERROR")
|| obs.startsWith("NOT_FOUND") || obs.startsWith("TIMEOUT")
|| obs.startsWith("UNAVAILABLE");
}
private boolean allSimilar(List<String> texts) {
Set<String> hashes = texts.stream()
.map(t -> DigestUtils.sha256Hex(t.trim().toLowerCase()))
.collect(Collectors.toSet());
return hashes.size() == 1; // 完全一样
}
}5.2 接入循环
java
// src/main/java/com/example/harness/agent/GuardedAgentRunner.java
public AgentResult run(String runId, String goal, Limits limits) {
for (int i = 1; i <= limits.maxIterations(); i++) {
// 第一层:迭代上限
if (i > limits.maxIterations()) {
return stop("达到迭代上限 " + limits.maxIterations());
}
Action action = decide(state);
// 第二/三层:循环检测(在工具调用前,省下这次调用的钱)
String loop = detector.check(state.history());
if (loop != null) {
log.warn("运行 {} 检测到循环:{}", runId, loop);
Metrics.recordLoopDetected(loop);
// 给模型最后一次机会:强制它总结并结束
return forceFinish(state, loop);
}
// 成本上限
if (state.tokenUsed() > limits.budgetTokens()) {
return stop("达到 Token 预算 " + limits.budgetTokens());
}
... 执行与迭代
}
}
public record Limits(int maxIterations, long budgetTokens, Duration totalTimeout) {}循环检测放在工具调用前:检测到就不执行工具了,直接省下这次调用。
5.3 强制结束
java
private AgentResult forceFinish(AgentState state, String reason) {
// 让模型基于已有信息给出结论,而不是硬返回「失败」
String summary = chatClient.prompt()
.system("基于已有的执行记录,给出目前能得出的最好结论。不要再调用工具。")
.user(String.join("\n", state.history()))
.call().content();
return AgentResult.stopped(summary, reason, state.tokenUsed());
}强制结束时让模型总结已有信息,比直接返回「任务失败」有用得多。用户至少得到了部分成果。
6. 跑起来
bash
git checkout ch03-10-loop-detection
mvn -q test -Dtest=LoopDetectionTest三种循环场景各测一次:
bash
# 1. 反复调用同一工具同一参数
curl -X POST http://localhost:8080/api/agent/run \
-d '{"goal":"查询一个不存在的订单","fault":"always-not-found"}'
# 期望:3 次后停止,日志「检测到重复动作」
# 2. 换工具但都失败
curl -X POST http://localhost:8080/api/agent/run \
-d '{"goal":"查数据","fault":"all-tools-fail"}'
# 期望:3 步后停止,日志「连续 3 步未取得进展」
# 3. 无重复但一直不结束
curl -X POST http://localhost:8080/api/agent/run \
-d '{"goal":"开放式任务","fault":"never-finish"}'
# 期望:达到迭代上限停止
# 4. 成本上限
curl -X POST http://localhost:8080/api/agent/run \
-d '{"goal":"复杂任务","limits":{"budgetTokens":5000}}'
# 期望:Token 超 5000 后停止期望的对比(这是本节的价值证明):
无防护: 运行 50 轮,消耗 42 万 Token,耗时 18 分钟,成本 3.7 元/次
三层防护后: 运行 4 轮即停止,消耗 3.2 万 Token,耗时 42s,成本 0.28 元/次| 检查项 | 通过标准 |
|---|---|
| 三种循环都能抓 | 每种场景在 5 轮内停止 |
| 停止有产出 | 返回已有信息的总结,不是「失败」 |
| Token 上限 | 超过预算立即停止 |
| 指标可见 | agent.loop_detected 有计数 |
| 告警 | 检测到循环时通知(高频循环可能说明 prompt 有问题) |
7. 生产避坑
- 迭代上限不要设得太大。50 次的「上限」等于没有上限——最坏情况仍然会烧 50 轮的钱。合理值是预期轮次的 1.5~2 倍(预期 8 轮就设 12~15)。上限的作用是兜底,不是让它真的跑那么多次。
- 必须同时设 Token 预算,不能只设轮次。不同轮次的成本差异很大(后期上下文大),只限制轮次无法限制成本。「三限额」表(轮次 + Token + 超时)要一起配。
- 检测到循环后要告警,不能只静默停止。高频出现循环通常说明 prompt 设计或工具设计有问题(比如工具描述不清导致模型反复试)。把循环日志当作需求优化的线索,而不是当成正常现象。
8. 延伸与锚点
- 思考题:循环防住了,但如果 Agent 调用了一个它不该调用的工具(比如查询工具却能删数据)呢?(答案在下一课时:权限沙箱)
- 代码锚点:
git checkout ch03-10-loop-detection - 下一课时:03-11 工具权限沙箱
- 对应课件:L03-10 死循环检测