我第一次意识到单层进度条不够用,是在一次批量下载任务里。外层要处理几十个数据文件,内层是每个文件里上万条记录,当时我天真地写了两层tqdm嵌套,结果终端里两条进度条像抢地盘一样叠在同一行,进度数字乱跳,日志和百分比糊成一片,最后只能把进度条全删掉,改回最朴素的print打卡输出。后来把tqdm的position参数真正弄明白,我才发现这个库天生就支持多层进度条,只是大多数教程只讲了最表层那点用法。
这篇文章只聚焦一件事:如何用tqdm做出信息清晰、显示稳定的双层进度条。我会先用一个小场景说明单层条在嵌套循环里为什么不够,再把position这个关键参数的工作原理讲透,接着给出一份可以直接抄走的完整代码模板,然后复盘嵌套场景里最容易翻车的几个坑,最后补充动态描述、多线程合并进度这类进阶玩法。无论你是在写模型训练循环、数据处理管道还是批量采集脚本,这篇内容都能直接用上。
1. 双层进度条不是在炫技:先看它解决了什么痛点
1.1 单层进度条在嵌套循环里的尴尬
绝大多数Python脚本里,耗时的任务长这样:
for epoch in range(10): for batch in range(200): train(batch)外层是轮次,内层是批次,这是典型的循环套循环。如果你只在最内层放一条tqdm(range(200)),你能看到当前这一轮的200个批次跑了多少,但你看不到这是第几轮、总共还剩几轮;如果你只在最外层放一条tqdm(range(10)),那更难受——内层跑几分钟,外层进度条一动不动,你以为脚本卡死了,其实它在埋头干活。
很多人会用一个折中方案:把外层索引塞进内层进度条的描述里,比如tqdm(range(200), desc=f"epoch {epoch}/10"),然后内层跑完再用print打一行日志。这种做法能用,但代价是:每完成一轮,终端里就多一行print输出,日志堆积得很乱,而且你没法同时看到"当前这轮跑多快"和"整体还剩多久"这两层信息。
1.2 两层各显示什么信息,才算有意义
双层进度条的核心思想是信息分层:外层管全局,内层管局部。
| 层级 | position取值 | 典型用途 | 主要信息 |
|---|---|---|---|
| 外层 | 0 | 总任务数、总轮数、总文件数 | 总进度、总体剩余时间 |
| 内层 | 1 | 单个任务内的细分步骤 | 当前任务进度、每秒处理量、当前任务剩余时间 |
外层条的时间跨度通常是分钟级甚至小时级,内层条是秒级。两层是"部分与整体"的关系:内层条回答"现在这个任务做到哪一步了",外层条回答"所有任务整体走到哪了"。这样你在终端前扫一眼,就能同时回答两个问题,不需要去翻日志。
可能有人觉得"我把这些都写进desc里不就行了"。确实可以,但desc字符串会越来越长,刷屏严重,而且在宽终端上换行问题极难处理。把进度条分开画在固定行上,是更工程化的做法。
2. position参数:让两个进度条各占一行的核心机制
2.1 进度条为什么可以"原地刷新"
tqdm之所以能在一行上动态更新,靠的是回车符\r:它不是像print那样不断换行输出,而是让光标回到当前行行首,然后覆盖重写整行。问题就在这里——当同时存在多个tqdm实例时,如果它们都想用同一行做覆盖写,那就必然打架。
position参数就是给每个实例分配一个固定的行号。position=0表示从终端第一行开始画这条进度条,position=1表示画在第二行,以此类推。只要两条进度条各占各的位置,它们就各画各的,互不干扰。
需要强调一点:position不是"显示顺序",而是"行偏移量的硬编码"。如果你不指定,tqdm会维护一个活动实例列表,自动给新实例找个空闲行号,简单嵌套时看起来没问题;但一旦进度条被反复创建和销毁,比如内层循环每轮都新建一个bar,旧的刚释放行号,新的马上占用同一行,两个bar交替存在时就会闪烁、错乱。所以做双层进度条,我的建议永远是手动指定position,不要依赖自动分配。
2.2 最小可运行的嵌套写法
先看一段最核心的代码,两分钟就能跑通:
from tqdm import tqdm import time for i in tqdm(range(5), position=0, desc="外层任务"): for j in tqdm(range(10), position=1, desc=f"内层{i}", leave=False): time.sleep(0.02)运行效果是:外层进度条常驻在第一行,内层进度条在第二行动态刷新;内层任务完成后,第二行自动清空,接着开始下一轮。视觉上非常干净。
这里有两个参数值得单独讲:
leave=False:内层进度条完成后自动从终端抹掉。如果你改成leave=True,那么每完成一个内层任务,第二行就留下一条历史记录,跑完一层任务后终端里会堆满旧进度条,信息熵反而变大。desc=f"内层{i}":动态描述,让每条内层条都能告诉你它属于哪个外层任务。desc只能是字符串开头的东西,不要漏掉,否则跑的时候你根本分不清当前内层条对应哪个外层索引。
2.3 total配合update:两种推进方式
tqdm支持两种推进方式。第一种是直接迭代一个已知长度的对象:
for item in tqdm(range(100), position=1): do_something(item)第二种是不知道总长度、或者需要按事件触发时手动推进:
inner = tqdm(total=None, position=1, desc="处理中", leave=False) for item in generator: process(item) inner.update(1) inner.close()双层场景里,外层条我格外推荐"手动update"模式。原因很实际:外层通常对应"完成了多少个任务文件/多少个批次",这个数字往往来自任务的完成回调,而不是一个现成的range对象。你在内层跑完时调用一次outer.update(1),语义上就是"一个任务完成,外层走一格",代码读起来也清晰。
3. 可复制的完整方案:一个规整的双层进度条长什么样
3.1 场景设定:批量文件的逐条处理
为了让代码更接近真实生产环境,我设定一个具体场景:要处理8个数据文件,每个文件行数不等,需要逐行读取并模拟耗时操作。我们想看两层信息:第一层是"8个文件处理到第几个了",第二层是"当前文件内部处理到第几行、速率是多少"。
3.2 完整代码模板
from tqdm import tqdm import time import random total_files = 8 outer = tqdm(total=total_files, position=0, desc="批量文件处理", bar_format="{l_bar}{bar:20}{r_bar}", ncols=100) for f in range(total_files): records = random.randint(60, 150) inner = tqdm(total=records, position=1, desc=f"文件{f+1}/{total_files}", leave=False, bar_format="{desc}: {percentage:3.0f}%|{bar:20}|" "{n_fmt}/{total_fmt} [{elapsed}<{remaining}, {rate_fmt}]", ncols=100) for r in range(records): time.sleep(0.01) inner.update(1) if r % 10 == 0: # 每隔10条刷新一次右侧附加信息,避免刷新太频繁 speed = inner.n / max(inner.elapsed, 1e-6) inner.set_postfix(rate=f"{speed:.1f}条/s") inner.close() outer.update(1) outer.close()这段代码跑起来,终端第一行是总文件进度,第二行是当前文件的行级进度,同时在右侧显示每秒处理条数。文件处理完,第二行自动消失,外层前进一格,然后下一轮内层条重新出现在第二行。
3.3 关于bar_format和ncols的取舍
我实际调进度条的时候,混得最多的不是逻辑,而是样式。默认的tqdm输出其实够用,但双层条同时存在时,我更倾向于自己控制bar的宽度和样式:
bar_format里的{bar:20}表示进度条本体占20个字符宽度。两条bar都设成20,视觉上就能左右对齐,不会一条长一条短。ncols=100是设定总宽度。如果不设,终端窗口宽度变化时进度条会被拉得很长或者被迫换行,嵌套时尤其难看。{rate_fmt}是自动生成的速率显示,比如"100.00 it/s",不用自己算。{elapsed}<{remaining}显示已用时间和预计剩余时间,{percentage:3.0f}格式化百分比。
如果你不想让样式太花哨,就把bar_format里这一段当成默认模板使用,把bar:20改成你想要的宽度就行。我自己在项目里通常还会在bar_format里把单位带出来,比如{desc}: {percentage:3.0f}%|{bar:20}| {n_fmt}/{total_fmt},信息已经足够。
3.4 leave和desc的取舍
内层条必须leave=False,这个前面说过,是为了避免历史堆积。外层条我建议leave=True,因为整个任务跑完后,终端里留一行"8/8"的完成记录是合理的,方便事后确认。desc必须动态化,比如desc=f"文件{f+1}/{total_files}",这样就算某一个文件卡住,你也能立刻知道卡在哪个文件上。这是排查耗时任务时最有用的一行信息。
提示:不要把bar_format写得太长。双层条叠加时,终端宽度有限,格式串越长,剩余信息的空间就越小,最后百分比和时间会被挤到看不见。
4. 踩坑实录:四个最容易让进度条崩掉的地方
4.1 两个进度条挤在同一行互相覆盖
这个坑就是我开头说的场景。症状是内层条一出现,外层条就被顶掉,数字乱跳,屏幕像两个进程在抢同一块画布。
根因在于:内层bar每次外层迭代都会被重新创建,旧的还没完全释放行号,新的又创建出来,自动position分配到了同一行。解决办法是给外层、内层都显式指定position,外层0、内层1,并且在代码里用inner.close()或者with tqdm(...) as pbar:保证旧实例及时释放。
如果内层进度条是包在某个函数里面的,就更要注意:那个函数内部创建的bar必须能从外面传入position,否则你在不同作用域里创建的两个bar,自动分配时大概率撞车。我一般会写成一个可接收position参数的辅助函数,这样最稳妥。
4.2 内层循环太快,两条进度条疯狂闪烁
另一个高频问题是闪烁。内层循环如果跑几千几万次,而且每次都update(1),终端刷新频率会非常高,看起来就像打字机在飞快跳动,时间长了眼睛很累,而且大量\r写入也会带来一点性能损耗。
解决办法是调整刷新节流参数:
inner = tqdm(total=records, position=1, mininterval=0.2, miniters=50, leave=False, desc="处理中")mininterval=0.2:两次刷新之间至少间隔0.2秒,视觉上足够流畅。miniters=50:至少累积50次update才允许刷新一次。
这两个参数配合起来,可以让tqdm合并刷新,进度条反而更稳定。另外,smoothing=0.3可以平滑剩余时间的估计,避免"剩余时间"在1秒和10秒之间来回横跳。我实测下来,双层条里内外层都设上mininterval=0.2是最舒服的。
4.3 Jupyter Notebook里的双层条是另一个物种
如果你在Jupyter里直接跑终端的tqdm,会发现进度条变成了一坨奇怪的东西。正确做法是引入notebook版本:
from tqdm.notebook import tqdm # 或者 from tqdm.auto import tqdmtqdm.auto会自动根据环境选择终端版还是notebook版,我推荐直接用tqdm.auto,省心。
但notebook版的position机制和终端不太一样。终端是行覆盖,notebook是渲染成HTML组件,两条bar会各自渲染成独立的组件堆叠在一起。leave=False在notebook里也有效,但视觉上不如终端那么干净。我的经验是:在notebook里做双层条,不如只用一个外层条,把内层信息动态写进set_description里:
from tqdm.auto import tqdm pbar = tqdm(total=10, desc="训练开始", ncols=100) for epoch in range(10): for batch in range(100): do_train(batch) pbar.set_description(f"epoch {epoch+1}/10, loss: {loss:.4f}") pbar.update(1) pbar.close()Notebook里追求的是"少折腾、看得清"。
4.4 print日志把进度条撕碎
这是我在真实项目里最头疼的一个坑。脚本跑着进度条,代码里时不时来个print调试,或者日志模块往stdout里写内容,结果进度条上方出现一堆残留文本,有的还被\r吃掉了半截,整个终端像被揉过的纸。
tqdm实际上对print做过处理,它会在输出前把进度条暂时挪开,但你print的频率一高,或者日志和进度条输出到同一个缓冲区时,错乱是难免的。正确做法是:在进度条存在的范围内,一律用tqdm.write()代替print()。它会把消息写到进度条上方,然后重新渲染进度条,保证两者互不覆盖:
tqdm.write(f"文件 {f+1} 处理完成,共 {records} 条")结合日志模块时也一样:给logging的handler包一层tqdm.write,或者干脆让日志只写文件、终端只留进度条。我自己的项目里,终端日志全部走tqdm.write,日志文件走正常的logging,两边互不污染。
5. 进阶用法:动态描述、多线程和三层以上的进度条
5.1 set_description和set_postfix的实时更新
除了desc参数,tqdm还提供了两个方法,可以在运行中动态改信息:
set_description(desc):修改左侧描述文本。set_postfix(**kwargs):在右侧附加一组键值对,常用于显示loss、速度、计数等实时指标。
在双层条里,我一般这样分工:外层条的desc放"当前任务名",外层条右端用postfix放"已完成/总数、成功率";内层条的desc放"内部阶段",右端用postfix放"每秒处理量、当前行号"。还是那句话,不要每个迭代都更新一次postfix,否则性能和视觉都受罪,隔几个迭代更新一次就足够。
inner.set_description("正在解析") inner.set_postfix(speed=f"{speed:.1f}条/s", checkpoint=r)5.2 多线程任务合并成一根外层条
很多人会问:我用ThreadPoolExecutor并行跑一堆任务,能不能给每个线程一个进度条?理论上能给每个线程指定position,但多线程同时刷新多个tqdm实例时,终端写入会互相打断,进度条会乱掉。tqdm本身不是为并发写入多根条设计的,我不建议这样做。
更可靠的做法是:让每个worker执行完一个任务后,由主线程统一推进一根外层条。用as_completed就能优雅地实现:
from concurrent.futures import ThreadPoolExecutor, as_completed from tqdm import tqdm import time total_tasks = 30 pbar = tqdm(total=total_tasks, position=0, desc="任务总进度", ncols=100) def worker(task_id): time.sleep(0.1) return task_id with ThreadPoolExecutor(max_workers=4) as pool: futures = [pool.submit(worker, i) for i in range(total_tasks)] for _ in as_completed(futures): pbar.update(1) pbar.close()这样终端只有一根条,不会乱,而且确实能看到并行任务的总体进度。如果你的worker内部还有细分进度,可以让worker返回进度信息,主线程汇总后再决定要不要更新内层条。这个模式在下载器、采集器、数据校验脚本里都很实用。
5.3 三层乃至更多层:不是越多越好
position支持无限递增,理论上你可以做出三层、四层进度条:最外层是数据集,中层是文件,内层是行。但我实际写过一次三层条之后就后悔了——屏幕高度有限,三层条加上日志,终端几乎被占满,信息密度太高,人眼根本忙不过来。
如果循环嵌套超过三层,我建议做减法:只保留最外层和最关键的内层,中间层级塞进desc里,或者干脆把内层合并到一层的postfix里。进度条的价值是让人一眼看懂,而不是把终端变成仪表盘。两层的收益最大,三层以上是负收益。
最后说一点个人习惯:我在生产环境里的双层进度条,外层一定显示总任务名和总进度,内层一定显示当前项序号和实时速率,所有中间日志都走tqdm.write。这样即使脚本跑一整夜,第二天看终端也能一眼知道它现在停在哪、还剩多久。你不需要把每个循环都套上进度条,但任务复杂到需要两层时,position、leave、bar_format这几个参数值得花十分钟认真调一遍,这个时间投入绝对物有所值。