跳到主要内容

第 5 课 · 它会跑偏:错误该不该让它看见

读这一课前你需要会什么:读过第 1、3、4 课。 你需要知道:它追求的是「像」不是「对」(第 1 课)、 错误可以变成一句话喂回去(第 3 课)、以及那个循环长什么样(第 4 课)。 出现的每一个新词都会当场用大白话讲清。讲不清楚的地方就是我的问题,请直接标出来。

这一课结束时你会

  1. 说得出为什么「它会跑偏」不是故障,而是必然;
  2. 说得出两派相反的处置各在什么时候用——以及判据是什么;
  3. 说得出为什么每一个补救机制都必须自带次数上限。

1. 先看第 4 课那个循环有多脆

第 4 课末尾那段代码能跑了。但它只在一切顺利时能跑。

把天气工具改成「总是超时」,再跑一遍那句「北京和上海哪个更暖和?」:

第 1 轮 → 调 get_weather({"city":"北京"})
← {"error":"连接超时"}
第 2 轮 → 调 get_weather({"city":"北京"})
← {"error":"连接超时"}
第 3 轮 → 调 get_weather({"city":"北京"})
← {"error":"连接超时"}
……
【为什么停】max_turns_reached(8)

它一模一样地试了八次,烧完了轮数上限。

这不是 bug。 我们照第 3 课那条共识做的:错误不抛异常,变成一句话喂回去让它自己改。 它看到了错误,它「改」了——它决定再试一次。这在它的逻辑里完全成立。

这一课讲的就是:它会用哪些方式跑偏,以及各家拿它怎么办。

先给这个词:

跑偏 = 它没在做你要它做的事,但它自己不知道。


2. 顶层全景:五种处置,分成相反的两派

先看总图。 一次跑偏发生后,能做的事只有五种:

它这一轮的输出有问题

├─ ① 就地修 它写的差一点点,我们直接改对 → 它不知道发生过

├─ ② 写进历史 把错误当成一次观察喂回去 → 它看得见,自己改

├─ ③ 抹掉重来 把刚加的那条整个撤掉 → 它看不见,重说一次

├─ ④ 边说边拦 它还在写,写到一半就掐断 → 省下后半段的钱

└─ ⑤ 熔断 不让它再试了 → 这一次任务失败

图说:② 和 ③ 是相反的两派——一派让它看见错误,一派不让。
两派都有生产系统在用,§4.3 讲判据。

第 4 课末尾那个死循环,五种处置里一种都没做。 它只做了 ②,而且没配 ⑤。


3. 主走查:一条真实的失败链,五种处置各在哪儿介入

盯住同一句话,把上面那个死循环从头到尾走一遍,看每一种处置该在哪一步插进去。 数值和错误文案是我编的演示。

第 1 轮:参数写错了

它要调:get_weather{"citys": "北京"} ← 参数名多了个 s

这时候有两个选择:

处置做法
就地修我们认出 cityscity 的别名,直接改对再执行,它压根不知道发生过
② 写进历史回一句「没有叫 citys 的参数,应该是 city」,让它重写一遍

有一份资料就是这么修的:参数别名改名、补上缺的必填参数, 甚至从系统提示里反解出正确的工具名(依据:本库摘录 · mirothinker)。

「从系统提示里反解」这一条很妙:它写错了工具名, 但正确的名字就在我们发给它的那段文字里,所以能反查着修回去。 这是「就地修」的典型场景:信息是全的,只是它拼错了。

我们选 ①。 理由:让它为一个拼写错误多转一圈,是纯粹的浪费。

第 2 轮:工具超时了

它要调:get_weather{"city": "北京"}
执行: 连接超时

这次不能就地修——我们没有它要的信息。 只能走 ②,把错误写进历史。

这一步是对的。 第 3 课那条六家共识就是为这种情况准备的。

第 3 轮:它一模一样地又试了一次

它要调:get_weather{"city": "北京"} ← 和第 2 轮一字不差

这就是那个死循环的起点。 先给这个词:

打转 = 它反复做同一件已经失败过的事。

第 4 轮、第 5 轮……如果不管,它会一直转到轮数上限。

该在第几轮拦下来

这是这一课最实的一个问题。 各家的答案不一样,但都有一条共同的形状:

第 1 次失败 → 让它试 (可能只是网络抖了一下)
第 2 次失败 → 让它试 (给它一次改主意的机会)
第 3 次失败 → 拦下来

拦下来之后做什么,才是分歧所在 ——③ 抹掉重来、④ 提前拦、⑤ 熔断,下面一节一节讲。


