🏠 总目录📚 本教程 07 · 异常、traceback 和调试 ← →
📑 本页目录(点开跳转)

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: ... '八千' 整段消失

⭐ 实践规则:


🧯 三、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)

实跑输出:

信息关系

Ctrl-C→except Exception:
漏了出去:KeyboardInterrupt
Ctrl-C→except BaseException:
被 BaseException 接住了
sys.exit→except Exception:
漏了出去:SystemExit
sys.exit→except BaseException:
被 BaseException 接住了
普通错误→except Exception:
被 Exception 接住了
普通错误→except BaseException:
被 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])

实跑输出:

信息关系

--- python (无参数) child.py→退出码 1
AssertionError: 年龄不能是负数
--- python -O child.py→退出码 0
{'age': -5}

⚠️ 加了 -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 "(无输出)")

实跑输出(截取):

信息关系

--- python (无参数) warn.py→退出码 0
照样跑完
warn.py:2: DeprecationWarning: 这个参数下个版本要删
warnings.warn('这个参数下个版本要删', DeprecationWarning)
--- python -W error::DeprecationWarning warn.py→退出码 1
Traceback (most recent call last):
File "warn.py", line 2, in <module>
warnings.warn('这个参数下个版本要删', DeprecationWarning)
DeprecationWarning: 这个参数下个版本要删

⭐ 它真正解决的问题不是「让警告变响」,是「拿到完整调用栈」。 一条从某个库深处发出来的警告,只告诉你库里的行号;变成异常之后你能看到是你哪一行调进去的。 💀 站里有个现成的教训:一次基础镜像升级(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)

实跑输出:

信息关系

PYTHONBREAKPOINT=0→跑完了,x = 42 | 退出码 0

⚠️ 注意这行没有停下来等输入 —— 换成 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 不降、验证分数抖);这边是语言层的排查。两张表不重叠,报错先分清是哪一类

✅ 检查点

  1. 拿到一段 traceback,你先看哪一行、再看哪一段?为什么不能从上往下读?
  2. ~~~^^^ 这几行符号是什么,它替你省掉了什么?
  3. raise X from e、raise X(在 except 里)、raise X from None 三者在 traceback 上有什么不同?默认该用哪个?
  4. from None 唯一合理的使用场景是什么?
  5. except Exception 和裸 except: 差在哪?举出两个会被后者吞掉的东西。
  6. 「解析失败要计数不要 pass」这条规则,三档写法各自失去了什么?
  7. finally 里写 return 会发生什么?规则怎么记?
  8. python -O 对 assert 做了什么?所以校验外部数据该用什么写法?
  9. -X dev 和 -W error 各自解决什么问题?后者真正的价值是什么?
  10. breakpoint() 相比 import pdb; pdb.set_trace() 好在哪?pdb 里迷路时按哪个命令?
👀 答案
  1. 先看最后一行(错误种类 + 值),再看倒数第二段(真正出事的现场)。因为 Traceback (most recent call last) 明说了最下面才是最近的一层,从上往下读看到的是入口,方向反了。
  2. 3.11 起加的细粒度定位。例子里 return int(v) / 100 底下 ~~~^^^ 对准 int(v),说明炸的是 int() 不是除法 —— 一行里有多个调用时直接省掉一轮猜。
  3. 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。
  4. 只有一种:底层异常的内容会泄露敏感信息(例如连接串里的密码出现在 ValueError 里)。其余情况用它等于亲手删掉唯一线索。
  5. 裸 except: 等价于 except BaseException:。会被它吞掉而 except Exception 放过去的两个:KeyboardInterrupt(Ctrl-C) 和 SystemExit。所以「跑了 40 分钟的重试脚本按 Ctrl-C 掐不掉」就是这么来的。
  6. pass 失去全部(发生过没有、几次、哪条都不知道);log.warning 少一点,但一条 warning 淹在每秒几千条日志里等于没有;计数 + 留样本才拿到能行动的东西:「3.2% 的行解析失败,长这样」。
  7. finally 里的 return(以及 break/continue)会覆盖掉正在飞的异常 —— 例子里 bad() 抛了 ValueError,调用方拿到的却是字符串 一切正常,且没有任何报错。规则:finally 里只写清理,不写控制流。
  8. -O(或 PYTHONOPTIMIZE=1)会在编译期把 assert 整条删掉 —— 实跑对照里同一份代码,无参数时退出码 1 报 AssertionError,加 -O 后退出码 0 且输出 {'age': -5}。校验外部数据要写 if not ...: raise ValueError(...) 或用 pydantic。
  9. -X dev 把默认静音的警告(如 ResourceWarning: unclosed file)放出来,避免泄漏积累成位置无关的 Too many open files;-W error 把警告变成异常 —— 真正的价值是拿到完整调用栈,能看见是你哪一行调进去的,而不只是库里的行号。
  10. 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

打卡记录保存在你的浏览器里,首页能看到总进度