聊聊机器学习模型落地的几个大坑

低代码旁观者
2026-07-06 15:05
阅读 761

说实话,写这篇文章的时候我刚从工位上站起来活动了一下,腰疼得厉害。坐标杭州,做外包第四年了,从最早给某大厂做外包驻场,到现在自己接一些私活,什么奇葩需求没见过。去年双11前,有个客户非要把一个机器学习模型塞进他们现有的Spring Boot微服务体系里,还要求响应时间在200ms以内。当时我听到这个需求,心里就咯噔一下——兄弟,你这是要我命啊。

不过话说回来,这活儿还是得干,毕竟甲方爸爸说了算。今天就把这段时间折腾机器学习部署的一些经验掏出来跟大家唠唠,希望能帮到同样在坑里挣扎的兄弟们。

这破需求到底要干嘛

先说说背景。客户是做电商推荐系统的,之前他们的推荐算法团队用Python搞了一套模型,训练效果还不错,准确率能到87%左右。但问题是,这套模型跑在Python环境里,跟他们主站的Java微服务体系完全是两套东西。运维那边天天骂娘,说维护两套技术栈太累了,而且Python服务的监控、限流、熔断这些能力都不完善,线上出过几次事故。

所以甲方的诉求很明确:把模型部署到Java体系里,用Spring Boot包一层,跟现有微服务无缝对接

听起来好像挺简单的对吧?把模型导出成ONNX格式,Java端用ONNX Runtime加载,再包个REST接口就完事了。我当时也是这么想的,结果一上手才发现,坑是一个接一个。

模型选型和格式转换的那些事

第一个大问题就是模型格式。他们算法团队用的是PyTorch,训练出来的模型是个ResNet变体,用于商品图片的特征提取。要把这个模型搬到Java端,得先搞清楚用什么推理框架。

我研究了一圈,最后选定了几个方案做对比:

方案 优点 缺点 Java支持度
ONNX Runtime 跨平台,性能好 部分算子不支持 官方支持
DJL (Deep Java Library) AWS出品,Java原生 生态还在建设中 原生
TensorFlow Java 生态成熟 包太大,启动慢 官方支持
Tribuo (Oracle) 纯Java实现 模型格式支持有限 原生

最后综合考虑,选了DJL + ONNX Runtime的组合。DJL的API设计比较优雅,而且对Spring Boot的集成也做得不错。

// DJL加载ONNX模型的核心代码
@Service
public class ImageFeatureExtractor {

    private Predictor<Image, float[]> predictor;

    @PostConstruct
    public void init() throws ModelException, IOException {
        // 这里有个坑:模型文件不能放在resources里直接加载
        // 因为DJL需要知道模型的输入输出shape
        Criteria<Image, float[]> criteria = Criteria.builder()
                .optApplication(Application.CV.IMAGE_CLASSIFICATION)
                .setTypes(Image.class, float[].class)
                .optModelUrls("file:/opt/models/resnet_feature.onnx")
                .optTranslator(new FeatureExtractorTranslator())
                .optEngine("OnnxRuntime")
                .build();

        ZooModel<Image, float[]> model = criteria.loadModel();
        this.predictor = model.newPredictor();
    }

    public float[] extract(Image image) throws TranslateException {
        return predictor.predict(image);
    }
}

说到模型转换,这里必须吐槽一下。他们算法同事给的PyTorch模型里用了几个自定义算子,导出ONNX的时候直接报错。我折腾了两天,最后用torch.onnx.export的时候加了opset_version=13,又把自定义算子替换成等价的标准算子,才算搞定。当时真的想砸电脑,心想我一个做后端的,凭什么要搞这些深度学习的东西。

后来我学聪明了,用Trae这个AI辅助工具来帮忙看PyTorch的算子文档,确实省了不少时间。Claude也帮了大忙,特别是在调试ONNX导出的时候,把报错信息丢给它,它能很准确地告诉你是哪个算子不支持,以及怎么替换。

Spring Boot集成的架构设计

