content/02-agent/harness/agent-doom-loop.md

一个模糊词让 Agent 空转 5 步

先说结论。一个 ReAct 助手被喂进两个字「背景」,连搜 5 次、调 10 次 LLM、跑了 35 秒,最后撞满步数上限,硬编出一段《今日天气与某设备能耗的关联分析》。问题不在模型不够强,在这套 agent loop 只有「步数上限」一道刹车:它既不判断「这几步有没有进展」,也不给模型一个「我做不到」的出口,于是对着一个查不出结果的模糊任务一路撞墙。

缺的从来不是更高的步数上限,是无进展检测和弃权式终止。下面用这次压测把这条线讲透,日志已脱敏。


序幕 · 钻到核心

一条脱敏日志加五个为什么,先把核心挖出来。

0. 现场:两个字打出 5 步空转

我在压一个基于 ReAct 的领域助手(能查数据、调工具、联网搜索),故意发了一个极度模糊的输入看它怎么处理:只发两个字,「背景」。前一轮我刚问过「今天天气」,更早聊过「某设备运行异常」。

服务端把每次调用都落了 JSONL,userQuery="背景" 那条结构一目了然:

userQuery     : 背景
outcome       : max_rounds        ← 撞上限被强制收尾
roundsUsed    : 5   maxRoundsCfg: 5
toolCallCount : 5                  ← 5 次工具调用,全是联网搜索
llmCallCount  : 10                 ← 5 轮 LLM + 多次滚动摘要
durationMs    : 35875              (35.8 秒)

逐轮展开,前端那张「推理过程(5 步)」的卡片就是这么来的:

阶段工具备注
0initial预告:「我来搜一下天气」
1–5loopwebSearch连续 5 次联网搜索,信息零增益
6synthesis撞上限,强制合成最终答案

两个细节最能说明问题。一是 outcome=max_rounds,它不是想停,是被步数上限砍停的。二是前后自相矛盾:round 0 说「想查天气」,round 6 又改口「原来『背景』是指设备异常的背景信息」,同一轮对话两个解释打架,说明它全程在猜。

所以「5 步」就是模型对着一个查不出结果的模糊任务,反复搜索直到撞满 5 轮上限。

1. 五个为什么:顺着 5 步钻到底

别急着分类、别急着上方案,先纵向钻到底:

  1. 5 步从哪来? → 模型对着一个查不出结果的任务反复搜,直到撞满 5 轮。
  2. 为什么反复搜? → 它拿到的是一个空数组,不知道「这条路走不通」,只觉得「可能没搜好,再试一次」。
  3. 为什么不知道走不通? → 联网搜索失败/查不到时,代码 catch 掉异常、返回空 List,没有任何「失败 / 无结果」的显式信号回灌给模型。
  4. 为什么连搜 5 次没人喊停? → 循环唯一的退出条件是「模型不再要求调工具」或「撞 maxRounds」,全程没有「这几步有没有进展」的判断。
  5. 为什么第一步就走深度调查? → 系统提示是「勤奋调查型」(主动调查原因、逐步收集信息),它不区分输入复杂度,模糊输入也照样触发深度调查。

空结果被静默吞掉的那段,是整条链的导火索:

catch (Exception e) {
    log.warn("search failed: {}", e.getMessage());
    return Result.builder().results(List.of()).build();  // ← 静默返回空数组
}

而那个只会撞墙的循环,本质就这么几行:

while (response.hasToolCalls() && round < maxRounds) {
    // 执行工具 → 再问 LLM → 看它还要不要调工具
}

两条最底层的道理

  • (A) 模型看不见工具的「失败 / 空」,就会把它当成「没搜好」无限重试。 工具返回值是模型唯一的感官,感官里没有「失败」这个信号,它就只会重试。
  • (B) 只有步数上限的循环,不是会收敛的循环,是会撞墙的循环。 maxRounds 判的是「还能跑几轮」,不是「这几轮有没有用」。

下面三幕,都顺着这两条展开。


第Ⅰ幕 · 问题的本质

先把问题归位,再看它在 Agent 领域是什么经典毛病。

2. 先定位:这是什么问题,不是什么问题

排查第一步是别走错房间。这事最容易被误诊成「模型 / prompt 问题」,其实不沾边:

