
很多做 Java 后端的朋友这两年估计都有一个共同感受人工智能项目铺天盖地但 Python 生态的 AI 模型要落到自己维护的 Spring Boot 服务里总有种“隔行如隔山”的别扭。要么在 Python 侧起一个独立服务走 HTTP 接口来回传数据要么用 JNI 去调底层 C 推理库一调就是一道维护的坎。这套链路也不是不能跑但调试成本、部署成本、团队协作成本全都在暗处慢慢堆高。Java 人工智能框架的成熟其实就是在解决这个问题让你在 JVM 内直接完成模型的加载、推理和结果解析不跨进程、不绕语言边界把 AI 能力当普通 Java 库一样引进来。这篇文章就围绕这个思路展开聊聊我用 DJL 和 Spring AI 这两类框架落地的实际经验包括选型怎么定、代码怎么写、生产环境里哪些坑必须提前踩。不画大饼只讲能直接抄走的方案。1. 为什么 Java 团队需要一条独立的 AI 落地路线先审视一个场景你所在的团队全部由 Java 工程师构成业务系统是典型的微服务架构有完善的 CI/CD、监控运维体系。现在产品经理提了个需求——给已有系统加一个图像分类能力。如果是 Python 团队他们大概率直接起一个 FastAPI 服务把 PyTorch 模型加载进去就完事。但 Java 团队要接问题就来了。1.1 “工程易、算法难”的尴尬跨语言调用有多苦各路模型大多用 Python 训练推理也自然首选 Python。Java 要调它看到的基本是三条路每条都带暗伤HTTP/GRPC 远程调用Python 服务独立部署Java 通过网络拿结果。架构清晰但引入了额外的服务治理、网络延迟和可用性维护问题小型团队多维护一个 Python 服务压力不是一般的大JNI 或进程调用直接调 C 推理库或者拿 Java 去 exec Python 脚本。性能很直接但每换一个底层库就要重编一遍 JVM 桥接错误排查难如登天一到生产环境你就会发现连环境变量都能卡你半天规则替代纯 Java 代码里写一堆启发式规则试图“近似”模型的效果。做做 Demo 可以真正上线后维护成本比写个正经模型还高。这三条路我都试过最后都回到了同一个结论Java 吐槽 Python 环境难管Python 吐槽 Java 依赖太重真正的病灶根本不是算法本身而是运行时隔离。1.2 Java AI 框架给 JVM 带来的本质变化Java 人工智能框架的核心价值是把推理能力“下沉”到 JVM 内部。以 DJLDeep Java Library为例它做的事情很直接——在 Java 层封装了一套统一 API底层可以挂 PyTorch、TensorFlow、ONNX Runtime 等原生推理引擎。你写的 Java 代码不关心当前跑在哪个引擎上模型加载、输入预处理、推理执行、输出解析全部发生在同一个 JVM 进程里。带来的好处很实际不再需要第二套部署单元原有的告警、日志、灰度体系直接覆盖 AI 服务Java 工程师只需理解“模型输入是什么、输出是什么”不需要学 Python内存中的中间结果可以直接复用业务对象不用把 Tensor 转 JSON 再转业务 DTO省掉一大截开销。这才谈得上“高效”。Java 团队不用变成算法团队也能在既有架构内把 AI 能力做成常态化业务功能。2. 快速选型Java AI 框架各有各的“主场”市面上的 Java AI 框架不少但绝对不是某个框架能包打天下。我习惯把选型问题拆成“场景类型、团队技术栈、部署方式、模型来源”四个维度来看你再对照选型就容易得多。2.1 主流 Java AI 框架横向对比下面这个表是我综合近几年实践经验整理的思路不是官方定义但对决策很有帮助框架底层能力最佳适用场景上手难度典型示例DJL统一支持 PyTorch / TensorFlow / ONNX深度学习模型推理CV / NLP中图像分类、目标检测Spring AI适配各类大模型 API 及本地推理服务对话、生成式应用、LLM 工作流低知识库问答助手Smile纯 Java 机器学习算法库表格数据、统计模型、数据挖掘中聚类、分类、回归Tribuo纯 Java ML 模型格式兼容传统机器学习 可解释性需求中高欺诈检测、评分卡ONNX Runtime Java 绑定ONNX 模型推理加速引擎已有 ONNX 模型追求极致推理性能高自定义算子、多模型调度2.2 决策路径别按“哪个流行”选按“瓶颈在哪”选选框架之前先回答下面三个问题每一题都会帮你排除一批候选你手里的模型是什么格式如果是通用的 PyTorch 或 TensorFlow 权重文件DJL 是最省心的如果是 ONNX可以直接评估 ONNX Runtime 的 Java 绑定性能上限更高如果根本没有模型文件只有一堆结构化业务数据Smile 或 Tribuo 可能才是你真正要的工具。你的业务需求是“理解图像文字”还是“生成内容”前者几乎只能走深度学习推理DJL 这类框架是必需品后者通常意味着接入外部大模型能力Spring AI 的抽象会让你省下大量模板代码。你的部署形态允许引入多少 native 依赖DJL 依赖底层引擎的 native 库体积不小Spring AI 本质是 HTTP 客户端部署上极轻量。如果团队对容器镜像体积有硬指标要提前权衡。拿我自己的经历来说某跨平台系统要做一个“图像合规性检查”模块我直接上了 DJL因为上线目标是把本地训练好的 PyTorch 模型无缝集成进旧系统且已有运维体系不许新增独立 Python 节点另一个某公司内部智能问答助手需求是快速接入大模型对话运营团队希望后续能灵活切换底层模型Spring AI 明显更合适。两个项目放到同一套技术体系下反而不合适。2.3 一个被反复低估的选项直接拥抱 ONNX 生态如果你们的算法团队已经能把模型导出成 ONNX 格式我特别建议关注一下 ONNX Runtime 在各语言上的表现。Java 绑定虽然文档不多但稳定性非常好。执行图被静态优化之后推理延迟往往比直接跑 PyTorch 更低。我们曾经在一个目标检测模型上做过对比ONNX Runtime 的 CPU 推理耗时比 PyTorch 原生低大约 18%内存占用也更平缓。关键点在于模型已经是 ONNX你就不再用 DJL 那套封装的 Translator 逻辑转而直接定义输入输出名和维度。代码更接近底层可调节的参数也更细。代价是需要自己处理的东西变多了——没有现成的 ModelZoo没有自动下载一切从本地模型文件开始。但换来的是整个服务几乎不再需要 Python 侧的间接层。3. 实操用 DJL 把图像分类模型集成进 Spring Boot 服务接下来给出一套可以直接落地的过程。这段基于我做的某图像分析 Demo模型用的是预训练 ResNet50服务框架是 Spring Boot 3构建工具 Gradle。这套配置在 JDK 17 和 DJL 0.26.0 下运行稳定。3.1 搭建最小工程依赖、模型、目录结构先看关键的 Gradle 配置plugins { id java id org.springframework.boot version 3.2.5 } dependencies { implementation org.springframework.boot:spring-boot-starter-web // DJL 核心 implementation ai.djl:api:0.26.0 // PyTorch 引擎会自动根据当前系统下载对应 native 库 implementation ai.djl.pytorch:pytorch-engine:0.26.0 // ModelZoo 提供预训练模型下载能力 implementation ai.djl:model-zoo:0.26.0 }注意pytorch-engine加载时会从标准仓库拉取 native JNI 库这个我们后面在坑点部分细说。先把这个目录结构定好src/main/resources/ ├── models/ # 本地模型存放目录 │ └── resnet50.pt ├── application.yml模型文件不一定必须放 resources你也可以放在外部磁盘路径通过配置项传入。DJL 加载模型时传一个Path即可这一点很灵活。3.2 编写可复用的推理服务类关键代码拆成三个部分每一部分都有设计考量。模型加载和推理封装Service public class ImageClassificationService { private final Model model; private final PredictorImage, Classifications predictor; public ImageClassificationService() throws MalformedModelException, IOException { // 1. 单例加载模型避免每次请求都创建一个 Model CriteriaImage, Classifications criteria Criteria.builder() .optModelPath(Paths.get(src/main/resources/models/resnet50.pt)) .optTranslator(new MyTranslator()) .optEngine(PyTorch) .build(); model criteria.loadModel(); // 2. Predictor 不是线程安全的但可以被同一线程反复使用 predictor model.newPredictor(); } public Classifications predict(Image image, int topK) throws TranslateException { return predictor.predict(image).topK(topK); } PreDestroy public void close() { predictor.close(); model.close(); } }这里最需要注意的是Predictor是线程不安全的但它内部可以反复调用所以如果你既想保证并发安全又想避免频繁 new通常做法是设计一个对象池或者按线程维度给每个线程分配一个 Predictor。Spring 场景下我更推荐简单点每个预测请求加一个synchronized或者用并发控制等吞吐量上来了再升级成池化方案。MyTranslator要完成图像预处理和后处理。这个类决定了模型能不能和你手里的图片数据“对得上”public class MyTranslator implements TranslatorImage, Classifications { Override public NDList processInput(TranslatorContext ctx, Image input) { // 1. 中心裁剪到 224x224然后归一化 input input.getWrappedImage(); if (input.getWidth() ! 224 || input.getHeight() ! 224) { input ImageFactory.getInstance() .resize(input, 224, 224, Image.Type.CENTER_CROP); } NDArray array input.toNDArray(ctx.getNDManager(), Image.Flag.COLOR); // 2. PyTorch 预训练模型需要减均值再除方差 double[] mean {0.485, 0.456, 0.406}; double[] std {0.229, 0.224, 0.225}; array array.sub(mean).div(std); // 3. 调整维度为 NCHW return new NDList(array.transpose(2, 0, 1).expandDims(0)); } Override public Classifications processOutput(TranslatorContext ctx, NDList list) { NDArray probabilities list.get(0).softmax(1); return new Classifications(probabilities); } }预处理代码的每一个步骤都对应 ResNet50 的标准输入要求。团队里如果没人读过原模型文档这段代码很容易写错。这里的核心经验是不要靠“感觉”记忆均值方差直接从模型的预处理配置里抄。3.3 暴露 REST 接口并把异步能力加进去上面那个 Service 是同步阻塞的如果并发起来一个推理请求要几百毫秒到几秒不等业务接口的线程池很容易被撑爆。这里我通常会再加一层异步RestController RequestMapping(/api/classify) public class ClassificationController { private final ExecutorService executors Executors.newFixedThreadPool(4); private final ImageClassificationService service; PostMapping public CompletableFutureResultDTO classify(RequestBody MultipartFile file) { return CompletableFuture.supplyAsync(() - { try { Image img ImageFactory.getInstance() .fromInputStream(file.getInputStream()); Classifications result service.predict(img, 3); return ResultDTO.of(result); } catch (Exception e) { throw new RuntimeException(推理执行失败, e); } }, executors); } }CompletableFuture配独立线程池可以让 Web 容器线程快速归还给请求处理避免默认 Tomcat 线程池积压。这里线程池大小可以按 CPU 核数 模型单次推理耗时来估算比如单次推理 200ms希望吞吐 20 QPS大约 4 个线程起步。3.4 实测效果与性能基线上面这套在我本机实测ResNet50 在 CPU 上的单张推理耗时约 180~250ms取决于机器内存占用在 heap 1GB 左右 native 内存约 1.2GB。如果是多人并发的场景建议上 GPU 或者换个轻量化模型比如 MobileNet 系列的延迟可以降到 30ms 以内。注意DJL 加载 PyTorch 模型后有一部分内存不在 JVM 堆内属于 native memory。排查内存问题时不要只看 JVM Xmx。4. 组合拳Spring AI 让 LLM 应用开发不再从堆轮子开始DJL 解决的是“深度学习模型如何进 JVM”而 Spring AI 解决的是“大模型应用如何和 Spring 生态无缝咬合”。我落地过的某公司内部智能问答助手就是把 Spring AI 当主角把业务数据源、权限控制、文档检索全部串在服务里。4.1 Spring AI 的核心抽象ChatClient 模板Spring AI 提供ChatClient用法有点像JdbcTemplate核心思想是把多变的模型供应商 API 差异压平。不管你想接的是哪家大模型接口还是本地部署的推理服务只要实现了统一的接口上层代码几乎不用改。一个最小可用的示例Service public class AssistantService { private final ChatClient chatClient; public AssistantService(ChatClient.Builder builder) { this.chatClient builder.build(); } public String ask(String question) { return chatClient.prompt() .user(question) .call() .content(); } }就这几行一个可控的对话接口就通了。里面有几个容易忽略的细节ChatClient.Builder可以从配置文件里读取模型地址、密钥、超时等参数.user()可以是纯字符串也可以是一个Message对象支持多轮对话历史的组装.call()是同步阻塞有对应的.stream()方法拿到流式响应适合打字机效果。4.2 让 LLM 输出“直接变成”业务对象LLM 应用的开发痛点往往不是“能不能对话”而是“模型输出怎么变成结构化数据”。Spring AI 的 Structured Output 功能这时候非常实用public record ProductInfo(String productName, String category, BigDecimal price) {} ProductInfo info chatClient.prompt() .user(请从以下商品描述中提取字段...) .call() .entity(ProductInfo.class);底层会让模型输出一段 JSON再由框架反序列化到目标类型。这里有个经验字段名语义要足够明确否则模型不知道某个字段该填什么。例如把price声明为BigDecimal时描述里最好带上“价格只输出数字”这类约束词否则模型可能会给你返回约100元这种脏数据。如果你要给多个场景复用模板我建议把 prompt 模板外置到配置文件而不是写死在注解里String systemPrompt 你是内部智能助手回答要简短只依据给定文档内容回答。 ;4.3 本地模型也能接入搬到内网毫无压力很多企业场景不允许多余网络交互Spring AI 对本地推理服务同样好使。你把大模型跑在本地推理引擎上例如通过 Ollama 这类工具Spring AI 只需改几个配置项就能无缝切换。接口层面感受不到差别这对架构师来说是最舒服的状态——前期调试用云服务真正上线换成本地模型业务代码一行不动。注意Spring AI 本身极轻量设计上就是一个更高级的 HTTP 客户端 prompt 管理工具。如果你的团队只是临时调用一次 API不一定非要引入它只有你的应用里存在多轮对话、角色设定、模型切换、结构化输出等复杂场景时它的价值才真正凸显。5. 生产环境落地性能调优、部署形态与运维策略写代码只是顺利走通第一步。在真实环境里Java AI 应用要接得住流量还有三件事必须提前打算好。5.1 推理内存与 JVM 参数调整别乱调堆大小Java 工程师拿到 DJL 后最容易犯的错是发现内存飙升就无限调大-Xmx。但 DJL 的 native 内存根本不受Xmx管理你调大了堆native 内存一样涨进程迟早被 OOMKilled。我总结的调参顺序是先固定模型和无状态服务占用的 native 内存基线用-XX:MaxDirectMemorySize或容器内存限制去约束总量再把 JVM 堆调成整体内存的 50%~60% 左右给 native 内存留出余地开-XX:ExitOnOutOfMemoryError让内存异常时迅速重启而不是僵死半秒超时模型推理如果是 CPU 为主关注线程数而不是堆大小通常一个 CPU 核只跑一个推理线程。5.2 推理批处理小请求合并成大 batch吞吐大幅提升单个请求逐个推理CPU 利用率上不去。如果业务侧对延迟不太敏感可以设计一个批处理队列多个图像或文本请求先排队攒够 4 个或 8 个再一次性推理。这个技巧对并发场景尤其有效。批量推理时的关键点在于预处理的统一。比如图像大小不同batch 前必须全部 resize 到同一尺寸序列长度不同文本要统一 padding 到最长长度。这些都要在 Translator 的batchProcessInput里定制DJL 的抽象会给你保留相应扩展点。5.3 模型文件的生命周期管理注册、灰度、回滚模型不是静态文件。上线后一旦算法团队给了新权重整个服务要不要跟着发版我的经验是模型文件与代码工程分离用配置中心管理模型版本用单独的存储来挂载模型文件。比如在容器环境把模型目录挂载成外部卷发布时只发布新模型到目标路径然后做一个平滑重启。这样服务代码没有变化时只是模型文件替换就能实现模型更新的快速试错。如果推理效果不达标直接切回上一个版本的目录即可。这个流程用 Java 代码量不多但架构层面能省下无数半夜被叫醒的时间。5.4 两种部署形态的经验内嵌式 vs 独立推理服务有些团队愿意把 AI 推理直接内嵌到业务服务团队小、模型少的时候这样最清爽但一旦你需要给多个微服务共享同一个模型或者单个模型对 GPU 有强依赖独立推理服务反而更合理。如果是独立推理服务形态推荐直接用 gRPC 而不是 HTTP。JVM 之间用 gRPC 做 protobuf 传输序列化开销低流式传输支持好。Java AI 框架本身管理模型外层再用 gRPC Gateway 暴露出来灵活性和性能都能兼顾。6. 常见问题与排查技巧实录这块内容来自我的实战笔记基本每个项目都会遇到其中几条。问题特征排查思路解决方法native library not found启动加载引擎直接抛 UnsatisfiedLinkError检查 native 库是否在 classpath 或指定路径手动指定-Djava.library.path或用容器构建时预装依赖库模型加载超时初次运行卡在下载模型阶段看日志是网络异常还是模型路径不存在设置镜像仓库/提前下载模型到本地目录避免运行时下载堆外内存溢出进程无响应容器内存超限被 kill不只看 JVM heap要看容器整体 RSS限制 DirectMemory或者降低并发 batch size并发推理互相卡顿压测时大量线程等待排查是否共用同一个 Predictor设计 Predictor 对象池按线程分发实例CPU 推理延迟波动剧烈前几次很快后来越来越慢JIT 未热或垃圾回收周期影响启动时加预热请求让代码路径提前编译中文内容出现乱码返回内容编码异常检查 Reader/Writer 字符集配置统一 UTF-8并检查模型的 tokenizer 配置Tensor 维度不匹配推理报维度错误在 Translator 打印 shape 对比模型期望对输入做更严格的 resize/padding写单元测试锁 shape这里想重点讲一个比较隐蔽的坑模型预热。模型加载完成后第一轮推理往往要比后续慢好几个数量级因为 JIT 还未生效、相关 native 算子尚未缓存。如果在服务刚启动时立刻有真实流量进来很容易触发超时告警。所以现在我把这一步直接写进启动流程EventListener(ApplicationReadyEvent.class) public void warmUp() throws Exception { Image placeholder ImageFactory.getInstance() .fromFilePath(classpath:warmup.jpg); service.predict(placeholder, 3); log.info(模型预热完成); }别小看一行预热代码它能让服务刚铺完流量时的 P99 延迟不至于冲上天。7. 最后讲讲我的真实体感跑完这些 Java AI 项目之后我最大的感悟是框架从来不是瓶颈工程意识才是。DJL 和 Spring AI 已经把“加载一个模型”和“调用一个大模型”这两件事简化到近乎极致的程度真正决定项目成败的是团队有没有想清楚输入输出边界、模型版本怎么管、并发特征是什么、部署环境允许多大的 native 脏内存。Java 工程师做 AI多数时候不是在和算法较劲而是在和自己习惯的工程模式较劲——能早一点把“模型当库依赖”这个思路建立起来后面的路会顺很多。如果你的团队还没起步我建议第一步不要贪大先挑一个十几行代码就能跑通的最小 Demo 试试水。比如用 DJL 加载一个预训练模型做图片分类再配一个 Spring Boot 接口做输出。等这个链路完全打通你对框架底层的脾气摸熟了再往多模型、批处理、独立推理服务上延伸也不迟。最后再分享一个小习惯把模型文件、配置参数和代码版本三位一体地管理好哪怕只加一个简单的模型版本字段也会在故障排查时救你一命。