调试
调试不是“盯着代码猜哪里错了”,而是一个不断缩小范围的过程:稳定复现问题,收集证据,提出假设,用最小实验验证,最后修复根因并防止复发。
本文使用 Python 3.10 及以上版本。学完后,你应该能够:
- 根据现象和 traceback 找到最值得检查的位置;
- 用最小可复现示例缩小问题范围;
- 合理使用临时输出、
logging、assert和pdb; - 设置断点、单步执行并检查调用栈和变量;
- 调试异常、死循环、数据错误和只在特定环境出现的问题;
- 修复根因,并用测试保护已经修复的行为。
一套可靠的调试流程
遇到错误时,可以按下面的顺序处理:
- 记录现象:期望结果是什么,实际结果是什么,有没有异常信息;
- 稳定复现:找出触发问题的输入、环境和操作顺序;
- 缩小范围:减少输入和无关代码,确定错误第一次出现的位置;
- 提出假设:例如“折扣在这里被计算了两次”;
- 验证假设:检查变量、增加断点或写一个最小测试;
- 修复根因:不要只修改最终输出或吞掉异常;
- 验证边界:检查正常输入、边界值和异常输入;
- 留下保护:增加回归测试,必要时保留有价值的日志。
例如,下面的函数在购物车为空时会失败:
def average_price(prices: list[float]) -> float:
return sum(prices) / len(prices)
print(average_price([]))
不要看到 ZeroDivisionError 就在外层写一个宽泛的 try。先明确函数对空列表应当采用什么规则:拒绝输入、返回约定值,还是让调用方处理异常。假设空列表属于非法参数,可以修复为:
def average_price(prices: list[float]) -> float:
if not prices:
raise ValueError("prices 不能为空")
return sum(prices) / len(prices)
调试解决的是“为什么会错”,错误处理解决的是“失败时怎么办”,两者不要混为一谈。
第一步:让问题可以复现
无法稳定复现的问题很难验证是否真正修好。记录至少以下信息:
- 最小输入数据;
- 完整错误信息和 traceback;
- Python 与依赖版本;
- 操作系统、配置和环境变量;
- 问题是每次出现,还是偶尔出现;
- 最近发生过哪些代码或配置变化。
可以用下面的命令确认当前解释器:
python --version
python -c "import sys; print(sys.executable)"
如果问题与第三方包有关,还应检查实际导入位置和版本:
python -c "import package_name; print(package_name.__file__)"
python -m pip show package-name
构造最小可复现示例
最小可复现示例只保留触发错误所必需的代码和数据。假设一个处理数千行 CSV 的程序报错,可以逐步:
- 保留能触发问题的一行数据;
- 去掉网络、数据库和界面代码;
- 用普通字符串或列表代替外部输入;
- 把相关逻辑提取成一个小函数。
缩小后的程序既容易观察,也很适合转成回归测试。
使用二分法缩小范围
如果错误可能出现在一长串步骤中,先检查中间位置:
- 中间结果正确,问题在后半段;
- 中间结果错误,问题在前半段。
重复这个过程,比从第一行开始逐行猜测更快。代码变更较多时,也可以用相同思路缩小“从哪个版本开始出错”,但在运行来源不明的旧版本前应先确认安全性。
读懂错误现场
未处理异常会输出 traceback:
Traceback (most recent call last):
File "app.py", line 12, in <module>
print_total(["10", "unknown"])
File "app.py", line 8, in print_total
total = calculate_total(values)
File "app.py", line 4, in calculate_total
return sum(int(value) for value in values)
File "app.py", line 4, in <genexpr>
return sum(int(value) for value in values)
ValueError: invalid literal for int() with base 10: 'unknown'
阅读时先看:
- 最后一行的异常类型和消息;
- 最后一个属于自己项目的调用栈帧;
- 参数如何沿调用链到达该位置。
异常发生行不一定是根因所在行。例如 int(value) 在这里失败,但真正的根因也可能是更早的数据校验遗漏。关于异常传播和处理方式,可继续阅读错误处理。
没有异常也可能有错误
逻辑错误通常不会生成 traceback:
def apply_discount(price: float, rate: float) -> float:
return price * rate # 本意是返回打折后的价格
这时需要比较“期望值”和“实际值”,并沿数据流寻找二者第一次不同的位置:
price = 100
rate = 0.2
result = apply_discount(price, rate)
print(f"price={price!r}, rate={rate!r}, result={result!r}")
!r 会调用 repr(),能够暴露空格、转义字符以及字符串和数字之间的区别,因此调试时往往比普通输出更清楚。
用临时输出快速观察
对于范围很小、能够立即复现的问题,print() 是成本最低的工具:
def calculate_total(items):
print(f"DEBUG items={items!r}")
total = sum(item["price"] * item["quantity"] for item in items)
print(f"DEBUG total={total!r}")
return total
输出要包含变量名和表示形式,不要只写 print(total),否则多处输出混在一起时难以识别。
临时输出适合短期实验,但不适合长期诊断:它没有级别、时间和来源信息,也不方便统一关闭。完成调试后应删除无用输出;需要长期保留的运行信息应使用日志。
使用 logging 保留诊断信息
标准库 logging 可以按级别记录时间、模块和消息:
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
def load_user(user_id: int) -> dict:
logger.debug("准备加载用户:user_id=%s", user_id)
user = {"id": user_id, "name": "小林"}
logger.info("用户加载完成:user_id=%s", user_id)
return user
常见日志级别:
| 级别 | 适用情况 |
|---|---|
DEBUG | 调试所需的详细状态 |
INFO | 程序按预期完成的重要步骤 |
WARNING | 出现异常迹象,但程序仍能继续 |
ERROR | 当前操作失败 |
CRITICAL | 严重失败,程序可能无法继续提供核心功能 |
记录变量时优先让日志系统延迟格式化:
logger.debug("查询参数:user_id=%s, active=%s", user_id, active)
发生异常时,logger.exception() 会在当前异常处理块中同时记录消息和 traceback:
try:
result = 10 / divisor
except ZeroDivisionError:
logger.exception("计算比例失败:divisor=%s", divisor)
raise
日志中不要写入密码、访问令牌、银行卡号等敏感信息。生产环境还应控制日志大小、保留时间和访问权限。
使用断言暴露“不可能状态”
assert 用来检查开发者认为程序内部必须成立的条件:
def calculate_percentage(part: int, total: int) -> float:
assert total > 0, "total 在此处必须为正数"
return part / total * 100
条件为假时会抛出 AssertionError。断言适合暴露内部约束,不适合验证用户输入或承担业务错误处理:
# 不推荐:优化模式可能移除 assert
assert user.is_admin, "只有管理员可以删除数据"
# 推荐:业务规则使用正常控制流
if not user.is_admin:
raise PermissionError("只有管理员可以删除数据")
使用 python -O 运行时,断言会被移除。因此,权限检查、参数校验和任何程序正常运行所必需的逻辑都不能依赖 assert。
使用 breakpoint() 进入调试器
当多个变量相互影响,或需要观察代码如何一步步执行时,可以设置断点:
def final_price(price: float, discount: float) -> float:
subtotal = price * (1 - discount)
breakpoint()
tax = subtotal * 0.06
return subtotal + tax
print(final_price(100, 0.2))
程序运行到 breakpoint() 会暂停,并默认进入标准库调试器 pdb。终端出现 (Pdb) 提示符后,可以输入命令检查当前状态。
常用 pdb 命令
| 命令 | 作用 |
|---|---|
l / list | 显示当前位置附近的源码 |
ll / longlist | 显示当前函数或栈帧的源码 |
p expression | 计算并输出表达式 |
pp expression | 用更易读的格式输出表达式 |
n / next | 执行当前行,停在当前函数的下一行 |
s / step | 执行当前行,必要时进入被调用函数 |
r / return | 继续执行,直到当前函数返回 |
c / continue | 继续执行,直到下一个断点或程序结束 |
b / break | 查看断点,或在指定位置增加断点 |
cl / clear | 清除断点 |
w / where | 显示完整调用栈 |
u / up | 切换到上一层调用者的栈帧 |
d / down | 切换到下一层被调用者的栈帧 |
display expr | 每次暂停时自动显示表达式 |
q / quit | 退出调试器并终止程序 |
help command | 查看某个命令的帮助 |
一次简单会话可能是:
(Pdb) p price
100
(Pdb) p discount
0.2
(Pdb) n
> app.py(5)final_price()
-> tax = subtotal * 0.06
(Pdb) p subtotal
80.0
(Pdb) c
如果变量名刚好与命令冲突,可以明确使用 p variable_name,不要直接输入变量名。
从命令行启动 pdb
不修改源码也可以在调试器中运行脚本:
python -m pdb app.py
进入后可先设置断点,再继续执行:
(Pdb) b app.py:20
(Pdb) c
也可以按函数名设置断点:
(Pdb) b calculate_total
断点可以带条件,只在特定数据出现时暂停:
(Pdb) b app.py:20, user_id == 42
条件断点特别适合循环次数很多、但只有某个元素出错的情况。
调试已经发生的异常
在交互式解释器中,异常刚刚发生后可以执行:
import pdb
pdb.pm()
pdb.pm() 会进入最近一次异常的 traceback,适合检查出错时各层栈帧中的变量。程序中也可以捕获异常后调用 pdb.post_mortem(),但这通常只用于本地诊断,不应留在无人值守的生产程序中。
控制 breakpoint()
breakpoint() 默认调用 pdb.set_trace()。环境变量 PYTHONBREAKPOINT 可以改变其行为:
# 忽略代码中的所有 breakpoint()
PYTHONBREAKPOINT=0 python app.py
不同操作系统设置环境变量的语法可能不同。提交代码前仍应检查临时断点,避免程序在线上意外暂停。
IDE 调试器的核心能力
大多数 IDE 和编辑器提供图形化调试器,常见能力包括:
- 单击行号设置普通断点或条件断点;
- 单步跳过、单步进入和跳出当前函数;
- 查看局部变量、全局变量和表达式;
- 浏览调用栈并切换栈帧;
- 捕获指定类型的异常;
- 调试测试、子进程或远程程序。
界面虽然不同,背后的思路和 pdb 相同:暂停执行、选择栈帧、检查状态、控制下一步。要确保 IDE 选择的 Python 解释器、工作目录和环境变量与正常运行时一致,否则可能调试的是另一个环境。
按问题类型选择方法
数据结果错误
沿数据流检查每个关键阶段的输入和输出,寻找“第一次变错”的位置。重点确认:
- 类型是否符合预期,例如
"10"和10; - 容器是否被意外原地修改;
- 单位、时区、编码和舍入规则是否一致;
- 边界条件是否存在差一错误;
- 可变默认参数或共享状态是否造成污染。
循环不结束或程序卡住
检查循环条件和每轮用于推进状态的变量:
while remaining > 0:
logger.debug("remaining=%s", remaining)
# 确保每个分支都会让 remaining 接近终止条件
如果程序在等待文件、网络、锁或用户输入,调用栈通常比变量值更重要。为外部操作设置合理超时,避免“慢”被误认为“死循环”。调试多线程和异步代码时,还要记录任务名、线程名和关键时间点,因为执行顺序可能每次不同。
偶发错误
不要只增加大量无结构输出。应记录足以还原现场的上下文,例如请求标识、输入摘要、时间、重试次数和异常 traceback。然后寻找变化因素:
- 并发执行顺序;
- 时间和随机数;
- 外部服务响应;
- 全局变量或缓存;
- 不同机器上的版本与配置。
测试中可以固定随机种子和时间来源,但固定种子只能帮助复现,不能证明代码不存在其他随机输入下的问题。
只在另一台机器上失败
对比两个环境,而不是立即修改业务逻辑:
- Python、依赖和操作系统版本;
- 当前工作目录与文件路径大小写;
- 默认编码、语言和时区;
- 环境变量与配置文件;
- 文件权限、网络和可用资源。
不要把完整环境变量直接写入日志,其中可能包含密钥。只记录经过筛选、与问题相关的配置。
修复后如何确认
一次可靠修复至少要完成三项验证:
- 原来能够触发问题的输入现在得到正确结果;
- 正常输入和相邻边界没有被破坏;
- 增加一个修复前失败、修复后通过的自动化测试。
这类测试称为回归测试。它把本次调试得到的知识保存下来,避免相同错误以后重新出现。如何编写测试,参见单元测试。
常见低效做法
同时修改很多地方
一次改变多个变量后,即使问题消失,也很难知道哪项修改真正有效。每次只验证一个清晰假设,并保持修改范围尽量小。
只修补最终结果
在错误输出上加一个特殊判断可能暂时“看起来正确”,却让错误数据继续流动。应定位数据第一次偏离预期的位置。
捕获并忽略所有异常
try:
run_job()
except Exception:
pass
这种写法会销毁最有价值的诊断信息。只捕获能够处理的具体异常;无法处理时记录上下文并重新抛出。
调试时改变了问题本身
增加输出或逐步执行会改变程序时序,尤其容易影响并发问题。出现这种情况时,应减少侵入式操作,改用带时间和任务标识的日志,或在更接近真实环境的条件下收集现场。
把敏感数据复制到调试材料
最小示例、截图和日志在分享前必须清除密码、令牌、个人信息和真实业务数据。可以保留数据结构,但应用虚构值代替真实内容。
调试检查清单
- 能否用一组明确步骤稳定复现?
- 期望结果与实际结果分别是什么?
- traceback 中最后一个项目代码栈帧在哪里?
- 错误数据第一次出现在哪个步骤?
- 当前结论是证据,还是尚未验证的猜测?
- 能否删掉一半代码或输入后仍然复现?
- 修复的是根因,还是只遮住了症状?
- 是否验证了正常输入、边界值和异常输入?
- 是否删除了临时输出和断点,并避免记录敏感信息?
- 是否增加了能够防止复发的测试?
掌握工具固然重要,但高效调试的核心始终是:保留现场、缩小范围、验证假设。这样即使换了编辑器、框架或操作系统,仍然能用同一套方法找到问题。