内容
不是模型不够强 / prompt 不好 / tool-calling 不可靠——那属模型选型与解码,是另一篇文章
Agent loop 的不收敛 / 退化循环,业界口语叫 doom loopagent spinning
同类的东西AutoGPT 早期的无限刷任务、ReAct agent 的重复动作、各种「跑着跑着停不下来」的自动化 loop
在 loop 的位置reason → act → observe 的循环控制本身,也就是「停止判据」这一层塌了

推理和工具都没坏,坏的是「什么时候该停」这层判断。

3. 一个复合体:三个已命名的子问题

把业务抽象掉,这个 case 是三个经典子问题凑在一起:

子问题学名本案表现
无进展的重复工具调用Unproductive / repeated tool-call loop连刷 5 次相同搜索,信息零增益
对简单输入过度推理Overthinking / over-acting两个字触发 35 秒深度调查
不确定时编造而非弃权Confabulation under uncertainty撞上限后硬编「天气×能耗关联」

根因范畴其实只有一句话:缺少有效的停止判据(stopping criterion)和无进展检测(no-progress detection)。当前唯一的刹车 maxRounds 是最钝的兜底,它不是收敛机制,只是撞墙才停。


第Ⅱ幕 · 最该补的一层

三个子问题对应几道护栏,但真正缺的、也最常被漏的,只有一道。

业界对 agent loop 的标准护栏,按「入口控深度 → 过程防空转 → 出口优雅终止 → 底线兜底」四层来配。这四层里,本案缺得最致命的是过程层的无进展检测

护栏作用本案状态
入口自适应计算深度按复杂度分配预算,简单/模糊输入走浅路径,治 overthinking❌ 一套「勤奋调查」套所有输入
过程无进展 / 循环检测重复或零增益动作即停,治 repeated-loop完全没有(最致命)
工具层显式失败反馈把「没拿到」明确回灌,而不是返回空数组❌ 静默吞成空数组
出口优雅终止 + 弃权到预算如实认输,不编造,治 confabulation❌ 撞上限硬编答案
底线步数 / 成本预算maxRounds 这种上限,兜底而非主收敛✅ 只有这一层,所以一路撞墙

无进展检测是最核心也最常被跳过的一层,因为它要引擎去回答「这一步带来新信息了吗」,比加一个 maxRounds++ 麻烦得多。但少了它,循环就只能靠撞墙停。出口层的弃权同样关键:没有一个合法的「我做不到」出口,模型到了预算只能硬编。


第Ⅲ幕 · 怎么修

抽象护栏落回工程,就是几条按性价比排序的小改动。

4. 落地映射

改动对应护栏改动面
系统提示加「克制原则」:能直接答就答,查不到就如实说,别反复凑入口·自适应深度仅提示词
搜索空结果改成显式文本「未检索到结果,请勿重试或编造」工具层·显式反馈工具返回值
循环加「连续空结果 / 重复动作」早停过程·无进展检测引擎循环
撞上限时输出「信息不足」而非硬编出口·弃权式终止合成兜底

最便宜的一刀是工具层那条:把空数组改成一句明确的「没检索到」,模型立刻就知道收手,改动只在工具返回值。前两行(提示词、工具返回值)甚至不用碰引擎,下面先看提示词这一刀长什么样。

提示词这一刀:从「勤奋调查」改成「克制 + 诚实」

本案的系统提示原本是勤奋导向的,工作方式从头到尾都在鼓励「逐步收集、主动调查」,却不区分输入复杂度:

## 工作方式
1. 分析意图,判断需要哪些数据
2. 逐步调用工具收集信息,每次调用后评估是否足够回答
3. 发现异常数据(如能耗同比 +20%)即主动调查原因
4. 诊断出故障且用户要求处理,创建维修工单
5. 综合所有数据给结论

改动是给它加一条克制优先、把「主动调查」收上相关性约束,再补一段原本完全缺失的「诚实与边界」:

## 工作方式
0. 【克制优先】能直接答就直接答,不为「更完整」反复调工具
2. …每次评估「是否已足够回答」,足够就立即作答,不再追加调用
3. 仅当异常数据「且与当前问题直接相关」时才延伸调查,无关的延伸不做

