写Python写了六七年,真正让我开始认真用VS Code调试,不是因为它界面好看,而是某次线上问题排查时,我被迫在一个五千行的脚本里靠print定位了两个小时——那次之后我把F5按出肌肉记忆了。很多人对VS Code跑Python的印象还停留在“装个插件然后点绿色三角”,但实际用起来你会发现:解释器选错了、虚拟环境没激活、断点不生效、调试时模块导入失败……这些坑几乎人人都会踩一遍。今天这篇就围绕VS Code运行和调试Python这件事,把我平时的工作流完整拆开讲清楚,从环境准备、运行模式的差异,到launch.json每个字段的用途,再到几个高频报错的完整排查链路。适合刚入门想理顺调试流程的人,也适合已经会跑但想系统搞懂调试器的人。
1. 别急着按F5:先弄清楚VS Code里的Python环境到底是谁说了算
1.1 扩展装对了,调试器才会跟着来
VS Code跑Python,第一件事是装官方发的Python扩展。现在装完这个扩展之后,Pylance(代码提示和类型检查)和debugpy(调试引擎)会作为依赖一起装上,不用再单独装第三方的代码高亮或自动补全插件。很多老教程会让你分别装什么Python、Pylance、Python Debugger,现实中没必要——你装官方Python扩展就行,调试功能会自动集成到编辑器里。
装完之后有个容易忽略的点:从Python扩展的某个版本开始,调试内核不再叫ptvsd,而是换成了debugpy。你在launch.json里看到"type": "debugpy"是很正常的,不必惊慌。如果翻看网上老帖子里写的"type": "python",那是旧版写法,新版本虽然为了兼容还保留着,但新建配置时默认会给你debugpy。
1.2 解释器选择:状态栏左下角的小字决定一切
装好扩展后,打开任意一个.py文件,看窗口底部状态栏,正常情况下会显示当前选中的Python解释器路径或版本号。我把这一步叫“调试前的地基”——因为VS Code里所有运行和调试操作,用的都是这个解释器,而不是你终端里python命令指向的那个。
实际操作中最常见的场景是:项目目录下用python -m venv .venv创建了虚拟环境,你也确实在终端里执行了.venv\Scripts\activate激活了它,但VS Code状态栏显示的却是全局Python。这时候你按F5调试,解释器就是全局的,项目里装的依赖全都不认识,第一个报错大概率是ModuleNotFoundError。
解决办法很直接:按Ctrl+Shift+P打开命令面板,输入Python: Select Interpreter,在列表里选.venv下的解释器。也可以直接点状态栏左下角的版本号,同样会弹出解释器选择列表。选完以后,VS Code会在项目根目录生成一个.vscode/settings.json,里面会记录一行:
json复制{
"python.defaultInterpreterPath": "${workspaceFolder}/.venv/Scripts/python.exe"
}
这里有个关键点要记住:状态栏显示的只是VS Code调试器用的解释器,它管不到你的集成终端。哪怕你在状态栏切到了虚拟环境,新开一个终端,终端里默认用的还是你shell的PATH指向的那个Python。所以终端里要跑Python命令前,还是要自己激活环境。这个“两层口径”的差异,是后面一系列诡异问题的源头。
1.3 准备一份可复现的Python环境自检清单
为了减少后面调试时的玄学问题,我建议在每个项目开始时按下面这套顺序走一遍,五分钟能搞定:
- 创建虚拟环境:
python -m venv .venv(Windows是.venv\Scripts\activate,macOS/Linux是source .venv/bin/activate) - 用命令面板执行
Python: Select Interpreter,选中当前项目的.venv解释器 - 在VS Code终端里执行
python -c "import sys; print(sys.executable)",确认终端里的Python路径也指向.venv - 用
pip install把所有项目依赖装进这个环境
第3步特别值得养成习惯。很多时候你排查了半天,发现一个在终端里能跑、在调试器里不能跑的问题,本质上就是终端用的是A环境的Python,调试用的却是B环境的Python。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 右上角那三个按钮:运行、调试和Code Runner的分工与坑
2.1 绿色三角和F5到底有什么区别
VS Code右上角那个绿色三角,默认执行的是“Run Python File”,本质是在当前激活的集成终端里用选中的解释器直接跑脚本。而F5触发的“Start Debugging”,会启动debugpy、加载launch配置,进入调试模式。两者的区别用一句话概括:Run只是“把代码跑完看结果”,Debug是在跑的过程中停下来看内部状态。
实际工作中我基本这样分配:如果只是快速验证一个脚本有没有语法错误,或者输出结果对不对,我直接点三角或者Ctrl+F5。但凡是涉及到数据不对、循环逻辑不确定、某个函数返回值和预期不符,我几乎不会靠print,而是直接上断点。因为print排错有个致命缺陷:你改了代码加print,就得重新跑一遍;断点则可以在任意位置停住,随时修改变量的值继续跑,时间是几何级节省。
Ctrl+F5在某些版本里被映射成“Run Without Debugging”,意思是虽然有调试配置,但不启动调试器,只是运行。这个快捷键在“我已经配好调试参数,但现在只想快速执行一次”的时候特别好用,它保证了你用的是调试配置里指定的解释器、参数和环境变量,只是不打断点而已。
2.2 Code Runner扩展:能用,但别让它和调试纠缠
很多新手会安装Code Runner扩展,因为它能在代码右上角显示一个一键运行按钮,对单文件脚本很友好。但我得提醒一句:Code Runner默认用终端里的Python解释器,而且完全不读launch.json里的调试配置。这意味着你在launch.json里配好的环境变量、参数、cwd,它一概不认。如果你同时装了Code Runner又用F5调试,很容易产生“咦,我明明配置了这个环境变量,为什么跑起来没有”的困惑——因为右上角那个按钮可能走的是Code Runner,而不是VS Code自己的运行机制。
我的建议是:如果你主要做的是小型脚本、算法题练习、单文件测试,留着Code Runner很方便;但如果你开始涉及多文件项目、需要断点调试、依赖环境变量,就把右上角运行方式切回VS Code原生方式,或者在Code Runner设置里把code-runner.executorMap中的python改成一个明确的解释器路径。总结成一句话:快速验证用Code Runner,正式排查用原生调试。
2.3 运行方式对比表:什么场景该点哪里
我把常用运行方式整理成一个表,可以直接对照着选:
| 运行方式 | 快捷键 | 特点 | 推荐场景 |
|---|---|---|---|
| Run Python File(原生) | 右上角三角 | 用当前解释器跑脚本,不进入调试 | 快速看输出、验证单文件 |
| Run Without Debugging | Ctrl+F5 | 使用调试配置的执行参数,但不启用断点 | 用配置参数跑一遍,观察整体表现 |
| Start Debugging | F5 | 加载launch.json,启用debugpy和断点 | 排查逻辑错误、查看变量和堆栈 |
| Code Runner | 各自快捷键 | 独立运行,不受launch.json控制 | 简单脚本练习,不适合项目级调试 |
用熟了之后,你会发现大部分真实工作流是:先用Run把程序跑通,再用Run Without Debugging验证带参数的启动,最后用F5开始逐段排查。
3. launch.json才是真正的主角:一份可靠配置的逐字段拆解
3.1 什么时候需要碰launch.json
很多初学者用VS Code跑Python,全程没听过launch.json这个东西。原因很简单:你不配它,VS Code会用一套默认行为——拿当前文件当主入口,用状态栏选中的解释器,在当前目录下运行。这个默认行为对单文件脚本完全够用。一旦你开始处理命令行参数、需要指定工作目录、设置环境变量、启动Django/Flask服务,默认行为就不够用了。这时候你需要在.vscode/launch.json里写一份明确的调试配置。
创建方式:打开一个Python文件,切到“运行和调试”侧边栏,点“创建launch.json文件”,选择“Python Debugger”,然后选“Python File”。这会在.vscode目录下创建一个launch.json。
3.2 一份能跑的配置长什么样
我惯用的基础配置如下,直接放到.vscode/launch.json里:
json复制{
"version": "0.2.0",
"configurations": [
{
"name": "Python: 当前文件",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"cwd": "${workspaceFolder}",
"env": {},
"python": "${workspaceFolder}/.venv/Scripts/python.exe",
"justMyCode": false
}
]
}
我来逐一解释我这么写的原因。program用${file}表示“调试当前打开的文件”,适合写脚本时候随手调试。如果你希望每次调试固定某个入口文件,就把它改成例如"${workspaceFolder}/src/main.py"。console设成integratedTerminal,意思是调试时程序跑在集成终端里,这样input()允许输入,print的输出能看到,用户交互能正常进行。新手如果发现点了调试之后,终端里没有任何反应,十有八九是console被设成了internalConsole——那是调试控制台,一般不支持交互输入,很多功能受限。
cwd就是当前工作目录,影响相对路径的解析。如果你代码里有open("data/config.yaml")这样的写法,这个路径是相对cwd的,不是相对.py文件所在目录的。默认设成项目根目录${workspaceFolder}最不容易出错。
3.3 字段拆解:每个配置选项到底在解决什么问题
我把常用字段列成表,方便查阅:
| 字段 | 作用 | 典型坑 |
|---|---|---|
type |
调试器类型,Python统一用debugpy |
老教程里的python不是新版本的首选 |
request |
launch表示启动新进程,attach表示连接已运行的进程 |
多进程调试时常需要attach |
program |
要启动的入口文件 | 用${file}时注意切换文件会影响入口 |
args |
传给程序的命令行参数,比如["--port", "8080"] |
参数是JSON数组,不是空格字符串 |
env |
额外设置的环境变量,如{"PYTHONPATH": "${workspaceFolder}/src"} |
不会覆盖系统已有变量,要覆盖得显式写 |
cwd |
程序的当前工作目录 | 相对路径读取文件最容易在这里栽跟头 |
python |
指定调试用解释器路径 | 不写就用状态栏选的解释器,但状态栏可能被改动 |
justMyCode |
false时允许进入第三方库内部调试 |
排查库内部逻辑时很关键,默认只跳过 |
console |
控制台类型:integratedTerminal/externalTerminal/internalConsole |
input()在internalConsole里无法输入 |
preLaunchTask |
调试前先执行的任务,比如编译、启动依赖服务 | 任务没配好会先报错 |
env.PYTHONPATH是我很喜欢用的一个字段。多文件项目里,你经常写from utils import helper这类代码,当utils模块在src子目录下面时,直接运行会报找不到模块。解决方案要么是在代码里硬编码sys.path,要么老老实实在launch.json里加:
json复制"env": {
"PYTHONPATH": "${workspaceFolder}/src"
}
这比在代码里改sys.path干净得多,因为代码不用动,只是调试运行环境变了。团队其他人拉下代码后也能用同一份配置复现。
3.4 配置里有多个条目怎么切换
实际项目的launch.json里不会只有一条配置。我会同时保留几个:调试当前文件、调试主入口、调试带参数的启动、调试Django服务。这些配置全部平铺在configurations数组里。启动调试时,侧边栏顶部会有一个下拉框,让你选择当前用哪条配置。按F5默认用下拉框里选中的那条。
这个下拉框特别容易被忽略,导致一个经典问题:你改了launch.json,按F5发现根本没生效。真相不是你改错了,而是下拉框里选中的还是另一条配置。我一开始也犯过这个错,后来养成了一个习惯:每次按F5之前先看一眼侧边栏选中的名字,确认和我要调试的目标一致。
4. 断点不会用等于白调试:变量、监视、调用堆栈的实战组合
4.1 三种断点的玩法,不只点一下子
断点是调试最基本的工具,但很多人只知道单击行号左侧设置普通断点,然后F5运行。其实断点能玩出三种花样:
第一种是条件断点:右键断点,选择Edit Breakpoint,输入表达式,只有当表达式为真时程序才会停住。举个例子,在一个一万次的循环里,你只想知道i == 5000时变量的状态,普通断点要手动按八次F9跳过,条件断点则直接精准命中。对于经常处理列表推导、循环计算的人来说,这招每天都能用上。
第二种是命中次数断点:在某些调试器里写作Hit Count,值为10表示第10次经过时停住。适合“循环前几次没问题,后面某次开始出错”的场景。注意新版本里条件断点编辑框也支持hit count的写法,具体看版本界面。
第三种是日志断点:日志点不会让程序暂停,但会在调试控制台打印你指定的消息。右键断点选Log Message,输入格式如当前i的值是 {i},花括号里的内容会被替换成变量值。这本质上是把print从代码里挪到了调试配置里,好处是不用改代码、不用重新运行程序。团队协作时,我不会往代码里塞调试用print,但我会临时加一个日志点看输出。
4.2 变量面板和监视:别靠猜,要让数据自己说
程序在断点处停住后,左侧“变量”面板会显示当前作用域里的所有变量。locals是一层层的函数局部变量,globals是全局变量。我最常用的操作是在变量上右键选择“Add to Watch”,把它加入“监视”面板,然后随时观察它的变化。监视面板里不只是变量名,任何可求值的表达式都能填,比如len(items)、users[0]["name"]、list(map(lambda x: x*2, nums))。甚至可以直接在“调试控制台”里输入表达式回车,动态执行,比如在断点处手动写result.append(1),直接改变程序继续运行时的状态。
这里分享一个我踩过的经验:当你的程序处理一个大字典或超长列表时,变量面板默认只显示到一定层级,太多数据还会卡。这时候该看的是表达式,不是整个变量。比如你想知道config的嵌套路径,在监视面板里填config["model"]["hidden_dim"],比一层层展开快得多。
4.3 调用堆栈:出错的现场别只盯最上面
调试器里“调用堆栈”面板会显示当前停住的调用链,从内到外一层层列出。新手容易犯的错误是只看当前函数内部,一旦当前函数里没有明显问题就发懵。其实正确的排查方式是顺着调用堆栈往上一层层跳,看看是谁在什么参数下调用进来的。我处理某个第三方库的诡异行为时,经常先跳回调用方那一层,观察传给库函数的参数到底是啥——很多时候问题是调用方给的参数类型不对,库内部才各种表现异常。
熟练操作这几种调试动作:F10逐过程(不进入函数内部),F11逐语句(进入函数内部),Shift+F11跳出当前函数。如果在我调试标准库函数或者第三方库的时候,进不去内部,多半是因为justMyCode默认是true,只在该项目自己的代码里单步。把它改成false之后,就能钻进库函数内部一步步看它到底做了什么。
4.4 异常断点的作用:让错误发生在它发生的时刻
调试器左侧边栏里有一个“异常”断点区域,可以勾选“Raised Exceptions”或“Uncaught Exceptions”。默认情况下,没被捕获的异常会导致程序在退出时报告,但如果你勾选了“Raised Exceptions”,那么任何异常被抛出的瞬间就会停下来,哪怕那异常后面会被try/except捕获。这对排查“明明有异常被捕获但程序行为诡异”的情况很有用,因为程序不会等到finally里才暴露问题,你能在异常发生的那一刻直接查看调用栈和变量状态。
我日常习惯是:怀疑某个异常被静默吞掉时,临时勾上Raised Exceptions,跑一遍,找到地点后取消勾选。别长期开着它,否则有些库内部故意用异常做控制流,会让你断到怀疑人生。
5. 报错和“不听话”:Python调试中几个高频问题排查链路
5.1 终端能跑,调试却ModuleNotFoundError
这是最经典的高频问题,现象是:在终端里python app.py跑得好好的,一切正常;一按F5调试,立刻报ModuleNotFoundError: No module named 'xxx'。我遇到的90%情况都是同一个根因:调试用的解释器和终端用的解释器不是同一个。前面讲过状态栏解释器只管调试器,终端里运行用的是shell激活的环境。所以排查链路从第一步就应该是:
先在项目里跑一句python -c "import sys; print(sys.executable)",记住这个路径;然后在VS Code状态栏看当前选择的解释器路径。如果两个路径不一致,基本就实锤了。解决办法:要么用命令面板重选解释器,要么在launch.json的python字段里显式写明解释器路径。
如果解释器确认一致还是报找不到模块,那就是模块不在sys.path里。常见于代码从src目录引用模块、或者模块是本地包的情况。处理方式是前面讲过的env.PYTHONPATH。我见过有人反复卸载重装模块来挣扎这个问题,其实换一下路径设置就能解决。
5.2 明明在conda环境里,调试用的却是base环境
用conda或者miniconda的朋友经常会遇到这个问题:终端里conda activate myenv清清楚楚,运行也用的myenv,但F5调试时的依赖完全不对。本质上和第一个问题同源:VS Code调试器不认你这个终端激活了谁,它只认状态栏里选的那个解释器。所以用conda时,更要主动在状态栏或命令面板里选择myenv环境下的解释器,不要指望终端激活会自动同步。
还有一个连带问题:环境切换后,launch.json里如果写了python字段,那么这个字段的优先级最高,会无视状态栏的选择。所以如果你在多个环境间切换,要检查一下是不是launch.json里写死了某个具体路径。我的做法是:团队项目里通常不写死python字段,交给状态栏选择;但如果项目里只有一个稳定的.venv路径,写死反而对团队更友好,避免新人选错环境。
5.3 断点不生效的几种原因
断点打上了,按F5,程序跑完没停,这是每个人都会遇到的大事。按我的排查顺序:
第一,看断点是不是灰色。灰色断点表示调试器认为那个位置没有可执行代码。最常见原因是文件已经改动但还没保存,或者文件里那一行其实是注释。先Ctrl+S保存再试一次。
第二,检查launch.json的program到底指向哪个文件。如果你用${file}调试,而右下角当前聚焦的文件不是你想调的那个,那断点当然不会停。我踩过最深的一次坑是在多标签页之间来回切,忘了当前激活的是哪个文件,调试器却按预期调试了“当前文件”——只是那个“当前文件”跟我脑子里想的不一样。
第三,如果涉及多进程或远程场景,看是不是调试器没挂到目标进程上。普通单进程脚本不存在这个问题,但Django调试时如果开着reloader,代码会在子进程里重新加载运行,断点往往不生效,我后面会专门讲。
第四,某些优化模式下,解释器可能调整执行顺序。如果你设置了"python": {"optimize": true}之类的高阶参数,行断点会不稳定。正常调试不建议开优化。
5.4 调试时中文乱码、UnicodeEncodeError
Windows上尤其常见,终端里打印中文没事,但调试时一打印就抛UnicodeEncodeError。这是因为集成终端的默认编码在Windows下经常是cp936,而你的控制台环境变量没有正确传递。最省事的方案是在launch.json的env里加上:
json复制"env": {
"PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1"
}
PYTHONUTF8=1会强制Python把标准输入输出编码设为UTF-8,PYTHONIOENCODING则更精确地指定I/O编码。我在Windows上跑数据处理脚本时默认都带这两个变量,省掉了大量编码相关的幺蛾子。
5.5 调试控制台里无法用input输入内容
如果你发现调试启动后,程序停在某个input()等待输入,但怎么打字都没反应,那大概率是console设成了internalConsole。internalConsole就是侧边栏里的“调试控制台”,它主要显示调试信息和表达式求值,交互输入支持很差。把console改成"integratedTerminal",程序就会跑在集成终端里,输入输出都正常。这个改动虽然微小,却是新手最容易卡住半小时的问题。
6. 更进一步:带参数启动、Django/Flask和远程场景的调试配置
6.1 给程序传命令行参数:args的完整用法
调试时给程序传参是个高频率需求。比如你写了一个能接收--input和--output的脚本,想在调试模式下一遍遍验证不同路径组合,总不能每次在终端里手敲。现成的做法是在launch.json里配:
json复制{
"name": "Python: 带参数启动",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/src/main.py",
"args": ["--input", "${workspaceFolder}/data/input.csv", "--output", "result.json"],
"console": "integratedTerminal"
}
这样每次F5就直接用这套参数启动。如果经常需要临时改参数,可以在args里改一个值,保存再F5,几十秒一轮,效率很高。注意args是JSON数组,每个参数单独一个字符串,不要写成一整个带空格的字符串。
6.2 Django调试的两个关键点:关reloader、用manage.py
调试Django项目时,如果直接用“Python: Django”模板,生成的配置大概长这样:
json复制{
"name": "Python: Django",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/manage.py",
"args": ["runserver", "0.0.0.0:8000"],
"django": true
}
看起来简单,实操里有个大坑:Django开发服务器默认开着auto-reload,当代码变动时会重新加载整个项目,这个重载通常发生在子进程里。断点打在视图里,第一次请求能停,改完代码后自动重载,断点就再也不停了。我在某次项目里被这个问题折磨了大半个下午,后来查日志才发现是reloader在“作怪”。
解决办法是启动参数里不带自动重载:args改成["runserver", "0.0.0.0:8000", "--noreload"]。代价是改代码后要手动重启调试器,但是换来的是断点稳定可预测。我个人宁可手动重启,也不想让断点在子进程里跟你捉迷藏。如果非要热重载,那用debugpy的attach方式连到跑起来的Django进程,这是另一套玩法,复杂度更高,普通场景不值得。
6.3 Flask调试:直接跑还是用flask run
Flask项目常见两种启动方式:一种是最简单的app.run()写在文件末尾,另一种是命令行flask run。前者调试配置很简单,program指向你的入口文件就行;后者就麻烦一些,因为需要设置环境变量。比如:
json复制{
"name": "Python: Flask",
"type": "debugpy",
"request": "launch",
"module": "flask",
"env": {
"FLASK_APP": "${workspaceFolder}/app.py",
"FLASK_ENV": "development",
"FLASK_DEBUG": "0"
},
"args": ["run", "--no-debugger", "--no-reload"],
"console": "integratedTerminal"
}
这里用module而不是program,意思是让调试器启动flask这个模块,然后用args传run子命令。和Django一样,我习惯把debugger和reload都关掉,让VS Code的调试器接管,不然子进程会导致断点失效。FLASK_ENV这个变量在Flask新版本里改成了FLASK_DEBUG这样的方式,你按所用版本调整即可。
6.4 远程场景:WSL、SSH和容器的调试思路
VS Code的Remote系列扩展让远程调试变得比较透明:先在左侧“远程资源管理器”连上目标环境,再打开远程目录,调试的launch.json其实写法差不多,区别是解释器路径是远程路径,文件路径可能和本地不同。比如在WSL里,${workspaceFolder}在远程是指/home/xxx/project,在Windows本地则可能是//wsl$/...。所以如果你在Windows和WSL两端共用一套launch.json,路径字段会互相干扰。我的建议是给不同平台各写一条配置,名字里标明,需要调试哪个就切换哪条。
远程调试时还有个实用技巧:进程不需要由VS Code启动,而是在目标机器上由别人或别的服务跑着,你想连上去看它的状态。这时用request: attach连接调试端口。你需要让目标进程先启动一个debugpy的监听,典型写法:
python复制import debugpy
debugpy.listen(("0.0.0.0", 5678))
debugpy.wait_for_client()
然后launch.json里配:
json复制{
"name": "Python: Attach 远程",
"type": "debugpy",
"request": "attach",
"connect": {
"host": "127.0.0.1",
"port": 5678
}
}
这属于进阶用法,知道有这条路就行。遇到“程序是被别人跑起来的,我只看得到现象但看不见变量”的苦逼场景时,这条配置能救命。
6.5 .vscode目录要不要提交到代码仓库
最后聊一个团队协作里一定会遇到的问题:.vscode/launch.json要不要提交到Git?我的经验是:launch.json可以提交,但settings.json要谨慎。launch.json里包含的是调试入口、参数、环境变量这些对团队都有用的信息,提交了能让新人拉下代码后直接F5,不用自己猜配置。而settings.json里经常存个人的格式化偏好、缩进大小、Python路径等,这些每人偏好不同,提交了反而造成无谓的冲突。
如果团队公有配置和个人私有配置要分开,我会在.vscode目录下用settings.json只放团队统一的规范,个人的设置放在用户级别的“设置”里。这样调试配置共享了,个人习惯也保住了。
写到这里,我把VS Code运行和调试Python的全链路都过了一遍:从解释器选择、运行模式差异、launch.json逐字段配置,到断点玩法和高频排坑,最后覆盖了Django、Flask和远程attach场景。最后再分享一个我自己的小习惯:为了让调试配置不打架,我会在下拉框保持一条“Python: 当前文件”作为兜底,所有项目专用的入口配置单独命名。这样新人打开项目、随手按F5,不会被一堆陌生配置搞蒙,而真正需要的时候随时切到具体那条。这套工作流我现在每个项目都用,基本没再被调试配置本身拦住过。
