第11篇|异常处理
本篇定位:《Python 教程》专栏一「Python 从零入门」的第 11 篇。承接第 10 篇文件读写与
with——读写失败几乎总以异常形式出现;本篇系统建立「抛出 → 沿调用栈展开 → 捕获或终止」的心智模型,并为第 12 篇面向对象(自定义异常类)、第 17 篇上下文管理器(exit与异常协作)、以及后续日志/HTTP/Agent 工具循环打地基。读完你将能够:说清异常相对返回码的优势与调用栈展开过程;认识
BaseException/Exception层级与常见内置异常;正确使用try/except/else/finally;按粒度捕获、用as e取信息、用raise/raise X from e抛出与链式传递;写最小自定义异常;在 AI Infra / Agent 场景里区分可恢复错误与不该吞掉的致命错误(如 OOM)。预计用时:阅读 35~45 分钟,动手 25~35 分钟。
上一篇你学会了用 with open(...) 安全开关文件。一旦文件不存在、编码不对、磁盘满、权限不足,解释器不会悄悄返回 -1——它会抛出异常(raise an exception)。若你不处理,程序带着完整的追溯(traceback)停掉;若你处理得当,可以换路径重试、写友好日志,或把错误包装成对上层更有意义的类型。
本篇不满足于“包一层 try/except Exception 就不崩了”。我们要建立一条清晰流水线:哪里出错 → 异常对象如何沿调用栈向上展开 → 哪一层该捕获、哪一层该放行 → finally / with 如何保证清理。弄清这些,except: 空吞、误捕 KeyboardInterrupt、以及 Agent 循环里“工具失败却假装成功”才不再是玄学。
和第 10 篇的衔接很直接:with 的 exit 在异常路径上也会被调用——它和本篇的 finally 是近亲;第 17 篇会展开完整上下文协议。和第 07、08 篇的衔接是:异常会穿过多层函数调用,理解调用栈才能读懂 traceback。第 12 篇会讲类与继承——自定义异常就是最小的 class XxxError(Exception)。
建议边读边开一个练习目录(uv init --no-package),故意触发 ZeroDivisionError、FileNotFoundError,观察 traceback;再写几层函数看异常如何“往上冒”。阅读顺序:为何需要异常 → 类层级 → try 四件套 → 捕获粒度与链式 → 自定义与策略 → AI Infra 场景。踩坑大全请过一遍,尤其是“裸 except:”和“吞掉后什么都不做”。
也可以先用一句话定心:该捕则捕、该放则放;清理靠 finally/with;致命错误不要装没事。 本篇把这句话拆开。
动手时建议把每个小节的代码存成独立小脚本跑一遍:用 raise 造错,用多层函数看展开,用 traceback 模块打印细节。比只朗读“记得写 except”记得牢。和第 10 篇一样,报错信息本身就是教材——类型名、消息、以及 traceback 里从下往上的调用帧,建议整段抄进笔记。
和“所有失败都用返回值 None / -1 表示”相比,本篇带来的最大变化是:正常路径保持干净,错误路径带着类型与上下文向上传递,直到某层有能力处理。 弄清展开、捕获粒度与清理,后面读训练脚本里的 try、推理服务里的 HTTP 错误、Agent 工具循环里的超时重试时,就不会只剩“再套一层 except 吧”的条件反射。
你也可以把本篇看成第 10 篇的“失败面”:文件篇解决资源如何进出与关闭;异常篇解决失败如何被看见、被分类、被恢复或被诚实地上报。两边合在一起,一个最小可运行的实验程序才算齐——有成功路径,也有可观测的失败路径。
和第 06、07 篇的循环与函数合在一起看:异常会打断正常控制流,但不会自动回滚你已经做过的副作用(写了一半的文件、已经提交的请求)。因此“展开”只负责找处理器;一致性仍要靠 with、事务、或你在 finally 里写的补偿逻辑。把这一点记住,后面做 Agent 工具调用时才不会以为“抛了异常世界就自动复原”。
一、为什么需要异常:返回码与调用栈展开
1.1 返回码的痛点
许多 C 风格 API 用整数返回码表示成败:0 成功,负数失败。调用方必须每一层都检查,否则错误被静默吞掉:
# 伪代码:返回码风格(Python 里不推荐这样设计业务 API)
def read_config(path):
# 假设失败返回 None
try:
return open(path, encoding="utf-8").read()
except OSError:
return None
def load_model(cfg_text):
if cfg_text is None:
return None
return {"ok": True, "cfg": cfg_text[:20]}
cfg = read_config("missing.json")
model = load_model(cfg)
print(model) # None —— 哪一步失败的?要一层层追
None
问题有三:正常逻辑被 if err 淹没;中间层忘检查就会带着脏状态继续跑;错误类型被压成一个模糊的“失败”。
异常把职责反过来:默认向上传播,只有打算处理的层才写 except。成功路径保持线性可读。
1.2 抛出与调用栈展开(stack unwinding)
当某处执行 raise SomeError(...)(或内置操作失败时由解释器代劳),当前函数立刻中止,异常带着类型、消息和 traceback,沿调用栈一帧一帧向上找有没有匹配的 except。找不到就打印 traceback 并结束进程(交互环境里则回到提示符)。