## 诚实与边界(原本完全缺失)
- 工具返回空 / 失败:如实说「未获取到相关信息」,不用相同参数重试,不编造
- 输入含糊:基于最合理理解给一句简短回应,不自行假设大段上下文再展开
- 不确定的内容明确标注,不要把推测当事实

效果差别很直接:改造前,「背景」跑 35 秒、5 步、撞上限、硬编关联分析;提示词配上工具层的空结果显式反馈之后,「背景」变成 1~2 步、几秒,给一句克制回应(「不太确定『背景』指什么,需要我做什么可以再说」),不再触发工具循环。

但别把提示词当成银弹。它管得住「第一步要不要勤奋」和「认不认输」,管不住一个已经拿到空数组的模型——空数组在它眼里就是「没搜好」,提示词劝不动。所以最核心的一刀仍在引擎里的无进展检测,下面单讲。

5. 无进展检测:三种常见做法

过程层这道护栏有几种成熟技术,按实现成本从低到高:

  • 动作去重:对 (tool, args) 或观察结果做 hash,连续重复即判定空转,break。最便宜,本案直接能挡住「连搜 5 次相同 query」。
  • 重复动作熔断:连续 N 次同类无效动作(比如都返回空)强制收敛。
  • 信息增益判据:每步让模型自问「这一步带来新信息了吗」,没有就停(Reflexion / self-critique 那一路)。最贵但最通用。

6. 横向对照:业界怎么解

方案机制对应本文哪层适用 / 局限
LangGraph recursion_limit + 自定义 should_continue图执行的步数上限 + 可插停止条件底线 + 过程停止条件要自己写,默认仍是步数上限
Reflexion每步自我批评,无进展则修正或停过程·信息增益多一轮 LLM 开销,换收敛性
动作去重 / loop detection对动作或观察做 hash 比对过程·去重最轻,挡不住「换个 query 但同样无效」
Calibrated refusal / abstention训练或提示模型在不确定时说「不知道」出口·弃权治 confabulation 的根本,需提示或微调配合
Step / cost budgetmaxRounds、token 预算底线兜底,不能当主收敛机制(本案教训)

MVP 阶段,先上「动作去重 + 显式失败反馈 + 弃权兜底」三条,复杂度最低就拿到九成收益;信息增益判据和 Reflexion 留给以后真要上深度调查时再说。


第Ⅳ幕 · 沉淀

把这次压测收成一张能复用的清单,再留三条心得。

7. 可复用检查清单

  • 工具的失败 / 空结果,是显式回灌给模型,还是被静默吞成空数组?
  • 循环除了步数上限,有没有「无进展 / 重复动作」的早停判据?
  • 撞预算时,是如实说「信息不足」,还是硬编一个答案?
  • 系统提示区不区分输入复杂度?模糊输入会不会照样触发深度调查?
  • 压测有没有专门喂模糊 / 无解输入,把缺失的护栏照出来?

8. 三条心得

  1. 模糊输入是 Agent 的照妖镜。正常 query 看不出问题,一个「背景」就把缺失的护栏全照出来了,压测要专门喂模糊和无解输入。
  2. 工具的失败必须是一等公民。静默返回空,等于让模型在黑暗里乱撞;明确告诉它「失败了 / 没有」,它才知道收手。
  3. 步数上限不是收敛机制。真正让 Agent 收敛的是「有没有进展」和「能不能体面地认输」,而不是「还能跑几轮」。

9. 参考

  • 业界:Reflexion(self-critique 停止)、LangGraph recursion_limit 与自定义停止条件、calibrated abstention / refusal、ReAct 的重复动作问题
  • 关联问题域:弱模型 tool-calling(约束解码 / tool_choice / 解析重试)是另一类问题,别和「循环不收敛」混;沙箱运行时可靠性见 agent-sandbox-lifecycle-reliability
  • 案例原始证据(私有):源项目压测 JSONL 日志 + 修复计划

接着写的话:给团队看的工作文档,重点放第Ⅱ幕那张四层护栏表和第Ⅲ幕的落地映射;个人博客,重点放开头的「两个字打 5 步」现场、第Ⅰ幕的三个学名子问题、和 §8 的照妖镜比喻。骨架已经搭好,扩写时把这次压测落到每一幕里就行。

评论