4. 拆开看:五种处置

4.1 ② 写进历史:这是默认,但它有条件

第 3 课那条共识再说一遍:错误不抛异常,变成一句话喂回去让它自己改。

这一派的做法各家都很像:

出处做法
(依据:本库摘录 · cline)工具报错和参数非法都不抛异常,一律变成消息喂回去
(依据:本库摘录 · semantic-kernel)一条几乎不抛异常的五关流水线,任何错都变成一段文字告诉它错在哪
(依据:本库摘录 · db-gpt)失败原因原样变成下一轮的输入,而且失败本身也写进记忆
(依据:本库摘录 · aider)所有失败收敛成同一个字段——格式错、匹配不上、检查不过、测试挂,全走这一个口子

最后那一行的做法值得单独看,因为它同时是优点和缺点。

优点:整个循环的续转理由只剩一个——那个字段是不是空的。 判断极简单。

缺点:失败原因被抹平了。 格式错和测试挂被同等对待,都只是「再来一轮」。

判断(无锚): 可以保留这个结构但让那个字段带一个原因标签, 这样「连续三次都是格式错」和「三次都是测试挂」能被区分开—— 前者说明我们的提示词有问题,后者说明它真的不会做这道题。 如果错,会错在: 如果上限本来就很短(三轮)、而且每次都会把原文回灌, 那它自己看得到区别,加标签是多余的抽象。

这一派还有一条第 3 课提过、这里要加重的:

给它的错误信息应该是一条修改指令,不是一句故障描述。 (依据:本库摘录 · agenticseek)

对照着看:

❌ 故障描述✅ 修改指令
EOFError「这段代码要等键盘输入,但它跑在没有终端的环境里。把值写进变量、结果打到标准输出。」
没有叫 get_weathr 的工具「没有叫 get_weathr 的工具。可用的是:get_weather、get_air_quality。」

第二行那个做法有出处——工具不存在时不报错也不停, 回一段「你点的不存在,可用的是这些」当成一次观察(依据:本库摘录 · dong-shou-zuo-ai-agent)。

4.2 ③ 抹掉重来:相反的一派

先给这个词:

回滚 = 把刚加进历史的那一条撤掉,当这一轮没发生过,让它重说一次。

这一派的代表做法是这样的(依据:本库摘录 · mirothinker):

每一轮模型说完话之后,先不急着执行,拿四把尺子去量:

① 它是不是根本没按格式写?
② 它是不是拒答了?
③ 这个查询是不是刚才查过?
④ 工具执行完,结果是不是废的(报错 / 空)?

任何一把亮红灯 → 回滚:
轮数减一 · 连续回滚计数加一 · 把刚加的那条撤掉 · 重来

为什么要撤掉而不是喂回去? 那份资料的场景是让开源模型稳跑几百轮, 它的判断是:让开源模型稳定跑下去的关键不是模型更聪明, 而是把「它会犯的错」系统性地检测、回滚、修复。

换个角度说:有些错误让它看见了,它会学坏。

举个能想明白的:它写错了格式,你把「你格式写错了」连同那段错格式一起放进历史。 下一轮它读到的那段文字里,就有一个错误格式的样例。 而它挑下一个 token 的标准是「像人会写的」(第 1 课)—— 你刚给了它一个「这里可以这么写」的例子。

还有一种更早介入的,可以算这一派的极端版(依据:本库摘录 · oh-my-pi):

不等它说完再检查,边吐字边查规则。一犯规就中止这次生成, 注入一条提醒,从这一轮的起点重来。

它给的理由很实在:等它把一整段错的东西生成完、你再事后检查,太晚了。

这就是总图里的 ④ 边说边拦。

什么时候拦好处代价
③ 抹掉重来它说完了判断准那一整段的钱已经花了
④ 边说边拦它还在说省下后半段的钱容易误判——它可能正要说「我不该这么写」

4.3 判据:两派怎么分

这是这一课最该带走的一条。

按失败的性质分: 信息型失败(命令报错、字段不存在、超时)→ 写进历史,它据此改正; 格式型失败(写错格式、拒答、原地复读)→ 抹掉重来,写进历史只会让它学坏。

为什么这条判据成立? 一句话:看那段错误文字对它下一轮有没有正面价值。

类型那段文字进历史之后结论
「字段 xxx 不存在」它知道了一个之前不知道的事实有价值,放进去
「你的格式写错了」+ 那段错格式它多了一个错误示范没价值,撤掉

