写Python写了快十年,最深的体会是:代码能不能跑起来,考验的是你写代码的能力;代码出Bug后能不能快速定位,考验的才是你真正的工程水平。很多人觉得调试就是"加print、看报错、改代码",但遇到真实项目里那种"数据算错但程序不报错""线上环境出问题本地复现不了""模块一多就不知道谁改了谁的值"的时候,print往往不够用,报错也常常看不懂。这篇东西,我想把这些年积累的Python调试思路、工具、步骤,连同踩过的坑一起梳理出来,目标很直接:让读到的人下次遇到Bug时,有一套可以照做的排查路径,而不是靠肉眼盯代码和运气。
适合的对象很宽——刚学完Python语法、还在被各种报错劝退的新手,能在这里建立一套正确的排错方法;写了一阵子业务代码、一遇到复杂Bug就抓狂的进阶者,也能从pdb、断点调试、异常链分析这些章节里拿到真正能落地的东西。咱们不聊虚的理论,直接进入正题。
1. 调试的本质:你不是在找Bug,而是在找"信息与认知的落差"
1.1 一次让我改变看法的线上事故
有段时间我负责的一个数据处理服务,每天早上都会收到一批"结果偏差"的工单——不是报错,是算出来的数字跟对不上。我一开始的做法跟大多数人一样:打开代码,从头到尾读,试图"看出"哪里逻辑不对。读了三遍,没发现任何问题。后来被逼急了,往关键节点塞了一堆print,跑了一次线上日志,才发现问题根本不在我盯着的那几行——是上游传入的时间字段有时是字符串、有时是datetime,而我在类型判断时用了一个只看"长得像不像"的写法。
那次之后我明白了一个道理:绝大多数难解的Bug,不是你"不会修",而是你掌握的现场信息太少,导致你在用错误的假设去找问题。你盯着代码看,是在用"代码应该怎么运行"的认知去猜测,但机器实际怎么运行,才是真相。调试的全部工作,本质上是"缩小信息落差"——把"代码内部实际发生了什么"这个信息,一点点补全到你的脑子里。
1.2 调试的第一原则:先复现,再动手
无论用什么工具,第一步永远是复现。这个原则听起来像废话,但我在各种项目里见过太多人跳过它:看到报错就直接改代码,改完再跑一次,发现还是报错,于是接着改,进入"瞎试"循环。正确姿势是:
- 先确认:这个Bug能不能稳定复现?如果偶尔出现,能不能找到触发条件?
- 尽量把问题"缩小":去掉无关的依赖、数据、分支,构造一个最小案例。
- 在最小案例上确认Bug存在,然后才开始动手定位。
复现的意义在于,它把"不确定的玄学问题"变成了"确定的输入输出问题"。只要你能稳定复现,那它就是一个可以用调试器、日志、断点逐步逼近的普通问题;不能复现的问题,多半是并发、缓存、外部依赖时序这些"环境型"问题,处理思路完全不同(后面章节会聊到)。我自己的习惯是:任何Bug,先花五分钟复现和缩小范围,再花五分钟设计排查方案,最后才动手改代码。这个习惯帮我省下的时间,远远超过我在"看上去很努力"的瞎改里浪费的时间。
2. 读懂Traceback:绝大多数人只看了最后一行
2.1 自底向上的阅读法
Python报错时输出的Traceback(回溯信息),是调试时最直接的第一手线索。但我发现太多新手(甚至一些老手)的习惯是:只瞟一眼最后那行"xxxError: xxx",然后就去搜这个错误信息。这其实浪费了一半以上的信息量。
看一段典型的报错:
Traceback (most recent call last): File "main.py", line 12, in <module> result = process_data(raw) File "main.py", line 8, in process_data cleaned = clean_data(item) File "main.py", line 5, in clean_data return item["price"] * item["count"] TypeError: unsupported operand type(s) for *: 'str' and 'int'正确的读法是从下往上:
- 最上面是你程序的入口,也就是调用链的起点;
- 中间每一行是一次函数调用,
File "main.py", line 8, in process_data告诉你错误发生在第8行的clean_data(item)这一句调用里; - 最下面的错误类型和消息,是最内层的实际异常。
但注意,最后一行只能告诉你"在哪一行炸了",不能告诉你"为什么在这一行炸"。上面这个例子里,真正有价值的信息是:item["price"]是字符串,而item["count"]是整数,两者相乘触发了TypeError。也就是说,Bug其实可能出在构造item的那个地方——上游把价格字段存成了字符串。如果你只看最后一行,就会去改第5行的乘法逻辑,改来改去也没用,因为根源在上游的数据类型。
2.2 异常链里藏着的"真实原因"
Python 3引入了raise ... from ...的异常链机制。当一个异常是在捕获另一个异常后触发的,Traceback里会出现During handling of the above exception, another exception occurred:这样的提示,这时被捕获的原始异常往往会作为__context__保留。
实战里最常见的场景:你在except里又调用了别的函数,结果那个函数也抛异常了,于是新的异常把原来的异常"盖住"了。新手会看到新异常的Traceback,然后顺着查,查半天发现跟真正的Bug没关系。我的建议是:看到异常链提示,一定要先展开看原始异常。Python 3.11之后报错信息里甚至会自动用^^^标出触发异常的表达式位置,这个细节对定位非常有用。
如果异常链被层层包裹,代码里也没写from,你还可以在调试时手动抓:
try: dangerous_call() except Exception as exc: while exc.__cause__ is not None: print("原始异常:", exc.__cause__) exc = exc.__cause__这招在处理"根因被异常包裹层层吞掉"的疑难杂症时非常管用。
2.3 常见异常速查:看到就知道往哪查
我把高频异常整理成一张表,配合对应的排查方向,之后遇到报错可以先按表索骥:
| 异常类型 | 触发场景 | 优先排查方向 |
|---|---|---|
SyntaxError | 语法错误 | 往往是括号、引号、缩进问题,看提示行号附近 |
NameError | 变量未定义 | 拼写错误、变量作用域、是否在定义前使用 |
TypeError | 类型不匹配的操作 | 检查操作数类型,尤其是函数返回值类型 |
ValueError | 值不合法 | 检查传入值的范围、格式;int("abc")之类的转换 |
IndexError | 索引越界 | 检查列表长度、循环边界、切片逻辑 |
KeyError | 字典键不存在 | 检查键拼写、字典是否被覆盖、get()默认值 |
AttributeError | 对象没有该属性 | 检查对象是否为你预期的类型,常见于None引用 |
FileNotFoundError | 文件不存在 | 检查相对路径、工作目录、文件是否真的生成 |
ImportError | 导入失败 | 检查包是否安装、模块名拼写、循环导入 |
RecursionError | 递归过深 | 检查递归终止条件、是否误调用了自身 |
注意一张表里藏着两个高频真相:AttributeError: 'NoneType' object has no attribute 'xxx'十有八九是某个函数在异常分支返回了None,你却在下一行直接用了它的返回值,这比"属性名拼错"常见得多;**KeyError**如果出现在大字典上,先怀疑是不是字典被某个函数原地修改或重新赋值了,别急着怀疑"键不存在"。
3. print调试为什么被低估:临时打印的正确打开方式
3.1 三原则:标记、量化、留痕
我知道很多"高端"教程会嘲讽print调试,但说实话,我至今在快速定位简单Bug时还是会用print——它的优势是零成本、即时、对代码侵入小。问题在于很多人用得没章法,打印出来的信息没法用。我的三个原则是:
第一,打印要有标记。不要只写print(a),整个程序里可能打印出七八个数字,你根本分不清谁是谁。要带上变量名和位置说明:
print(f"[process_data] price={item['price']!r}, count={item['count']!r}")第二,打印要能量化。循环里打印进度、函数入口打印参数、条件分支打印走了哪条路,都是好习惯。我特别推荐在"你以为不会走"的分支里打一行print("[unexpected] reached else branch")——很多时候Bug就藏在你以为不会执行的代码里。
第三,打印要能留痕。临时调试用的print,定位完之后要删掉或改成正式日志,别留在代码里成为下一次排查的干扰信息。
3.2 永远用repr()而不是str()
这是print调试里最容易踩的一个坑。举个例子:
user_input = "1234" value = int(user_input) # 如果这里报错,你想看 user_input 是什么 print(str(user_input)) # 输出: 1234 print(repr(user_input)) # 输出: '1234'如果user_input是个字符串,str()输出的1234和整数1234看起来完全一样,你根本意识不到它是字符串。而repr()会带上引号和类型信息,让你一眼看清真实类型。再比如字符串末尾的空格、换行符、转义字符,str()都会"美化"掉,repr()则会原形毕露:
s = "hello\n" print(str(s)) # 看起来是 hello 然后换行 print(repr(s)) # 输出: 'hello\n'调试时的原则很简单:不确定这个对象到底是什么,一律用repr()。配合f-string的!r简化写法(f"{value!r}")也很方便。
3.3 从"打印变量"到"搭一个临时调试面板"
当一个问题涉及多处的状态变化时,零散的print会淹没在输出里。我的做法是搭一个极简的"调试面板"——自定义一个函数,统一格式,带上时间戳和调用位置:
import time def dbg(*args, **kwargs): if not DEBUG: return frame = sys._getframe(1) print(f"[{time.strftime('%H:%M:%S')}] {frame.f_code.co_filename}:{frame.f_lineno}", *args, **kwargs, flush=True)这样输出的每一行都带时间和代码位置,你就能按时间轴还原程序的实际执行顺序。尤其适合排查"我以为会先执行A再执行B,结果发现B在A之前"这类时序问题。flush=True别忘了,否则在输出被缓冲、程序又崩溃的情况下,最后几条打印可能丢失——这个细节让不少人误判过Bug的位置。
4. pdb上手:每个Python开发者都该会的兜底技能
4.1 三种进入方式,总有一种适合当前场景
pdb是Python标准库自带的交互式调试器,不需要安装任何东西。它也许不如IDE的图形化断点方便,但在服务器上、在没有IDE的容器里、在无法改代码的只读环境里,它是真正的兜底工具。进入pdb有三种方式:
方式一:起点断点。在代码里插入import pdb; pdb.set_trace(),运行到这一行会自动进入交互模式。适合在已知可疑位置打断点的情况:
def calculate_statistics(data): total = sum(data) # 在这里想看一看 data 到底是什么 import pdb; pdb.set_trace() return total / len(data)方式二:命令行方式。在Python 3.7之后可以直接用python -m pdb your_script.py,程序会从第一行开始进入调试器,你可以自己设置断点、单步执行,完全不用改源码。
方式三:异常后进入。程序崩溃时通常直接退出,如果你想在异常的现场"停下来看看当时的变量",可以用python -m pdb your_script.py配合c命令,或者在代码里用pdb.post_mortem(),这个函数会用当前的traceback信息直接进入崩溃点的调试状态。我在处理"只有生产环境才崩"的问题时,经常在异常处理里临时加一行pdb.post_mortem(),非常有效。
4.2 命令太多?记住这8个就够用
pdb的命令一上来一堆,新手容易记不住。我实际使用中翻来覆去就这几个:
| 命令 | 作用 | 使用频率 |
|---|---|---|
n | next,执行下一行,不进入函数内部 | ★★★★★ |
s | step,步入函数内部 | ★★★★★ |
c | continue,一直执行到下一个断点 | ★★★★★ |
p 表达式 | print,打印变量或表达式的值 | ★★★★★ |
pp 表达式 | 美化打印,处理复杂对象 | ★★★★ |
l | list,查看当前行附近的源代码 | ★★★★ |
w | where,查看当前调用栈位置 | ★★★★ |
q | quit,退出调试器 | ★★★★★ |
b 行号/break 行号 | 设置断点 | ★★★ |
u/d | up/down,在调用栈上下移动帧 | ★★★ |
注意一个新手常困惑的点:p后面跟的是Python表达式,不是普通文本,所以p len(data)、p data[0:3]、p type(x)都是合法的。我经常一口气打印一串:p locals()看当前函数的所有局部变量——这一招在"变量莫名其妙变了"的问题里特别有用。
4.3 pdb实战:一个典型的"变量莫名其妙变了"排查
假设有一段代码,循环处理一批订单,结果发现某个变量在循环中途被改掉了。用pdb的完整流程是这样:
> /home/user/order.py(18)<module>() -> new_order = process(order, discount_policy) (Pdb) p discount_policy {'rate': 0.8, 'apply_tier': True} (Pdb) b 25 Breakpoint 1 at /home/user/order.py:25 (Pdb) c > /home/user/order.py(25)process() -> return result (Pdb) p discount_policy {'rate': 0.5, 'apply_tier': True} # 这里发现 policy 的值变了 (Pdb) w /home/user/order.py(18)<module>() -> new_order = process(order, discount_policy) > /home/user/order.py(25)process() -> return result (Pdb) u > /home/user/order.py(18)<module>() (Pdb) p discount_policy is discount_policy_original True看到打印结果后基本可以断定:discount_policy这个可变对象在函数内部被原地修改了(比如调用了.update()或直接改了键值),导致外面的变量也变了。这时候再去process()内部找哪里改了它,思路一下子就清晰了。用w看栈、用u回退到外层查看调用时的状态,这两个命令组合起来,能轻松揭穿"变量被谁改了"的悬案。
5. 断点调试:让代码运行变成一帧帧可回放的电影
5.1 VSCode断点的几个别忽略的细节
如果你日常用的是VSCode或PyCharm,图形化断点调试比pdb更直观。但很多人只是"会点一下行号打红点",很多关键操作都没用上。先说VSCode里最常被我安利的几个细节:
一是调试配置里的"stopOnEntry"。launch.json里设置"stopOnEntry": true,程序会在第一行就暂停,方便你从头跟起。调试"初始化阶段就出错"的问题时非常好用。
二是"内联值查看"。VSCode默认会在当前行上方直接显示各变量的值,这个功能如果你关了,建议打开。它让"观察变量变化"变成了读代码时的即时反馈,不用频繁hover变量。
三是调试控制台里直接执行表达式。在调试面板下方的DEBUG CONSOLE里,你可以直接输入type(x)、x.__dict__、[i for i in dir(obj) if not i.startswith('_')]这些Python表达式,相当于一个可以随时使用的REPL。我经常在这里面"偷看"对象的内部结构,比在代码里写print戳对象安全多了。
5.2 条件断点与命中次数
条件断点是我认为最能节省调试时间的函数。场景很典型:一个循环跑10000次,第8765次数据就有问题。你不想按10000次F5,直接在断点上右键,设置条件表达式i == 8765,程序会直接停在第8765次迭代。同样,在字典被某个键访问时才断下、在某个函数参数为特定值时才断下,都是条件断点的典型用法。
命中次数(hit count)也是被忽略的好工具。比如你想在循环执行到第50次时停下来看一眼,不需要条件表达式,直接把命中次数设为50即可。两者的区别:条件断点是"满足条件才停",命中次数是"累计执行N次才停"。在处理偶发性Bug时,我通常先设条件断点确认触发条件,再配合hit count观察是否每次都在同一位置触发,这能帮你判断问题是不是"完全确定性"的。
5.3 调试器里"看"比"跑"更重要的三样东西
图形化调试器最大的价值,不是"让代码一步一步跑",而是让你随时观察三样东西:
调用栈(Call Stack)。程序当前停在哪一层函数,上面每一层是谁调用了它。出现Bug时,调用栈是还原执行路径的骨架。我见过太多人只盯着当前函数内的变量,完全不去看栈顶之外的外层调用——很多Bug的根源恰恰在外层传进来的参数或外层修改的状态。
监视表达式(Watch)。把len(data)、current_sum、error_threshold这些"关键指标"加入监视列表,随着单步执行,它们的实时变化一目了然。
变量面板的"类型"栏。很多IDE的变量面板默认只显示值,不显示类型。看到值的时候,顺便看一眼类型是不是你预期的——Python的鸭子类型特性决定了,类型错误是最隐蔽、最容易造成Bug的因素之一。如果一个本该是int的变量显示成了str,那后面的所有运算都可能在你没意识到的情况下走了别的路径。
6. 主动防御:异常处理和日志是给未来的你写纸条
6.1 try/except的粒度:只抓你能处理的
关于异常处理,我见过最典型的新手写法是:
try: # 一百行代码 ... except Exception: print("出错了")这种写法有三个问题:第一,except Exception把所有异常一网打尽,真正的错误类型信息丢失了;第二,你根本不知道是哪一行出的错;第三,更可怕的是它把错误"吞掉"之后程序继续往下跑,数据在半路就是错的,后面再排查难上加难。
正确的做法是分两层来看。捕获的粒度要细:只捕获你能处理的异常类型,并且单独处理。比如读文件时:
try: content = read_config(path) except FileNotFoundError: content = DEFAULT_CONFIG except PermissionError: raise RuntimeError(f"没有权限读取配置文件: {path}") from None处理的方式要"留痕":如果这个异常你处理不了,就让它继续抛出去,或者重新包装一层更清晰的信息再抛。永远不要静默吞掉异常。我在团队里常说一句话:异常是一个信号,不是敌人。你把它吞了,等于把看到信号的机会也吞了。
6.2 logging配置一次,受用终身
print在正式环境里基本没法用——没有时间戳、没有级别、大量输出会把日志文件撑爆。你需要的是logging模块。很多人对logging的印象是"配置麻烦",但一次配置好,后面所有项目都能复用。这是我最常用的基础配置:
import logging logging.basicConfig( level=logging.DEBUG, format="%(asctime)s [%(levelname)s] %(name)s:%(lineno)d - %(message)s", handlers=[ logging.StreamHandler(), logging.FileHandler("app.log", encoding="utf-8"), ], ) logger = logging.getLogger(__name__) logger.debug("进入函数,参数: %r", kwargs) logger.info("任务完成,耗时 %.2fs", elapsed) logger.warning("重试第 %d 次", attempt) logger.error("处理失败: %s", exc_info=True)几个关键点:用%r看变量最直观;exc_info=True能在日志里带上完整的Traceback;logging是按级别过滤的,开发时设DEBUG,生产设INFO或WARNING,不用改业务代码。还有一个不为人知但很有用的参数stack_info=True,它可以额外打印出调用栈信息,相当于在日志里自动附带"我是被谁调用的"。
有了结构化日志,"线上问题排查"就从"猜"变成了"查日志时间轴"。标准做法是:异常处理时用logger.exception(...)(它会自动带上当前异常信息),然后把关键的中间量以DEBUG级别打出来,出问题时把日志级别调低即可看到完整链路。
6.3 assert的作用域和正确使用时机
assert是做"主动防御"的利器,但也常被误用。它的核心语义是:"我确信这里不可能是这样,如果真的是这样,那就是程序有Bug,直接崩给我看。"典型的用法是检查前置条件、中间不变量和后置条件:
def divide(a, b): assert b != 0, "除数为0,调用方传参错误" return a / b def process(data): result = transform(data) assert result is not None, "transform竟然返回了None,必须查" return result但务必记住:assert在Python优化模式下(python -O)会被完全忽略,所以它不能替代真正的运行时校验(比如用户输入验证),只适合表达"开发者之间的约定"。另外不要用它来检查用户输入——用户输入永远是不可靠的,该用if/raise。
7. Python高频坑点排雷:很多Bug根本不是手误
7.1 可变默认参数:一个经典陷阱
这个坑几乎每个Python开发者都踩过。看这段代码:
def add_item(name, cart=[]): cart.append(name) return cart print(add_item("苹果")) # ['苹果'] print(add_item("香蕉")) # ['苹果', '香蕉'] ← 这里"惊喜"了原因是:默认参数在函数定义时只创建一次,之后每次调用用的都是同一个列表对象。第二次调用时,cart已经不是空列表了。修复很简单:默认值用None,函数体内再创建新对象。
def add_item(name, cart=None): if cart is None: cart = [] cart.append(name) return cart排查这种Bug时有个技巧:如果函数的默认参数是列表、字典、集合这些可变类型,先怀疑这里。
7.2 闭包与延迟绑定
循环里创建闭包,结果所有闭包都"记住"了循环变量的最终值——这也是高频坑:
funcs = [] for i in range(3): funcs.append(lambda: i) for f in funcs: print(f()) # 输出 2 2 2,而不是 0 1 2原因在于闭包捕获的是变量i的引用,而不是定义那一刻的"快照",循环结束后i停在2,所有lambda读到的都是2。修复方式是给默认参数传值,形成"快照":
funcs = [] for i in range(3): funcs.append(lambda i=i: i)这个问题在GUI回调、多线程任务、列表推导式里都容易出现。排查时的特征是:结果全一样,而且都等于循环的最后一个值。看到这种症状,基本可以锁定延迟绑定。
7.3 赋值与拷贝:别再改到"别人的"变量了
Python的变量是"引用",b = a只是让b指向和a同一个对象。于是:
original = {"config": {"rate": 0.8}} copy = original # 这只是增加了一个引用 copy["config"]["rate"] = 0.5 print(original) # {'config': {'rate': 0.5}} ← 原来也被改了处理方式是明确区分"我要共享"还是"我要独立副本":浅拷贝用copy.copy()或.copy(),深拷贝用copy.deepcopy()。在函数传参、缓存、配置项默认值这些场景里,如果不确定外部是否会修改,宁可多拷贝一次。这类Bug最磨人的地方在于:程序不报错、结果不对、而且只在某些调用路径上才触发,因为不是所有地方都会原地修改。
7.4 类型相关的隐藏坑
Python虽然动态类型,但类型相关的Bug非常普遍,而且破坏力大。常见的有:True参与整数运算(True + 1 == 2)、字符串和数字拼接、None混入列表后排序或统计出错、浮点数精度(0.1 + 0.2 != 0.3)。面对这种问题,我的建议是:入口处做类型校验或转换,核心计算处用isinstance做防御,需要精确计算时直接用decimal.Decimal。数据从外部进来的一瞬间,把它洗干净、转成明确类型,是性价比最高的防Bug手段。
8. 让Bug无处遁形的最后两招:测试驱动定位与性能剖析
8.1 用pytest把"怀疑"变成"证据"
当你缩小范围、怀疑某个函数有问题时,与其在完整程序里反复运行调试,不如直接为这个函数写一个最小测试,把输入输出固定下来。pytest是这里的最佳拍档:
# test_processor.py import pytest from processor import process_data def test_process_data_normal(): raw = [{"price": "10", "count": 2}] # 故意用字符串price复现线上场景 assert process_data(raw) == [20.0] def test_process_data_with_missing_key(): raw = [{"price": None, "count": 2}] with pytest.raises(KeyError): process_data(raw)好处太多了:每次改完代码,跑一遍测试就能确认修复是否有效,还不会影响其他代码;测试本身就是文档,记录了这个函数的边界行为;下次重构时,这些测试还能防止同样的Bug回归。我调试Bug的常规流程是:先写一个能复现Bug的测试(它现在应该失败),然后修代码,直到测试变绿。这一套流程配合pytest的-x(失败即停)、--pdb(失败时直接进入调试器)参数,简直是为调试量身定做的。
8.2 二分定位法:删代码找Bug
如果Bug范围很不明确,又没法用调试器跟(比如偶发、多线程),我强烈推荐"二分定位法",或者说"代码消去法"。具体做法:把出问题的代码段用注释或条件跳过的方式"切成两半",先禁用后半段,看Bug是否还出现;如果还出现,说明问题在前半段;如果不出现了,说明在后半段。然后继续在嫌疑段里二分。每次排查都能把范围缩小一半,理论上十几次就能定位到一行。
这个方法特别适合处理"改哪哪错、不知道从哪查起"的复杂Bug。配合git的暂存能力也可以做"版本二分"——如果最近一次正常版本到当前版本之间有大量提交,可以用git bisect自动二分定位是哪一次提交引入了Bug,这个功能值得每个团队熟练掌握。
8.3 cProfile:性能Bug的照妖镜
最后聊一种特殊但高频的Bug:不是结果不对,而是太慢。慢Bug的排查不能靠肉眼猜,要用剖析工具。标准库cProfile的用法很简单:
python -m cProfile -s cumulative your_script.py运行后它会输出每个函数的调用次数、总耗时、单位耗时,按累计耗时排序。一眼就能看出性能热点在哪——通常是某个函数被调用了海量次数,或者某个操作(比如循环里的正则匹配、数据库查询、大列表拷贝)在拖后腿。我见过一个"批量导入耗时40分钟"的问题,cProfile一跑,发现90%的时间花在了一个循环里反复调用external_api_check(),而这个检查本可以缓存结果。没有cProfile,这种问题靠读代码几乎不可能在合理时间内找到根因。
生产中更合适的是py-spy这类工具,它可以attach到正在运行的进程上采样,不需要改代码。为了让这类剖析有效,一个重要的前提是:把热路径上的计算尽量用向量化、缓存、批量的方式组织,剖析的价值才能最大化。
调试这件事,说到底拼的不是"灵光一现",而是你愿不愿意把信息补全到足够再做判断。我个人的习惯是:遇到Bug先按"复现→缩小→读Traceback→观察现场→修复→验证"的顺序走一遍,每一步都踏实做完,而不是急着重写代码。次数多了你会形成直觉,看到某类异常、某种症状,条件反射就知道该往哪个方向排查。这份手感,就是Python代码调试里最值钱的东西。希望这篇内容能帮你少走一些我走过的弯路。