图1 异常沿调用栈展开
def parse_batch_size(text: str) -> int:
return int(text) # text 非法时抛 ValueError
def build_config(raw: str) -> dict:
n = parse_batch_size(raw)
return {"batch_size": n}
def main():
print(build_config("32"))
print(build_config("abc")) # 这里会炸
if __name__ == "__main__":
main()
第二行调用时,traceback 会类似(帧顺序:最内层在上或按版本显示,阅读时盯住最后抛出的类型与消息,再沿帧看调用链):
ValueError: invalid literal for int() with base 10: 'abc'
心智模型:异常是“带类型的紧急出口”,不是普通返回值。它会跳过本函数剩余语句,并一层层拆栈,直到被捕获或程序终止。
读 traceback 时建议固定顺序:先看最后一行的异常类型与消息(真正的失败原因),再从最靠近炸点的帧往上扫,辨认“是你的业务函数还是库内部”。初学阶段常见误区是被中间一长串库帧吓住——真正该改的往往是你传入的路径、参数或前置条件。把完整 traceback 贴进笔记或 issue,比只写“又报错了”有效得多。
AI Infra 视角:训练脚本里
torch.load路径错误、数据 loader 读到坏样本、HTTP 调 LLM 超时——这些几乎都是异常。先学会读 traceback 的“从业务入口到炸点”的链,比急着加except Exception: pass有用一百倍。集群日志里若只有一句Error没有栈,排障成本会指数上升;因此中间层即便要包装异常,也应用raise X from e把根因留在链上。
1.3 异常对象长什么样
异常是对象:有类型(如 ValueError),有参数(args),转成字符串通常就是错误消息。可用 except SomeError as e 接住后查看:
try:
int("x")
except ValueError as e:
print(type(e).__name__)
print(e.args)
print(str(e))
ValueError
("invalid literal for int() with base 10: 'x'",)
invalid literal for int() with base 10: 'x'
二、异常类层级:BaseException 与常见内置
2.1 BaseException vs Exception
所有内置异常组成一棵继承树。树根是 BaseException;你日常该捕获的业务错误几乎都属于 Exception(BaseException 的子类)。
不要用 except BaseException(或裸 except:)去“兜底业务”——那样会连 KeyboardInterrupt(Ctrl+C)、SystemExit(sys.exit) 一并吞掉,进程变得难以正常退出。

