9.7GB AI 模型总下崩?我用 Python 写了个支持分段续传的多线程下载器(源码解析)

关键词:Python、多线程、HTTP Range、断点续传、wxPython、PyInstaller

本文基于一个真实开源小工具的源码,讲清楚"多线程下载器"到底该怎么写------不只是开几个线程那么简单,真正的难点在于:如何保证暂停后能续传、远程文件变了能察觉、传输中断时半成品永远不会伪装成成品。

一、背景:为什么需要它

本地部署 ComfyUI 玩 Z-Image 文生图,先要下载约 9.7 GB 的文件:

文件 大小 来源
ComfyUI Windows 便携版 约 1.99 GB GitHub Releases
Z-Image Turbo 主模型(Q4_K_M GGUF) 约 4.98 GB Hugging Face
Qwen3-4B 文本编码器 约 2.38 GB Hugging Face
VAE 解码器 约 335 MB Hugging Face

浏览器下载这些大文件时,痛点很明显:

  1. 单连接吃不满带宽,尤其是跨洋访问 GitHub / Hugging Face;
  2. 网络一抖前功尽弃,断点续传时常失效;
  3. 多个文件要逐个手动点,保存路径还各不相同(主程序放 D:\,模型要按 models/unet、models/vae 等目录分门别类)。

于是就有了这个工具:一个带图形界面、支持多任务并行 + 每文件多线程分段 + 可靠暂停续传的下载器,最终打包成双击即用的 exe。

整体代码结构非常克制,核心只有两个文件:

复制代码
download_core.py   # 下载引擎:纯标准库,不依赖任何 GUI
app.py             # wxPython 桌面界面

把"引擎"和"界面"彻底分开,是这个项目第一个值得称道的决定------核心逻辑可以脱离界面被单测直接调用。

"C:\Users\86182\Desktop\下载文生图zimage模型的工具\app.py"

二、整体架构:两级线程池

工具的并发模型是两级线程池,这是理解全部代码的骨架:

text 复制代码
GUI 主线程 (wxPython)
  │
  └─ 第一级:任务级线程池(默认 max_workers=2,即同时下载 2 个任务)
        ├─ 任务 A: DownloadTask.run()
        │     └─ 第二级:分段线程池(默认 4 线程)
        │           ├─ 线程1 → 0.part(字节段 [0, total/4-1])
        │           ├─ 线程2 → 1.part
        │           ├─ 线程3 → 2.part
        │           └─ 线程4 → 3.part
        └─ 任务 B: DownloadTask.run()
              └─ 第二级:分段线程池(默认 4 线程)

第一级池在 GUI 侧,控制"同时下载几个任务"(1~8 可调);第二级池在每个任务内部,控制"一个文件开几个连接分段下载"(1~16 可调)。

很多人看到 Python 多线程会立刻想到 GIL。但下载是典型的 IO 密集型任务 :线程绝大多数时间阻塞在 socket.recv() 上等待网络数据,此时 GIL 已被释放,其他线程可以照常运行。所以这里多线程提速是真实有效的,不需要上 asyncio 或多进程。

任务级线程池的创建有个细节------只在没有任务在跑时才按新的并发数重建:

python 复制代码
def ensure_pool(self):
    if self.pool is None or not self.active():
        if self.pool:
            self.pool.shutdown(wait=False)
        self.pool = ThreadPoolExecutor(
            max_workers=self.concurrent.GetValue(),
            thread_name_prefix='download')

三、核心之一:先探测,再决定怎么下

不是所有服务器都支持分段下载。HTTP 的规则是:客户端发 Range: bytes=起始-结束 请求头,支持的服务器返回 206 Partial Content 和 Content-Range 头;不支持的服务器无视 Range,直接返回 200 和完整文件。

探测代码(download_core.py):

