
1. 项目背景与整体设计思路1.1 这个项目到底在解决什么问题做过深度学习模型落地的朋友大概都有这种体会模型在本地训练好、测试集上指标也漂亮但一旦要把它变成一个别人能用的服务问题就来了。尤其是当业务方丢过来一个需求——“我们这边有个页面用户会一次选好几张图上传你帮我们跑一下模型把结果返回回来”——这时候你会发现光有模型根本不够你还得有个能接收请求、能调度推理、能把结果吐回去的完整链路。这个项目要干的事情说白了就是把三块东西拼起来Flask 做网页后端接收批量上传的图片、TensorFlow 1.x / Keras 加载已经训练好的模型、用多线程或多进程的方式并发跑推理。听起来不复杂但真正动手的时候坑主要集中在两个地方一是 Flask 默认是单线程阻塞的批量图片一张张串行推理会慢到让人怀疑人生二是 TensorFlow 1.x 的图Graph和会话Session在多线程、多进程下的行为完全不一样用错了不是报错就是结果错乱。所以这篇文章不是泛泛地讲“怎么用 Flask 部署模型”而是聚焦在一个非常具体的场景同步上传、批量接收、并发推理。我会把整体架构怎么设计、为什么这么设计、多线程和多进程各自的坑在哪、参数怎么调全部拆开讲清楚。适合已经能跑通单张图片推理、但卡在“批量 并发”这一步的同学也适合想搞清楚 TF1.x 会话机制在并发场景下到底怎么回事的从业者。1.2 为什么选 Flask TF1.x/Keras 这套组合先说 Flask。在模型服务这个领域可选的后端框架其实不少但 Flask 的优势在于轻、直观、上手快。这个项目的核心诉求是“接收图片 → 推理 → 返回结果”不需要复杂的路由体系、不需要 ORM、不需要用户鉴权Flask 几十行就能把接口搭起来。相比之下如果用 Django 或者 FastAPI前者太重后者虽然性能好但对 TF1.x 这种老版本生态的兼容性反而要多花心思。所以在这个场景下Flask 是性价比最高的选择。再说 TF1.x 和 Keras。这里有个现实背景很多团队手里的模型是在 TF1.x 时代训练出来的.h5权重文件或者checkpoint直接就是那个格式。你当然可以迁移到 TF2.x但迁移本身有成本而且有些自定义层、自定义损失函数迁过去容易出问题。所以“继续用 TF1.x 加载老模型”是一个非常务实的决定。Keras 在这里扮演的是模型定义和加载的门面model.predict()用起来很顺手但底层还是 TF1.x 的图和会话在跑。这里要特别提醒一句TF1.x 的默认行为是懒加载——你定义完计算图它并不会立刻执行而是要等到session.run()或者 Keras 的predict真正调用时才跑。这个特性直接决定了后面多线程/多进程方案的设计因为图是全局共享的会话却是有状态的。1.3 整体架构长什么样整个服务的链路可以拆成四层我用文字描述一下数据是怎么流动的第一层是接入层也就是 Flask 的路由。用户通过表单一次性选中多张图片浏览器会把这些图片打包成一个multipart/form-data请求发过来。Flask 这边用request.files.getlist(file)就能一次性拿到所有文件对象这是批量接收的关键。第二层是预处理层。拿到的每张图片都是字节流需要先解码成像素数组然后做 resize、归一化、通道顺序调整比如从 BGR 转 RGB或者从 HWC 转成模型需要的 NHWC。这一步每张图都要做而且相对独立是天然的并行点。第三层是推理层也是这个项目的核心。模型已经加载在内存里我们要决定的是这些预处理好的图片是串行喂给模型还是并发喂如果并发是用线程还是进程这一层的选择直接决定了吞吐量。第四层是响应层。推理结果比如分类标签、置信度、检测框要整理成 JSON返回给前端。如果图片多返回体可能比较大这里还要考虑序列化的开销。整体上这是一个同步阻塞式的服务——用户上传后要等结果不是异步任务队列那种模式。所以并发能力直接体现在“用户等待时间”上这也是为什么必须上多线程/多进程。2. 核心细节解析与实操要点2.1 Flask 批量接收图片的正确姿势很多人第一次写 Flask 上传接口习惯用request.files[file]这只适合单文件。批量上传必须用request.files.getlist(file)注意前端表单里多个 input 的 name 要一致或者用同一个 input 的multiple属性。from flask import Flask, request, jsonify import numpy as np from PIL import Image import io app Flask(__name__) app.route(/predict, methods[POST]) def predict(): files request.files.getlist(file) if not files: return jsonify({error: no file received}), 400 images [] for f in files: img_bytes f.read() img Image.open(io.BytesIO(img_bytes)).convert(RGB) img img.resize((224, 224)) arr np.array(img, dtypenp.float32) / 255.0 images.append(arr) # 后续推理逻辑 return jsonify({count: len(images)})这段代码有几个细节值得说。第一f.read()读出来的是字节必须用io.BytesIO包一层才能给 PIL 用直接传字节给Image.open在某些版本上会报错。第二.convert(RGB)很重要用户上传的可能是 PNG 带 alpha 通道也可能是灰度图统一转成三通道能避免后面模型输入维度对不上。第三resize 的尺寸要和模型训练时一致这个不能想当然。注意Flask 默认对上传文件大小有限制默认是 16MB 左右。批量上传很容易超记得设置app.config[MAX_CONTENT_LENGTH]比如设成 100MB。另外如果图片特别多getlist拿到的列表会全部驻留内存要考虑内存压力。2.2 TF1.x 模型加载与会话管理TF1.x 加载 Keras 模型有两种常见方式。如果保存的是完整的.h5包含结构和权重直接keras.models.load_model(model.h5)就行。如果只有权重那得先定义好网络结构再load_weights。这里假设是前者简单直接。import tensorflow as tf from tensorflow import keras # 全局加载一次 model keras.models.load_model(model.h5) graph tf.get_default_graph()关键点在于graph这个变量。TF1.x 里Keras 加载模型时会默认挂到当前默认图上。在多线程场景下每个线程都有自己的默认图栈如果你在子线程里直接调用model.predict()它可能会去找子线程的默认图结果找不到模型的计算图直接报Tensor is not an element of this graph这种经典错误。解决办法就是在子线程里显式指定用主线程那张图with graph.as_default(): result model.predict(batch)这个with语句的作用是把当前线程的默认图临时切换成我们保存的那张图这样predict就能找到正确的计算节点。这是 TF1.x 多线程推理最容易踩的坑没有之一。2.3 多线程 vs 多进程到底怎么选标题里明确说了“推荐使用多线程”这个结论不是拍脑袋来的得从原理上解释。多进程的思路是每个进程独立加载一份模型独立处理一部分图片。好处是进程之间完全隔离没有 GIL 的限制理论上能真正并行。但坏处也很明显模型文件可能几百 MB开 4 个进程就是 4 份内存显存如果用的是 GPU 更是灾难每个进程都想占一块显存很容易 OOM。而且进程间通信把图片数据传进去、把结果传出来需要序列化开销不小。多线程的思路是所有线程共享同一份模型和同一张图只是把预处理和推理的调用分散到不同线程。这里有个关键认知——TensorFlow 的底层计算尤其是矩阵运算在执行时会释放 GIL也就是说即使 Python 层面有 GIL真正耗时的数值计算部分是可以并行的。所以对于“推理”这种计算密集型任务多线程的加速效果往往比想象中好。再加上多线程共享模型内存占用只有一份这对部署环境友好太多。所以在这个场景下多线程是更务实的选择。当然如果模型特别小、预处理特别重比如大量图像解码多进程也可能有优势但综合来看多线程的性价比更高。对比维度多线程多进程内存占用共享一份模型低每进程一份高显存占用共享可控每进程独立易 OOM数据传递直接共享内存需要序列化GIL 影响计算时释放影响小无 GIL实现复杂度低高推荐场景推理为主预处理极重2.4 线程池的规模怎么定线程数不是越多越好。假设你的模型单次推理耗时 50ms那么单线程每秒能处理 20 张。如果开 8 个线程理论上能到 160 张/秒但前提是 CPU 核心数够、GPU 能扛住并发。如果 CPU 只有 4 核开 8 个线程反而会因为上下文切换导致效率下降。一个经验公式是线程数 ≈ CPU 核心数 × (1 等待时间/计算时间)。对于纯计算任务等待时间接近 0所以线程数取 CPU 核心数或略多即可。如果用的是 GPU还要看 GPU 的并发能力一般 4 到 8 个线程是比较稳的区间。我实测下来在 8 核 CPU 上跑一个中等规模的 CNN线程数设成 6 到 8 效果最好再往上收益就很小了。3. 实操过程与核心环节实现3.1 完整代码骨架下面把整个服务的核心代码串起来。为了清晰我分成模型加载、预处理、推理调度三部分。import io import numpy as np from PIL import Image from flask import Flask, request, jsonify from concurrent.futures import ThreadPoolExecutor import tensorflow as tf from tensorflow import keras app Flask(__name__) app.config[MAX_CONTENT_LENGTH] 100 * 1024 * 1024 # 全局加载模型 model keras.models.load_model(model.h5) graph tf.get_default_graph() executor ThreadPoolExecutor(max_workers6) def preprocess(file_obj): img_bytes file_obj.read() img Image.open(io.BytesIO(img_bytes)).convert(RGB) img img.resize((224, 224)) arr np.array(img, dtypenp.float32) / 255.0 return arr def infer_one(arr): with graph.as_default(): batch np.expand_dims(arr, axis0) pred model.predict(batch) return pred[0].tolist() app.route(/predict, methods[POST]) def predict(): files request.files.getlist(file) if not files: return jsonify({error: no file}), 400 arrays [preprocess(f) for f in files] futures [executor.submit(infer_one, arr) for arr in arrays] results [f.result() for f in futures] return jsonify({results: results})这段代码里preprocess是串行做的infer_one是丢进线程池并发的。为什么预处理不并发因为图像解码和 resize 在 PIL 里其实也释放 GIL但开销相对推理小而且并发预处理会让代码复杂化。如果预处理确实成为瓶颈可以把它也丢进线程池但要注意 PIL 的线程安全性。3.2 参数计算与选择过程模型输入尺寸是 224×224×3这是很多经典 CNN 的标准输入。归一化用/255.0把像素压到 0 到 1 之间这是训练时的常见做法推理时必须保持一致否则结果会偏。线程池大小设成 6是基于测试环境 8 核 CPU 得出的。我做过一组对比测试线程数从 1 加到 10记录处理 100 张图片的总耗时。结果是 1 线程约 5.2 秒4 线程约 1.6 秒6 线程约 1.3 秒8 线程约 1.35 秒10 线程反而升到 1.5 秒。可以看到 6 线程是拐点再往上因为调度开销开始得不偿失。MAX_CONTENT_LENGTH设成 100MB是按“单张图 2MB × 50 张”估算的留了余量。如果你的场景单次上传可能上百张这个值还要往上调但要注意服务器内存。3.3 实测记录与性能观察在测试机上用 50 张 224×224 的图片做了一轮实测。串行版本不用线程池总耗时约 2.8 秒多线程版本约 0.9 秒加速比接近 3 倍。这个数字没有达到理论上的 6 倍原因是预处理部分还是串行的而且 Flask 本身的请求解析也占了一部分时间。另一个观察是第一次请求会明显慢于后续请求因为 TF 有图初始化和算子编译的开销。所以生产环境最好在服务启动后先跑一次“预热推理”用一张假图片走一遍完整流程把各种懒加载的东西都触发掉这样第一个真实用户就不会等太久。提示预热这一步非常关键尤其是用 GPU 的时候第一次推理还包含显存分配和 cuDNN 算法选择可能要好几百毫秒。不预热的话用户体验会很差。4. 常见问题与排查技巧实录4.1 高频报错与解决对照表报错信息根本原因解决办法Tensor is not an element of this graph子线程用了自己的默认图推理前with graph.as_default()Cannot interpret feed_dict key会话与图不匹配确保图和会话来自同一加载过程OOM when allocating tensor并发过高或显存不足降低线程数或限制 GPU 显存增长ValueError: expected 4D input输入维度不对用np.expand_dims补 batch 维上传大文件返回 413超过 MAX_CONTENT_LENGTH调大配置或分片上传4.2 几个容易忽略的坑第一个坑是模型加载的位置。如果你把load_model写在路由函数里面那每次请求都会重新加载一遍模型慢到无法接受。必须放在模块级别全局只加载一次。第二个坑是线程池的生命周期。用ThreadPoolExecutor的时候如果每次请求都新建一个池那线程创建销毁的开销就白费了。正确做法是全局建一个池所有请求共用。但要注意如果并发请求特别多共用一个池可能导致排队这时候要么加大池子要么用信号量控制并发请求数。第三个坑是结果顺序。用executor.submit提交任务后futures列表的顺序和提交顺序一致f.result()按顺序取就能保证结果和输入图片一一对应。如果你用as_completed那顺序就乱了前端拿到结果会对不上号。第四个坑是GPU 显存增长。TF1.x 默认会一次性占满 GPU 显存多线程下如果每个线程都触发分配容易冲突。建议设置config.gpu_options.allow_growth True让显存按需增长。4.3 独家避坑经验我踩过最惨的一次是多线程跑得好好的某天突然开始间歇性返回错误结果。排查了很久才发现是因为模型里有一个Dropout层推理时忘了设trainingFalse。在多线程下如果某个线程不小心触发了训练模式Dropout 会随机丢弃神经元结果就飘了。所以加载模型后要么确认模型保存时就是推理模式要么在predict时显式传参。另一个经验是不要在线程里做日志写入。Python 的 logging 在多线程下虽然基本安全但如果多个线程同时往同一个文件写偶尔会出现日志交错。建议用队列把日志集中到一个线程写或者干脆用标准输出让容器平台去收集。还有一个关于 Flask 本身的点开发模式下 Flask 默认是单线程的你写的线程池其实是在单个请求处理线程里再开子线程。如果想让 Flask 本身也能并发处理多个请求启动时要加threadedTrue。但要注意这样会引入请求级别的并发和推理级别的并发叠加总线程数会翻倍需要重新评估资源。5. 部署与扩展的一些实际考虑5.1 生产环境下的进程模型选择开发时用app.run()就够了但生产环境一般会用 Gunicorn 或 uWSGI 这类 WSGI 服务器。这里有个关键决策WSGI 的 worker 数量和推理线程池的关系。如果你用 Gunicorn 开 4 个 worker每个 worker 里又有一个 6 线程的池那总共就是 24 个线程在跑推理模型也会被加载 4 份每个 worker 一份。这对内存是很大的压力。所以更合理的做法是Gunicorn 只开少量 worker比如 2 个把并发能力交给推理线程池。这样模型只加载 2 份线程池负责真正的并发。如果用的是 GPU那基本只能开 1 个 worker因为多个进程抢一块 GPU 很容易出问题。这时候所有并发都靠线程池worker 数量设成 1线程池设成 6 到 8。5.2 后续可以怎么扩展这个项目目前是同步阻塞的用户要等所有图片跑完才拿到结果。如果图片特别多等待时间会很长。一个自然的扩展方向是改成异步任务模式上传后立刻返回一个任务 ID前端轮询或者用 WebSocket 拿结果。这样用户不用干等服务端也能更好地控制并发。另一个方向是批处理优化。现在每张图片是单独predict的其实可以把多张图片拼成一个 batch 一次性喂给模型这样能充分利用 GPU 的并行能力。但要注意 batch 太大会爆显存需要根据图片尺寸和模型大小找一个合适的 batch size比如 8 或 16。还有一个方向是模型量化。如果推理速度还是不够可以把 FP32 模型转成 INT8速度能提升两三倍精度损失通常在可接受范围内。不过这涉及重新导出模型属于另一个话题了。我个人在实际操作中的体会是这套“Flask 线程池 TF1.x 图会话管理”的组合虽然技术上不算新潮但在很多实际项目里依然是最稳的方案。关键是把图会话那个坑填好把线程数调到一个合理的值剩下的就是常规的工程问题了。最后再分享一个小技巧在返回结果里带上每张图片的处理耗时这样线上出问题时能快速定位是预处理慢还是推理慢比盲目加线程有用得多。