判断(无锚): 这条判据在两种情况下会失效。 一是它的格式错是因为我们的提示里有矛盾指令——那样抹掉重来会无限重复同一个错, 这时错误必须进历史,让它看见「你上次这么写不行」; 二是信息型失败重复太多次——查了五次都是同一个「字段不存在」, 那五条一模一样的错误在历史里就成了噪声,该压成一条。 如果错,会错在: 如果模型足够强,读到自己的错误格式反而会主动避开, 那「学坏」这个担心就不成立,一律写进历史更简单。这一点我们的原型可以实测。

4.4 打转怎么认出来

上一节说了该怎么处置,这一节说怎么发现。 各家的检测办法从粗到细:

办法怎么判出处
同一个工具、同样的参数,连着失败分档熔断(依据:本库摘录 · cowagent)
这个查询刚才查过查询指纹比对(依据:本库摘录 · mirothinker)
两级比对文字见下(依据:本库摘录 · kun)

第三种最讲究,因为它要抓的是「它说的是同一个意思,但字面不同」。

一级:归一化后完全一致
(转小写,去掉所有空白、标点、符号,中英文标点一起清)
二级:两串都够长(≥12 个字)时,算字符两两组合的相似度,≥0.85 判为复读

图说:用「一模一样」判抓不住它——
同一句废话每次的标点、语序都略有不同。

那份资料还给了两个细节:

  • 太短的不判,避免误伤(所以有那个 12 个字的门槛);
  • 抓到之后不是立刻停:前三次注入一段恢复指令再给一次机会,第四次才写警告并停。

还有一种最省事的,而且是「事前」而不是「事后」: 直接规定「不许和上一步是同一个工具」(依据:本库摘录 · beeai-framework)。

前面几家都是「事后检测到打转再干预」,这一家是事前就不给它这个选项。

4.5 ⑤ 熔断,以及所有补救都要有上限

先给这个词:

熔断 = 判定「这条路走不通了」,不让它再试。

这一节只讲一条规矩,但它是这一课的安全底线:

每一个补救机制,都必须有它自己的次数上限。

这不是我总结的,是四份出身完全不同的资料撞在了一起:

上限出处
连续回滚上限 5 次,连续太多次就放行或结束,防止在同一个坎上死循环(依据:本库摘录 · mirothinker)
压缩失败 3 次就不再试(依据:本库摘录 · dexter)
整轮只允许兜底一次,挖不到就认输(依据:本库摘录 · onyx)
反思最多三轮(依据:本库摘录 · aider)

为什么这条是底线:补救机制本身也是一段会失败的代码。 没有上限的补救,就是一个新的死循环——而且这个死循环藏在「我们已经处理了错误」的错觉后面。

但上限设死也会出事 —— 这一条是我们实测撞出来的

上面那条只说了一半。我们照着做,然后撞了墙。

先看现象。 我们造了一个工具:前两次调必失败,第三次才成功, 而且失败信息里明写着「这是临时故障,重试通常可以成功」。

熔断设成「连续失败 2 次就拦」。结果:它永远拿不到那个数。

问题出在「连续失败几次」这个判据本身——它把两种性质完全不同的失败当成了一种:

失败重试有用吗
「服务繁忙,请稍后重试」有用,第三次就成了
「没有这个城市的数据」没用,重试一百次也是这个结果

治法在第 3 课那批资料里就有,我写进讲义了、代码里没做: 让工具自己声明这次失败可不可重试(依据:本库摘录 · db-gpt)。

做完之后(同一组题各跑三遍取平均):

平均分那两道「必须重试才拿得到」的题
只数次数7.3 / 11三遍全不过
按失败性质分档9.0 / 11三遍全过

(依据:本库实验 · 001-agent-loop/graded-anthropic-1)

而这个修复又制造了一个新问题

分档之后,「重试也没用」那类只给 1 次机会——这是对的。 但也因此,问「一个根本没有数据的城市」时,它第一轮就被拦死、直接退出,用户拿到一片空白。

那道题从「三遍全过」变成了「三遍全不过」。

只看总分,你会以为分档成了(7.3 → 9.0);逐题看才发现它同时弄坏了一道。 而新病比原病更难发现——程序不报错,只是什么都没说。

兜住它的是另一个机制:被掐断时摘掉工具再问一次(第 4 课 §4.3)。 两个一起开,那道题回到「三遍全过」,总分 9.7 / 11 (依据:本库实验 · 001-agent-loop/both-anthropic-1)。

这一条是这一课最该带走的东西,而且它比任何一条具体做法都通用:

「拦得更早」和「拦住之后说句人话」是一对,不能只做前半。

一般化:每加一道防线,都要问一句「它拦住之后,用户看到什么」。 答不上来,那道防线就还没做完。