python 复制代码
def _probe(self):
    # GET 而不是 HEAD:部分 CDN / 发行站会拒绝 HEAD 请求
    for attempt in range(3):
        try:
            with self._request({'Range': 'bytes=0-0'}) as response:
                self.validator = response.headers.get('ETag', '')
                self.validator_header = 'ETag'
                if self.validator.startswith('W/'):   # 弱 ETag 不可靠,丢弃
                    self.validator = ''
                if not self.validator:
                    self.validator = response.headers.get('Last-Modified', '')
                    self.validator_header = 'Last-Modified'
                value = response.headers.get('Content-Range', '')
                match = re.fullmatch(r'bytes 0-0/(\d+)', value)
                if response.status == 206 and match:
                    self.total = int(match.group(1))  # 真实总大小在分母里
                    return True
                if response.status != 200:
                    raise ValueError('服务器返回了无效的 Range 响应。')
                self.total = int(response.headers.get('Content-Length', '0'))
                return False
        except Paused:
            raise
        except Exception:
            if attempt == 2:
                raise
            if self.stop.wait(attempt + 1):
                raise Paused()

这里有三个实战经验点:

  1. 用 GET 而非 HEAD 探测 。很多对象存储和 CDN 对 HEAD 的处理与 GET 不一致,HEAD 说不支持,GET 却支持。只请求第一个字节(bytes=0-0),拿到响应头后立即关闭连接,成本极低。
  2. 文件总大小优先从 Content-Range 的分母取 (bytes 0-0/4981532736),这是服务器对"整个资源有多大"的明确回答。
  3. 探测本身也带 3 次重试,重试间隔 1s、2s 递增,且暂停信号能立刻打断等待。

四、核心之二:没有"身份证",就绝不分段

这是整个项目里最有工程思维的一行:

python 复制代码
ranged = self._probe() and bool(self.validator)

仅仅支持 Range 还不够,服务器必须同时提供可靠的文件标识------强 ETag 或 Last-Modified------才启用多线程分段。

为什么这么保守?设想一个场景:

你用 4 个线程下载一个 5 GB 的模型,下到 50% 时,服务端把这个文件替换成了新版本(URL 完全没变)。此时第 1、2 段是旧文件的字节,第 3、4 段是新文件的字节。最后拼接出来的是一个结构完好、长度正确、内容却是"缝合怪"的 GGUF 文件------权重加载不报错,但生成的图全是噪声。这类损坏最难排查。

防御手段是 HTTP 的 If-Range 机制:分段请求时带上文件标识,告诉服务器"只有当文件还是这一版时,才给我发分片;否则请重新发完整文件"。

python 复制代码
headers = {}
if ranged:
    headers['Range'] = f'bytes={start + offset}-{end}'
    if self.validator:
        headers['If-Range'] = self.validator

响应回来后还要做二次核对(不信任是安全的底色):

python 复制代码
if ranged:
    correct = f'bytes {start + offset}-{end}/{self.total}'
    if response.status != 206 or response.headers.get('Content-Range') != correct:
        raise ValueError('服务器未按分段请求返回数据,或远程文件已变化。')
elif response.status != 200:
    raise ValueError('服务器未返回完整文件。')
if self.validator and response.headers.get(self.validator_header) != self.validator:
    raise ValueError('远程文件标识已变化,请重试以重新探测文件。')
  • 状态码必须是 206;
  • Content-Range 必须与请求的区间逐字节吻合(防止服务器"答非所问");
  • ETag / Last-Modified 必须与探测时一致。

任何一条不满足,直接报错重来,绝不带着可疑数据继续拼接。

而对那些不支持 Range、或给不出可靠标识的服务器,工具会自动降级为单线程整文件下载(界面状态明确显示"下载中(单线程)"),暂停后只能从头重下。宁可慢,不可错。

五、核心之三:分段、续传与身份失效

5.1 字节区间切分

