☰
YOLOv5+Flask实战:从模型导出到Web推理服务部署全指南
2026/10/7 9:15:47 网站建设 项目流程

简介:压缩包内提供 Yolov5 目标检测模型与 Flask 框架结合的 Web 部署工程代码,面向需要将深度学习模型快速包装成在线服务的开发者,也可用于课程设计或毕设原型展示。项目共 15 个文件,核心为 Python 脚本(app.py、restapi.py、test_request.py 等),分别承担 Flask 应用、API 路由、请求测试等职责;搭配前端页面(index.html、style.css)实现图片上传与展示,requirements.txt 与 Dockerfile 帮助一键复现环境,两份 Markdown 文档说明使用流程,配图便于预览效果,整体压缩后仅 187KB,结构精炼,适合快速阅读与二次开发。该资源已被 713 人学习下载,验证了其在轻量级目标检测 Web 化方向上的实际参考价值。从代码中可以完整理解 Yolov5 模型加载、图片预处理、推理输出解析以及 JSON 结果返回的关键流程;围绕 /detect 接口形成可运行的最小闭环,借助测试脚本可直接发起请求验证服务,规避常见的环境依赖和接口联调问题,是快速搭建实时目标检测 Web 应用的高效起点。

1. YOLOv5 Web部署到底在做什么:从本地模型到可用的HTTP服务

把训练好的YOLOv5权重文件挂到Flask上,让浏览器能上传一张图、回传一个带标注框的检测结果,这件事听起来像“加个接口就行”,但真做起来会撞上模型加载、推理效率、视频流、并发冲突这些连环坑。很多人第一次做Web部署时,把模型直接塞进视图函数里,结果一个请求把GPU显存占满,第二个请求直接卡死。这篇文章我会用一个完整的Flask项目讲清楚:怎么把YOLOv5封装成可被HTTP调用的推理服务,怎么设计路由和数据格式,怎么处理视频流与并发,以及那些让新手翻车的玄学问题到底出在哪里。

这套方案适合两类人:一类是刚训练完YOLOv5自定义数据集、想让同事或客户通过网页快速体验效果的算法工程师;另一类是后端开发者,需要把别人训练好的权重落成一个能接入内部系统的服务接口。无论你是哪种,这篇文章给的代码和参数调法,都能直接复制到你的项目里跑通。

2. 导出与封装:把YOLOv5权重转成可部署的服务核心

2.1 模型导出:pt转TorchScript或ONNX,权衡的是灵活性与速度

训练好的YOLOv5通常是一个.pt文件,里面除了权重还存了模型结构、优化器状态、训练超参数等冗余信息。直接拿它做Web推理当然能跑,但每次加载都要重新解析整个模型结构,首次请求的延迟可能高达十几秒。常见做法是先导出成更适合推理的格式。我给不同项目用得最多的是两种:TorchScript和ONNX。

TorchScript是PyTorch官方推荐的部署格式,和PyTorch版本绑定紧,加载后就是一个已编译的计算图,推理速度比原版.pt快不少,而且不依赖原始模型定义代码。ONNX则更通用一些,如果后续要切换到TensorRT、ONNX Runtime或者CPU推理,它是更好的中间格式。我的建议是:如果Web服务就是Flask + PyTorch这个大环境,直接用TorchScript就够;如果考虑将来换推理引擎,先导出ONNX。

YOLOv5官方仓库的export.py可以直接做这件事。跑通一个最小导出命令很快:

python export.py --weights ./runs/train/exp/weights/best.pt --include torchscript --img-size 640

参数说明:--include指定导出格式,这里只导出TorchScript;--img-size要和训练时一致,YOLOv5默认训练是640;导出完成后会在权重文件夹下生成best.torchscript文件。如果想导出ONNX,就把--include改成onnx。注意导出的设备和训练设备尽量一致,不然有时候会因为设备不匹配导致加载报错。

导出之后还要验证一下:加载导出的模型,对一张测试图跑一遍推理,对比原.pt模型的前5个检测框是否一致。这个步骤别跳,很多人导完就直接部署,结果检测框乱飞,最后才发现是导出的预处理和后处理参数没对齐。