图2 异常类层级(简化)
| 类型 | 常见触发 | 入门期建议 |
|---|---|---|
| ValueError | 值类型对但内容非法,如 int("x") | 校验输入时常捕 |
| TypeError | 类型不对,如 "a" + 1 | 多半是代码 bug,慎捕 |
| KeyError | 字典缺键 | 可用 dict.get 或捕 |
| IndexError | 序列下标越界 | 先检查长度更清晰 |
| FileNotFoundError | 路径不存在 | 文件 IO 常捕;属 OSError |
| OSError | 系统级 IO/OS 错误族 | 较宽;需要时再收窄 |
| ZeroDivisionError | 除以零 | 算数前先校验更佳 |
| AttributeError | 对象无该属性/方法 | 常是 bug 或可选依赖 |
| RuntimeError | 未归入更具体类型的运行错误 | 少见;见到要读消息 |
| StopIteration | 迭代器耗尽 | 通常别手动捕;for 已处理 |
FileNotFoundError 是 OSError 的子类,因此 except OSError 也能捕到它——这既方便也危险:捕获过宽时,权限错误、磁盘满等也会进同一分支。入门期宁可多写两个 except 分支,也不要图省事用一个巨大的 OSError 把所有 IO 问题糊成同一种处理。
StopIteration 列在表里,是为了提醒:迭代协议的内部信号,不要当成普通业务错误去捕。你写 for x in it: 时,解释器已经帮你处理了耗尽;自己在业务代码里 except StopIteration 往往是设计味道不对——更常见的需求其实是“容器空了怎么办”,那应该在调用前检查或捕获你自己定义的领域异常。
print(issubclass(FileNotFoundError, OSError))
print(issubclass(FileNotFoundError, Exception))
print(issubclass(KeyboardInterrupt, BaseException))
print(issubclass(KeyboardInterrupt, Exception)) # False
True
True
True
False
2.2 先认类型,再谈处理
同一句“失败了”,类型不同处理策略就不同:缺文件可以提示用户路径;类型错误多半该修代码;除零可能是脏数据。用类型表达语义,正是异常相对“统统返回 -1”的优势。
def safe_div(a, b):
try:
return a / b
except ZeroDivisionError:
return float("inf") # 业务上约定的降级
print(safe_div(1, 2), safe_div(1, 0))
0.5 inf
三、try / except / else / finally 语义
3.1 四段式执行顺序
完整形态:
from pathlib import Path
path = Path("demo_cfg.txt")
path.write_text("batch=8\n", encoding="utf-8")
try:
text = path.read_text(encoding="utf-8")
except FileNotFoundError:
print("missing")
text = ""
else:
print("read ok, chars =", len(text))
finally:
print("cleanup always runs")
print("text =", repr(text))
read ok, chars = 8
cleanup always runs
text = 'batch=8\n'
规则(务必背熟):
try:受保护的代码;一旦异常,剩余语句跳过。except:按书写顺序匹配;命中一个后不再试后面的except。else:仅当try没有异常时执行;不要把“成功后逻辑”塞进try里扩大捕获范围。finally:无论成功、失败、return,只要离开该复合句就会执行(适合释放锁、关连接;文件更推荐with)。

图3 try-except-else-finally 控制流
3.2 else 为什么值得用
把“成功后的后续步骤”放进 else,可以避免后续步骤自己抛出的异常被同一个 except 误伤:
def load_n(path):
try:
raw = open(path, encoding="utf-8").read().strip()
except FileNotFoundError:
return None
else:
return int(raw) # 若 raw 非法,这里的 ValueError 不会被上面的 except 吃掉
# 演示:文件存在但内容非法
from pathlib import Path
p = Path("n.txt")
p.write_text("not-a-number", encoding="utf-8")
try:
print(load_n(p))
except ValueError as e:
print("ValueError escaped as expected:", e)
ValueError escaped as expected: invalid literal for int() with base 10: 'not-a-number'
(上面为演示写了手写 open 而未用 with——生产代码请用第 10 篇的 with / Path.read_text。)
3.3 finally 与 return
finally 在函数 return 之前仍会跑。若 finally 里也 return 或抛新异常,会覆盖 try 里的返回值/异常——入门期请避免在 finally 里 return。
def f():
try:
return "from try"
finally:
print("finally before real return")
print(f())
finally before real return
from try
四、捕获粒度、多重 except、链式 raise
4.1 精确捕获 vs 捕获过宽
def read_first_line(path):
try:
with open(path, encoding="utf-8") as f:
return f.readline()
except FileNotFoundError:
return "" # 明确:缺文件 → 空串
# 不要轻易写 except Exception,除非你打算记录并继续向上或终止
坏习惯:
try:
do_work()
except Exception:
pass # 错误被吞,现场全无
稍好但仍需谨慎:
import logging
logger = logging.getLogger(__name__)
try:
do_work()
except Exception:
logger.exception("do_work failed") # 记录完整 traceback
raise # 记录后仍然重新抛出
原则:捕获范围尽量窄到你真正有策略处理的类型;需要兜底时,先
logger.exception,再决定raise还是返回降级值。
logging 与异常是一对好朋友:logger.exception("...") 必须写在 except 块内,它会自动附带当前异常的 traceback;若在块外用 logger.error("...", exc_info=True) 也能达到类似效果。第 21 篇会系统讲日志;本篇只要养成“捕获即留痕”的肌肉记忆——静默失败是分布式训练与 Agent 系统里最贵的一类 bug。
4.2 多个 except 与 as e
def parse_int_field(data: dict, key: str) -> int:
try:
return int(data[key])
except KeyError as e:
raise KeyError(f"missing field: {key}") from e
except (TypeError, ValueError) as e:
raise ValueError(f"field {key!r} must be int-like, got {data.get(key)!r}") from e
print(parse_int_field({"n": "8"}, "n"))
try:
parse_int_field({"n": "x"}, "n")
except ValueError as e:
print(e)
8
field 'n' must be int-like, got 'x'
同一 except 元组可合并多种类型;as e 绑定异常实例。更具体的类型应写在更宽类型前面(虽然本例未混用父子类)。
4.3 raise、裸 raise 与 raise X from e