模型能加载了,接下来就是怎么跟Spring Boot集成。这里我的设计思路是这样的:

核心原则:模型推理必须异步化,不能阻塞主线程。

为什么?因为模型推理是个CPU密集型操作,而且耗时不稳定,快的话30ms,慢的话可能200ms以上。如果直接在Controller里同步调用,一旦并发上来,Tomcat的线程池很快就被打满了。

我的方案是用线程池 + CompletableFuture来做异步处理:

@RestController
@RequestMapping("/api/v1/recommend")
public class RecommendController {

    @Autowired
    private ImageFeatureExtractor extractor;

    @Autowired
    private RecommendationService recommendationService;

    // 专用推理线程池,跟业务线程池隔离
    private final ExecutorService inferenceExecutor = 
        new ThreadPoolExecutor(
            4, 8, 60L, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(100),
            new ThreadFactoryBuilder()
                .setNameFormat("inference-thread-%d")
                .build(),
            new ThreadPoolExecutor.CallerRunsPolicy()
        );

    @PostMapping("/feature")
    public CompletableFuture<ResponseEntity<FeatureResponse>> getFeature(
            @RequestParam("image_url") String imageUrl) {
        
        return CompletableFuture.supplyAsync(() -> {
            try {
                // 1. 下载图片
                Image img = ImageFactory.getInstance()
                    .fromUrl(imageUrl);
                
                // 2. 模型推理
                float[] features = extractor.extract(img);
                
                // 3. 返回特征向量
                return ResponseEntity.ok(
                    new FeatureResponse(features));
            } catch (Exception e) {
                log.error("特征提取失败", e);
                return ResponseEntity
                    .status(500)
                    .body(new FeatureResponse("error"));
            }
        }, inferenceExecutor);
    }
}

这里有个细节要注意:线程池的核心线程数要跟CPU核心数匹配。我在4核的机器上设了4个核心线程,8个最大线程。设太多反而会因为线程切换导致性能下降。这个参数我是用JMeter压测调出来的,后面会说。

另外,图片下载这一步也是个坑。如果图片在OSS上,走内网下载还好,如果是外网URL,那延迟就不可控了。我后来加了个本地缓存,用Caffeine做了个LRU缓存,命中率能到60%以上,整体RT降了不少。

模型版本管理和热更新

这个需求是后来加上的。甲方说,算法团队会经常迭代模型,不能每次更新模型都重启服务。好吧,那就得做热更新。

我的思路是:模型文件放在一个固定目录,通过监听文件变化来触发模型重新加载。但这里有个问题——模型加载是耗时操作,大概需要3-5秒,这期间如果有请求进来怎么办?

解决方案是双缓冲(Double Buffering)

@Service
public class ModelManager {

    // 用AtomicReference保证线程安全
    private AtomicReference<Predictor<Image, float[]>> 
        currentPredictor = new AtomicReference<>();
    
    private AtomicReference<Predictor<Image, float[]>> 
        nextPredictor = new AtomicReference<>();

    @Scheduled(fixedDelay = 30000) // 每30秒检查一次
    public void checkModelUpdate() {
        Path modelPath = Paths.get("/opt/models/resnet_feature.onnx");
        long lastModified = Files.getLastModifiedTime(modelPath)
            .toMillis();
        
        if (lastModified > lastLoadTime) {
            try {
                // 1. 在后台线程加载新模型
                Predictor<Image, float[]> newPredictor = loadModel();
                nextPredictor.set(newPredictor);
                
                // 2. 原子替换引用
                Predictor<Image, float[]> old = 
                    currentPredictor.getAndSet(newPredictor);
                
                // 3. 关闭旧模型,释放内存
                if (old != null) {
                    old.close();
                }
                
                lastLoadTime = lastModified;
                log.info("模型热更新成功");
            } catch (Exception e) {
                log.error("模型加载失败,保持旧模型", e);
            }
        }
    }
}