2.2 封装推理类:把预处理、推理、后处理做成稳定的接口

不管用什么框架部署,我都习惯先把模型包装成一个独立的推理类,而不是在Flask路由里直接写model(img)。原因很简单:Flask路由层只负责处理HTTP请求和响应,模型推理的预处理、前向传播、NMS、坐标换算这些逻辑,应该被隔离成一个可单测的单元。这样以后换FastAPI、换ONNX Runtime,只需要改这个类内部实现,路由代码一行不动。

下面是一个最精简的推理类封装,假设你已经用YOLOv5官方仓库的detect.py跑通了检测流程:

import torch import cv2 import numpy as np from pathlib import Path class YOLOv5Detector: def __init__(self, weights_path: str, device: str = "cuda:0"): # 用 torch.jit.load 加载 TorchScript 模型 self.model = torch.jit.load(weights_path, map_location=device) self.model = self.model.to(device) self.model.eval() self.device = device self.img_size = 640 self.conf_thres = 0.25 self.iou_thres = 0.45 # YOLOv5 的类别名,按训练配置文件顺序 self.names = ["person", "car", "dog", "cat"] # 替换成你的类别 def preprocess(self, img_bgr: np.ndarray) -> torch.Tensor: # YOLOv5 用的是 BGR 输入,letterbox 缩放,然后归一化到 [0,1] h, w = img_bgr.shape[:2] r = min(self.img_size / h, self.img_size / w) new_w, new_h = int(round(w * r)), int(round(h * r)) resized = cv2.resize(img_bgr, (new_w, new_h), interpolation=cv2.INTER_LINEAR) canvas = np.full((self.img_size, self.img_size, 3), 114, dtype=np.float32) canvas[:new_h, :new_w] = resized # HWC -> CHW, BGR -> RGB,模型内部用 RGB 顺序 x = canvas[:, :, ::-1].transpose(2, 0, 1) x = np.ascontiguousarray(x) x = torch.from_numpy(x).float() / 255.0 x = x.unsqueeze(0).to(self.device) return x, r, new_w, new_h def postprocess(self, pred, r, new_w, new_h, orig_shape): # 模型输出 shape: [1, 25200, 85],85 = 4(框) + 1(置信度) + 80(类别) # 这里做简单过滤:先按置信度过滤,再按类别取最大值 pred = pred[0].cpu().numpy() boxes, scores, class_ids = [], [], [] for row in pred: score = row[4] if score < self.conf_thres: continue cls_id = int(np.argmax(row[5:])) cls_score = row[5 + cls_id] if cls_score * score < self.conf_thres: continue x1, y1, x2, y2 = row[:4] # 还原到 letterbox 前坐标 x1 /= r; y1 /= r; x2 /= r; y2 /= r boxes.append([x1, y1, x2, y2]) scores.append(score * cls_score) class_ids.append(cls_id) return boxes, scores, class_ids def detect(self, img_bgr: np.ndarray): x, r, new_w, new_h = self.preprocess(img_bgr) with torch.no_grad(): pred = self.model(x) boxes, scores, class_ids = self.postprocess(pred[0], r, new_w, new_h) return boxes, scores, class_ids

这里的postprocess刻意没有实现完整NMS,正式使用建议直接用官方utils/general.py里的non_max_suppression函数,代码会更稳定。我这样写是为了让你看清坐标还原的步骤:YOLOv5输出的xyxy是相对输入图像(即letterbox填充后的640×640)的坐标,要除以缩放比例r得到原始图像坐标。这一步做错,检测框就会整体偏移。

初始化这个类时要注意:torch.jit.load之后一定要调用.eval(),否则模型里某些在训练时才有行为的模块(如dropout或BN统计)会干扰推理结果。另外,如果模型是在GPU训练的,而服务器没有GPU,请用map_location="cpu"加载,然后把推理时的self.device也设成CPU,否则会报device mismatch。

3. Flask接口设计与前后端交互:POST JSON、上传文件、视频流