raise、重抛、异常链from的三种常用写法。①抛出新异常,使用raise ValueError("bad batch"),创建并抛出带消息的异常对象,是常见写法;②原样重抛,通过except OSError: logger.exception(...) raise保留原始traceback,记录后继续向上,推荐使用裸raise;③链式包装,except ValueError as e: raise ConfigError("bad cfg") from e,新异常__cause__ = e,日志可见因果链,适用于Agent/库边界。底部说明raise e可能截断栈,裸raise保留炸点,raise X from None可隐藏内部原因,仅在确需脱敏时使用。图4 raise / 重抛 / 异常链
def tool_call(name: str):
if name == "boom":
raise RuntimeError("tool crashed")
def agent_step(name: str):
try:
tool_call(name)
except RuntimeError as e:
# 包装成对上层更友好的错误,并保留因果链
raise RuntimeError(f"agent step failed on tool {name!r}") from e
try:
agent_step("boom")
except RuntimeError as e:
print("msg:", e)
print("cause:", e.__cause__)
msg: agent step failed on tool 'boom'
cause: tool crashed
三种常用写法:
| 写法 | 含义 |
|---|---|
| raise ValueError("msg") | 抛出新异常 |
| raise | 在 except 块内原样重抛当前异常(保留 traceback) |
| raise NewError("...") from e | 抛新异常,并把 e 记为 cause(显式链式) |
from None 可抑制旧异常显示——仅在你确定不想暴露内部细节时使用。
五、自定义异常与“该不该捕获”
5.1 最小自定义异常
自定义异常通常只需继承 Exception(或更合适的中间基类),给类型一个有业务含义的名字:
class ConfigError(Exception):
"""配置无效或缺失。"""
class ConfigKeyError(ConfigError):
def __init__(self, key: str):
self.key = key
super().__init__(f"missing config key: {key}")
def require_key(cfg: dict, key: str):
if key not in cfg:
raise ConfigKeyError(key)
return cfg[key]
try:
require_key({"lr": 1e-3}, "batch_size")
except ConfigError as e:
print(type(e).__name__, "->", e)
ConfigKeyError -> missing config key: batch_size
上层可选择捕获宽一点的 ConfigError,或只处理 ConfigKeyError。第 12 篇讲清类与继承后,你还能给异常加更多属性与方法;本篇记住:名字即文档,层级即策略挂钩点。
5.2 何时不该捕获:fail-fast vs 恢复
| 策略 | 适用 | 例子 |
|---|---|---|
| Fail-fast | 编程错误、不变量被破坏、继续跑会损坏数据 | TypeError、训练里形状断言失败 |
| 恢复/降级 | 可预期的外部环境故障,且有合理后备 | 缺可选配置文件 → 用默认值;单次 API 超时 → 重试 |
| 记录后重抛 | 需要可观测性,但本层无最终决策权 | 中间库 logger.exception 后 raise |
经验法则:
- 不知道怎么处理就别捕——让 traceback 暴露问题。
- 捕了就要有动作:重试、降级、翻译成领域异常、或记录后终止。
- 永远不要用空
except把 GPU OOM、磁盘满、权限错误装成“成功”。
“恢复”也要有预算:重试三次、退避上限、总墙钟时间——没有预算的 while True: try 会把一次短暂抖动放大成雪崩。校验类错误(坏 JSON、缺字段、非法枚举)通常立刻失败比重试更正确:同样的坏请求再发一百次也不会变好,只会消耗配额与耐心。
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("demo")
def fragile():
raise FileNotFoundError("weights.bin")
try:
fragile()
except FileNotFoundError:
logger.exception("weight missing; refusing to continue silently")
raise
日志里会出现完整 traceback(此处不整段粘贴);关键是:记录 ≠ 吞掉。
5.3 与 with 的协作(衔接到第 10 / 17 篇)
第 10 篇强调:with 离开代码块时一定会走清理逻辑,即使块内抛异常。这等价于“把 finally: f.close() 写对”。自定义上下文管理器的 exit(exc_type, exc, tb) 还能决定是否抑制异常——那是第 17 篇的主题;本篇只要建立直觉:资源清理优先用 with,而不是手写 try/finally 关文件。
from pathlib import Path
p = Path("will_fail_read.txt")
# 故意不创建该文件
try:
with open(p, encoding="utf-8") as f:
data = f.read()
except FileNotFoundError as e:
print("caught:", e)
# with 若曾成功进入,关闭仍会发生;本例在 open 阶段就失败,故无文件对象可关
caught: [Errno 2] No such file or directory: 'will_fail_read.txt'
六、AI Infra / Agent:该捕什么、不该吞什么
6.1 工具循环里的可恢复错误
Agent / 工具调用循环里,常见的可恢复失败包括:HTTP 超时、429 限流、参数校验失败、工具返回结构化错误。典型模式是:捕具体异常 → 记日志 → 有限次重试或把错误观察写回对话 → 超过预算再失败。

