Skip to content

第 11 章 被忽略的鲁棒性

第11章隐喻:一台空转冒烟、停不下来的失控机器,没人按下停止键

做 Agent 项目的时候,我犯了一个特别普遍的错误——精力全扑在功能开发上,传统软件的鲁棒性保证被我忽略了。这一章先讲我栽过的坑,再讲后来怎么补上的。

开篇:一个空转了一整夜的系统

我有一个项目的错误日志,记录了一件挺荒唐的事。

系统的调度器每隔几分钟触发一次任务,调 LLM 做决策。某天晚上,LLM 的接口开始报错——原因很简单,账户余额用完了。但调度器不知道,照常每隔几分钟触发一次。LLM 照常失败。没有任何东西把这个循环打断——没有熔断、没有降级、没有告警。

从晚上十点开始,日志一直在重复刷同一行:"LLM 调用失败"。每隔几分钟一次。一直刷到第二天早上九点多,我起床打开电脑看到日志,手动把服务停了。整整一整夜。

这件事让我意识到一个被我忽略的问题:我花了大量时间让 Agent 学会做各种事,但几乎没有花时间保证它在出问题时能安全地活着。

这个故事先放在这儿。这一章的最后,我会回来讲它后来怎么被根治的——因为补鲁棒性的过程,比我想的要更讲究。

鲁棒性这件事,传统软件里我都会做

说起来挺矛盾的。在传统软件项目里,这些事情我都会做。

调一个外部 HTTP 接口——我会配超时,会加重试,失败了会记日志降级。跑一个后台任务——我会加健康检查,挂了会自动重启。数据库连接——我会配连接池,配超时,配失败回退。

这些是传统软件工程师的肌肉记忆。不需要谁教,做项目的时候自然就会想到。

但到了 Agent 项目里,这些肌肉记忆好像突然失灵了。为什么?

为什么会忽略

我后来想了想,大概有三个原因。

第一个原因:Agent 功能的正反馈太强了。 你让 Agent 学会一个新技能,跑一遍就能看到效果——"它居然能记住我说的话了""它居然会自己查资料了"。这种即时的、可视的成就感,会让你不自觉地把所有精力往功能上堆。而鲁棒性是"不出事你感觉不到"的东西——它没有正反馈,只有"没出事"这个隐性状态。

第二个原因:Agent 项目的节奏太快了。 用 Claude Code 写代码,一个功能从想法到能跑,可能就半天时间。这种速度让你产生一种惯性——"赶紧做下一个功能"。停下来配超时、加重试、写降级逻辑?太慢了,下个功能还在等我。

第三个原因:LLM 本身就是"不稳定"的,让我产生了"反正也不稳定,鲁棒性也没用"的错觉。 这个想法是错的——正因为 LLM 不稳定,鲁棒性才更重要。但在当时,"Agent 本来就偶尔抽风"这个认知,反而成了我忽略鲁棒性的借口。

具体忽略了什么,以及后来怎么补的

盘点一下,我忽略的东西主要有三类。这一节不只列清单——每一样我都会讲后来是怎么补的,因为补的方式本身有讲究。

第一类:外部调用没有超时和重试

调 LLM 的请求,很多地方没有配超时。LLM 不响应的时候,请求就卡在那里,可能卡几分钟。如果一个请求卡住了,后面的请求也会排队等着——链式阻塞,整个服务就僵住了。

补这一类,直觉做法是"给每个已知没配超时的地方都加上"。我一开始就是这么干的,给 LLM 客户端补上 timeout=60max_retries=3

但这么做有一个问题:它只解决了"已知的旧代码",拦不住"明天新写的代码"。 我今天记得补,下周写一个新功能、新起一个客户端,很可能又忘了配超时——又回到原点。

后来我换了个思路:把"带鲁棒性默认值"这件事,从一个补丁变成一个默认就长这样的构造方式。具体说,我抽了一个客户端工厂出来——所有新建 LLM 客户端的代码,都走这个工厂,工厂内部把超时和重试都配好了:

python
def make_llm_client(api_key, base_url=None):
    # 鲁棒性默认值封在工厂里——新代码只要走工厂,就自动带超时和重试
    return OpenAI(
        api_key=api_key,
        base_url=base_url,
        timeout=60.0,
        max_retries=3,
    )