3.1 一个最小但完整的Flask应用:路由设计与参数

模型封装好之后,Flask端就清晰了。我一般会在Flask应用初始化时就把检测器实例化好,而不是在每个请求里重新加载模型。这听起来理所当然,但很多初学者会把YOLOv5Detector(...)写到某个路由函数内部,结果每次请求都要花几秒加载模型,服务基本不可用。

from flask import Flask, request, jsonify, render_template, Response import cv2 import numpy as np import base64 from detector import YOLOv5Detector app = Flask(__name__) detector = YOLOv5Detector("best.torchscript", device="cuda:0") @app.route("/", methods=["GET"]) def index(): return render_template("index.html") @app.route("/detect", methods=["POST"]) def detect(): if "file" not in request.files: return jsonify({"error": "no file uploaded"}), 400 file = request.files["file"] img_bytes = file.read() # 用一个便捷函数把字节流转成 OpenCV 矩阵 img_bgr = cv2.imdecode(np.frombuffer(img_bytes, np.uint8), cv2.IMREAD_COLOR) if img_bgr is None: return jsonify({"error": "invalid image"}), 400 boxes, scores, class_ids = detector.detect(img_bgr) # 把结果画到图上,转成 base64 回传,省得前端再调一次绘图接口 for box, score, cls_id in zip(boxes, scores, class_ids): x1, y1, x2, y2 = [int(v) for v in box] label = f"{detector.names[cls_id]} {score:.2f}" cv2.rectangle(img_bgr, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img_bgr, label, (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) _, encoded = cv2.imencode(".jpg", img_bgr) img_base64 = base64.b64encode(encoded).decode("utf-8") return jsonify({ "image_base64": img_base64, "count": len(boxes), "boxes": boxes, "scores": scores, "class_ids": class_ids }) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, threaded=True)

这段代码的核心设计是:detector在模块加载时创建一次,所有请求共享同一个实例。threaded=True允许Flask同时处理多个请求,但要注意,OpenCV的imdecode在并发环境下是安全的,而PyTorch的模型forward也是线程安全的,只是如果多个请求同时推入显存,显存占用会成倍增加。这个问题后面避坑章节再细说。

cv2.imdecode读取的是文件字节流,不能用cv2.imread直接读request.files里的路径,因为Flask拿到的是内存中的文件对象,不是真实文件路径。这个细节很多人第一次写都会踩。另外,返回的boxes是Python list,不是numpy数组,jsonify才能正常序列化。

3.2 前端页面:上传图片与显示结果

前端不需要复杂,一个HTML文件加一点原生JavaScript就够,不需要引入axios或jQuery。核心逻辑是:<input type="file">选择图片后,用FormData把文件POST到/detect接口,拿到base64图片后直接塞到<img>的src属性里。

<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>YOLOv5 Web Demo</title> </head> <body> <h2>YOLOv5 目标检测</h2> <input type="file" id="upload" accept="image/*"> <button onclick="uploadImage()">检测</button> <br><br> <img id="result" width="640" alt="检测结果"> <script> async function uploadImage() { const fileInput = document.getElementById('upload'); if (!fileInput.files.length) { alert('请先选择图片'); return; } const formData = new FormData(); formData.append('file', fileInput.files[0]); const resp = await fetch('/detect', { method: 'POST', body: formData }); const data = await resp.json(); if (data.error) { alert(data.error); return; } document.getElementById('result').src = 'data:image/jpeg;base64,' + data.image_base64; } </script> </body> </html>

这段代码的要点是accept="image/*"限制了文件选择器只显示图片,避免用户传一个.txt上来然后后端解析失败。fetch的body直接传FormData,不要手动设置Content-Type,浏览器会自动加上带boundary的multipart/form-data,否则Flask的request.files拿不到文件。这种方式足以支撑内部测试和demo演示,但生产环境建议加一个上传文件大小限制,比如MAX_CONTENT_LENGTH = 16 * 1024 * 1024,防止大图把服务端内存打爆。

3.3 视频流与实时检测:如何把帧送到模型再回推

