📑 本页目录(点开跳转)
07 · 异常、traceback 和调试
⏱ 74 分钟 | ⭐ except Exception: pass 让脚本「跑完了」—— 而你要的是它在第 3 分钟就停下
🎯 一句话
异常不是麻烦,是你唯一能免费拿到的现场记录 —— 大多数难查的 bug,都是因为有人把它扔了。 这一章讲三件事:traceback 该怎么读、异常该怎么串起来不丢根因、以及当 traceback 不够用时怎么进去看一眼。
🧩 一、traceback 从哪头读
先看一个真的:
# tb_read.py —— 一个三层调用崩掉的样子
def load_batch(rows):
return [to_score(r) for r in rows]
def to_score(row):
return normalize(row["score"])
def normalize(v):
return int(v) / 100
print(load_batch([{"score": "80"}, {"score": "n/a"}]))
实跑输出(路径已缩短):
Traceback (most recent call last):
File "tb_read.py", line 14, in <module>
print(load_batch([{"score": "80"}, {"score": "n/a"}]))
~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "tb_read.py", line 3, in load_batch
return [to_score(r) for r in rows]
~~~~~~~~^^^
File "tb_read.py", line 7, in to_score
return normalize(row["score"])
File "tb_read.py", line 11, in normalize
return int(v) / 100
~~~^^^
ValueError: invalid literal for int() with base 10: 'n/a'
⭐ 三条读法,按重要性排:
| 顺序 | 看哪 | 得到什么 |
|---|---|---|
| ① | 最后一行 | 错误的种类 + 值。ValueError: ... 'n/a' —— 已经告诉你是哪个值坏了 |
| ② | 倒数第二段 | 现场:normalize 第 11 行的 int(v)。⭐ 这是真正出事的地方 |
| ③ | 从上往下扫文件名 | 找第一个属于你的文件 —— 报错发生在库里时,这一行才是你能改的地方 |
⚠️ Traceback (most recent call last) 这句话本身就是说明书:最下面才是最近的一层。
很多人从上往下读,第一眼看到的是入口,然后就开始怀疑入口 —— 方向反了。
⭐ 那几行 ~~~^^^ 是 3.11 之后加的,白送的信息别浪费:
return int(v) / 100 底下标的是 ~~~^^^ 对准 int(v),
说明炸的是 int(v) 不是除法。一行里有好几个调用时,这几个符号直接省掉你一轮猜。
💡 一个立刻能用的习惯:贴 traceback 求助 / 搜索时,至少带上最后一行和倒数第二段。 只贴最后一行等于只说「我发烧了」;只贴第一段等于只说「我进了医院」。
🧩 二、异常链:raise ... from 和它的两个变体
底层报了个 ValueError,你想换成业务语言的 ConfigError 抛出去。这有三种写法,三种 traceback 完全不同:
# chain.py —— 同一个错误,三种转抛方式
import traceback
class ConfigError(Exception):
pass
def read_port(raw, mode):
try:
return int(raw)
except ValueError as e:
if mode == "from":
raise ConfigError("PORT 配置不是整数") from e # ⭐ 显式串上
if mode == "from_none":
raise ConfigError("PORT 配置不是整数") from None # ⚠️ 抹掉根因
raise ConfigError("PORT 配置不是整数") # 隐式串上
for mode in ("from", "from_none", "bare"):
print("=" * 12, mode)
try:
read_port("八千", mode)
except ConfigError:
print(traceback.format_exc().strip())
实跑输出里,三种模式的中间那句连接词是关键(其余部分省略):
| 写法 | traceback 里的连接句 | 根因还在不在 |
|---|---|---|
raise X from e |
The above exception was the direct cause of the following exception | ✅ 在,且明说「是它导致的」 |
raise X(在 except 里) |
During handling of the above exception, another exception occurred | ✅ 在,但语气是「处理时又出了一个」 |
raise X from None |
(没有连接句) | 💀 ValueError: ... '八千' 整段消失 |
⭐ 实践规则:
- 转抛时默认写
from e。它比什么都不写更准确 —— 你是故意把它转成业务错误的,不是「处理时不小心又炸了」。 - ⚠️
from None只在一种场景下用:底层异常的内容会泄露敏感信息(比如连接串里的密码出现在ValueError里)。 除此之外用它,等于亲手删掉排查这个 bug 唯一的线索。 - 💀
from None最坏的形态是「为了日志好看」。日志好看了,出事那天你只有一句「PORT 配置不是整数」, 而不知道读到的原值是什么、从哪个文件读的。
🧯 三、except 该写多宽:三个真正的分界
🚦 except Exception 和裸 except: 差在哪
# bare_except.py —— 裸 except 会连 Ctrl-C 一起吞
def run(body, catcher):
try:
body()
except catcher:
print(f" 被 {catcher.__name__} 接住了")
else:
print(" 没有异常")
def ctrl_c():
raise KeyboardInterrupt
def sys_exit():
raise SystemExit(1)
def normal():
raise ValueError("一个普通业务错误")
for name, body in (("Ctrl-C", ctrl_c), ("sys.exit", sys_exit), ("普通错误", normal)):
print(name, "→ except Exception:")
try:
run(body, Exception)
except BaseException as e:
print(f" 漏了出去:{type(e).__name__}")
print(name, "→ except BaseException:")
run(body, BaseException)
实跑输出:
信息关系
⭐ KeyboardInterrupt 和 SystemExit 不是 Exception 的子类,所以 except Exception 放它们过去 ——
这是设计出来的,让你能 Ctrl-C 掐掉一个循环。
而裸 except: 等价于 except BaseException:,它把 Ctrl-C 也接住了:
💀 一个「跑了 40 分钟还在重试」的脚本,你按 Ctrl-C 它只会打一条「重试中」然后继续跑。
⭐ 一句话:
except Exception是「宽」,裸except:是「错」。想宽就写前者。
🚦 except Exception: pass 的代价
《数据这一关》第 4 章讲嵌套 JSON 结构漂移时,明写了一条:
解析失败不要
try/except: pass,要计数。
⭐ 为什么这条这么重要,用这一章的语言说就是:pass 把「有多少条坏了」这个数字也一起扔了。
症状变成「解析出来的列突然全是 NULL,而 JSON 本身完全合法 —— 所以什么都不会报错」。
三档写法,代价递减:
| 写法 | 你失去了什么 |
|---|---|
except Exception: pass |
💀 全部。不知道发生过、不知道几次、不知道哪条 |
except Exception: log.warning(...) |
⚠️ 少一点。但一条 warning 淹在每秒几千条日志里等于没有 |
⭐ except Exception: bad += 1; sample.append(row) |
拿到比例和样本——「3.2% 的行解析失败,长这样」是能立刻行动的 |
🚦 白名单,不是黑名单
《AI 全栈》第 4 章的重试规则写着:
⚠️ 必须写成白名单:
except Exception: retry会把「prompt 超长」这种确定性错误也重试三遍, 让本该 0.2 秒返回的 400 变成 8 秒 —— 而且日志里看起来像上游在抖。
⭐ 这条判断的机制正是异常类型层次:except 后面写什么,就是你在声明「我能处理哪一类」。
写 Exception 等于声明「什么我都能处理」—— 你显然不能。
⭐ 自定义异常该分几层,判据是「调用方要不要分开处理」:
# 够用的三层,别再多了
class AppError(Exception): ... # 兜底:所有自己抛的
class RetryableError(AppError): ... # ⭐ 调用方看到它就重试
class FatalError(AppError): ... # ⭐ 调用方看到它就放弃并上报
⚠️ 不要给每个函数配一个异常类。异常层次的价值在分叉点: 只有当上层会因为类型不同而走不同分支时,多一个类才有意义;否则加的只是打字量。
🧩 四、两个会静默改变语义的写法
💀 finally 里 return
# finally_return.py —— finally 里的 return 会把异常整个吃掉
def bad():
try:
raise ValueError("真正的错误")
finally:
return "一切正常" # 💀 异常被丢弃,函数正常返回
def good():
try:
raise ValueError("真正的错误")
finally:
print(" 清理动作跑了") # ✅ 只做清理,不 return
print("bad() =", bad())
try:
good()
except ValueError as e:
print("good() 抛出:", e)
实跑输出:
要点
bad() = 一切正常
清理动作跑了
good() 抛出: 真正的错误
💀 bad() 抛了 ValueError,调用方拿到的却是字符串 一切正常。
没有报错、没有警告、traceback 里什么都没有。 finally 里的 return(以及 break、continue)
会覆盖掉正在飞的异常。⭐ 规则:finally 里只写清理,不写控制流。
💀 assert 在 -O 下会被整条删掉
# assert_o.py —— 同一份代码,加不加 -O 结果相反
import os
import subprocess
import sys
import tempfile
CHILD = "\n".join([
"def add_user(age):",
" assert age >= 0, '年龄不能是负数'",
" return {'age': age}",
"",
"print('__debug__ =', __debug__)",
"print(add_user(-5))",
])
script = os.path.join(tempfile.mkdtemp(), "child.py")
with open(script, "w", encoding="utf-8") as fh:
fh.write(CHILD)
for flags in ([], ["-O"]):
r = subprocess.run([sys.executable, *flags, script],
capture_output=True, text=True, encoding="utf-8")
print(f"--- python {' '.join(flags) or '(无参数)'} child.py → 退出码 {r.returncode}")
print((r.stdout + r.stderr).strip().splitlines()[-1])
实跑输出:
信息关系
⚠️ 加了 -O(或环境变量 PYTHONOPTIMIZE=1)之后,assert 那一行根本不存在了 ——
不是「不抛异常」,是编译期就被删掉。于是负数一路进了数据。
⭐ 分界线:
| 用途 | 该用什么 |
|---|---|
| 校验外部数据(用户输入、配置、上游返回的 JSON) | ⭐ if not ...: raise ValueError(...),或用 pydantic(06 章) |
| 写下「我认为这里不可能发生」 | assert 合适 —— 它就是给开发者读的内部不变量 |
| 测试里的断言 | assert 合适(pytest 就靠它,见 AI全栈 15b) |
🛑 读到这里可以停 —— 前半章讲完了(约 32 分钟)。 后半章还有:traceback 不够用的时候:让解释器多说一点 回来的时候不用重读,直接从下一节接着看就行。
🧩 五、traceback 不够用的时候:让解释器多说一点
🚦 -X dev:把平时压着的警告放出来
# devmode.py —— 忘了关文件,默认一声不吭
import os
import subprocess
import sys
import tempfile
CHILD = "\n".join([
"import tempfile, os",
"path = os.path.join(tempfile.mkdtemp(), 't.txt')",
"f = open(path, 'w', encoding='utf-8')",
"f.write('忘了关')",
"del f",
"print('跑完了,没报任何错')",
])
script = os.path.join(tempfile.mkdtemp(), "leak.py")
with open(script, "w", encoding="utf-8") as fh:
fh.write(CHILD)
for flags in ([], ["-X", "dev"]):
r = subprocess.run([sys.executable, *flags, script],
capture_output=True, text=True, encoding="utf-8")
print(f"--- python {' '.join(flags) or '(无参数)'} leak.py")
print((r.stdout + r.stderr).strip() or "(无输出)")
实跑输出:
要点
--- python (无参数) leak.py
跑完了,没报任何错
--- python -X dev leak.py
跑完了,没报任何错
leak.py:5: ResourceWarning: unclosed file <_io.TextIOWrapper name=' · t.txt' mode='w' encoding='utf-8'>
del f
ResourceWarning: Enable tracemalloc to get the object allocation traceback
⭐ 默认模式下 ResourceWarning 是被静音的,-X dev 把它打开。
在长跑脚本里,「文件句柄没关」会一路积累到 OSError: Too many open files ——
那个报错出现的位置和真正的泄漏点完全无关,非常难查。
🚦 -W error:把警告变成异常,拿到出事的那一行
# werror.py —— DeprecationWarning 平时只是一行字
import os
import subprocess
import sys
import tempfile
CHILD = "\n".join([
"import warnings",
"warnings.warn('这个参数下个版本要删', DeprecationWarning)",
"print('照样跑完')",
])
script = os.path.join(tempfile.mkdtemp(), "warn.py")
with open(script, "w", encoding="utf-8") as fh:
fh.write(CHILD)
for flags in ([], ["-W", "error::DeprecationWarning"]):
r = subprocess.run([sys.executable, *flags, script],
capture_output=True, text=True, encoding="utf-8")
print(f"--- python {' '.join(flags) or '(无参数)'} warn.py → 退出码 {r.returncode}")
print((r.stdout + r.stderr).strip() or "(无输出)")
实跑输出(截取):
信息关系
⭐ 它真正解决的问题不是「让警告变响」,是「拿到完整调用栈」。
一条从某个库深处发出来的警告,只告诉你库里的行号;变成异常之后你能看到是你哪一行调进去的。
💀 站里有个现成的教训:一次基础镜像升级(sklearn 1.2→1.4)唯一的信号就是一条
InconsistentVersionWarning,级别只是 WARNING,淹没在每秒几千条日志里,
后果是 3 个月多拒约 1.4 万人(模型上线之后 17 章)。
🚦 breakpoint():不是只有 pdb 一条路
breakpoint() 是 Python 3.7 起的内置函数,它做的事只有一件:调用 sys.breakpointhook()。
默认那个 hook 是「进 pdb」,但你可以换掉它 —— 这就得到一个不打断执行的观察点:
# bp_hook.py —— 把 breakpoint() 换成「打印我关心的几个变量」
import inspect
import sys
def dump(*names):
f = inspect.currentframe().f_back
shown = {k: v for k, v in f.f_locals.items() if k in names}
print(f" [{f.f_code.co_name}:{f.f_lineno}] {shown}")
sys.breakpointhook = dump # ⭐ breakpoint() 从此变成 dump()
def score(rows):
total = 0
for i, r in enumerate(rows):
total += r["v"]
if r["v"] < 0:
breakpoint("i", "r", "total")
return total
print("结果 =", score([{"v": 3}, {"v": -1}, {"v": 5}]))
实跑输出:
要点
[score:20] {'total': 2, 'i': 1, 'r': {'v': -1}}
结果 = 7
⭐ 这比 print 好在两处:行号和函数名是自动的(print 大坑就是「我到底是哪个 print 打的」),
而且一个环境变量就能全关掉:
# bp_off.py —— PYTHONBREAKPOINT=0 让所有 breakpoint() 变成空操作
import os
import subprocess
import sys
import tempfile
CHILD = "\n".join(["x = 41", "breakpoint()", "print('跑完了,x =', x + 1)"])
script = os.path.join(tempfile.mkdtemp(), "bp.py")
with open(script, "w", encoding="utf-8") as fh:
fh.write(CHILD)
env = dict(os.environ, PYTHONBREAKPOINT="0")
r = subprocess.run([sys.executable, script], capture_output=True, text=True,
encoding="utf-8", env=env)
print("PYTHONBREAKPOINT=0 →", (r.stdout + r.stderr).strip(), "| 退出码", r.returncode)
实跑输出:
信息关系
⚠️ 注意这行没有停下来等输入 —— 换成 import pdb; pdb.set_trace() 就没有这个开关,
忘了删一行就能让一个 CI 任务挂到超时。这是 breakpoint() 存在的主要理由。
📋 真进了 pdb,六个命令够用
| 命令 | 干什么 |
|---|---|
l(list) |
看当前位置前后的源码 —— 先搞清楚自己在哪 |
p 表达式 / pp |
打印。pp 是 pretty-print,看 dict / list 用它 |
n(next) |
执行下一行,不进函数 |
s(step) |
执行下一行,进函数 |
c(continue) |
跑到下一个断点或结束 |
w(where) |
打印调用栈 —— ⭐ 和 traceback 长得一样,迷路时按它 |
💡 加一个:u / d(up / down)在栈帧之间上下走。
⭐ 最常用的组合是 w → u → p 变量:先看栈,跳到你自己的那一层,再看变量。
🔗 这一章连到哪里
| 相关的地方 | 为什么 |
|---|---|
| 06 · 类型标注是给工具看的 | 标注不做运行时检查 → 类型错误会流到很远才炸。那一章讲边界上该用 pydantic 拦,这一章讲拦不住时怎么读 traceback 倒推 |
| 08 · 环境、依赖和 import | ImportError / ModuleNotFoundError 是一类特殊的异常:traceback 只告诉你「找不到」,不告诉你「在哪找过」。那一章讲怎么把搜索路径打出来 |
| AI全栈 04 · 调用层 | ⭐ 它给了「哪些错该重试」的白名单表。本章补的是那条规则背后的机制:except 后面写什么,就是在声明你能处理哪一类 |
| 数据这一关 04 · 脏数据的十种形态 | 它的第 ⑨ 条说「解析失败不要 try/except: pass,要计数」。本章第三节把那条展开成三档写法和各自的代价 |
| AI全栈 15 · 可观测性 | 本章讲异常怎么被抛出和读懂,那一章讲异常发生之后信息怎么留下来(结构化日志、contextvars、打 stdout)。本章不重做日志体系 |
| AI全栈 15b · 怎么测不确定的系统 | assert 在测试里是合适的;本章只讲它在生产代码里的坑(-O 会删掉)。测试怎么写去那边 |
| AI全栈 03 · 后端骨架 | 它讲「对外统一的错误形状」——本章的异常层次是那个形状在服务内部的一半 |
| 模型上线之后 17 · 版本回溯与可复现 | 💀 那一章的 sklearn 升级事故,唯一信号是一条被淹没的 WARNING。本章第五节的 -W error 就是把这类信号变成能定位的东西 |
| 机器学习与深度学习基础 11 · 训练调试手册 | 那边是训练语境的排查(loss 不降、验证分数抖);这边是语言层的排查。两张表不重叠,报错先分清是哪一类 |
✅ 检查点
- 拿到一段 traceback,你先看哪一行、再看哪一段?为什么不能从上往下读?
~~~^^^这几行符号是什么,它替你省掉了什么?raise X from e、raise X(在except里)、raise X from None三者在 traceback 上有什么不同?默认该用哪个?from None唯一合理的使用场景是什么?except Exception和裸except:差在哪?举出两个会被后者吞掉的东西。- 「解析失败要计数不要
pass」这条规则,三档写法各自失去了什么? finally里写return会发生什么?规则怎么记?python -O对assert做了什么?所以校验外部数据该用什么写法?-X dev和-W error各自解决什么问题?后者真正的价值是什么?breakpoint()相比import pdb; pdb.set_trace()好在哪?pdb 里迷路时按哪个命令?
👀 答案
- 先看最后一行(错误种类 + 值),再看倒数第二段(真正出事的现场)。因为
Traceback (most recent call last)明说了最下面才是最近的一层,从上往下读看到的是入口,方向反了。 - 3.11 起加的细粒度定位。例子里
return int(v) / 100底下~~~^^^对准int(v),说明炸的是int()不是除法 —— 一行里有多个调用时直接省掉一轮猜。 from e→ 连接句是「The above exception was the direct cause of the following exception」;raise X→ 「During handling of the above exception, another exception occurred」;from None→ 没有连接句,根因整段消失。转抛默认写from e。- 只有一种:底层异常的内容会泄露敏感信息(例如连接串里的密码出现在
ValueError里)。其余情况用它等于亲手删掉唯一线索。 - 裸
except:等价于except BaseException:。会被它吞掉而except Exception放过去的两个:KeyboardInterrupt(Ctrl-C) 和SystemExit。所以「跑了 40 分钟的重试脚本按 Ctrl-C 掐不掉」就是这么来的。 pass失去全部(发生过没有、几次、哪条都不知道);log.warning少一点,但一条 warning 淹在每秒几千条日志里等于没有;计数 + 留样本才拿到能行动的东西:「3.2% 的行解析失败,长这样」。finally里的return(以及break/continue)会覆盖掉正在飞的异常 —— 例子里bad()抛了ValueError,调用方拿到的却是字符串一切正常,且没有任何报错。规则:finally里只写清理,不写控制流。-O(或PYTHONOPTIMIZE=1)会在编译期把assert整条删掉 —— 实跑对照里同一份代码,无参数时退出码 1 报AssertionError,加-O后退出码 0 且输出{'age': -5}。校验外部数据要写if not ...: raise ValueError(...)或用 pydantic。-X dev把默认静音的警告(如ResourceWarning: unclosed file)放出来,避免泄漏积累成位置无关的Too many open files;-W error把警告变成异常 —— 真正的价值是拿到完整调用栈,能看见是你哪一行调进去的,而不只是库里的行号。breakpoint()只是调用sys.breakpointhook(),所以①可以换成「打印几个变量」这种不打断执行的观察点,②一个PYTHONBREAKPOINT=0就能全关掉(pdb.set_trace()没有这个开关,忘删一行能让 CI 挂到超时)。迷路时按w(where),然后u走到自己那一层再p 变量。
🛑 可以停在这里
⚡ 走神救援
⭐⭐ 异常是你唯一免费拿到的现场记录,而大多数难查的 bug,是有人把它扔了。
读 traceback 的顺序:最后一行(种类和值,往往已经指出坏值)→ 倒数第二段(真正的现场)→ 往上扫找第一个属于你的文件。⚠️
most recent call last这句话本身就是说明书:最下面才是最近的一层。 ⭐ 新版本那几个波浪线是白送的——它直接指出炸的是哪个子表达式。异常链三种写法:显式连上根因、裸抛(会显示成「处理过程中又发生了」)、⚠️ 以及
from None——它会让根因整段消失。⭐ 转抛默认就该连上根因;💀 为了「日志好看」用from None,等于亲手删掉唯一的线索。⚠️ 中断和退出这两个异常不是
Exception的子类,而裸except:等价于捕获一切——⭐ 一个跑了半小时的脚本 Ctrl-C 掐不掉,就是这么来的。一句话:except Exception是宽,裸except:是错。⭐
except后面写什么,就是在声明「我能处理哪一类」——那是白名单,不是黑名单。 而except: pass的三档代价很有教学价值:直接吞掉失去全部信息 → 打个警告淹在日志里 → ⭐ 计数加留样本,只有最后一档能让你说出「有百分之几的行失败了,长这样」。两个静默改变语义的写法:💀
finally里return会把正在飞的异常盖掉,调用方拿到的是一个正常返回值——⭐ 规则是 finally 只写清理、不写控制流;💀 优化模式会在编译期整条删掉assert——⭐ 所以校验外部数据只能用显式的if ... raise或校验库。⭐ 最后一条:开发者模式能放出平时被静音的「文件没关」警告——否则它会积累成一个位置完全无关的「打开文件过多」。
下一节 👉 08-环境、依赖和import.md