python 复制代码
bounds = [
    (i,
     self.total * i // count,
     self.total * (i + 1) // count - 1)
    for i in range(count)
]

用整除而不是简单的 total // count * i,是为了让余数均匀分布,并且最后一段的结束位置精确落在 total - 1:区间首尾相接、不重叠、不遗漏。

5.2 每个分段就是一个独立的可续传文件

所有临时数据放在目标文件旁边的 <文件名>.download/ 目录里:

text 复制代码
D:\ComfyUI\models\unet\
├─ z_image_turbo-Q4_K_M.gguf.download\
│    ├─ metadata.json     # 这次下载的"身份档案"
│    ├─ 0.part
│    ├─ 1.part
│    ├─ 2.part
│    └─ 3.part
└─ (完成后出现)z_image_turbo-Q4_K_M.gguf

分段下载函数启动时,先看本地 .part 已经有多少字节,从断点处接着下:

python 复制代码
part = self.work / f'{index}.part'
expected = end - start + 1 if self.total else None
...
offset = part.stat().st_size if part.exists() and ranged else 0
...
if ranged:
    headers['Range'] = f'bytes={start + offset}-{end}'
...
with part.open('ab' if ranged else 'wb') as stream:   # 分段追加 / 整文件重写
    received = offset
    while True:
        chunk = response.read(128 * 1024)
        if not chunk:
            break
        received += len(chunk)
        if expected is not None and received > expected:
            raise ValueError('服务器数据长度超出预期。')
        stream.write(chunk)
        self._progress(index, received)
    if expected is not None and received != expected:
        raise IOError('连接提前结束,数据未下载完整。')

注意单线程模式打开文件用的是 'wb' 而不是 'ab':没有可靠身份标识时,旧字节可能属于一个已被替换的文件,追加就是在延续错误,所以从头写入。

5.3 metadata.json:跨重启续传的"身份档案"

暂停后关程序、第二天再开,凭什么相信磁盘上那堆 .part 还能用?靠的是 metadata.json:

python 复制代码
identity = dict(url=self.url, size=self.total,
                validator=self.validator, header=self.validator_header,
                parts=count, ranged=ranged)
...
try:
    previous = json.loads(metadata.read_text(encoding='utf-8'))
except (OSError, ValueError):
    previous = None

if previous != identity or not self.validator or not ranged:
    for file in self.work.glob('*.part'):
        file.unlink()
metadata.write_text(json.dumps(identity), encoding='utf-8')

身份五元组里任何一个对不上------URL 变了、远程文件大小变了、ETag 变了、分段数量变了------旧分段全部作废,干净利落地重新开始。

这也解释了使用说明里那条看似奇怪的规定:"重启后续传需使用相同目标路径和线程数"。不是技术做不到兼容,而是分段边界一旦改变,旧 part 的字节区间与新边界对不上,与其做复杂的迁移,不如明确地重下------把规则写在明面上,反而更不容易出错。

六、核心之四:协作式暂停,而不是强杀线程

下载这类任务最忌讳 thread.kill() 式的粗暴终止:线程可能死在写文件的中途,留下长度未知的半成品。

工具用一个 threading.Event 实现协作式暂停,并在所有可能阻塞的节点插入检查点:

python 复制代码
class Paused(Exception):
    pass

def _check(self):
    if self.stop.is_set():
        raise Paused()

检查点遍布:发请求前、每读 128 KB 数据块前、重试退避等待中、合并文件每读 1 MB 时。其中重试等待的写法值得一看:

python 复制代码
if self.stop.wait(attempt + 1):   # 最多等 1/2 秒,但暂停信号一到立刻醒来
    raise Paused()

Event.wait(timeout) 同时承担了"退避计时"和"暂停唤醒"两件事,暂停操作的响应延迟被压到最低。

收到暂停后,控制流跳到 run() 中的统一出口:

python 复制代码
except Paused:
    with self.lock:
        self.status = '已暂停'

线程自己走到安全位置、抛出内部信号、留下状态完整的 .part 文件后平静退出。下次点"继续",就是重新跑一遍 run():探测 → 读 metadata → 读各 part 的 offset → 从断点接着下。

七、核心之五:原子收尾,半成品永不"转正"

多线程下载最危险的时刻其实是最后一步:合并。工具的收尾流程是:

text 复制代码
4 个 part 全部下完且各自长度校验通过
        │
        ▼
按编号顺序合并 → merged.tmp
        │
        ▼
merged.tmp 总长度 == 服务器声明的总长度?
        │ 否 → 抛错,状态"失败",临时目录保留
        ▼ 是
目标文件此刻是否已存在?(下载期间可能被用户手动放入)
        │ 是 → 抛错,绝不覆盖
        ▼ 否
rename: merged.tmp → 目标文件(同盘原子操作)
        │
        ▼
状态置"已完成",删除 .download 目录

对应代码:

python 复制代码
merged = self.work / 'merged.tmp'
with merged.open('wb') as output:
    for i in range(count):
        with (self.work / f'{i}.part').open('rb') as source:
            while chunk := source.read(1024 * 1024):
                self._check()
                output.write(chunk)
if self.total and merged.stat().st_size != self.total:
    raise IOError('合并后的文件长度不正确。')
self._check()
if self.target.exists():
    raise FileExistsError('目标文件在下载期间被创建,未覆盖。')
merged.rename(self.target)

同分区上的 rename 由操作系统保证原子性------目标文件要么不存在,要么是完整的,不存在任何中间状态。配合下载开头的"目标已存在即跳过"逻辑,工具对用户数据的承诺是:已有的文件一个字节都不会动。

代价也要诚实告知:合并阶段需要额外一个完整文件大小的临时空间,所以下载大文件前建议预留约两倍文件大小的磁盘空间。README 里明确写了这一点。

另外,文档没有提供 SHA256,工具就不伪称做过哈希校验,只校验文件长度;也不提供自动解压、自动安装到 ComfyUI 的功能。能力边界写得清清楚楚,这本身就是可靠软件的一部分。

八、GUI 与工作线程如何安全协作

wxPython(以及几乎所有 GUI 框架)要求控件只能在主线程访问。工具的处理方式是经典的"共享状态 + 定时轮询":

  1. 任务侧用一把 RLock 保护所有可变字段;
  2. 对外只暴露 snapshot() 一个读接口,返回普通 dict 快照:
python 复制代码
def snapshot(self):
    with self.lock:
        return dict(status=self.status, error=self.error,
                    total=self.total, downloaded=self.downloaded,
                    speed=self.speed)
  1. GUI 用 wx.Timer 每 350 ms 拉取快照、刷新表格:
python 复制代码
self.timer = wx.Timer(self)
self.Bind(wx.EVT_TIMER, self.refresh, self.timer)
self.timer.Start(350)

工作线程全程不碰任何 wx 对象,因此不存在跨线程操作控件的隐患。

排队任务的暂停 也有专门处理:任务在线程池队列里还没开始执行时,暂停它必须做到"一个网络请求都不发"。做法是在入队前于 UI 线程清掉 Event 并置状态,暂停时对未开始的 Future 调用 cancel():

python 复制代码
task.stop.clear()                 # UI 线程中先清信号再入队
with task.lock:
    task.status = '排队中'
self.futures[index] = self.pool.submit(task.run)
...
def pause_task(self, index):
    task = self.tasks[index]
    future = self.futures.get(index)
    if future and not future.done():
        task.pause()
        if future.cancel():       # 还在队列里 → 直接取消,状态立刻变"已暂停"
            with task.lock:
                task.status = '已暂停'

关闭窗口时也不是一杀了之:先暂停全部任务,把关闭事件 veto 掉,界面提示"等待当前网络读取结束",靠 350 ms 的刷新轮询发现所有 Future 结束后才真正销毁窗口------保证退出时磁盘上的续传数据一定是自洽的。

九、不连外网也能测:内置 Mock HTTP 服务器

下载器这种软件,如果测试依赖真实网站,那既慢又不稳定。项目用标准库 ThreadingHTTPServer 在本地搭了一个"故障注入服务器",用不同 URL 路径模拟真实世界的各种幺蛾子:

python 复制代码
class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == '/missing':
            self.send_error(404)                    # 404
        ...
        use_range = value and self.path != '/plain' # /plain 不支持 Range
        if self.path != '/noidentity':
            self.send_header('ETag', '"test-v1"')  # /noidentity 不给标识
        ...
        if self.path == '/truncated' and value != 'bytes=0-0':
            self.wfile.write(DATA[start:start + 1024])  # /truncated 传一半断连
        if self.path == '/slow':
            time.sleep(0.01)                        # /slow 慢速,给暂停测试留窗口

12 个测试用例覆盖了全部关键路径,其中几个特别能体现设计意图:

  • test_pause_and_resume:慢速下载中暂停 → 工作线程必须真的退出、状态"已暂停"、目标文件不存在 → 继续后文件内容与源数据逐字节一致;
  • test_truncated_data_never_becomes_final_file:传输截断时状态必须是"失败",目标文件必须不存在,临时目录保留;
  • test_restart_resumes_existing_segments:模拟重启程序,断言新请求的起始字节正好等于旧 .part 的长度------续传是真的,不是从头重下;
  • test_existing_target_not_overwritten:目标里先写入 b'keep me',下载后内容必须原封不动;
  • test_cleanup_failure_does_not_hide_success:即使临时目录删除失败(模拟文件被占用),文件照样算成功,错误只作为附注提示。

测试与设计形成了闭环:每一条安全承诺,都有一个用例在盯着。

十、打包:让用户完全不碰 Python

最终用户可能不知道 Python 是什么。用 PyInstaller 打成单文件、无控制台窗口的 exe:

powershell 复制代码
python -m PyInstaller --noconfirm --clean --onefile --windowed `
    --name ZImageDownloader --add-data 'downloads.txt:.' app.py

几个细节:

  • --windowed 去掉黑色控制台窗口;--onefile 产出单个 exe;
  • --add-data 'downloads.txt:.' 把修正过的下载清单打进包内,程序启动时按 downloads.txt → miaoshu.txt → 内置清单 的顺序寻找配置;
  • 通过 sys.frozen 判断自己是否运行在打包环境中,从而正确定位 exe 旁边的清单文件------配置文件放包外,用户不用重新下载 exe 也能更新任务清单。

十一、写在最后:一个"小"下载器里的工程原则

回头看,这个工具没有用任何高深技术,urllib、threading、ThreadPoolExecutor 都是 Python 自带的东西。它的价值在于把朴素的原则坚持到底:

  1. 先探测能力,再选择策略,对不支持的场景优雅降级;
  2. 身份先于数据------没有 ETag / Last-Modified,再快的分段也不开;
  3. 半成品永远不能伪装成成品:长度校验 + 原子 rename + 绝不覆盖;
  4. 协作式取消,让线程在安全点自己停下,而不是被杀死在半路;
  5. GUI 与工作线程严格隔离,只通过锁保护的快照通信;
  6. 诚实地声明能力边界:不做哈希校验就明说,不支持解压就明说;
  7. 用可注入故障的 mock 服务器测试,让每一条承诺都有自动化用例兜底。

这些原则并不局限于下载器。任何需要与不可靠网络、并发和用户数据打交道的程序,本质上都在回答同一组问题:如何识别变化?如何隔离失败?如何保证系统在任意时刻被打断后,状态依然自洽?

想清楚这三个问题,比"用什么框架"重要得多。


源码运行:python app.py(需 wxPython);测试:python -m unittest -v;完整使用说明见项目 README。

相关推荐
深蓝AI1 小时前
不生成文本的AI日吞一万亿Token:决策模型Jev撕开大模型的替代路线
人工智能·ai编程
小宋10211 小时前
一套服务托管多个 LoRA:适配器加载、租户隔离与热切换实战
大数据·人工智能·算法
思考着亮1 小时前
14.Agentic RAG -3
人工智能
黑妹天下第一乖1 小时前
第 04 讲:阿加犀 AIMO 模型优化平台与 Model Farm 模型广场实战
人工智能·嵌入式硬件·矩阵·架构·iot
郝学胜_神的一滴1 小时前
AI 编程智能体 06:用Anaconda搞定Python多环境,彻底告别版本兼容灾难
人工智能·python
橘和柠1 小时前
显存计算与模型选择:你的显卡能跑多大的模型
人工智能
黑妹天下第一乖1 小时前
第08讲 · 视觉与相机流水:Spectra ISP 与实时检测
人工智能·嵌入式硬件·数码相机·机器人·接口隔离原则·iot
alonglong1 小时前
8,513 个向量、0.21 毫秒:给本地知识库搭一套语义检索,不引向量数据库
人工智能
数聚天成DeepSData1 小时前
OPUS 不是一份统一许可证的语料:子语料许可如何逐项管理?
人工智能·深度学习·机器学习·数据集·deepsdata