图片检测是最基础的需求,但实际业务里更多人问的是“能不能直接检测视频流”,比如摄像头画面实时检测。这个场景用Flask + YOLOv5实现的方式是:Flask后端不断从视频源读帧,送入检测器,把标注后的帧以MJPEG流格式推给前端。前端只需要一个<img>标签指向/video_feed接口,浏览器就能持续刷新画面。

@app.route("/video_feed") def video_feed(): # 这个生成器函数会被 Flask 持续调用,不断 yield JPEG 帧 def generate(): # cap 在请求内部创建,请求结束自动释放 cap = cv2.VideoCapture(0) # 摄像头索引,也可以是 RTSP 地址 while True: ok, frame = cap.read() if not ok: break boxes, scores, class_ids = detector.detect(frame) for box, score, cls_id in zip(boxes, scores, class_ids): x1, y1, x2, y2 = [int(v) for v in box] cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) label = f"{detector.names[cls_id]} {score:.2f}" cv2.putText(frame, label, (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) # 编码成 JPEG,按 multipart 协议输出 ok, jpeg = cv2.imencode(".jpg", frame) if not ok: continue frame_bytes = jpeg.tobytes() yield (b'--frame\r\n' b'Content-Type: image/jpeg\r\n\r\n' + frame_bytes + b'\r\n') return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame")

这里有三个参数值得关注。第一个是cv2.VideoCapture的输入源,摄像头索引在OpenCV里从0开始;如果是网络摄像头或RTSP流,直接传"rtsp://..."字符串即可。第二个是mimetype,必须是multipart/x-mixed-replace,前端才能持续收到新帧;如果写错,浏览器会直接下载而不是播放。第三个是帧率控制,generate()循环里没有time.sleep,如果模型检测速度只有5 FPS,那推流也只有5 FPS,这是正常的;但如果摄像头帧率本身就高,模型跟不上,就要在循环里加一个等待或跳帧,不然CPU会被读帧操作占满,后面避坑里会细讲。

4. 性能与并发:卡顿、超时、GPU占用异常怎么调

4.1 单进程到多线程:模型加载的位置决定了并发上限

很多人在Flask里跑YOLOv5,一压测就发现:并发请求一多,GPU利用率上不去,但响应时间暴涨,甚至会内存溢出。问题出在对GIL和PyTorch推理方式的理解不到位。Flask默认是单进程多线程,Python线程共享一个解释器,但PyTorch的CPU推理会释放GIL,所以CPU推理时多线程确实能提升吞吐。然而GPU推理时,多个线程同时调用model(x),CUDA会为每个请求分配独立的上下文,显存开销成倍上涨。

常见的解法有两种。第一种是串行化推理:给检测器加一个线程锁,同一时间只允许一个请求进入模型前向传播。这种方式简单粗暴,但会损失并发能力。第二种是请求排队:把推理任务丢进队列,由单独的后台线程消费,Flask路由只负责把结果取回。我一般用后者,因为这样GPU利用率稳定,不会出现多个请求同时抢显存导致OOM。

import queue import threading import uuid task_queue = queue.Queue(maxsize=100) def inference_worker(): while True: task_id, img_bgr = task_queue.get() try: boxes, scores, class_ids = detector.detect(img_bgr) task_results[task_id] = (boxes, scores, class_ids) except Exception as e: task_results[task_id] = None finally: task_queue.task_done() threading.Thread(target=inference_worker, daemon=True).start()

这个写法把「接收请求」和「执行推理」解耦。task_queue.maxsize=100是队列积压上限,超过这个数的请求会立刻阻塞等待,相当于一个天然限流器,防止系统被请求风暴打垮。每个请求进来时生成唯一task_id,然后把(task_id, img_bgr)丢入队列,循环等待自己的结果出现在task_results里。这个方案适合GPU推理,因为后台只有一个线程在调用model,显存占用是稳定的,不会随并发升高而膨胀。

4.2 模型加载缓存、半精度与批量推理:三个立竿见影的参数