还有一条计数上的讲究:数「连续」,不数「累计」。 成功一次就归零(依据:本库摘录 · mirothinker)—— 因为「一共失败了五次」和「连着失败了五次」是两回事。

熔断的分档也有讲究(依据:本库摘录 · cowagent): 同一个工具、同样的参数,连着失败几次算一档,失败得越离谱降级越快。

4.6 一个容易忽略的:有些错误根本不该执行

前面五种处置都是「它已经说完了,我们怎么办」。这一节讲更早的一步:有些调用压根不该跑。

最有说服力的一个例子(依据:本库摘录 · qwen-code):

当这次生成是因为「输出长度到顶」而结束时,给这一轮所有待执行的调用打上标记, 据此拒绝执行被截断的编辑类工具——因为参数写了一半就落盘,比不执行更糟。

把这句话摊开:

  1. 它正在写一次调用的参数,写到一半被长度上限掐断了;
  2. 我们拿到的那份参数是残缺的,但它在格式上完全合法;
  3. 如果这是个只读工具,跑一下顶多是查错东西;
  4. 如果这是个写文件的工具,那就是拿半截内容覆盖了原文件。

它只拒绝编辑类工具,不拒绝只读工具。 判据是这个动作能不能撤销

还有一条关于「撤销」的,做得最彻底(依据:本库摘录 · langchain4j):

工具出错后不只回滚副作用,还把历史里那条「成功」改写成「已回滚」—— 免得它基于一个错误前提继续推理。

这一条打破了「历史只能追加、不能改」这个通常的规矩,而它给的理由站得住: 一条留在历史里的假「成功」,比一条「失败」危险得多。


5. 各家的分歧:一处

幻觉出来的工具:算错误还是算状态

它点了一个不存在的工具(第 3 课那个真实记录里就发生过)。这算什么?

看法做法出处
算一次普通错误回一句「不存在,可用的是这些」,走 ②(依据:本库摘录 · dong-shou-zuo-ai-agent)
算一种正式状态把它当成状态机里的一等公民,配了五种恢复动作(依据:本库摘录 · rig)

第二种为什么值得? 因为「它点了不存在的工具」这件事的原因不止一种:

  • 它记错了名字 → 就地修(反解出正确的名字);
  • 我们这一轮没给它这个工具,但上一轮给过 → 应该把工具清单的变化告诉它;
  • 它凭空编的 → 写进历史让它重选

三种原因,三种处置。当成同一种错误处理,就只能用最粗的那一种。

顺带一条相关的:工具清单是会变的。 有一份实现在每次请求前按后端真实能力现场增删工具, 而且删了工具还要把别的工具描述里提到它的那句话一起换掉 (依据:本库摘录 · deepagents)。 不然它会照着一句「先用 A 再用 B」的说明去点已经不存在的 A。

一份来自实践者的清单

有一本书的作者记了自己亲历的失控(依据:本库摘录 · ai-agents-in-action):

下载不该下的文件、在没被要求时写并执行代码、在工具之间不断打转、删掉了不该删的文件。

这四条正好对上前面各家的防护: 打转对应 §4.4,删文件对应「动作能不能撤销」, 而「在没被要求时写并执行代码」对应的是第 2 课那条——最小可用的工具集要小。

那份资料给的建议是:只给它完成目标所需的那几个动作。 四条理由:

  1. 动作多了它选不准;
  2. 接口对工具数量有上限;
  3. 它可能以你没预期的方式使用动作;
  4. 安全——它会独立执行任何动作。

6. 动手:把第 4 课那个死循环治好

在第 4 课那段代码上加三样东西。

加法一:就地修(§4.1 的 ①)

// 参数别名表:它常写错的那几个,直接改对,不为拼写错误多转一圈
const ALIASES = { citys: "city", 城市: "city", cityName: "city" }

function fixArgs(args) {
const out = {}
for (const [k, v] of Object.entries(args)) out[ALIASES[k] ?? k] = v
return out
}

加法二:打转检测 + 熔断(§4.4、§4.5)

// 每次调用的指纹:工具名 + 参数。同一个指纹连着失败几次就熔断
const failStreak = new Map()
const MAX_SAME_FAILURE = 2 // 连着失败 2 次就不让它再试

function fingerprint(name, args) {
return name + ":" + JSON.stringify(args, Object.keys(args).sort())
}

在执行那一段里插进去:

const fp = fingerprint(name, args)