这样写新代码的人,只要用工厂,就绕不开默认值。

但这里有个更深的问题:怎么保证新代码真的会用工厂,而不是又手起一个裸的客户端? 工厂摆在那儿,不等于人人都会用。于是我又加了一道强制检查——写了个脚本扫代码,只要看到有人直接 new 一个客户端(而不是走工厂),就报违规。这样"用工厂"就不是靠自觉,而是被机制拦住的。

补到这里,我以为"默认配置"这个目标达成了。但实际验证时发现一个意外:强制力对不同形态的鲁棒性,效果不一样。

我故意写了两段测试代码去验证:

鲁棒性形态补丁够吗工厂够吗工厂+检查够吗
客户端构造(有明确禁用目标:禁裸 new拦不住还是拦不住✅ 能拦住
重试装饰器(某个方法该不该重试,没有明确禁用目标)拦不住拦不住也拦不住

第一类(客户端)能被强制——因为"禁止裸 new"是个明确的、能用代码识别的目标。第二类(重试)强制不了——因为你没法用代码判断"这个方法到底该不该加重试"(不是每个方法都该重试的)。对第二类,你只能把正确做法做得足够省力(一个装饰器加上就行),再靠文档和 review 约定,但没法机械拦死。

这逼我修正了一个原本模糊的认知:"鲁棒性应是默认配置"这句话,不是一个能一刀切达成的状态。 它分两层——

  • 强制默认:对"有明确禁用目标"的形态(禁止裸 new 客户端),工厂 + 检查能强制,新代码绕不过。
  • 约定默认:对"应当用但非强制"的形态(应当加重试),只能靠抽象 + 文档 + review,仍依赖纪律。
「鲁棒性应是默认配置」—— 落地时分裂成两层
「鲁棒性应是默认配置」—— 落地时分裂成两层 一条统一的经验法则,落到实践里总能分成「能强制的」和「只能约定的」—— 鲁棒性、eval、CI 都撞见过 强制默认 有明确禁用目标 → 机制能拦死 新代码绕不过 工厂 默认值封进构造方式 make_llm_client() 内部配好超时重试 + 检查 扫代码,裸 new 就报违规 从「靠自觉」变成「被机制拦住」 适用形态 客户端构造(禁裸 new)、熔断、超时 有明确禁用目标,代码能识别 ✓ 能强制 约定默认 应当用但非强制 → 检查判不了 仍依赖纪律 抽象 正确做法做得足够省力 一个装饰器加上就行 + 文档 + review 靠人盯,没法机械拦死 「这方法该不该重试」代码判不了 适用形态 重试(某方法该不该重试) 没有明确禁用目标,检查失效 △ 只能约定 不是「补丁救旧代码」那么简单 —— 要拦住新代码,得把默认值封进构造方式 + 检查;而检查的强制力对不同形态还不等效 两层鲁棒性:Agent 行为层护栏(前几章)+ 系统层防御(本章补的超时/重试/熔断)
图 11-1 | 左:强制默认(有明确禁用目标,工厂+检查能拦死,新代码绕不过);右:约定默认(该用但非强制,检查判不了,靠抽象+文档+review 仍依赖纪律)。这跟 eval、CI 撞见的是同一个母题

一个反复出现的模式

"默认配置"听起来是个统一的目标,落到实践里却分裂成"能强制的"和"只能约定的"两层。这跟前面讲 eval 时遇到的事一样——一条统一的经验法则,落地时总要分层。鲁棒性如此,下一章讲 CI 还会再撞见一次。

第二类:没有熔断和降级

LLM 接口偶尔会限流或者波动。正常做法应该是——连续失败 N 次之后熔断,一段时间内不再调用,直接返回降级响应。但我没有这套机制。开头那个空转一整夜的例子,就是因为没有熔断——调度器不知道 LLM 已经持续失败了,还在傻乎乎地触发。

这个坑,我在补的时候撞见一个手记原本没回答的问题:熔断到底该放在哪一层?

一个 Agent 项目里,LLM 调用至少经过三层:最底层的 HTTP 客户端、中间的 Agent 循环、最上层的调度器。熔断可以放在任何一层。我一开始没多想,差点放在调度器层——毕竟"空转"是调度器在空转嘛。

但仔细一比,调度器层是最差的放法。我后来整理出三条判据,用来选熔断放在哪层:

判据客户端层调度器层
检测准不准直接看 LLM 调用成败,最准看的是整个任务管线成败,失败原因可能不是 LLM,粒度粗
保护范围广不广所有 LLM 调用都经过它(调度器、Web 接口、手动触发)只保护调度器这条 cron 路径,Web 接口的 LLM 调用管不到
状态好不好维护客户端是单例,天然能跨请求维护"连续失败计数"单次任务内维护,下一次 cron 触发时状态早没了

三条都指向同一个答案:熔断放在客户端层最好。 检测最准、保护最广、状态能持久。

于是我这么实现:客户端内部维护一个"连续失败计数",连续失败达到阈值(我设的 5 次)就熔断,断开一段时间(我设的 60 秒)。断开期间,任何调用直接抛一个"熔断中"的异常,根本不去碰 LLM。60 秒后进入"半开"状态,放一个请求过去试探,成功就恢复,失败就继续断开。

这里有个细节值得说:熔断计数必须用单调时钟(monotonic time),不能用墙上时钟。 因为墙上时钟会被系统时间校准跳变,一旦往回跳,你的"断开到 X 时刻"就可能变成"已经过了",熔断提前解除。单调时钟只往前走,不受校准影响。这种坑不踩一次想不到。

那怎么验证它真的根治了"空转"?我写了个端到端测试:让客户端连续失败到熔断,然后看它还会不会去调 LLM。结果是——熔断触发后,底层 HTTP 调用的次数就停在了阈值那几次,不再增加。也就是说,调度器再怎么触发,到了客户端这一层就被熔断挡住了,一个多余的 LLM 请求都不会发出去。 开头那个"空转一整夜、日志刷到天亮"的场景,从机制上不可能再发生了。

还有一个收尾的细节:上层(调度器)要认识这个"熔断中"异常,把它当成"这次跳过",而不是当成普通失败去刷错误日志、去计入失败次数。否则熔断虽然挡住了 LLM 调用,但调度器还在为每一次"熔断跳过"记一笔失败,日志照样刷屏——只是刷的内容从"LLM 调用失败"变成了别的。所以熔断要配合上层的"优雅跳过",才算真正闭环。

这里要区分两个容易混的概念,它们是互补的,不是替代关系:

  • 重试(前面第一类讲的 max_retries):是单次调用内的——这一次调用失败了,立刻重试几次。
  • 熔断:是跨调用的——盯的是很多次调用的累计结果,发现"连续失败"这个模式后,在一段时间内整体停掉。

光有重试不够(单次重试解决不了"服务持续挂了"的情况),光有熔断也不够(瞬时抖动还得靠重试扛过去)。两者一起,才覆盖了"偶尔抖"和"持续挂"两种情况。

第三类:工具执行的副作用没有兜底

Agent 调工具写数据的时候,如果中途出了错——比如写了一半网络断了——数据可能处于不一致的状态。正常应该有事务保证,要么全成功要么全回滚。但早期代码里很多写操作没有包在事务里。

这一类我补得相对顺利,因为它是个"做了就做了"的事——把该包事务的写操作包上事务,没有前面两类那种"放哪层""能不能强制"的设计纠结。难点不在怎么做,而在想起来要做。

鲁棒性应该是默认配置,不是事后补丁

前面三类讲完,可以正面回答这一章标题暗示的那个问题了:鲁棒性怎么才能不靠"想起来才补"?

答案就是这一章演示的那套机制——把鲁棒性从"补丁"升级成"默认",再区分清楚哪些能强制、哪些只能约定:

  1. 把默认值封进构造方式(工厂),让正确做法变成最省力的做法。
  2. 对能强制的形态加检查(lint/脚本),把"该用工厂"从自觉变成机制。
  3. 对只能约定的形态(如重试),至少把正确做法做得足够顺手,降低绕过的动机。

这些不是"高级功能",是"基本卫生"。就像你写代码不会忘记声明变量类型一样——不是因为你每次都"想起"要声明,而是因为它已经变成了你写代码的默认方式。

鲁棒性也应该这样。写一个 HTTP 调用的时候,超时、重试、降级应该是随手就加的,不是"先跑起来再说,回头补"。因为"回头"通常意味着"永远不会"。

前面章节里那些"护栏",其实就是鲁棒性

回头看看这份手记前面讲的很多东西,其实都和鲁棒性有关:

  • 第 5 章讲的循环护栏——迭代上限、回复保证、卡住检测——这些就是在"Agent 行为层面"的鲁棒性保证
  • 第 4 章讲的"错误信息要可操作"——让 LLM 碰到错误时能自我修正——这也是一种鲁棒性
  • 第 9 章讲的双层验证和自动重试——这是在"多 Agent 协作层面"的鲁棒性

这些我做到了。但它们都偏"Agent 层面"。而在更底层的"系统层面"——HTTP 调用的超时重试、服务的熔断降级、数据操作的事务保证——我之前没做好,这一章讲的就是怎么补上。

完整的鲁棒性应该是两层都有的:Agent 行为层面的护栏(前面讲了)+ 系统层面的防御(这一章补的)。

这一章的工具:鲁棒性检查清单

🔧 鲁棒性检查清单

对照你的 Agent 项目,过一遍系统层面的防御(Agent 行为层面的护栏在第 5 章):

超时与重试

  • [ ] 所有的外部调用(LLM、HTTP、数据源)都配超时了吗?还是有些会无限阻塞?
  • [ ] 瞬时错误有没有重试?重试次数和退避策略设了吗?
  • [ ] 新代码会不会"绕过"已配好的默认值?(裸 new 一个客户端,而不是走带默认值的工厂)
  • [ ] 如果有工厂——有没有检查机制拦住"不走工厂"的写法?

熔断与降级

  • [ ] 连续失败时,有没有熔断?还是傻乎乎地一直重试到天亮?
  • [ ] 熔断放在哪一层了?检测够准、保护够广、状态能持久吗?
  • [ ] 熔断计数用的是单调时钟(monotonic)吗?还是会被系统时间跳变干扰?
  • [ ] 上层认不认识"熔断中"异常?是优雅跳过,还是当成普通失败刷日志?

强制 vs 约定

  • [ ] 你的鲁棒性默认值,哪些是"能强制的"(有明确禁用目标,工厂+检查能拦死)?
  • [ ] 哪些是"只能约定的"(如"该方法该不该重试",检查判不了,靠纪律)?
  • [ ] 对只能约定的部分,至少把正确做法做得足够省力了吗?

兜底

  • [ ] 写数据的操作,有没有事务保证?中途断了会不会留下不一致状态?
  • [ ] 工具执行失败时,副作用有没有回滚或兜底?

危险信号

  • [ ] 有没有任何调用的地方没配超时 → 它就是下一个深夜空转的来源
  • [ ] 连续失败只会无限重试,没有熔断 → 服务一挂就刷一夜日志
  • [ ] 鲁棒性全靠"想起来才加",没有默认机制 → 新代码永远在制造新的裸调用

小结

这一章没有讲什么高深的东西。它从几个朴素的例子开始——一个空转了一整夜的系统、几个没配超时的调用——说明一个朴素的问题:做 Agent 项目的时候,我过度关注功能开发,忽略了传统软件的鲁棒性保证。

但补的过程比"加上就行"要讲究:补丁只能救旧代码,拦不住新代码;要拦新代码,得把默认值封进构造方式、加检查强制;而检查的强制力对不同形态还不等效,得分清"强制默认"和"约定默认"。熔断这种东西,放哪层也有判据,不是拍脑袋。

开头那个空转一整夜的故事,最后是这样的:我用一个客户端层的熔断把它根治了。从那以后,就算 LLM 接口整个挂掉,我的系统最多空转 60 秒就进入熔断,不会再刷一夜日志。这件事给我的教训不是"要记得加熔断"——而是这些无聊的防御性工作,一旦做进默认机制里,就再也不用操心了;但不做,它就会在某个你睡觉的夜晚,安静地惩罚你。

下一章

第 12 章 · 质量门禁 —— CI、测试、监控闭环——那些"不性感但不能没有"的东西。