模型推理性能的瓶颈往往不在模型本身,而在三个周边参数:输入尺寸、精度、以及是否复用Tensor Core。第一个是输入尺寸,YOLOv5默认是640×640,如果检测目标较小或精度要求高,可以推高到1280,但推理时间会成倍增长(大约增长4倍);如果只是内网demo,建议保持640,优先保证实时性。第二个是半精度,PyTorch里用model.half()可以把模型和输入张量都转成FP16,在支持Tensor Core的GPU上推理速度提升明显,显存占用也减半。但要注意,如果你的模型权重是FP32导出的TorchScript,model.half()转换可能会让部分层的输出精度下降,检测框略微偏移。我一般会在导出时直接用--half参数导出FP16模型,这样不需要在运行时转换。

第三个是批量推理。单个请求一张图是常规用法,但如果在特殊场景下(比如巡检系统同时上传10张图),可以一次请求传入多张图,用torch.cat拼接成batch输入,YOLOv5对batch推理有优化,吞吐比单张循环高很多。批量推理要注意内存管理,batch size每增加1,显存占用约线性增加。我给一个参考经验:一张640×640的图在FP16下约占1.2GB显存(含中间激活值),如果你的GPU是8GB显存,batch size最多开4。

下面是一段批量推理的伪代码思路,参数值得逐行看:

def detect_batch(self, img_list: list): # 预处理所有图,stack成batch xs = [] r_list = [] for img in img_list: x, r, _, _ = self.preprocess(img) xs.append(x) r_list.append(r) x_batch = torch.cat(xs, dim=0) # shape: [B, 3, 640, 640] with torch.no_grad(): preds = self.model(x_batch) # [B, 25200, 85] results = [] for i, pred in enumerate(preds): boxes, scores, class_ids = self.postprocess(pred.unsqueeze(0), r_list[i]) results.append((boxes, scores, class_ids)) return results

注意torch.cat之后模型输出的第一个维度就是batch索引,后处理时按索引取回对应结果。r_list要保存每张图的缩放比例,后处理做坐标还原时对不同图用不同比例,这个细节很容易错,错的结果是第2张图之后的检测框都偏移。

4.3 并发线程数、超时与队列深度:经验参数表

这里给一套我在多台服务器上调过的参数基线,你可以直接作为起点,再根据压测结果微调:

参数默认值/建议值说明
FlaskthreadedTrue接收HTTP请求的线程数,不对推理并发负责
task_queue.maxsize50~100队列满了之后再来的请求会阻塞,起到限流作用
推理线程数1GPU推理建议单线程;CPU推理可尝试2~4个
conf_thres0.25太低会有大量误检,太高会漏检小目标
iou_thres0.45NMS去重阈值,多目标重叠场景适当调高到0.5
图片最大尺寸640超过建议宽度按比例缩放,防止内存暴涨
单张请求超时30sFlask默认无超时,需在Nginx层设置
上传文件大小限制16MB在Flask配置里设MAX_CONTENT_LENGTH

队列深度调得越小,系统在高并发下越稳定,但用户等待时间会变长;调得越大,吞吐越高,但极端尖峰可能导致系统内存暴涨。我一般先用wrk或ab做一次100并发、持续1分钟的压测,观察显存和内存曲线,再反向调整队列深度。如果显存已经到80%以上,就减小maxsize或降低img_size。

5. 避坑:我把YOLOv5部署到Flask时踩过的几个坑

5.1 首次请求特别慢,浏览器转圈十几秒

现象:Flask服务启动后,第一次发送检测请求,等了十几秒才出结果,第二次之后就快了。

原因:模型加载是惰性的。YOLOv5Detector.__init__里虽然调用了torch.jit.load,但真正的权重载入和CUDA上下文初始化可能延迟到第一次推理才触发。而且第一次CUDA调用会做cuDNN预热,花费几秒是正常的。

解决:在服务启动后主动跑一次“空推理”预热,用一张全黑的640×640图片调用一次detect()。这会把CUDA kernel、cuDNN context全部初始化完毕,之后普通请求延迟就能稳定在几十毫秒。我习惯在__main__里加一行:

