content/02-agent/harness/agent-no-progress-breaker.md

给 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 覆盖:

ABA∩BminContainment判定
北京天气北京今日天气 气温444/4 = 1.0相似(换词空转)✓
北京天气能源补贴政策040不相似(正常换题)✓

还有个前提:中文必须切到单字粒度。若只按空白切,中文整串极少相等,相似度恒为 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                   # 跳出循环,转「降级作答」

两个工程细节,都来自踩坑:

  1. 只对搜索类工具做指纹,且只在本轮全是 webSearch 时才熔断。业务工具(查能耗、查设备状态)重复调用往往合法(不同参数、分页);更关键的是工具是整批执行的,同一轮 [webSearch, queryDeviceStatus] 一旦熔断,会把那个合法的设备查询也一起跳掉。所以混合轮一律放行、不参与计数。
  2. 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 序列,确定性地验。

给自己的心得

  1. 先分层,再动手。同一个现象(搜索绕圈)可能横跨数据源层和控制层,修错层就是又贵又没用。问自己:这是触发器还是放大器?放大器优先。

  2. 护栏要成套,单件不闭环。检测到空转只是 break,没有配套的降级作答,模型会在半截中断处一脸懵。指纹去重加降级作答是最小闭环,缺一不可;而且降级那一半,你八成已经为 maxRounds 写过了,复用它。

  3. 诚实地承认护栏的洞。query 指纹挡不住同义改写,这没关系,写清楚这一层挡哪些、剩下的进展信号在 v2 补,远比假装它密不透风更可信。能说出自己护栏边界在哪,才是真把它想透了。

尾注:上面那个把 Jaccard 写成死代码的坑,是写测试时才暴露的。单测断言「北京天气 vs 北京今日天气 判相似」直接红了,回头才把相似度量换成包含系数。写测试逼出了设计 bug,这是上面几条心得的注脚。

评论