Skip to content

迭代上限与死循环检测:Agent 一夜烧掉几万元的故事 ​

1. 本节产出 ​

三层循环防护:硬迭代上限、重复动作检测、无进展检测。三者组合能把「死循环烧钱」从「一夜几万元」降到「几分钟内自动停止」。

2. 前置依赖 ​

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 预算总超时
简单查询52 万60s
标准任务108 万180s
复杂调研2030 万600s

这套「三限额」表是本节最实用的产出,可直接用于项目配置。

5. 代码走查 ​

5.1 循环检测器 ​

java
// ch03-agent/src/main/java/com/aitech/agent/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
// ch03-agent/src/main/java/com/aitech/agent/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. 生产避坑 ​

  1. 迭代上限不要设得太大。50 次的「上限」等于没有上限——最坏情况仍然会烧 50 轮的钱。合理值是预期轮次的 1.5~2 倍(预期 8 轮就设 12~15)。上限的作用是兜底,不是让它真的跑那么多次。
  2. 必须同时设 Token 预算,不能只设轮次。不同轮次的成本差异很大(后期上下文大),只限制轮次无法限制成本。「三限额」表(轮次 + Token + 超时)要一起配。
  3. 检测到循环后要告警,不能只静默停止。高频出现循环通常说明 prompt 设计或工具设计有问题(比如工具描述不清导致模型反复试)。把循环日志当作需求优化的线索,而不是当成正常现象。

8. 延伸与锚点 ​

  • 思考题:循环防住了,但如果 Agent 调用了一个它不该调用的工具(比如查询工具却能删数据)呢?(答案在下一课时:权限沙箱)
  • 代码锚点:git checkout ch03-10-loop-detection
  • 下一课时:03-11 工具权限沙箱
  • 对应课件:L03-10 死循环检测