warmup_img = np.zeros((640, 640, 3), dtype=np.uint8) detector.detect(warmup_img)

5.2 并发一高,显存直接OOM

现象:并发请求从5增加到10,GPU显存从4GB飙升到12GB,然后报CUDA out of memory。

原因:如果没有做请求排队或锁,多个线程同时进入模型前向传播,Pytorch会为每个调用分配独立的CUDA context和激活值缓存,显存占用是并发数量的线性函数。这是单进程多线程GPU推理最容易踩的雷。

解决:按思路4.1的方式,把推理放进队列由单线程消费。或者给detect方法加一个threading.Lock()。锁的缺点是会阻塞请求线程,让调用方等待;队列则更平滑。如果实在需要同时推理多路视频流,建议用多个进程,每个进程持有独立模型,进程之间用multiprocessing共享队列。

5.3 前端收到结果但图片显示空白

现象:接口返回了JSON,image_base64字段也有值,但图片在浏览器里显示为破碎图标。

原因:cv2.imencode(".jpg", img_bgr)返回的是(retval, buffer),buffer是numpy.ndarray,如果直接把这个数组交给base64.b64encode会报错,因为b64encode需要字节串。如果代码里用了base64.b64encode(encoded)而encoded是ndarray,Python会悄悄转成字符串再编码,结果就是无效的base64数据,浏览器解析失败。

解决:先调用encoded.tobytes()转成字节串再编码。文章3.1节的代码已经写了正确写法:

_, encoded = cv2.imencode(".jpg", img_bgr) img_base64 = base64.b64encode(encoded.tobytes()).decode("utf-8")

5.4 视频流延迟越来越大,直到画面变成幻灯片

现象:推流地址打开后前几秒流畅,过了一分钟开始卡顿,延迟持续累积。

原因:generate()生成器里,cv2.VideoCapture.read()从摄像头或RTSP源读取帧的速度远快于模型推理速度,如果不对输入帧做丢弃或延时,生成器内部积压的帧会越来越多,每帧处理耗时不断叠加,最终延迟不可控。

解决:在生成器里加一个帧率控制逻辑,比如目标推流15FPS,检测一次耗时0.1s,那么每帧之间sleep(max(0, 1/15 - process_time)),并且如果模型速度跟不上,直接丢弃下一帧,只检测最新帧。一个简单实现是:

import time prev_time = time.time() while True: ok, frame = cap.read() if not ok: break # 如果距离上一帧处理时间不足 1/15 秒,跳过这一帧 now = time.time() if now - prev_time < 1.0 / 15.0: continue prev_time = now # 推理与推流

这种“跳帧机制”能保证推流稳定在目标帧率,且不会因为输入源帧率过高而堆积。对于RTSP流,cap.read()本身会阻塞等待新帧,不需要额外sleep;但如果是本地视频文件或USB摄像头,一定要加这个控制。

5.5 模型在CPU服务器上跑,延迟高到没法用

现象:没有GPU的服务器上,单张图推理耗时2~3秒,完全达不到实时。

原因:YOLOv5在CPU上的推理确实慢,特别是800万像素照片缩放成640后,模型计算量大。但还有一个常被忽略的点:如果TorchScript模型是用FP16导出的,CPU加载会报错或不支持,最终退回FP32,反而比直接FP32导出更慢。

解决:CPU部署用ONNX Runtime替代PyTorch推理是常见做法。用onnxruntime加载同一个best.onnx模型,推理速度能比PyTorch CPU快2~5倍。这个方案的具体做法不展开,但有一条经验:CPU服务器就别用TorchScript,直接用ONNX + ONNX Runtime,providers=["CPUExecutionProvider"],输入图像预处理保持一致,代码结构和我给的推理类几乎一样,只是把model(x)换成了session.run(None, {"images": x_numpy})。如果你的需求就是要在廉价服务器上跑,这个方向值得尝试。

6. 进阶:把服务变得可观测、可维护、可升级

6.1 为推理接口添加日志与耗时监控:一把不起眼但必要的尺子

