第 11 章 被忽略的鲁棒性

做 Agent 项目的时候,我犯了一个特别普遍的错误——精力全扑在功能开发上,传统软件的鲁棒性保证被我忽略了。这一章先讲我栽过的坑,再讲后来怎么补上的。
开篇:一个空转了一整夜的系统
我有一个项目的错误日志,记录了一件挺荒唐的事。
系统的调度器每隔几分钟触发一次任务,调 LLM 做决策。某天晚上,LLM 的接口开始报错——原因很简单,账户余额用完了。但调度器不知道,照常每隔几分钟触发一次。LLM 照常失败。没有任何东西把这个循环打断——没有熔断、没有降级、没有告警。
从晚上十点开始,日志一直在重复刷同一行:"LLM 调用失败"。每隔几分钟一次。一直刷到第二天早上九点多,我起床打开电脑看到日志,手动把服务停了。整整一整夜。
这件事让我意识到一个被我忽略的问题:我花了大量时间让 Agent 学会做各种事,但几乎没有花时间保证它在出问题时能安全地活着。
这个故事先放在这儿。这一章的最后,我会回来讲它后来怎么被根治的——因为补鲁棒性的过程,比我想的要更讲究。
鲁棒性这件事,传统软件里我都会做
说起来挺矛盾的。在传统软件项目里,这些事情我都会做。
调一个外部 HTTP 接口——我会配超时,会加重试,失败了会记日志降级。跑一个后台任务——我会加健康检查,挂了会自动重启。数据库连接——我会配连接池,配超时,配失败回退。
这些是传统软件工程师的肌肉记忆。不需要谁教,做项目的时候自然就会想到。
但到了 Agent 项目里,这些肌肉记忆好像突然失灵了。为什么?
为什么会忽略
我后来想了想,大概有三个原因。
第一个原因:Agent 功能的正反馈太强了。 你让 Agent 学会一个新技能,跑一遍就能看到效果——"它居然能记住我说的话了""它居然会自己查资料了"。这种即时的、可视的成就感,会让你不自觉地把所有精力往功能上堆。而鲁棒性是"不出事你感觉不到"的东西——它没有正反馈,只有"没出事"这个隐性状态。
第二个原因:Agent 项目的节奏太快了。 用 Claude Code 写代码,一个功能从想法到能跑,可能就半天时间。这种速度让你产生一种惯性——"赶紧做下一个功能"。停下来配超时、加重试、写降级逻辑?太慢了,下个功能还在等我。
第三个原因:LLM 本身就是"不稳定"的,让我产生了"反正也不稳定,鲁棒性也没用"的错觉。 这个想法是错的——正因为 LLM 不稳定,鲁棒性才更重要。但在当时,"Agent 本来就偶尔抽风"这个认知,反而成了我忽略鲁棒性的借口。
具体忽略了什么,以及后来怎么补的
盘点一下,我忽略的东西主要有三类。这一节不只列清单——每一样我都会讲后来是怎么补的,因为补的方式本身有讲究。
第一类:外部调用没有超时和重试
调 LLM 的请求,很多地方没有配超时。LLM 不响应的时候,请求就卡在那里,可能卡几分钟。如果一个请求卡住了,后面的请求也会排队等着——链式阻塞,整个服务就僵住了。
补这一类,直觉做法是"给每个已知没配超时的地方都加上"。我一开始就是这么干的,给 LLM 客户端补上 timeout=60 和 max_retries=3。
但这么做有一个问题:它只解决了"已知的旧代码",拦不住"明天新写的代码"。 我今天记得补,下周写一个新功能、新起一个客户端,很可能又忘了配超时——又回到原点。
后来我换了个思路:把"带鲁棒性默认值"这件事,从一个补丁变成一个默认就长这样的构造方式。具体说,我抽了一个客户端工厂出来——所有新建 LLM 客户端的代码,都走这个工厂,工厂内部把超时和重试都配好了:
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 还会再撞见一次。
第二类:没有熔断和降级
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 调工具写数据的时候,如果中途出了错——比如写了一半网络断了——数据可能处于不一致的状态。正常应该有事务保证,要么全成功要么全回滚。但早期代码里很多写操作没有包在事务里。
这一类我补得相对顺利,因为它是个"做了就做了"的事——把该包事务的写操作包上事务,没有前面两类那种"放哪层""能不能强制"的设计纠结。难点不在怎么做,而在想起来要做。
鲁棒性应该是默认配置,不是事后补丁
前面三类讲完,可以正面回答这一章标题暗示的那个问题了:鲁棒性怎么才能不靠"想起来才补"?
答案就是这一章演示的那套机制——把鲁棒性从"补丁"升级成"默认",再区分清楚哪些能强制、哪些只能约定:
- 把默认值封进构造方式(工厂),让正确做法变成最省力的做法。
- 对能强制的形态加检查(lint/脚本),把"该用工厂"从自觉变成机制。
- 对只能约定的形态(如重试),至少把正确做法做得足够顺手,降低绕过的动机。
这些不是"高级功能",是"基本卫生"。就像你写代码不会忘记声明变量类型一样——不是因为你每次都"想起"要声明,而是因为它已经变成了你写代码的默认方式。
鲁棒性也应该这样。写一个 HTTP 调用的时候,超时、重试、降级应该是随手就加的,不是"先跑起来再说,回头补"。因为"回头"通常意味着"永远不会"。
前面章节里那些"护栏",其实就是鲁棒性
回头看看这份手记前面讲的很多东西,其实都和鲁棒性有关:
- 第 5 章讲的循环护栏——迭代上限、回复保证、卡住检测——这些就是在"Agent 行为层面"的鲁棒性保证
- 第 4 章讲的"错误信息要可操作"——让 LLM 碰到错误时能自我修正——这也是一种鲁棒性
- 第 9 章讲的双层验证和自动重试——这是在"多 Agent 协作层面"的鲁棒性
这些我做到了。但它们都偏"Agent 层面"。而在更底层的"系统层面"——HTTP 调用的超时重试、服务的熔断降级、数据操作的事务保证——我之前没做好,这一章讲的就是怎么补上。
完整的鲁棒性应该是两层都有的:Agent 行为层面的护栏(前面讲了)+ 系统层面的防御(这一章补的)。
这一章的工具:鲁棒性检查清单
🔧 鲁棒性检查清单
对照你的 Agent 项目,过一遍系统层面的防御(Agent 行为层面的护栏在第 5 章):
超时与重试
- [ ] 所有的外部调用(LLM、HTTP、数据源)都配超时了吗?还是有些会无限阻塞?
- [ ] 瞬时错误有没有重试?重试次数和退避策略设了吗?
- [ ] 新代码会不会"绕过"已配好的默认值?(裸
new一个客户端,而不是走带默认值的工厂) - [ ] 如果有工厂——有没有检查机制拦住"不走工厂"的写法?
熔断与降级
- [ ] 连续失败时,有没有熔断?还是傻乎乎地一直重试到天亮?
- [ ] 熔断放在哪一层了?检测够准、保护够广、状态能持久吗?
- [ ] 熔断计数用的是单调时钟(monotonic)吗?还是会被系统时间跳变干扰?
- [ ] 上层认不认识"熔断中"异常?是优雅跳过,还是当成普通失败刷日志?
强制 vs 约定
- [ ] 你的鲁棒性默认值,哪些是"能强制的"(有明确禁用目标,工厂+检查能拦死)?
- [ ] 哪些是"只能约定的"(如"该方法该不该重试",检查判不了,靠纪律)?
- [ ] 对只能约定的部分,至少把正确做法做得足够省力了吗?
兜底
- [ ] 写数据的操作,有没有事务保证?中途断了会不会留下不一致状态?
- [ ] 工具执行失败时,副作用有没有回滚或兜底?
危险信号
- [ ] 有没有任何调用的地方没配超时 → 它就是下一个深夜空转的来源
- [ ] 连续失败只会无限重试,没有熔断 → 服务一挂就刷一夜日志
- [ ] 鲁棒性全靠"想起来才加",没有默认机制 → 新代码永远在制造新的裸调用
小结
这一章没有讲什么高深的东西。它从几个朴素的例子开始——一个空转了一整夜的系统、几个没配超时的调用——说明一个朴素的问题:做 Agent 项目的时候,我过度关注功能开发,忽略了传统软件的鲁棒性保证。
但补的过程比"加上就行"要讲究:补丁只能救旧代码,拦不住新代码;要拦新代码,得把默认值封进构造方式、加检查强制;而检查的强制力对不同形态还不等效,得分清"强制默认"和"约定默认"。熔断这种东西,放哪层也有判据,不是拍脑袋。
开头那个空转一整夜的故事,最后是这样的:我用一个客户端层的熔断把它根治了。从那以后,就算 LLM 接口整个挂掉,我的系统最多空转 60 秒就进入熔断,不会再刷一夜日志。这件事给我的教训不是"要记得加熔断"——而是这些无聊的防御性工作,一旦做进默认机制里,就再也不用操心了;但不做,它就会在某个你睡觉的夜晚,安静地惩罚你。
下一章
第 12 章 · 质量门禁 —— CI、测试、监控闭环——那些"不性感但不能没有"的东西。