聊聊机器学习模型落地的几个大坑
说实话,写这篇文章的时候我刚从工位上站起来活动了一下,腰疼得厉害。坐标杭州,做外包第四年了,从最早给某大厂做外包驻场,到现在自己接一些私活,什么奇葩需求没见过。去年双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% |
这数据根本没法看。问题出在哪?我分析了一下,主要有几个原因:
- 模型推理是CPU密集型的,单线程处理太慢
- 图片下载和网络IO阻塞了推理线程
- 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