第 5 课 · 它会跑偏:错误该不该让它看见
读这一课前你需要会什么:读过第 1、3、4 课。 你需要知道:它追求的是「像」不是「对」(第 1 课)、 错误可以变成一句话喂回去(第 3 课)、以及那个循环长什么样(第 4 课)。 出现的每一个新词都会当场用大白话讲清。讲不清楚的地方就是我的问题,请直接标出来。
这一课结束时你会
- 说得出为什么「它会跑偏」不是故障,而是必然;
- 说得出两派相反的处置各在什么时候用——以及判据是什么;
- 说得出为什么每一个补救机制都必须自带次数上限。
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
这时候有两个选择:
| 处置 | 做法 |
|---|---|
| ① 就地修 | 我们认出 citys 是 city 的别名,直接改对再执行,它压根不知道发生过 |
| ② 写进历史 | 回一句「没有叫 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):
当这次生成是因为「输出长度到顶」而结束时,给这一轮所有待执行的调用打上标记, 据此拒绝执行被截断的编辑类工具——因为参数写了一半就落盘,比不执行更糟。
把这句话摊开:
- 它正在写一次调用的参数,写到一半被长度上限掐断了;
- 我们拿到的那份参数是残缺的,但它在格式上完全合法;
- 如果这是个只读工具,跑一下顶多是查错东西;
- 如果这是个写文件的工具,那就是拿半截 内容覆盖了原文件。
它只拒绝编辑类工具,不拒绝只读工具。 判据是这个动作能不能撤销。
还有一条关于「撤销」的,做得最彻底(依据:本库摘录 · langchain4j):
工具出错后不只回滚副作用,还把历史里那条「成功」改写成「已回滚」—— 免得它基于一个错误前提继续推理。
这一条打破了「历史只能追加、不能改」这个通常的规矩,而它给的理由站得住: 一条留在历史里的假「成功」,比一条「失败」危险得多。
5. 各家的分歧:一处
幻觉出来的工具:算错误还是算状态
它点了一个不存在的工具(第 3 课那个真实记录里就发生过)。这算什么?
| 看法 | 做法 | 出处 |
|---|---|---|
| 算一次普通错误 | 回一句「不存在,可用的是这些」,走 ② | (依据:本库摘录 · dong-shou-zuo-ai-agent) |
| 算一种正式状态 | 把 它当成状态机里的一等公民,配了五种恢复动作 | (依据:本库摘录 · rig) |
第二种为什么值得? 因为「它点了不存在的工具」这件事的原因不止一种:
- 它记错了名字 → 就地修(反解出正确的名字);
- 我们这一轮没给它这个工具,但上一轮给过 → 应该把工具清单的变化告诉它;
- 它凭空编的 → 写进历史让它重选。
三种原因,三种处置。当成同一种错误处理,就只能用最粗的那一种。
顺带一条相关的:工具清单是会变的。 有一份实现在每次请求前按后端真实能力现场增删工具, 而且删了工具还要把别的工具描述里提到它的那句话一起换掉 (依据:本库摘录 · deepagents)。 不然它会照着一句「先用 A 再用 B」的说明去点已经不存在的 A。
一份来自实践者的清单
有一本书的作者记了自己亲历的失控(依据:本库摘录 · ai-agents-in-action):
下载不该下的文件、在没被要求时写并执行代码、在工具之间不断打转、删掉了不该删的文件。
这四条正好对上前面各家的防护: 打转对应 §4.4,删 文件对应「动作能不能撤销」, 而「在没被要求时写并执行代码」对应的是第 2 课那条——最小可用的工具集要小。
那份资料给的建议是:只给它完成目标所需的那几个动作。 四条理由:
- 动作多了它选不准;
- 接口对工具数量有上限;
- 它可能以你没预期的方式使用动作;
- 安全——它会独立执行任何动作。
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——错误信息要是修改指令,不是故障描述 |
| 2 | 把 failStreak.delete(fp) 那行删掉(改成不归零) | 一个工具偶尔失败几次之后,明明能用了却被永久拉黑 | §4.5——数连续,不数累计 |
| 3 | 让上海正常、北京超时 | 它查完上海,然后拿着一半的信息给出一个诚实的回答 | 这就是我们要的行为:局部失败不等于整体失败 |
7. 可带走的
- 跑偏不是故障,是必然。 它追求的是「像」不是「对」(第 1 课), 没人管的时候,跑偏就是它正常工作方式的自然结果。
- 五种处置:就地修、写进历史、抹掉重来、边说边拦、熔断。 其中写进历史和抹掉重来是相反的两派,都有生产系统在用。
- 判据是那段错误文字对它下一轮有没有正面价值: 信息型失败(报错、字段不存在)→ 写进历史; 格式型失败(格式错、拒答、复读)→ 抹掉重来,因为你等于给了它一个错误示范。
- 能就地修就别让它重来。 拼写错误让它多转一圈是纯浪费, 而且正确的名字往往就在我们发出去的那段文字里,能反查着修回去。
- 给它的错误信息要是一条修改指令,不是一句故障描述。
- 打转要用「意思相同」判,不能用「一模一样」判——同一句废话每次的标点语序都略有不同。 而最省事的做法是事前禁止:不许和上一步是同一个工具。
- 每一个补救机制都必须有自己的次数上限。 没有上限的补救,是一个藏在「我们已经处理了错误」错觉后面的新死循环。
- 数「连续」,不数「累计」。 成功一次就归零。
- 有些调用压根不该执行——比如输出被长度上限截断时的那次编辑操作。 判据是这个动作能不能撤销。
- 一条留在历史里的假「成功」,比一条「失败」危险得多。
- 只给它完成目标所需的那几个工具。 有作者记下了亲历的失控: 在没被要求时写并执行代码、删掉了不该删的文件。
8. 下一课,以及我需要你的反馈
最后一课讲这个循环转久了会遇到的那件事:那段文字会越堆越长。
它会回答三个问题:
同一句话摆在那段文字的开头还是结尾,差别有多大? 堆到装不下时,该砍哪些? 为什么有一份资料说「直接删旧消息」是架构级的错误?
那一课有一个真实的翻车故事:某团队在系统提示里加了一行实时时间戳, 第二天首个词的延迟从 0.5 秒涨到 3 到 5 秒,月账单几乎翻倍。
读完请标出三种地方:
| 标什么 | 我会怎么改 |
|---|---|
| 哪一段读不下去 | 那一段拆成更小的台阶重写 |
| 哪个词没解释清楚 | 换一种解释法,或者干脆不用那个词 |
| 哪里嫌啰嗦 | 删掉 |