ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

VSCode Jupyter调试体验:告别print大法,像IDE一样断点调试Notebook

VSCode Jupyter调试体验:告别print大法,像IDE一样断点调试Notebook 用 Jupyter 写代码最崩溃的是什么不是代码写得慢而是单元报错之后你盯着 traceback 看半天只能默默在里面加 print然后重跑一遍继续猜。这种print 大法用了好多年我一直觉得 notebook 的调试体验是缺的直到我在 VSCode 里试了下 Jupyter 插件的 debug 功能才发现原来 notebook 也能像断点调试普通 Python 脚本一样可视化、逐行跑、还能直接看变量。这篇文章我就把整个实操过程、踩过的坑、以及我目前认为最好用的调试方式全部记录下来。不管你是刚接触 Jupyter还是已经在数据分析和机器学习里泡了很久只要你还在用 print 看变量这篇内容都值得花十分钟看完。1. 为什么 Jupyter 调试一直是个老大难1.1 Notebook 的工作方式和普通脚本不一样以前大家说Jupyter 不能用断点调试很多人以为 Jupyter 不够先进其实根本原因是它的执行模型和普通 Python 脚本差别太大。你写一个.py文件一次性运行整个文件调试器从头到尾走一遍这个逻辑是线性的特别直观。但是 Jupyter 里的 notebook 是一个 cell 一个 cell 独立执行的代码被拆成了一个个小格子你可以在任意一个格子上反复执行也可以只执行某一个单元格。每个 cell 的执行顺序不一定是单元格从上到下的顺序加上内核kernel是一直在内存里运行的变量可以跨 cell 保持状态。这就导致一个问题断点应该打在哪个 cell 上调试器断下来之后内核的整个状态应该怎么处理如果每个 cell 是独立执行的那单步执行指的是脚本里的每一行还是整个 cell 作为一步所以很多早期工具都选择了回避直接让大家用%debug魔法命令在报错之后进入事后调试模式或者用pdb.set_trace()强制设置断点但这两种方式的交互体验都比较原始要熟悉命令行调试器的那套命令才能玩得转。1.2 以前的土办法到底有多痛我先说说自己以前调试 notebook 的常用套路你们看看有没有同感。第一招print 大法。所有中间变量都用 print 打印出来然后在输出区里慢慢翻。问题是数据量一大几百行输出糊在一起看一会就眼花了。有时候你换了参数重新跑一次上一次的 print 输出还在新旧信息混在一起非常容易看错。第二招把报错信息复制出来自己脑补执行流程。如果报错发生在第 3 个 cell 里但错误其实根源在第 1 个 cell 的某个宽缺失值那就得回头去检查前面的 cell。这种回溯式问题排查在数据清洗里非常常见每次都要在几个 cell 之间来回跳效率低得离谱。第三招pdb.set_trace()。这个确实能在代码里插桩但它的断点交互是全命令行的你得记住n、s、c、p这些命令而且在 notebook 的 cell 里跑起来还有点怪有时候会出现焦点丢失、输入框不响应的现象。我身边很多朋友试过一次就放弃了。2. 为什么 VSCode 的 Jupyter 插件能解决这个问题2.1 它把调试器和 notebook 的执行模型整合在了一起VSCode 的 Jupyter 插件做了一件事它把 Python 的调试协议和 notebook 的 cell 执行机制打通了。你现在可以像调试一个普通 Python 项目一样在某个 cell 的某一行代码左侧打断点然后点击调试按钮代码会在那行停下来所有变量状态、调用堆栈、监视表达式都是可视化的。这背后的核心其实是两个组件的配合Jupyter 扩展负责和 Jupyter 内核通信Python 扩展负责加载调试器Debugpy。当你在 cell 里打断点这个断点信息会映射到内核中对应代码行上内核执行到这一行时会触发调试协议的通知VSCode 就能把当前状态实时展示出来。更关键的是它支持 cell 与 cell 之间的连续调试。我在第 1 个 cell 里设一个断点在第 5 个 cell 里再设一个断点运行整个 notebook 的调试会话时它会按顺序执行并在断点处停下来这时候我可以继续单步、跳转、查看所有变量。2.2 和 Jupyter 网页版相比VSCode 的优势在哪Jupyter 网页版后来也在某些版本里加入了对 debug 的实验支持基于 ipydebugger但说实话我用下来的体验远不如 VSCode 顺手。网页版的调试界面比较简陋变量的查看颗粒度也不够细而且很多人是在远程服务器上跑 Jupyter网页版通过端口转发访问调试器经常因为网络超时断掉。VSCode 的插件则把调试面板做得很完整左上角有调试工具栏可以继续、单步、单步跳入、单步跳出、重启、停止左侧有变量、监视、调用堆栈、断点这几个面板和调试普通 Python 脚本时的体验几乎一样。只是底层交互的内核是 Jupyter 内核其他都无缝衔接。对于已经在用 VSCode 写 Python 的人来说这基本就是零成本迁移。不用再装一堆乱七八糟的依赖插件装好、内核选对就能在原来的 notebook 文件里直接开始调试。3. 环境准备与插件安装要点3.1 必需的软件环境先说版本要求。我用的是 VSCode 1.8x 以上的版本Jupyter 扩展更新也比较频繁建议把这两样都保持最新版。安装扩展的时候直接按CtrlShiftX打开扩展面板搜索Jupyter装那个由微软发布的扩展一般会连带安装 Python 扩展和 Pylance。如果你还没装过这些直接一起装掉后期省很多麻烦。另外你的 Python 环境里必须要有ipykernel和debugpy这两个包。我遇到过有些精简版的虚拟环境里只有ipykernel没有debugpy结果进去之后调试按钮是灰的一点反应都没有。检查方法很简单在终端里输入pip list | grep ipykernel pip list | grep debugpy没有的话直接装pip install --upgrade ipykernel debugpy如果你是 conda 环境也同理用conda install -c conda-forge ipykernel debugpy即可。3.2 选择正确的内核这一步很多人容易忽略。你的 VSCode 里可能同时有好几个 Python 环境系统默认解释器、conda 环境、venv 虚拟环境一堆混在一起。新建或打开一个.ipynb文件后注意看一下文件右下角显示的内核名称比如Python 3.11.5base: conda。如果这个内核不是你想要调试的那个环境点击它并重新选择。我自己的习惯是所有项目都建独立的虚拟环境notebook 用的内核也指定到这个环境里。这样调试时看到的变量依赖和项目里实际跑的环境是一致的不会出现本地调试正常、换环境就报错的问题。另外还需要注意只有选中了有效的内核调试界面才会激活。如果你打开 notebook 文件时右下角提示选择内核并且你一直没选那顶部 debug 按钮是不会亮起来的。3.3 修改 settings.json 里的关键配置Jupyter 插件的调试默认行为在大多数场景下够用但有两个配置我觉得值得手动调一下。第一个是jupyter.debugJustMyCode。默认值是true它表示只调试你自己的代码跳过 site-packages 里的第三方库。这个设计很好避免你调试时一头扎进 numpy 的源码里出不来。但有时候你会遇到一个问题你想看的逻辑恰好写在某个本地导入的.py文件里如果这个文件被识别成非用户代码断点就可能不会停。这时可以把jupyter.debugJustMyCode设为false或者给那个.py文件也打开并手动打断点。第二个是jupyter.logging.level。默认是info如果你在调试过程中遇到各种奇怪的连接错误比如内核连接不上、调试会话超时可以把日志级别改成debugVSCode 的输出面板会打出非常详细的日志能帮你定位具体原因。其他的配置项比如jupyter.debugStopOnException遇到异常是否自动停在异常处我觉得保持默认就好除非你有特殊需求。4. 在 VSCode 里用 Jupyter 插件 Debug 的实操流程4.1 打开 Notebook 并启用调试功能这一步很简单。随便打开一个.ipynb文件在 VSCode 的编辑区里可以看到这个 notebook 的单元格。顶部左侧会有一个Run All这样的工具按钮区通常也会有一个调试图标虫子图标。如果你的当前内核支持调试图标是亮的否则是灰色。点击调试图标你会发现当前 cell 或者整个 notebook 进入了调试模式。右上角会出现标准的调试工具栏和调试普通 Python 程序时的工具栏是一样的continue、step over、step into、step out、restart、stop。如果要调试整个 notebook从第一个 cell 开始执行点 continue 按钮代码会一直跑到第一个断点处停下如果只想调试局部先停到合适的位置再手动执行某些 cell 也行不过这个我后面再展开说。4.2 设置断点的几种方式和注意事项断点的设置方式和调试普通 Python 文件完全一样在代码行的左侧行号位置单击就会打上一个红点。打上行号之后代码跑起来会自动停在这个红点所在的那一行不会执行这一行。在 notebook 里打断点有一个非常要注意的地方断点打在单个 cell 里时只对这个 cell 的执行有效。也就是说如果你在第 2 个 cell 里打断点但手动点击第 1 个 cell 的运行按钮而不是 debug 整体运行代码不会在第 2 个 cell 的断点处停止除非你是通过 VSCode 的调试会话触发的。因为 Jupyter 的 cell 手动执行走的是 kernel 的 execute 接口而 debug 会话是通过调试器附加到 kernel 上手动执行不一定经过调试器。所以正确的做法是要么从头开始用 debug 模式运行整个 notebook要么在目标 cell 中点那个带有虫子图标的调试当前单元格按钮。我自己的实操习惯是遇到一个想仔细检查的 cell就直接在当前 cell 上点调试单元格。这样调试器会从当前 cell 的第一行开始跑碰到断点停下来我一个一个看变量比全局 debug 整个 notebook 要快很多。4.3 断点停住之后变量和堆栈怎么看代码在断点处停下后左侧会有几个面板我逐个说一下。变量面板这里会列出当前作用域里的所有变量包括局部变量、全局变量、还有闭包变量。在 notebook 里因为内核是常驻的全局变量列表通常特别长甚至包括之前执行过的 cell 里的所有变量。数据量一大找目标变量就不是那么直观。好在变量面板有搜索框直接输入变量名就能搜到。监视面板你可以在这里添加表达式比如输入df.shape、x y调试器会实时计算并显示结果。这个功能特别适合观察某些表达式在运行到当前断点时是不是符合预期。比如我在处理数据的时候会在监视里添加df.isnull().sum()断点停下来一眼就知道缺失值情况。调用堆栈这个在 notebook 调试里稍微有点特殊。你会看到好几层堆栈帧外面的是 kernel 的执行框架里面的是当前 cell 对应的 Python 代码路径。一般我们关心的是最里面自己的代码帧点进去才能看到当前 cell 里真正的局部变量。断点面板列出了所有设置过的断点可以勾选启用/禁用一个断点也可以在这里编辑断点的条件这个在进阶部分细说。4.4 单步执行时需要注意的 cell 边界问题在调试工具栏里单步跳过的概念和普通脚本调试有个细微差别。如果断点停在一个 cell 的中间你点击 step over它会执行当前行并停到下一行这和自己写普通 Python 脚本时没有区别。但如果你点击 step into单步跳入一个函数调用进入的是某个被调用的函数而该函数定义在另一个.py文件或另一个 cell 中此时 VSCode 会尝试打开对应的文件并把断点标记到函数内部。这个体验在常规脚本里很完美但是当函数定义在另一个 cell 中时有时候打开的文件会有点乱。我遇到过的情况是函数定义在 notebook 第 3 个 cell 里我在第 10 个 cell 调用了它step into 之后VSCode 跳转到第 3 个 cell 的定义处然后单步走函数内部的逻辑这个很好用。但如果你这个函数之前被修改过内核里的代码版本和文件显示不一致那跳转到的行号可能会偏需要以左侧变量面板里的实际变量变化为准不要信界面上的高亮。还有一点如果你的一个 cell 里有多行代码而且你用了魔法命令比如%timeit、%%bashdebug 模式下这些命令可能会表现异常。魔法命令在调试器下要么被忽略要么执行时间特别长尽量把魔法命令和核心业务逻辑分到不同的 cell 里。5. 实战案例一个数据清洗场景的复盘5.1 我实际遇到的 bug 是什么样的拿我自己最近的一次经历说吧。我在处理一份用户行为日志数据长这样每一行是一条操作记录字段包括user_id、action_type、timestamp、device_type。我在清洗阶段写了一个函数用于把timestamp从字符串转成 datetime然后按天生成新特征。函数大概长这样def process_timestamp(ts_str): dt pd.to_datetime(ts_str) if dt.hour 6: return 凌晨 elif dt.hour 12: return 上午 elif dt.hour 18: return 下午 else: return 晚上然后在主流程里我用apply对全列执行转换df[period] df[timestamp].apply(process_timestamp)报错信息显示Hour must be in 0..23我当时怀疑是某些timestamp为空值或者格式不统一导致to_datetime返回了NaT而NaT.hour会抛异常。按我以前的习惯肯定是在这个函数内部加一堆 print输出每个ts_str看看是什么。但这次我直接打开了 debug 会话在process_timestamp函数的入口行打了一个断点。5.2 用调试器的过程记录我在 VSCode 里打开那个 notebook选择好内核点击当前 cell 的调试按钮。断点很快就触发了第一次停留在ts_str作为参数的当前值。变量面板里我看到了ts_str的值是一个正常的时间字符串2024-03-15 14:22:01继续 step into跑到dt pd.to_datetime(ts_str)这一行后监视表达式里加了一个dt发现结果是正常的Timestamp。继续往下跑等到dt.hour的时候发现dt变成了NaT说明确实有空值进入了函数。我再往下继续跑了几次最终定位到空值是空字符串不是NaNpd.to_datetime()会返回NaT。如果是NaNto_datetime也会返回NaT但空字符串转化时抛出的异常路径有点不一样所以我之前一直没在 print 里发现真正原因。然后我在函数入口加了一个判断条件def process_timestamp(ts_str): if pd.isna(ts_str): return 未知 dt pd.to_datetime(ts_str) ...再跑一遍问题立刻就解决了。整个过程不用写一行 print所有信息都从左侧变量面板和监视面板里直接获取比之前靠 print 排查的效率高了好几倍。5.3 从这个案例里得到的经验调试器最大的价值不仅仅是定位 bug还能让你看到代码执行过程中每个变量的状态变化。对于数据清洗这种环节中间变量很多而且经常断在边界值上用 print 一遍遍输出真的非常低效。我后来养成了一个习惯只要遇到函数报错就在函数入口打一个断点然后单步走一遍观察入参和中间变量往往几秒钟就看出问题。因为很多情况下报错信息只告诉你在哪一行挂了但没告诉你是谁传了屎一样的参数进来。6. 你可能会踩的坑排查记录实录6.1 调试按钮灰着点不了最常见的情况是内核没选对。VSCode 里如果当前 notebook 没有可用的内核顶部工具栏的 debug 按钮就是灰色状态。你在右上角点一下内核名称弹出来的列表里选择一个 Python 环境等它右下角的状态变成就绪再试。还有可能是 notebook 文件有问题比如代码块里的语法错误导致内核无法启动。可以先尝试在普通 run 模式下执行一下任意一个 cell看看能不能正常运行。6.2 断点打上去了但运行到那一行不停这个我遇到过好几次原因大概有三种。第一种是断点打了但没有通过调试模式运行。如果你只是手动点击 cell 左侧的运行按钮断点是不会生效的因为手动执行不经过调试器所以你一定要用 debug 工具栏里的运行按钮或者点调试当前单元格。第二种是和.py文件相关。如果断点打在某个导入的.py文件中而jupyter.debugJustMyCode为true调试器会跳过非用户代码。你把该选项设为false或者直接打开那个.py文件再打一次断点。第三种是和内核的代码版本不同步。比如你先运行过某个 cell然后修改了函数定义但内核里保存的代码可能还是旧的。这种情况建议先 Kernel - Restart Kernel把内核状态清空再重新调试避免各种诡异的不一致。6.3 变量面板里看不到某些变量要注意作用域。如果断点当前停在一个函数内部左侧变量面板显示的是局部作用域和全局作用域的变量。但 notebook 的全局变量非常多可能会被折叠在浏览器的某个子项里。在变量面板右上角搜索框直接输入变量名是一个最快的办法。还有一点Pandas 的 DataFrame 变量在变量面板里默认只显示前 10 行左右的预览如果数据量很大需要点击 DataFrame 变量前面的箭头展开或者从面板底部查看某一个单元格的具体位置。如果你要看的不是前几行直接用监视面板添加df.tail(20)这样的表达式比在变量面板里翻更直接。6.4 调试过程中内核卡死有一次我在 debug 状态下执行到一个耗时的 for 循环我本来想单步看一下结果点击 step over 之后整个 VSCode 的调试会话像是卡住了转圈转了很久最后还是强制停止了。后来我分析了一下应该是循环太大单步操作的调度过程非常慢尤其在远程服务器上调试时每一次单步都要把内核状态传回本地网络延迟放大了等待时间。这种情况我的建议是不要用单步走大循环直接在循环内部设置条件断点只在满足某些条件时停下来。具体做法是在断点面板里右击断点选择编辑断点输入条件表达式比如i % 10000 0这样循环跑到第 1 万、2 万次才停一次速度就快很多。6.5 Debug 时绘图和魔法命令不好使调试模式下如果在断点附近调用了matplotlib的plt.show()有些环境会阻塞住因为图形窗口的事件循环和调试器的事件循环互相影响。以前在 Jupyter 网页版里经常遇到这个问题VSCode 里好一些但也不是完全没有。我的解决方案是需要画图的代码尽量不放进被调试的代码路径里或者把画图的代码单独放在一个 cell在调试结束后手动执行。如果你确实想在调试过程中查看某个数据可视化结果可以在监视面板里调用df.plot()试试但不要依赖它。还有%timeit这种魔法命令在调试会话里执行往往会改变执行时序导致结果不准确也尽量不要放在被调试的代码里。7. 进阶玩法条件断点、Logpoint 和远程调试7.1 条件断点帮你精准定位特定值上面提到过一次条件断点我再展开说说。notebook 调试里条件断点是排查特定数据问题时非常有效的工具。比如你在遍历所有用户时想只在某个特定user_id出现时停下来你就可以在循环那行设一个断点然后右键选择编辑断点输入user_id 12345然后恢复运行它会自动忽略所有user_id ! 12345的情况直到命中才停下。这样就避免了手动一遍遍单步越过大几千个循环。另外还有一种叫 Hit Count 的条件指定断点在第几次命中时才停下来。比如你怀疑第 50 次循环出现问题就可以把 Hit Count 设置为 50。7.2 Logpoint 到底有什么用Logpoint 是不打断执行的伪断点。它会在执行到某一行时将指定信息输出到调试控制台但程序本身不会暂停。在 notebook 调试里Logpoint 的应用场景也挺好的。比如你想知道某段循环里每个user_id的变化但不想一次次停下来看变量就可以在那个位置添加一个 Logpoint输出表达式user_id, action_type然后运行整个调试会话调试控制台会像 print 一样快速打印出每一轮的取值但执行不会中断。你可以一直观察完整流程最后再去分析输出。说实话我觉得 Logpoint 就是加强版的 print但它不用污染你的代码随时可以通过断点面板启停。7.3 远程服务器上的 Jupyter 如何调试很多人是在远程服务器上跑 Jupyter本地用 VSCode 连接。这种情况调试起来其实和本地基本一致只要确保远程环境里装了ipykernel和debugpy。但是有两个额外的注意事项一个是网络稳定性如果延迟太高单步操作的反馈会有点迟钝属于正常现象忍耐一下另一个是如果你通过 SSH 隧道访问远程 notebookVSCode 会自动处理端口转发不要手动再开一条隧道否则可能冲突。我在远程服务器上调试一个模型推理流程时基本就是本地打开 notebook选择远程内核然后正常打断点、看变量。体验和本地调试很接近除了一些大数据量的变量刷新有点慢。7.4 调试正在运行的 Jupyter 内核这个功能稍微冷门一点。如果你已经在终端或者网页端启动了一个 Jupyter 服务器并且有一个正在运行的内核VSCode 的 Jupyter 扩展可以选择连接到现有内核来附加调试器。我遇到的使用场景是这样的某些模型训练脚本在后台跑着我不想中断它但我想看看某个中间阶段变量的变化。这时通过附加调试我可以在另一段代码里打断点等下一次该断点触发时停住观察。不过要提醒一下附加调试的过程比较复杂而且并不是所有 Jupyter 内核都完全支持动态附加新手不建议优先尝鲜。先把基础的本机调试流程跑通再考虑这个进阶功能。8. 关于调试我最后想说的几点体会VSCode 的 Jupyter 插件调试功能确实是补全了 notebook 在工程化方向上的一块拼图。以前我们总觉得 Jupyter 适合做数据探索写正式代码还得靠 IDE其中一个很重要的原因就是没法像本地脚本那样打断点调试。现在这个限制被打破了你可以在 notebook 里直接做轻量的代码审查也可以把某些函数逻辑调到完全正确之后再封装成正式模块。不过我也想说句实在话debug 不是万能的它不会替你写代码也不会自动告诉你业务逻辑哪里设计错了。它的作用是让你更快地看到真相而不是让你跳过思考。遇到 bug先想清楚可能的原因再去打断点验证这是效率最高的路径。另外print 和 debug 并不是水火不容的关系。我现在的习惯是复杂的边缘条件我用条件断点去卡简单的输出我就用 Logpoint有些快速验证的临时逻辑我还是会直接打印。工具是死的怎么组合得好取决于你在现场的实际判断。如果你想开始体验 Jupyter 调试装好扩展、选对内核、打一个断点跑起来就知道它和以前所有土办法的区别了。别怕踩坑我上面记录的这些问题你如果遇到回来翻一下这篇内容大概率能省下不少排查时间。
返回列表