
很多团队聊起 AI 落地第一反应就是单独起一个 Python 推理服务再让 Java 后端通过 HTTP 去调它。但如果你们的后端本来就是 Java 技术栈业务代码又全是 Spring Boot其实完全可以把 YOLO 模型直接塞进 Java 工程作为普通业务接口对外提供服务。我这次就是这么干的把 YOLOv8 导出成 ONNX用 ONNX Runtime 在 Java 侧完成推理再封装成统一检测接口和 SSE 流式接口最后打包成 GPU 容器部署到生产环境。整个过程走下来最大的感受是少维护一个 Python 微服务少搭一条部署链路前后端联调也只需要面对一个服务地址。适合那些想在 Java 后端集成目标检测、又不想把架构横向拉大的团队参考。这篇文章会把关键决策的来龙去脉、ONNX 输出的解析方法、接口封装细节、GPU 容器化部署和各个踩坑点一一写清楚。有完整代码片段也有可以照抄的 Dockerfile 和 JVM 参数。1. 技术方案选型为什么直接嵌入 Java而不是另起 Python 服务1.1 Java 集成 YOLO 的四条路我衡量过四种做法各有各的适用场景。第一Python FastAPI/Flask 单独部署推理服务Java 后端用 HTTP 或 gRPC 去调。这种方案隔离性最好推理代码更新不影响主业务但代价是你要同时维护两套服务、两套监控、两套 CI/CD。对于人数不多的小团队这其实是一笔不小的隐性成本。第二用 JNI 直接封装 C 的 TensorRT 或 Darknet 推理库。性能确实最好但 JNI 的开发和排障成本高到什么程度做过的人都知道稍微换个显卡驱动版本so 文件加载失败排查起来能从晚上八点查到第二天早上。除非团队里有人专职做底层封装否则不建议碰。第三用亚马逊的 DJLDeep Java Library。它是整合了多种引擎的高级封装有一套统一的 Predictor 接口理念很好。不过 DJL 对 ONNX Runtime 的封装在自定义预处理和后处理上会绕一层遇到一些偏门算子时反而不如直接用底层引擎透明。第四直接用 ONNX Runtime 的 Java API。这是我最推荐的做法几乎不需要引入额外的框架概念只要把模型转成 ONNXJava 这边专注于图像预处理、张量解析、后处理过滤这三件事逻辑链路非常干净。从部署角度看ONNX Runtime 同时支持 CPU 和 CUDA 两个 Execution Provider同一个模型文件可以无缝切换设备。对生产环境来说这意味着一套代码既能跑在低价 CPU 机器上做接口回归又能跑在 GPU 机器上支撑大规模并发不需要改业务代码。1.2 生产架构定位内嵌推理节点还是独立服务有人会问就算用 Java 推理是不是也应该拆成一个独立推理服务而不是塞进现有业务服务里我的判断标准很简单。如果你的检测调用逻辑和业务逻辑耦合很深比如检测结果要立刻走业务审批流程、要查库、要落单那拆太开反而导致网络来回多、故障排查难。这种情况下把 YOLO 推理封装在同一个 Spring Boot 应用里走本地方法调用是最顺的。但如果你的检测量很大业务方会持续疯狂往里怼图片或者你需要独立扩缩容推理能力那就应该把 YOLO 部分封装成独立工作节点对外暴露 HTTP/SSE 接口内部只做推理和结果回传。核心代码完全复用只是部署形态不同而已。我这次属于前者检测是主流程的一部分所以选择了嵌入式方案。后面要扩展成独立节点时代码几乎不用动只需要把 Controller 层单独拆出去。2. 模型导出与 Java 侧推理前置准备2.1 从 Ultralytics 导出 ONNX 的正确姿势训练好的模型一般是.pt权重典型导出命令长这样yolo export modelyolov8n.pt formatonnx imgsz640 opset12 dynamicFalse simplifyTrueimgsz640是推理输入边长YOLO 默认会取 640x640。不要随便改成 1280虽然精度好一些但推理耗时会成倍上涨接口的吞吐量会掉得很难看。业务场景对精度要求确实高时再考虑否则 640 是性价比之王。dynamicFalse是静态输入尺寸意思是 batch 和长宽都是固定的。做生产部署时我建议先用静态尺寸代码能少一个维度的管理成本。等后面的批量推理跑通了再在代码侧构造多个帧拼成 batch底层算子照样以固定 shape 执行没必要真的用 dynamic。simplifyTrue会做 ONNX 图优化删掉一些冗余节点能减一点文件体积和推理耗时。导出完成之后最好用onnxruntime的 Python 包快速跑一张图验证一下结果确认模型本身没问题再进入 Java 侧省得后面在 Java 里排查时无从下手。顺便说一句训练阶段大家最常见到的box_loss、cls_loss、dfl_loss这些损失项在推理阶段是不直接参与的。损失只作用于训练回传推理落地时你拿到的是模型输出的原始检测张量需要自己在 Java 侧解析这一点很多人第一次做时会绕进去。2.2 ONNX 输出张量到底长什么样这地方必须花时间说清楚因为所有后处理代码都是围绕它写的。YOLOv8 等系列模型导出的输出形状通常是[1, 84, 8400]。其中1是 batch84 4 804是检测框的中心点 x、中心点 y、宽度 w、高度 h80是 COCO 80 类的类别得分8400是 3 个不同尺度特征图的总预测格子数640/8 * 640/8 640/16 * 640/16 640/32 * 640/32 8400刚好就是80*80 40*40 20*20。内存布局是行优先的Java 里取出来就是一个一维float[]访问第 i 个格子、第 c 个通道的值要用data[c * 8400 i]。前 4 行是框坐标从第 4 行开始遍历这 80 个类别分数取最大值及其索引就是该格子对应的类别。常见误区是把输出当成[1, 8400, 84]以为一行表示一个检测框。实际导出的排序不是这样如果你按错误的方式解析得到的结果会非常诡异坐标乱飞置信度全乱。记住这里输出是 HWC 里的 CHW 变体84是通道维度8400是空间维度。2.3 ONNX Runtime 依赖配置与版本匹配我用的 Spring Boot 3.2JDK 17ONNX Runtime 版本是 1.17.1。pom.xml里加这个dependency groupIdcom.microsoft.onnxruntime/groupId artifactIdonnxruntime_gpu/artifactId version1.17.1/version /dependency如果你暂时没有 GPU可以先引入 CPU 版dependency groupIdcom.microsoft.onnxruntime/groupId artifactIdonnxruntime/artifactId version1.17.1/version /dependency这里有个大坑onnxruntime_gpu和onnxruntime不能同时出现在依赖里否则类加载冲突运行时直接报错排查起来还挺费劲的。GPU 版底层自带 CUDA、cuDNN 的 JNI 扩展选型时一次只引一个。GPU 版还要求本机或容器的 CUDA runtime 跟 ONNX Runtime 编译时对齐。1.17.x 系列对应的是 CUDA 11.8 cuDNN 8.x这一点在后面的 Docker 部署章节还会重点提。如果你的显卡驱动只支持 CUDA 12.x需要换用对应的 ONNX Runtime 版本版本号排序一般都能在官方 Release Notes 里查到匹配不上的话会一直报cuda provider initialization failed。3. 接口设计与核心代码封装3.1 先定接口协议再写代码每次做接口封装我都建议先“纸上谈兵”把协议定清楚不然写一半回头改接口形状Java 侧强类型代码改起来是很痛苦的。这次我设计了两个接口。一个是同步检测接口适合单张图片调用POST /api/yolo/detect { imageBase64: /9j/4AAQSkZJRg... }返回{ code: 200, data: { width: 1920, height: 1080, boxes: [ {classId: 0, className: person, confidence: 0.91, x1: 100, y1: 200, x2: 320, y2: 480} ] } }另一个是 SSE 流式接口适合在长任务里持续把检测结果推给前端比如视频帧序列的实时解析。这里的“接口封装”就体现出来了不管外层是同步 HTTP 还是 SSE 推送内部都应该复用同一个YoloDetector推理核心不允许在 Controller 里复制推理逻辑。对应的 Java DTO 长这样public class DetectionBox { private int classId; private String className; private float confidence; private int x1; private int y1; private int x2; private int y2; // 省略 getter / setter } public class DetectResult { private int imageWidth; private int imageHeight; private ListDetectionBox boxes; }3.2 YOLO 推理器封装预处理、推理、后处理三段式核心类命名YoloDetector构造时加载 ONNX 模型加载后只暴露一个detect(Mat image)方法。内部拆成三步letterbox 预处理、session.run 推理、解析输出并做 NMS。先看模型加载import ai.onnxruntime.*; public class YoloDetector { private final OrtEnvironment env; private final OrtSession session; private final String inputName; public YoloDetector(String modelPath) throws OrtException { this.env OrtEnvironment.getEnvironment(); OrtSession.SessionOptions options new OrtSession.SessionOptions(); // 生产环境根据设备选择 EP if (useGPU) { options.addCUDA(0); } this.session env.createSession(modelPath, options); this.inputName session.getInputNames().iterator().next(); } public DetectResult detect(Mat image) throws OrtException { // 1. 预处理 float[][][] inputTensor preprocess(image); // 2. 推理 float[] output inference(inputTensor); // 3. 后处理 return postprocess(output, image.cols(), image.rows()); } }这里要记住一个关键点OrtSession不是线程安全的。如果你用并发线程池同时调用同一个 session 的 run 方法轻则结果张量错乱重则 JNI 层直接崩溃。后面部署章节我会给出生产环境的线程模型但现在先留个印象。letterbox 预处理是 YOLO 系列最经典的图像缩放方式。它不会直接把 1080p 图像强行拉伸到 640x640而是按比例缩放长边再把剩余区域用 114 灰度值填充。代码如下private float[][][] preprocess(Mat img) { int targetSize 640; int imgW img.cols(), imgH img.rows(); double ratio Math.min((double) targetSize / imgW, (double) targetSize / imgH); int newW (int) Math.round(imgW * ratio); int newH (int) Math.round(imgH * ratio); Mat resized new Mat(); org.opencv.imgproc.Imgproc.resize(img, resized, new org.opencv.core.Size(newW, newH)); Mat canvas new Mat(targetSize, targetSize, img.type(), new org.opencv.core.Scalar(114, 114, 114)); Rect roi new Rect((targetSize - newW) / 2, (targetSize - newH) / 2, newW, newH); resized.copyTo(canvas.apply(roi).apply(org.opencv.core.Mat.zeros(canvas.rows(), canvas.cols(), canvas.type()))); // 注意上面的 apply 逻辑仅供参考实际建议直接用 canvas.submat(roi) // BGR - RGBYOLO 训练时通常使用 RGB org.opencv.core.Mat rgb new Mat(); org.opencv.imgproc.Imgproc.cvtColor(canvas, rgb, org.opencv.imgproc.Imgproc.COLOR_BGR2RGB); // 归一化到 0-1且维度转成 [C,H,W] float[][][] result new float[3][targetSize][targetSize]; for (int c 0; c 3; c) { for (int i 0; i targetSize; i) { for (int j 0; j targetSize; j) { result[c][i][j] rgb.get(i, j)[c] / 255.0f; } } } return result; }上面代码里的canvas.submat(roi)才是最正确的做法直接一次性获取可写区域然后把缩放后的图拷贝进去。千万别用逐像素复制640x640 三通道就是 122 万次访问性能完全不可接受。实际项目里我用的是 JavaCV 的warpAffine做整块仿射变换比上面的写法更稳也更贴近推理引擎习惯的数据排列。推理部分把三维数组塞进OnnxTensorprivate float[] inference(float[][][] tensor) throws OrtException { long[] shape {1, 3, 640, 640}; OnnxTensor onnxTensor OnnxTensor.createTensor(env, tensor, shape); OrtSession.Result result session.run(java.util.Map.of(inputName, onnxTensor)); OnnxTensor output (OnnxTensor) result.get(0); return (float[]) output.getValue(); }后处理是重头戏。按 2.2 节说的内存布局我的解析代码如下private DetectResult postprocess(float[] data, int originW, int originH) { int numPredictions 8400; int numClasses 80; int channels 4 numClasses; // 84 float confThreshold 0.25f; Listfloat[] boxes new ArrayList(); // {x1,y1,x2,y2,score,classId} for (int i 0; i numPredictions; i) { float cx data[i]; // channel 0 float cy data[numPredictions i]; // channel 1 float w data[2 * numPredictions i]; float h data[3 * numPredictions i]; float bestScore 0; int bestClass -1; for (int c 0; c numClasses; c) { float score data[(4 c) * numPredictions i]; if (score bestScore) { bestScore score; bestClass c; } } if (bestScore confThreshold) { float x1 cx - w / 2f; float y1 cy - h / 2f; float x2 cx w / 2f; float y2 cy h / 2f; boxes.add(new float[]{x1, y1, x2, y2, bestScore, bestClass}); } } Listfloat[] kept nms(boxes, 0.45f); // 把 640x640 坐标还原回原图坐标这里必须减去 letterbox 的填充偏移并除以缩放比例 float scale Math.min(640f / originW, 640f / originH); int dw (int) ((640 - originW * scale) / 2); int dh (int) ((640 - originH * scale) / 2); for (float[] b : kept) { b[0] (b[0] - dw) / scale; b[1] (b[1] - dh) / scale; b[2] (b[2] - dw) / scale; b[3] (b[3] - dh) / scale; } return fillResult(kept, originW, originH); }坐标还原那一段最容易漏。如果你直接把 640 坐标系里的x1/y1/x2/y2返回给前端前端要么画出偏到角落的框要么把框画在完全错误的位置。原因就是原始 1920x1080 的图在 letterbox 后发生了缩放和平移。NMS 的实现有很多种生产环境建议用向量化优化的版本。这里给一个清晰的基础版本private Listfloat[] nms(Listfloat[] boxes, float iouThreshold) { boxes.sort((a, b) - Float.compare(b[4], a[4])); Listfloat[] keep new ArrayList(); while (!boxes.isEmpty()) { float[] a boxes.remove(0); keep.add(a); boxes.removeIf(b - iou(a, b) iouThreshold); } return keep; } private float iou(float[] a, float[] b) { float interX1 Math.max(a[0], b[0]); float interY1 Math.max(a[1], b[1]); float interX2 Math.min(a[2], b[2]); float interY2 Math.min(a[3], b[3]); float interW Math.max(0, interX2 - interX1); float interH Math.max(0, interY2 - interY1); float interArea interW * interH; float areaA (a[2] - a[0]) * (a[3] - a[1]); float areaB (b[2] - b[0]) * (b[3] - b[1]); return interArea / (areaA areaB - interArea); }这个 NMS 实时性还行但如果一帧里有几十个检测框全用浮点计算也足够。真正要优化的时候可以把 IOU 计算改成先排除坐标完全不相交的框减少计算量。3.3 同步接口与 SSE 流式接口封装Controller 层写起来很薄核心逻辑都在YoloDetector里。RestController RequestMapping(/api/yolo) public class YoloController { private final YoloDetector yoloDetector; PostMapping(/detect) public ApiResponseDetectResult detect(RequestBody DetectRequest request) throws IOException, OrtException { Mat img base64ToMat(request.getImageBase64()); DetectResult result yoloDetector.detect(img); return ApiResponse.ok(result); } }统一响应封装ApiResponse就是常规的code/message/data三件套业务系统里大家都习惯了。包装层不要放任何检测逻辑它只负责把异常翻译成统一错误码。SSE 接口这里多说两句。现在很多系统做前后端交互时遇到“慢处理”就上 WebSocket其实如果只是服务端单方向把结果推给前端SSE 的成本低得多。Spring MVC 原生提供了SseEmitter不需要额外引入 WebFlux 就能实现流式推送。GetMapping(value /detect-stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter detectStream() { SseEmitter emitter new SseEmitter(0L); // 丢给独立线程避免阻塞 Tomcat 工作线程 detectExecutor.execute(() - { try { // 模拟连续帧推理也可以把外部视频流帧放进来 for (int i 0; i 10; i) { DetectResult result yoloDetector.detect(nextFrame()); emitter.send(SseEmitter.event().name(detect).data(result)); Thread.sleep(100); } emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; }这里几个细节值得注意。new SseEmitter(0L)表示超时时间无限避免长任务中途断连用独立线程池执行推理不能让 SSE 的推送占满 Tomcat 的工作线程前端收到事件后如果 Logic 有断连重试需求emitter.completeWithError能明确传递失败信号SSE 客户端可以据此重连。如果你是用 Spring WebFlux 或响应式 Web 框架SSE 可以返回FluxDetectResult更自然但 MVC 项目没有必要为了 SSE 迁移技术栈SseEmitter已经够用。4. 生产级部署Docker、JVM 参数与多路视频性能4.1 CUDA 容器镜像与 JNI 库依赖生产环境我把应用打成了 Docker 镜像。GPU 推理容器的编排核心是基础镜像选择不是简单复制一个 JDK 镜像进去就能跑。我的 Dockerfile 大致长这样FROM nvidia/cuda:11.8-runtime-ubuntu20.04 RUN apt-get update apt-get install -y --no-install-recommends \ openjdk-17-jre-headless \ gnupg2 \ rm -rf /var/lib/apt/lists/* WORKDIR /app COPY target/app.jar /app/app.jar COPY onnx/model.onnx /app/onnx/model.onnx # ONNX Runtime GPU 运行时会通过 JNI 加载这些库 ENV CUDA_PATH/usr/local/cuda ENV LD_LIBRARY_PATH/usr/local/cuda/lib64:${LD_LIBRARY_PATH} EXPOSE 8080 ENTRYPOINT [java, -Xms2g, -Xmx2g, -XX:UseG1GC, -XX:MaxDirectMemorySize1g, -jar, app.jar]为什么选nvidia/cuda:11.8-runtime-ubuntu20.04而不是带完整编译工具的devel镜像runtime 镜像只包含运行动态库体积更小攻击面更小对生产更友好。用devel镜像等于给容器塞了一堆用不到的 gcc、nvcc完全没必要。但这里有个隐藏陷阱ONNX Runtime GPU 运行时要加载libonnxruntime_providers_cuda.so、libonnxruntime_providers_shared.so它们依赖 CUDA 的cudart、cublas、cudnn等库。nvidia/cuda:11.8-runtime-ubuntu20.04里默认不带 cuDNN需要额外从 CUDA 官网或基础镜像中打进去。最稳妥的办法是让运维在基础镜像之上用 NVIDIA 提供的 cuDNN runtime 包补齐而不是在 Java 应用层去硬塞 so 文件那样很容易出现版本号不匹配。容器里跑起来以后验证 GPU 是否生效最直接的办法是在启动日志里看 Execution Provider 激活信息。ONNX Runtime 如果成功加载 CUDA EPsession 创建时会把CUDAExecutionProvider打印出来如果失败可能直接抛异常或者日志里提示你当前正回退到 CPU。4.2 JVM 参数与堆外内存分配YOLO 推理的显式输入输出张量占的是堆外内存而不是 JVM 堆这一点经常被低估。ONNX Runtime 内部会为输入输出的float[]分配 native memoryJavaCV 的Mat对象也管理堆外内存。如果你把-Xmx调得很大却不给堆外内存留空间压测一上来就会报OutOfMemoryError: Direct buffer memory。我这边生产给的参数是-Xms2g -Xmx2g -XX:UseG1GC -XX:MaxDirectMemorySize1g。堆控制在 2G 用于业务对象和响应封装DirectMemory 给 1G 用于张量和图像缓冲。别把-Xmx给到 8G 甚至更高YOLO 一次推理的临时张量也就几十 MB 到一两百 MB堆开大了只会让 GC 压力变大收益很小。4.3 多路视频流性能怎么估标题里提到的“T4 1080p 25 帧每秒用 YOLO 640 分辨率能扛多少路”我实际压过类似配置可以给个经验值。先算单帧耗时的预算。1080p 输入做 letterbox 缩放和颜色转换JavaCV 在 CPU 上大约消耗 3-5msYOLOv8s 在 T4 上用 ONNX Runtime CUDA 推理640x640 输入差不多 10-15ms后处理和坐标还原 1-2ms。单路 25fps 的视频每帧时间是 40ms其中推理本身约占 12ms看起来好像只占三分之一但别忘了 CPU 端的预处理和内存拷贝是串在链路里的实际单路完整处理大约要 20ms 甚至更多。也就是说单张 T4 接一路 1080p 25fps 问题不大接两路也能勉强跑但想同时稳定接四路及以上瓶颈往往不是 GPU 算力而是 CPU 预处理和帧排队能跟不上。如果真的要多路并发必须做 batch 推理把多路的帧对齐成 batch一次性喂给 GPU。举例4 路 25fps 总帧率是 100fps。单帧推理 12ms 意味着 GPU 每毫秒能算约 0.083 帧100fps 需要 8.3ms 的推理时间预算按单帧 12ms 算已经超了。但如果打成[4, 3, 640, 640]的 batchT4 上显存带宽利用充分一次推理大约 25-30ms处理一批需要 25ms只要 batch 排队不积压就能在 100fps 下留出余量。具体能扛多少路不同型号和模型差异很大我建议上线前用脚本持续压 20 分钟观察显存、GPU 利用率和队列深度再定上限这个做法比任何估算都靠谱。4.4 并发访问同一会话的问题我在 3.2 节提到过OrtSession非线程安全问题这里给出生产环境推荐的线程模型。最简单可靠的做法线程池大小等于你期望的并行推理并发数每个线程持有独立的OrtSession实例通过线程局部变量或池上下文持有绝对不共享。这样每个 session 一次只跑一个推理内部 CUDA context 不会被多个线程搅乱。另一种做法是把推理请求丢进单线程任务队列由队列后端串行执行。这种方式实现最简单一个ExecutorService固定单线程就能解决但吞吐量受限于单卡上的串行推理。对大多数业务接口来说单路串行推理 15ms 的延迟其实完全能接受关键是别让接口并发上来以后把 session 弄崩。5. 避坑指南实战中遇到的五个经典问题为了节省大家时间我把这次踩坑最深的五个问题整理成了速查表后面再逐个说明现象和排查思路。现象根本原因解法置信度普遍很低几乎识别不出目标图像通道顺序没转换直接用 BGR 输入预处理加COLOR_BGR2RGB检测框坐标偏移明显不在目标位置坐标还原时忘了减 letterbox padding用“减偏移再除缩放比”还原输出张量形状解析错乱所有框位置都怪把[1,84,8400]当成了[1,8400,84]按data[c*8400i]索引GPU EP 初始化失败显示找不到 CUDA 库容器缺 cuDNN 或 CUDA 版本不对换匹配的 runtime 镜像装 cuDNN多线程调用后偶发崩溃/结果全零共享OrtSession非线程安全线程独立 session 或串行推理第一个问题通道顺序。YOLO 的训练数据通常是 RGB 顺序但 OpenCV/JavaCV 读取图像默认是 BGR。如果你直接把Mat转成 float 数组塞给模型模型会把红色通道当成蓝色通道来读特征对不上置信度全面下降。很多人以为是模型没训练好其实只是通道没转。第二个问题坐标还原。我在 3.2 节代码里给了公式(x - dw) / scale这里再三强调不是可加可不加的优化而是必要条件。原始 1920x1080 的图像 letterbox 到 640x640 时左上角被填充了约(640-1920*0.333)/2 ≈ 133像素的灰边。如果你不把这个偏移减掉检测框在水平方向和垂直方向都会偏出实际物体一大截。第三个问题张量索引。这可能是 Java 侧最容易错的地方因为大多数博客给的都是 Python 示例Python 里拿到的输出形状和内存访问方式很容易掩盖底层布局而 Java 拿到的float[]是裸内存你必须自己按行优先原则算偏移。我已经把 8400 这个数字和通道循环的写法写进了示例代码复制时注意别把i和c的位置调换。第四个问题GPU 库缺失。这种问题一上来通常是java.lang.UnsatisfiedLinkError或Caused by: java.io.IOException: Failed to load native library。看起来像 JNI 问题但根子经常是 CUDA runtime 版本不匹配。我在 4.1 节提过镜像版本一定要和 ONNX Runtime 内部编译目标对齐别信网上那种拿了镜像就跑的说法。第五个问题并发崩溃。这是生产环境最容易炸的雷。我在测试环境单线程调用一切正常压测工具一拉高并发偶尔出现进程直接崩掉或者返回一堆全零张量。最后定位就是多个线程并发调用同一个OrtSession.run。这个问题没有疑似必须在线程模型上解决不能依赖侥幸。结尾自己在实战里最大的体会是Java 集成 YOLO 本身并不复杂复杂的是把每个环节的“隐含假设”对齐。模型导出时使用的输入尺寸、letterbox 的填充规则、ONNX 输出的通道布局、容器里 CUDA 的版本每一个都对不上都会让整个链路卡死。我后来养成了一个习惯把模型文件的输出 shape 和输入预处理参数写进配置中心不硬编码在代码里。这样模型迭代时只要配置同步改Java 侧代码就不用动接口调用方也完全无感。最后再分享一个小技巧上线后保留一段时间的原始检测请求日志在前端反馈“框不准”时可以直接拿日志里存的原始图像离线复跑模型快速区分是模型问题、预处理问题还是前端绘制问题这个能力在生产排障时价值非常大。