部署不是“能跑就行”,尤其是要给业务方交付时,你需要能回答“为什么这个请求这么慢”“失败率是多少”。我给所有Flask推理服务都加一个装饰器来记录每个请求的耗时、返回码和实体数量。代码量很少,但排查问题时价值极高:

import time import logging from functools import wraps logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logger = logging.getLogger("yolov5-server") def log_request(fn): @wraps(fn) def wrapper(*args, **kwargs): start = time.perf_counter() resp = fn(*args, **kwargs) duration_ms = (time.perf_counter() - start) * 1000 # 如果响应是 jsonify 对象,能取到 status_code if isinstance(resp, tuple): status = resp[1] else: status = resp.status_code logger.info(f"{request.path} {status} {duration_ms:.1f}ms") return resp return wrapper

这个装饰器直接放在/detect路由函数上,每次请求结束都会打一行日志。生产环境可以追加到QueueHandler写入日志文件或ELK,这里不展开。另一个实操技巧是把推理的耗时单独打点,比如在detector.detect里用time.perf_counter分别记录预处理、前向传播、后处理三段耗时,这样你就知道瓶颈到底是模型本身,还是图像解码,还是坐标转换的Python循环。我之前遇到一个“慢请求”问题,排查后发现90%时间花在cv2.imdecode解码一张4000万像素原图上,和模型推理没关系。

6.2 模型热更新:不重启服务切换模型权重

训练新模型后,传统做法是替换文件、重启服务,这会中断线上检测。稍微聪明一点的做法是:模型路径派发到请求参数里,服务内部维护一个模型管理字典,当某个请求指定model_version时,先判断当前内存里有没有这个版本,如果不存在就从磁盘加载并加入字典,这样可以在不重启情况下随时切换模型。

MODEL_REGISTRY = {} MODEL_LOCK = threading.Lock() def get_model(version: str): if version not in MODEL_REGISTRY: with MODEL_LOCK: if version not in MODEL_REGISTRY: # 双检锁 MODEL_REGISTRY[version] = YOLOv5Detector(f"{version}.torchscript") return MODEL_REGISTRY[version]

这个实现适合模型文件不大(小于500MB)的场景。如果模型数量多且单个体积大,内存消耗会是问题,那时可以改成LRU淘汰机制。热更新能提升运维体验,但也要注意多版本并存时的显存管理,加载新模型前最好先看显存剩余,不够就torch.cuda.empty_cache()后再加载。

6.3 从Flask到生产级:什么时候该换FastAPI或加Gunicorn

Flask自带的开发服务器(Werkzeug)不适合直接暴露公网,这是老生常谈,但很多人直到上线才意识到。生产环境至少要在Flask前面加一层Nginx做反向代理和静态文件服务,用Gunicorn替代app.run()来启动Flask。如果项目后续接口数量变多、需要请求参数校验、自动生成API文档,那么换FastAPI是值得的,代码迁移成本并不高——因为我已经把推理逻辑全部封装在YOLOv5Detector类里,路由层换一个框架,内部代码一行不用改。

以下是一份Gunicorn启动命令的参考,参数按我的经验配置:

gunicorn -w 1 -b 0.0.0.0:5000 --timeout 120 app:app

-w设置worker进程数,对于GPU推理服务,建议设为1,避免多进程争用GPU;如果使用CPU推理,可以设为CPU核心数。--timeout 120是必须设置的,因为首次请求如果包含模型预热,耗时会超过默认的30秒。如果你有多个GPU,可以用-w 2并分别在两个worker里通过CUDA_VISIBLE_DEVICES指定不同GPU,这样单机吞吐能翻倍。

怎么说呢,部署这件事,表面上是把模型“挂到网上”,实际上是一次对工程化能力的检验。我自己做过几个版本才总结出一条经验:模型性能决定天花板,但工程细节决定你能不能摸到天花板。那些看似不起眼的预热、队列、日志,恰恰是线上服务稳定性的根基。希望这篇笔记里的代码和坑位记录能帮到你,让你少走几段我走过的弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询