Exception,可能吞掉超时、校验失败甚至OOM等异常,导致现场消失难以排查;“推荐这样”则按异常类型,对超时、限流等错误用退避重试,校验失败错误仅观察不重试,同时给出“调用工具/API→分类异常→按类型处理→写回对话”的Agent工具异常处理步骤,对不同错误明确了重试、观察或终止的处理规则。图5 好捕获 vs 坏捕获(含 API 错误)
import time
class TimeoutError_(Exception):
pass
class RateLimitError(Exception):
pass
def call_llm(prompt: str, attempt: int) -> str:
# 模拟:前两次失败,第三次成功
if attempt < 3:
if attempt == 1:
raise TimeoutError_("deadline exceeded")
raise RateLimitError("429")
return f"ok:{prompt}"
def call_with_retry(prompt: str, max_attempts: int = 3) -> str:
last: Exception | None = None
for i in range(1, max_attempts + 1):
try:
return call_llm(prompt, i)
except (TimeoutError_, RateLimitError) as e:
last = e
print(f"attempt {i} failed: {type(e).__name__}: {e}")
time.sleep(0) # 真实场景用指数退避
raise RuntimeError("llm call failed after retries") from last
print(call_with_retry("hi"))
attempt 1 failed: TimeoutError_: deadline exceeded
attempt 2 failed: RateLimitError: 429
ok:hi
注意捕获列表是具体类型,不是裸 Exception。校验错误(坏 JSON、缺字段)通常不应无限重试——重试相同坏请求只会烧钱。
6.2 不要盲目吞掉 GPU / OOM
MemoryError、CUDA OOM(常以运行时错误/框架特定异常出现)、磁盘满(OSError)属于资源耗尽。在训练或大批推理里“捕获后 continue”可能导致:静默跳过关键 batch、写出不完整 checkpoint、或死循环重试把机器打满。
更稳妥的策略:
- 让进程失败得响亮,由调度器/人来缩小 batch、清缓存、换机器。
- 若必须捕获,记录后终止当前 job,而不是假装 step 成功。
- 区分“单条样本坏了可跳过”与“整卡 OOM 必须停”——用不同异常类型或错误码表达,而不是一个巨大的
except Exception。
class CudaOOMError(RuntimeError):
pass
def train_step(batch_id: int):
if batch_id == 7:
raise CudaOOMError("CUDA out of memory")
return batch_id
def train_loop(n: int):
for i in range(n):
try:
print("step", train_step(i))
except CudaOOMError:
print("fatal OOM at batch", i, "- abort job")
raise # 不要 continue
try:
train_loop(10)
except CudaOOMError:
print("job stopped")
step 0
step 1
step 2
step 3
step 4
step 5
step 6
fatal OOM at batch 7 - abort job
job stopped
6.3 校验错误 vs 基础设施错误
| 类别 | 例子 | 建议 |
|---|---|---|
| 输入校验 | JSON 缺字段、batch_size <= 0 | 快速失败,返回 4xx / 结构化错误,不重试 |
| 依赖抖动 | HTTP 超时、偶发 503 | 有限重试 + 退避 + 抖动 |
| 资源耗尽 | OOM、磁盘满 | 记录、告警、终止;调参后再跑 |
| 编程错误 | TypeError、错误的 API 用法 | fail-fast;修代码 |
把这些类别映射到不同异常类型(或 HTTP 状态),上层策略才写得清。第 18 篇的 Pydantic、第 26 篇的 HTTP、第 28 篇的最小 Agent,都会反复用到本篇的刀法。
再补一个与第 10 篇交汇的场景:读配置失败时,是抛 ConfigError 让进程启动失败,还是落入默认配置?可执行文件/训练入口往往选择 fail-fast(配错比默默用错超参更安全);交互式工具可以提示并等待用户修正。同一种 FileNotFoundError,产品形态不同,策略就不同——异常类型提供挂钩,策略仍由你决定。
写推理服务时还有一条常见线:把内部 ValueError 翻译成 HTTP 400,把未预料的 Exception 翻译成 500 并打日志,而不要把栈痕迹直接回给公网客户端。边界上的“翻译”正是 raise X from e 与分层 except 的用武之地;服务框架细节留给第 27 篇 FastAPI。
七、踩坑大全
7.1 捕获范围
坑 1:裸 except: 或 except BaseException。 现象:Ctrl+C 杀不掉,sys.exit 被吞。解决:只捕 Exception 的具体子类;真正需要兜底时用 except Exception,且慎重。
坑 2:except Exception: pass。 现象:线上“偶尔结果不对”却无日志。解决:至少 logger.exception(...);默认倾向重抛。
坑 3:先宽后窄写反了。 现象:except OSError 写在 except FileNotFoundError 前面时,子类分支永远进不去(若两者分写)。解决:更具体的类型放前面,或合并策略到一处。
7.2 控制流与清理
坑 4:把成功后逻辑塞进 try,导致误捕。 现象:解析成功后的 int(...) 抛 ValueError,却被“文件不存在”的 except 处理。解决:用 else 分隔。
坑 5:在 finally 里 return。 现象:吞掉 try 中的异常或覆盖返回值。解决:finally 只做清理。
坑 6:手写 open + except 却漏关文件。 现象:异常路径句柄泄漏。解决:第 10 篇的 with;不要用异常处理代替上下文管理器。
7.3 重抛与链式
坑 7:在 except 里写 raise e 而不是 raise。 现象:某些版本/写法下 traceback 被截断到当前行,丢掉原始炸点。解决:原样重抛用裸 raise;包装时用 raise New from e。
坑 8:用新异常包装却不 from e。 现象:日志里看不到根因。解决:显式 raise X from e,保留 cause。
7.4 平台与 AI Infra
坑 9(通用):捕获 StopIteration 来“结束循环”。 现象:与生成器协议纠缠,代码难读。解决:用 for;现代 Python 中手写迭代器用 return 结束生成器。
坑 10(GPU 服务器):把 CUDA OOM 当普通可跳过样本错误。 现象:job “跑完”但指标离谱或后续全失败。解决:OOM 视为致命;缩小 batch / 清缓存后重提交流水线。
坑 11(Agent):工具失败后仍把空结果当成功观察。 现象:模型幻觉式继续。解决:把错误类型与消息写回观察;区分 retryable / fatal。
坑 12(Windows/macOS/Linux):同一 OSError 在不同平台 errno 不同。 现象:只判断消息字符串导致漏网。解决:捕 OSError 子类(如 FileNotFoundError、PermissionError),或检查 e.errno / e.winerror。
try:
open("nope.dat", encoding="utf-8")
except FileNotFoundError as e:
print(e.errno, e.strerror)
先看类型,再看 errno,最后才看字符串。
八、小结
合上文章后,试着用自己的话解释:“异常沿调用栈展开时跳过了什么”以及“else 和 finally 谁在成功路径执行”——讲得清,才算真的会了。
- 异常 vs 返回码:异常让成功路径保持线性,失败默认向上传播,直到有策略的一层捕获。
- 层级:业务捕获面向
Exception子树;勿轻易捕BaseException;用具体类型(ValueError、FileNotFoundError等)表达语义。 - 四件套:
try保护、except匹配、else仅成功、finally必清理;文件清理优先with。 - 粒度与链式:宁窄勿宽;
as e取信息;raise重抛;raise X from e保留因果。 - 自定义异常:最小
class XxxError(Exception),用名字与层级挂策略。 - 策略:fail-fast 对 bug 与 OOM;恢复/重试对超时与限流;记录 ≠ 吞掉。AI Infra / Agent 里把校验错误、抖动、资源耗尽分开建模。
把故事连起来:文件与 with(10)保证资源释放 → 本篇让失败可分类、可观测、可策略化 → 下一篇(12)用类把“自定义异常”和更多对象建模展开。再往后,上下文管理器(17)、日志(21)、HTTP(26)、最小 Agent(28)都会反复用到今天的刀法。
若只能带走一句话:精确捕获,清理用 with/finally,致命错误响亮失败。
也可以用“三个不要”自检:不要裸 except:;不要空 pass 吞异常;不要把 OOM/磁盘满当成可忽略的小警告。
再留一个自测:画出 try/except/else/finally 在“成功 / 失败”两条路径上的执行顺序;写出 raise X from e 后如何打印 cause;说明为何 KeyboardInterrupt 不应被业务 except Exception 误伤——三问都顺,本篇就站稳了。
本篇技能会立刻复利:读开源训练脚本时先找 except 列表是否过宽;写工具调用时把超时与校验失败分成不同类型;遇见“偶发静默失败”时先搜有没有 except Exception: pass。先把“展开 + 粒度 + 清理”练准,比背三十个冷门内置异常名更划算。
九、课后练习
- (基础) 写函数
to_int(s):成功返回整数;捕获ValueError,返回None。对"10"、"x"各测一次。 - (基础) 构造三层调用
main → mid → leaf,在leaf里raise RuntimeError("boom"),在main捕获并打印type(e).name与消息;观察(或截图)完整 traceback。 - (基础) 演示
else与finally:try读一个存在的文本文件,else打印行数,finally打印"done";再对缺失文件跑一遍,对比输出。 - (理解) 写
parse_port(cfg: dict) -> int:缺键抛自定义ConfigError;值无法转 int 时raise ValueError(...) from e;写出两种失败的演示。 - (理解) 故意写一个
except Exception: pass包住1/0,再改成logger.exception+raise;说明两种行为差异。 - (理解) 在
except中比较裸raise与raise e(e为捕获变量)对 traceback 的影响(打印或不打印均可,写清你的观察)。 - (进阶) 实现
retry(fn, errors, tries=3):仅对errors元组内的异常重试,耗尽后raise RuntimeError("retry exhausted") from last;用伪flaky()测试。 - (进阶 · AI Infra) 模拟 Agent 一步:
tool_run(arg)在arg=="bad"时抛ValueError,在"timeout"时抛超时异常。外层循环将错误写成{"ok": False, "error_type": ..., "message": ...}的观察字典,不要空结果冒充成功;对 OOM 类异常则直接重抛终止循环。
下一篇预告
异常让你能用类型表达失败;自定义异常已经轻轻碰到了 class。下一篇《第12篇|面向对象一:类、实例、属性、方法》会系统介绍如何用类建模数据与行为——实例属性、方法、self、以及和第 02 篇“一切皆对象”的会合。本篇的 class ConfigError(Exception) 只是预告片。
专栏一「Python 从零入门」的后续路线:面向对象(12~13)→ 魔术方法与数据模型(14)。带着本篇的捕获粒度与 fail-fast 直觉,读训练代码时先看“失败被怎样分类、资源在异常路径有没有清、OOM 有没有被假装成成功”,再钻模型细节。
若你想提前热身:给第 10 篇的 load_json 包一层,缺文件时抛自定义 ConfigError,JSON 坏了时 raise ConfigError("broken json") from e;再对调用方分别演示“捕获 ConfigError”与“放任 traceback”。两道小练习做完,下一篇谈类时你会带着具体场景,而不是抽象 UML。
也可以把本篇的 retry 练习改成:读取 configs/model.json(第 10 篇刀法),解析失败不重试,仅对“模拟网络超时”重试。两篇技能叠在一起——文件搬运数据,异常定义失败协议——专栏一中段的“让程序在真实环境里扛得住出错”主线就连上了。
若时间充裕,再多做一件事:阅读一次真实 traceback(任选本系列练习中的故意报错),用笔标注每一帧的文件名、行号、函数名,并圈出最终异常类型。你会同时复习调用栈(07)与模块路径(09)——这些都是日后啃 vLLM / SGLang 栈迹的零件。
