AI 写的错误处理全在兜底:哪些该抛、哪些该兜、哪些必须让用户看见
数据截至 2026-07,各产品的额度与报错口径以官方最新说明为准。
多数人把这类问题归成「AI 写代码不严谨」,我的判断是归错了因:AI 在错误处理上的默认倾向是让流程跑通,而不是让失败暴露。 它看到的目标是「这段代码不要崩」,于是给你套一层 try,捕获宽泛异常,返回一个 None、空列表或者零值,再顺手打一行日志。语法没错,测试也过,直到某天对账少了一批数据,你才发现三个月前那次超时被吞掉了。所以这个环节的分工很清楚:AI 能帮你把处理骨架铺满、把遗漏的分支补齐、把重复的样板抽出去;但「这个失败到底属于哪一类」——该抛给上层、该本地兜住、还是必须让用户看见——这条线只能你来画,因为它取决于业务能不能接受一次错误结果,而模型不知道你的账要不要平。
站内已经有两篇邻近的文章:Agent 工具返回值怎么设计 讲的是工具返回值的结构该怎么设计,让模型能读懂结果;Agent 失败了先别急着重跑,三类失败的判 讲的是任务跑挂之后怎么归类分析。本篇夹在两者中间,只谈一件事:在你自己的业务代码里,AI 生成的异常处理该怎么审、怎么改、怎么验收。
一、先把三条线画出来,再让 AI 动手
分类做不对,后面所有动作都是白干。我用的判据只有三个问题,按顺序问:
第一问:这次失败之后,继续往下走会不会产生错误的持久化结果? 会,就必须抛,而且要抛到能中止整个操作的那一层。典型是写库前的校验失败、金额解析失败、外部返回结构和预期对不上。这类失败最怕的就是被兜底成默认值,因为默认值会以「正常数据」的身份沉进数据库,之后没人能靠日志把它认出来。
第二问:失败是不是暂时性的、且重试代价可接受? 是,才轮得到本地兜底。网络抖动、连接被重置这类属于典型的暂时性失败,立刻重试一次通常就过去了。对端返回 429 要单独说:它表示「你被限流了」,而限流窗口没走完之前,立刻重试只会再撞一次,所以 429 必须配退避(有的服务会在响应头里给出建议的等待时长,有就按它来,没有就用指数退避),而不是原地重试。但兜底还有个前置条件常被跳过:操作必须幂等,否则你重试的是「再扣一次款」。这块的展开可以看 重试之后发现同一件事做了两遍,幂等键该怎么设。
第三问:用户接下来的动作会不会因为不知道这次失败而做错? 会,就必须让他看见。用户上传了文件、以为存好了、关掉页面走人,结果后台写失败静默吞了——这不是技术问题,是欺骗。判断标准不是「错误严不严重」,而是「他知不知道之后会不会改变行为」。
这三问的顺序不能换。先问持久化,再问可重试,最后问可见性。很多人一上来就问「用户需不需要知道」,结果把一个会写脏数据的错误做成了一条 toast 提示,数据照样脏了。
二、现象到成因:一张判别表
线上出问题的时候你拿到的是现象,不是成因。下面这张表是我排查这类问题的固定入口,从上往下过一遍,基本能定位到具体那一段代码。
| 现象 | 大概率成因 | 怎么验证 | 处置动作 |
|---|---|---|---|
| 接口返回 200,但业务数据缺一批,没有任何告警 | 宽泛捕获后返回默认值,异常被吞 | 在仓库里搜宽泛捕获且 catch 块内无重抛的位置;对照缺数据的时间段查日志有无对应记录 | 该处改为重抛,捕获后必须携带原始异常上抛;同时补一条计数指标 |
| 日志里有大量堆栈,但服务一直显示健康 | 异常被捕获后只记日志,没有反映到健康检查或错误率指标 | 对比日志中异常条数与监控面板的错误率曲线,两者不同步即坐实 | 把关键路径的异常接到错误率指标上,让告警能感知 |
| 偶发的字段为空或为零,重跑就好了 | 上游超时被兜成默认值,重试逻辑写在了错的层级 | 在兜底分支打一条带上下文的日志,跑一天看命中次数 | 超时改为向上传播,由调用方决定是否重试,见下节动作二 |
| 报错信息直接展示给用户,含内部路径或密钥片段 | AI 把异常对象整个塞进了响应体 | 构造一次失败请求,看响应体里有没有堆栈或配置项 | 分层:对外返回稳定的错误码与人话文案,细节只进日志 |
| 重试之后出现重复记录或重复扣减 | 兜底重试作用在非幂等操作上 | 检查重试包裹的调用是否带业务唯一键 | 先加幂等键,没有幂等键就先撤掉重试 |
| 上游 401/403 也在重试,重试到限流 | 重试条件按「有异常就重试」写,没有区分可重试与不可重试 | 看重试判定条件是否只判断异常类型不判断状态码 | 明确白名单:只有超时、连接重置、429、5xx 才重试 |
| 前端一直转圈,后端日志无异常 | 调用没设超时,连接挂在那里 | 抓一次请求耗时分布,看有没有长尾不返回的 | 补超时设置,展开见 接口超时、连接被重置 |
表里最容易被忽略的是第二行。异常有日志不等于系统知道自己错了:日志是给人看的,指标才是给告警看的。AI 补的处理默认只做前者。
三、动作清单:从一处样板到全仓约束
定位到成因之后按下面顺序做,别跳步。
动作一:先搜出全部宽泛捕获点,做一次盘点。 不用一次全改,先知道有多少。Python 项目:
git grep -n "except Exception" -- "*.py" | wc -l
git grep -n -A1 "except Exception" -- "*.py" | grep -cE -- '-[0-9]+-[[:space:]]*pass[[:space:]]*$'
第一条数的是宽泛捕获总量,第二条数的是「捕获后紧跟一行 pass」的位置,这是优先级最高的一批。第二条的正则别偷懒写成 grep -c "pass":git grep -A 的上下文行前缀是 文件名-行号-,直接数 pass 会把 password、passed 这类变量名一起算进去,我实测过一个只有一处真空块的样例,松正则数出来是两处。这个数字是要拿去排优先级的,虚高就等于白排。它也只覆盖最直白的那种写法,return None、return []、return 0 这几类「假装成功」的兜底得另外搜一轮,命中量往往比 pass 还大。JavaScript 侧同理搜 catch (e) 后面紧跟空块或只有 console 的位置。盘点结果先写进一个清单文件,按上一节的三问逐条标类型,标完再让 AI 改。顺序反过来的话,你会得到一堆看起来更严谨、实际上依然吞异常的代码。
动作二:把重试从底层挪到调用方。 AI 很喜欢在最底层的 HTTP 封装里塞重试,因为那里最「通用」。问题是底层不知道这次调用是不是幂等,也不知道调用方能不能等。正确的位置是调用方:底层只负责如实把超时和状态码抛出来,调用方根据业务决定重试几次、要不要退避、失败之后走降级还是直接失败。
动作三:给错误分层。 至少分三层:对内的异常类型(带完整上下文,进日志)、对外的错误码(稳定、可枚举、能写进接口文档)、对用户的文案(人话,说清下一步做什么)。这三层的映射关系写成一张表,让 AI 照着表补代码,比让它自由发挥可靠得多。日志里不要带敏感信息,这条容易踩,见 日志里把密钥和用户手机号打出来了。
动作四:验收要靠注入失败,不靠读代码。 读代码只能确认写了处理,不能确认处理是对的。最省事的注入方式是改配置指向一个不可达地址,看整条链路的表现:
export UPSTREAM_BASE_URL=http://127.0.0.1:9/
端口 9 是历史上的 discard 服务端口,现在的机器上一般没有进程监听,连接会立刻被拒绝,这比干等超时快得多。跑之前顺手确认一下本机确实没占用(curl -sv http://127.0.0.1:9/ 应该直接报连接被拒),占用了就换一个空闲端口。另外注意有些客户端库会限制访问这类保留端口,如果你的服务端 HTTP 客户端直接报「端口不被允许」而不是连接被拒,换成 9 以外的未监听端口即可。要测超时和 5xx,起一个本地假服务:
from http.server import BaseHTTPRequestHandler, HTTPServer
import time
class H(BaseHTTPRequestHandler):
def do_GET(self):
time.sleep(30) # 制造超时
self.send_response(500) # 或者直接返回 5xx
self.end_headers()
HTTPServer(("127.0.0.1", 8099), H).serve_forever()
两点使用说明。一是这个 handler 只实现了 do_GET,你的被测调用如果是 POST,得照着加一个 do_POST,否则拿到的是 501 而不是你想要的超时。二是 HTTPServer 是单线程串行处理的,time.sleep 期间后来的请求全在排队,所以它只适合一次注入一条链路;要并发压就换 ThreadingHTTPServer。还有,睡够 30 秒之后客户端多半已经断开,服务端这边会打一条 broken pipe 的报错,那是正常现象,不是你的代码写错了。
然后用 curl -i 看你自己服务对外返回了什么:
curl -i -X POST http://127.0.0.1:8080/api/orders -d '{"amount":"abc"}' -H 'Content-Type: application/json'
验收的判定标准只有三条:一,响应体里没有内部细节;二,日志里有完整上下文且能定位到具体请求;三,该失败的没有变成 200。三条缺一条都算没过。
动作五:把结论沉淀成项目约定。 一次改完不管,三周后新代码又是老样子。把「哪类失败必须抛」「重试只允许写在调用方」「对外响应不带堆栈」写进项目规则文件,让 AI 每次生成时都能读到。
四、什么时候别再折腾
有几种情况我会直接停手,不再继续修:
其一,改动开始蔓延到调用链之外。 你本来只想把一处吞异常改成上抛,结果发现上层没有对应的捕获,再往上是个批处理循环,一抛整批就断。这时候继续往上改就是在重构,不是在修 bug。止损做法是先在原处保留兜底,但补上计数指标和明确的日志标记,让这条路径变得可观测,把真正的改造排进独立的任务里。可观测比正确性先落地,是这类问题的通用退路。
其二,AI 连续两轮给出方向相同但细节不同的修法,且都跑不通。 这是它在原地打转的信号,再问下去只会拿到更长的代码。回滚到干净状态重来,把上下文换个切入口:不要让它「修这个错误」,而是让它「读这段调用链,列出所有可能的失败点」,你自己挑。
这里有个坑我踩过:只跑 git stash 加 git diff --stat,看着输出是空的就以为回到原点了。实际上 git stash 默认不收未跟踪文件,git diff 也从来不显示未跟踪文件,AI 这一轮新建的那个 retry_helper.py 还静静躺在工作区里,下一轮它读到自己上一轮的产物,打转打得更凶。所以判定干净要用 git status --short,输出为空才作数。
git stash -u # -u 连未跟踪文件一起收走
git status --short # 无输出才算真干净
其三,改的是历史遗留的关键路径,且没有回归测试。 没有测试托底的错误处理改造,风险高于收益,先补测试或者干脆不动。
其四,出现了数据已经被污染的迹象。 这时优先级立刻变了:先止血(停掉写入或加校验拦截),再做数据修复,最后才回来改代码。顺序搞反的话,你改代码的这段时间脏数据还在继续进。
回滚点怎么定:改错误处理之前先提交一次干净的 commit,改动按「一个失败类型一个 commit」拆。这样线上一旦出问题,你能精确回滚到某一类的改动,而不是整包退回去。
五、避坑清单
坑一:让 AI 一次性「把整个项目的错误处理补全」。 为什么会踩:这条指令听起来很高效,而且它真的会给你一个大 diff。问题是它没有业务语义,只能按代码形状统一处理,结果是把该抛的也兜了。怎么避:按调用链或按模块分批,每批先由你标好类型再让它写。
坑二:以为写了 try 就是处理了。 为什么会踩:代码评审时看到 try/except 会本能地打勾。怎么避:评审时只看 catch 块内部——有没有重抛、有没有指标、有没有把上下文带进日志。这三样一样没有的,就是吞异常,跟没写一个样。
坑三:在底层封装里加重试。 为什么会踩:底层是最容易「一处改动全局生效」的地方,AI 和人都爱往那放。怎么避:底层只做透传,重试策略必须由知道幂等性的那一层决定。
坑四:把异常对象直接返回给前端。 为什么会踩:调试阶段这样最方便,看得清楚,然后就忘了改。怎么避:从第一版就分层,对外只返回错误码加文案;上线前用 curl 打一遍失败路径,肉眼确认响应体干净。
坑五:对上游的错误分类照搬示例代码。 为什么会踩:不同服务对同一状态码的语义不一样,429 在有的地方是短时限流、有的地方是配额相关,各家规则不同而且会调整。怎么避:不猜语义,先在自己的日志里把响应状态和响应体记全,观察几天真实分布再定重试策略,具体口径以对方官方最新说明为准。
坑六:靠模型生成的错误文案直接上线。 为什么会踩:文案读起来很顺,像模像样。怎么避:错误文案的核心是告诉用户「下一步做什么」,不是描述发生了什么。凡是只描述现象、没有行动建议的文案,一律打回。
坑七:用海外工具做这类改造时忽略了可用性前提。 为什么会踩:教程里都在用,就默认自己也能接。实际情况是部分海外 AI 工具与模型服务对中国大陆有区域限制、官方不支持直连,市面上存在一些第三方中转,但稳定性和合规责任要你自己承担,我不背书也不推荐具体渠道。怎么避:把工具可用性当成前置条件先确认,别等改到一半断供。
六、收束与自检
错误处理这件事,AI 能替你干的是「铺满」和「统一」——补齐遗漏分支、抽掉重复样板、按你给的映射表批量生成。它干不了的是「定性」:哪类失败必须炸出来、哪类可以悄悄重试、哪类必须让用户知道。这条线画在哪,取决于你的业务能不能接受一次错误结果,模型不知道,也不该由它猜。
改完之后过一遍这张自检清单,能全部答「是」再合并:
- 全仓宽泛捕获点是否已经盘点,且「捕获后什么都不做」的位置清零?
- 每一处兜底分支,是否都能说清「兜的是哪类暂时性失败」?
- 重试是否只发生在调用方、被重试的操作有幂等键、且明确排除了 401/403 这类不可重试的情况?
- 对外响应里是否确认没有堆栈、内部路径、配置项?
- 关键失败路径是否既有日志上下文,也有可告警的计数指标?
- 是否用不可达地址或本地假服务实际注入过一次失败并观察了全链路表现?
- 这次改动是否按失败类型拆成了多个 commit,具备精确回滚能力?
- 三层错误映射(内部异常、对外错误码、用户文案)是否写进了项目规则文件?
最后一条最容易被跳过,也最决定这次改造能不能撑过三个月。