给 ReAct 装一个熔断闸 —— Agent 无进展检测的工程落地
这是 agent-doom-loop 的姊妹篇。 上一篇诊断了「退化循环」缺的是哪层护栏;这一篇只回答一个问题:知道病因之后,药该怎么开、怎么落到代码里。
起因
上一篇的结论是:一次「今日天气」查询,Agent 连续 5 轮换着关键词重复搜索(天气 → 今日天气 气温 → …晴雨 → …),直到撞满 maxRounds 才停,白白绕了 35 秒。归因是「搜索质量差(触发器)」乘上「引擎没有无进展熔断(放大器)」。
诊断完,工程问题才开始:这两个因素先修哪个、怎么修、修到什么程度算 MVP。很多人下意识去修搜索质量(换更好的引擎、加重排),但这条路既贵又治标。这篇讲该先修哪个、那一件怎么落地。
先分清两层:触发器 vs 放大器
这是最容易踩错的地方。我第一版就修错了对象:以为搜索结果是空的,想做「空结果反馈」,结果根本不空,只是不相关。
| 触发器:搜索质量差 | 放大器:无熔断 | |
|---|---|---|
| 它在哪一层 | 数据源层(搜索引擎、结果后处理) | 引擎控制层(Agent 的循环逻辑) |
| 改它要动什么 | 换引擎、加相关性重排、加时效过滤、query 收敛 | 在 ReAct 循环里加一个判断 |
| 成本 | 高,要调相关性算分/调参,治标 | 低,几十行,治本 |
| 不改的后果 | 结果偶尔烂,任何系统都难免 | 只要烂一次,就无限放行换词重搜 |
搜索质量差在任何系统都会偶发,但有护栏的 Agent 不会因此绕 5 步。让它空转的不是结果烂,是结果烂之后没人喊停。
所以优先级很清楚:先修放大器。它跟搜索质量正交,最稳也最便宜,是唯一能直接挡住空转的硬护栏。搜索质量优化重要,但那是 v2 的事。
一个常见的反面操作:去服务端把搜索引擎从差换成中(比如自建 SearXNG 多挂几个引擎)。这有用,但它改的是触发概率,放大器原封不动,换词空转该发生还是发生,只是少一点。别把数据源优化当成熔断的解,它解的是另一道题。
通用解法:可靠性护栏的「四件套」
这个问题的学名,在 Agent 失效模式里叫 looping / thrashing(兜圈、来回震荡),是 ReAct 类 Agent 最常见的失效之一。它属于「控制流与终止」(control flow & termination),细分是停滞检测 (no-progress / stagnation detection)。
对付它有一套标准武器库,按「便宜 → 重」排:
| # | 护栏 | 干什么 | 关系 |
|---|---|---|---|
| 1 | 重复检测 / 环检测 (loop detection) | 对 (action, args) 做指纹,命中历史即判兜圈 | MVP 主角 |
| 2 | 无进展检测 (no-progress / stagnation) | 定义「进展信号」,连续 N 轮无新增信息即熔断 | ①的升级,v2 |
| 3 | 预算上限 (budget limits) | max rounds / tokens / tool-calls 兜底 | 多数项目已有 |
| 4 | 降级作答 (graceful degradation) | 熔断后不报错,强制「用现有信息尽力答」 | 必须和①配套 |
注意 ③ 预算上限(maxRounds)几乎人人都有,但它是 time-based,撞墙才停;而我们要的是 progress-based,没在前进就停。两者都管「何时停」,但触发逻辑不同,maxRounds 救不了墙到来前的 5 轮空转。
MVP 只取 ① + ④ 两件,指纹去重加降级作答;② 留 v2,③ 已有。
无进展检测怎么做
落到 ReAct 循环里,最薄的实现是这样。
在哪儿挂
ReAct 循环长这样(化简):
while response.hasToolCalls() and round < maxRounds:
response = llm(messages) # 模型决定调哪个工具
results = execute(response.toolCalls)
messages = messages + results # 结果塞回上下文
round++
熔断点选在拿到带 toolCalls 的 response 之后、execute 之前。在执行前判断,连这一次冗余搜索的耗时都能省掉。还有个好处:如果循环里在 execute 前已经向前端发了「正在调用 webSearch…」的卡片事件,检测块要插在那之前,否则会留下永不完成的孤儿卡片。
三步:归一化 → 指纹 → 比对
归一化(query):
去首尾空白 → 转小写 → 去标点/全角符号
→ 切 token:中文按【单字】拆,英文/数字按【空白词】拆 → token 集合
指纹 = 归一化后的 token 集合
相似 = Containment(A,B) = |A∩B| / min(|A|,|B|) ≥ 0.8
这里有个坑,我第一版就栽了:很多人下意识用 Jaccard(|A∩B| / |A∪B|),但它对这个用例根本不成立。
- 「北京天气」= {北,京,天,气}(4 个字)
- 「北京今日天气 气温」= {北,京,今,日,天,气,温}(7 个字)
- Jaccard = 4/7 = 0.57 < 0.8,熔断永远不触发,feature 直接变成死代码。
根因是:换词空转的真实形态是保留核心词、增删修饰词,小集合几乎被大集合包含。所以该用包含系数(Containment),度量小 query 有多少被大 query 覆盖:
| A | B | A∩B | min | Containment | 判定 |
|---|---|---|---|---|---|
| 北京天气 | 北京今日天气 气温 | 4 | 4 | 4/4 = 1.0 | 相似(换词空转)✓ |
| 北京天气 | 能源补贴政策 | 0 | 4 | 0 | 不相似(正常换题)✓ |
还有个前提:中文必须切到单字粒度。若只按空白切,中文整串极少相等,相似度恒为 0,熔断形同虚设。选错相似度量、切错分词粒度,是这里最容易写挂的两个点,而且都不会报错,只会静默失效。
计数与熔断
seenFingerprints = [] # 本次请求累积的历史 webSearch 指纹(maxRounds 很小,全留即可)
noProgressCount = 0
每轮 execute 之前:
fps = 本轮 webSearch 调用的 query 指纹
if 任一 fp 与历史指纹 Containment ≥ 0.8:
noProgressCount++
else:
noProgressCount = 0 # ← 关键:出现新进展立刻清零
seenFingerprints += fps
if noProgressCount ≥ THRESHOLD(2):
break # 跳出循环,转「降级作答」
两个工程细节,都来自踩坑:
- 只对搜索类工具做指纹,且只在本轮全是 webSearch 时才熔断。业务工具(查能耗、查设备状态)重复调用往往合法(不同参数、分页);更关键的是工具是整批执行的,同一轮
[webSearch, queryDeviceStatus]一旦熔断,会把那个合法的设备查询也一起跳掉。所以混合轮一律放行、不参与计数。 - noProgressCount 要会清零。判定连续无进展,中间只要出现一次真进展(不相似的搜索,或一轮纯业务工具)就重置,否则一次正常的递进搜索会被历史累积误判。
善后:降级作答(第 ④ 件,别忘)
熔断只是 break,但不能让模型一脸懵地中断。break 后必须接一段降级作答,往上下文塞一句 system 提示:
「已就相似查询搜索多次且无新信息,请基于现有结果作答,不要再重复搜索。」
再让模型无工具地生成最终答案。多数项目为 maxRounds 超限时已经写过这段「强制收口」逻辑,无进展熔断直接复用即可,只需把收口分支的触发条件从 round >= maxRounds 扩成 (round >= maxRounds || noProgressTriggered)。别新写收尾。注意收口的提示文案要换一套(「已就相似查询多次搜索」而非「已达到最大轮次」),否则对模型是误导。
纯 query 指纹的边界
纯 query 指纹有个天花板:token 不重叠的同义改写。模型若把「天气」换成「气象」「气温实况」,token 集合可能不重叠,包含系数接近 0,指纹就绕过去了。
这不是 bug,是 MVP 的已知边界。修它需要从 query 指纹升级到进展信号检测:不看搜的词像不像,看搜回来的信息有没有新增,连续 N 轮没带来新事实、新 URL、新数据,就判无进展。这直接度量进展本身,而非进展的代理变量(query 文本)。其中返回 URL 集合重叠是个划算的信号:相同 URL 等于相同信息等于无进展,且与 query 语言无关,能挡住同义改写;代价是它要在搜索执行后才能判,省不掉那一次搜索,所以和 query 指纹互补而非替代。
但它更重,要定义信息增量怎么算,所以 MVP 不做、留 v2。承认护栏有洞、并写清楚下一层补在哪,比假装它密不透风更工程。query 指纹已经能挡住大部分换词空转,剩下的同义改写等进展信号补上。
落地映射
把方案按性价比排一下,MVP 只做第一行:
| 改动 | MVP? | 量级 | 理由 |
|---|---|---|---|
| 无进展熔断(指纹去重 + 复用收口) | ✅ 就做这个 | ~60–80 行,单文件,零依赖 | 直接消灭「绕 5 步」,收益/成本比最高 |
| Prompt 克制(「已知信息直接答,别动不动换词搜」) | 🔶 顺手加一句 | 改 1 行 system prompt | 免费,搭着上,但不能单靠 |
| 搜索后处理(重排 + 时效 + query 收敛) | ❌ v2 | 大 | 质量优化非止血,要调参 |
| 进展信号检测 | ❌ v2 | 中 | 补同义改写的洞 |
接入时复用已有件,能再省一半工作量:
- 取 query 参数 → 复用已有的「工具参数 JSON 解析」工具方法;
- 熔断后收口 → 复用
maxRounds超限时那段「强制综合作答」逻辑; - 留一个
enabled开关(默认开),随时灰度回退到旧行为——这是上线保险,也让回归测试能断言「关掉后逐字节同旧行为」。
验收标准要具体到现象:构造会触发换词的 query,断言日志里不再出现 5 次换词、循环在第 2–3 轮熔断作答、端到端耗时回到一次搜索的量级。别用「应该更好了」验收,用「绕几步」验收。测试也别依赖真实 LLM 是否真的换词,直接 mock 出连续相似的 toolCall 序列,确定性地验。
给自己的心得
-
先分层,再动手。同一个现象(搜索绕圈)可能横跨数据源层和控制层,修错层就是又贵又没用。问自己:这是触发器还是放大器?放大器优先。
-
护栏要成套,单件不闭环。检测到空转只是
break,没有配套的降级作答,模型会在半截中断处一脸懵。指纹去重加降级作答是最小闭环,缺一不可;而且降级那一半,你八成已经为maxRounds写过了,复用它。 -
诚实地承认护栏的洞。query 指纹挡不住同义改写,这没关系,写清楚这一层挡哪些、剩下的进展信号在 v2 补,远比假装它密不透风更可信。能说出自己护栏边界在哪,才是真把它想透了。
尾注:上面那个把 Jaccard 写成死代码的坑,是写测试时才暴露的。单测断言「北京天气 vs 北京今日天气 判相似」直接红了,回头才把相似度量换成包含系数。写测试逼出了设计 bug,这是上面几条心得的注脚。