// ① 熔断:这个指纹已经连着失败够多次了,不再执行
if ((failStreak.get(fp) ?? 0) >= MAX_SAME_FAILURE) {
result = {
error: `这个调用已经连续失败 ${MAX_SAME_FAILURE} 次,不会再执行。` +
`请换一种做法,或者用现有信息回答。`, // ← 修改指令,不是故障描述
}
} else {
result = impls[name] ? impls[name](args) : { error: `没有叫 ${name} 的工具。可用:${Object.keys(impls).join("、")}` }
// ② 数「连续」,不数「累计」:成功一次就归零(§4.5)
if (result.error) failStreak.set(fp, (failStreak.get(fp) ?? 0) + 1)
else failStreak.delete(fp)
}

加法三:停止原因要能说出「因为熔断」(§4.4 第 4 课那条)

// 一整轮里所有调用都被熔断了 → 这一轮什么也没做成,别让它空转下去
const allBlocked = calls.every((c) =>
(failStreak.get(fingerprint(c.function.name, JSON.parse(c.function.arguments))) ?? 0) >= MAX_SAME_FAILURE)
if (allBlocked) { stopReason = "all_tools_circuit_broken"; break }

再跑一次那个「总是超时」的场景

第 1 轮 → 调 get_weather({"city":"北京"})
← {"error":"连接超时"}
第 2 轮 → 调 get_weather({"city":"北京"})
← {"error":"连接超时"}
第 3 轮 → 调 get_weather({"city":"北京"})
← {"error":"这个调用已经连续失败 2 次,不会再执行。请换一种做法,或者用现有信息回答。"}

【最终答案】抱歉,我暂时查不到北京的天气数据。
【为什么停】text_response(stop)

从烧完八轮,变成三轮就体面收场,而且它给了用户一句人话。

三个值得你亲手试的改动

#改什么你会看到它验证的是
1把熔断那句话改成 {error: "失败"}它多半还会再试,或者干脆卡住§4.1——错误信息要是修改指令,不是故障描述
2failStreak.delete(fp) 那行删掉(改成不归零)一个工具偶尔失败几次之后,明明能用了却被永久拉黑§4.5——数连续,不数累计
3让上海正常、北京超时它查完上海,然后拿着一半的信息给出一个诚实的回答这就是我们要的行为:局部失败不等于整体失败

7. 可带走的

  1. 跑偏不是故障,是必然。 它追求的是「像」不是「对」(第 1 课), 没人管的时候,跑偏就是它正常工作方式的自然结果。
  2. 五种处置:就地修、写进历史、抹掉重来、边说边拦、熔断。 其中写进历史抹掉重来是相反的两派,都有生产系统在用。
  3. 判据是那段错误文字对它下一轮有没有正面价值: 信息型失败(报错、字段不存在)→ 写进历史; 格式型失败(格式错、拒答、复读)→ 抹掉重来,因为你等于给了它一个错误示范。
  4. 能就地修就别让它重来。 拼写错误让它多转一圈是纯浪费, 而且正确的名字往往就在我们发出去的那段文字里,能反查着修回去。
  5. 给它的错误信息要是一条修改指令,不是一句故障描述。
  6. 打转要用「意思相同」判,不能用「一模一样」判——同一句废话每次的标点语序都略有不同。 而最省事的做法是事前禁止:不许和上一步是同一个工具。
  7. 每一个补救机制都必须有自己的次数上限。 没有上限的补救,是一个藏在「我们已经处理了错误」错觉后面的新死循环。
  8. 数「连续」,不数「累计」。 成功一次就归零。
  9. 有些调用压根不该执行——比如输出被长度上限截断时的那次编辑操作。 判据是这个动作能不能撤销。
  10. 一条留在历史里的假「成功」,比一条「失败」危险得多。
  11. 只给它完成目标所需的那几个工具。 有作者记下了亲历的失控: 在没被要求时写并执行代码、删掉了不该删的文件。

8. 下一课,以及我需要你的反馈

最后一课讲这个循环转久了会遇到的那件事:那段文字会越堆越长。

它会回答三个问题:

同一句话摆在那段文字的开头还是结尾,差别有多大? 堆到装不下时,该砍哪些? 为什么有一份资料说「直接删旧消息」是架构级的错误?

那一课有一个真实的翻车故事:某团队在系统提示里加了一行实时时间戳, 第二天首个词的延迟从 0.5 秒涨到 3 到 5 秒,月账单几乎翻倍。


读完请标出三种地方:

标什么我会怎么改
哪一段读不下去那一段拆成更小的台阶重写
哪个词没解释清楚换一种解释法,或者干脆不用那个词
哪里嫌啰嗦删掉