这个方案的好处是,模型切换的过程中,正在处理的请求用的是旧模型,新请求用的是新模型,不会有任何中断。代价就是内存会短暂地占用两份模型,大概多占500MB左右。不过对于推荐系统来说,这点内存开销完全可以接受。

性能优化和压测

模型部署上去了,性能怎么样?这才是甲方最关心的。

我用JMeter做了一轮压测,初始结果惨不忍睹:

并发数 QPS 平均RT P99 RT 错误率
10 25 380ms 850ms 0%
50 32 1500ms 3200ms 2.1%
100 28 3400ms 6800ms 15.3%

这数据根本没法看。问题出在哪?我分析了一下,主要有几个原因:

  1. 模型推理是CPU密集型的,单线程处理太慢
  2. 图片下载和网络IO阻塞了推理线程
  3. JVM垃圾回收导致偶发长尾延迟

针对这些问题,我做了几个优化:

第一,引入批处理(Batching)。 把多个请求合并成一个batch一起推理,能显著提升吞吐量。DJL原生支持这个功能:

// 配置批处理
Criteria<Image, float[]> criteria = Criteria.builder()
        .optApplication(Application.CV.IMAGE_CLASSIFICATION)
        .setTypes(Image.class, float[].class)
        .optModelUrls("file:/opt/models/resnet_feature.onnx")
        .optEngine("OnnxRuntime")
        // 关键配置:开启批处理
        .optOption("batch_size", "8")
        .optOption("max_batch_delay", "10") // 最大等待10ms
        .build();

第二,图片预处理异步化。 把图片下载、resize、归一化这些操作放到单独的线程池里,不占用推理线程。

第三,JVM调优。 用了G1 GC,设置了-XX:MaxGCPauseMillis=50,减少GC停顿。同时把堆内存设到4G,避免频繁GC。

优化后的压测数据:

并发数 QPS 平均RT P99 RT 错误率
10 45 210ms 380ms 0%
50 120 400ms 680ms 0%
100 180 540ms 920ms 0.1%

这个数据甲方还算满意,虽然没达到200ms的RT要求,但考虑到模型本身的复杂度,他们也理解。

一些踩坑经验和心得

折腾了两个月,总算把这个项目交付了。回头看看,踩了不少坑,总结几点经验:

1. 模型大小要控制。 他们那个ResNet模型有200多MB,加载慢,占内存大。后来算法同事做了个模型蒸馏,压到50MB,性能几乎没损失,加载速度提升了3倍。所以部署前一定要跟算法团队沟通,看能不能优化模型大小。

2. 监控不能少。 模型推理的耗时、成功率、特征向量的分布,这些都要有监控。我用了Prometheus + Grafana,自定义了几个指标。有一次线上特征向量突然全变成0了,就是靠监控发现的,排查后发现是模型文件被误删了。

3. 降级策略要有。 模型服务挂了怎么办?我做了个降级方案:如果推理超时或者失败,就返回一个默认的特征向量。虽然推荐效果会差一些,但至少不会让整个链路挂掉。

4. 跟算法团队保持沟通。 很多后端同学觉得模型部署就是"把模型跑起来",其实不是。模型的输入输出格式、预处理逻辑、特征后处理,这些都要跟算法团队对齐。我就遇到过一次,算法团队改了模型的输入尺寸,没通知到我,线上直接报错。

最后说两句

做外包这些年,最大的感触就是:技术本身不是最难的,难的是怎么在有限的资源和时间内,把技术方案落地。 特别是机器学习部署这种跨领域的事情,既要懂模型,又要懂工程,还要会跟不同背景的人沟通。

对了,最近在用Trae和Claude辅助开发,确实能提效不少。特别是写一些样板代码、查文档、调试报错的时候,能省很多时间。不过该踩的坑还是得自己踩,AI只能帮你加速,不能替你思考。

好了,就写到这吧,产品经理又来找我对需求了。希望这篇文章能帮到正在折腾机器学习部署的兄弟们,少踩点坑,早点下班。

评论 0

最热最新
暂无评论
低代码旁观者Lv.1
0
影响力
0
文章
0
粉丝