Administrator
发布于 2026-04-11 / 326 阅读
5

Jlama:在纯 JVM 进程里跑大模型推理

「能不能不装 Python」

起因是运维同事的一个要求。我们的一个内部 Agent 需要在客户机房部署,而客户的环境管控很严:不能装 Python,不能连外网,只能给一个 JAR 包和一个 JDK 21。

我第一反应是这个需求没法满足——跑模型怎么可能不用 Python。然后我想起去年在技术雷达里标记过的 Jlama,就花了一周做了验证。

这篇是验证结果。结论是能跑,但只在特定场景下划算。我们把最终方案的一部分用了它,另一部分还是走了传统路线。

Jlama 是什么

简单说,Jlama 是一个纯 Java 的推理引擎,能加载 GGUF 格式的模型文件在 JVM 进程里做推理。核心特点:

  • 无 JNI、无 native 库,纯 Java 实现(用 Panama Vector API 做 SIMD 加速);
  • 支持 GGUF(llama.cpp 的格式),生态里的量化模型可以直接用;
  • 支持 Llama、Mistral、Qwen、Gemma、Phi 等主流架构;
  • 可以作为独立服务跑,也可以内嵌到你的 Java 应用里。

最后一点是关键。它不是一个「Java 调 Python」的封装,是真正在 JVM 里跑的推理。

<dependency>
    <groupId>com.github.tjake</groupId>
    <artifactId>jlama-core</artifactId>
    <version>0.8.4</version>
</dependency>
<!-- 要 native 加速就加这个,会带平台相关的 SIMD 实现 -->
<dependency>
    <groupId>com.github.tjake</groupId>
    <artifactId>jlama-native</artifactId>
    <classifier>${os.detected.classifier}</classifier>
    <version>0.8.4</version>
</dependency>

第一次跑起来

先说个前提:GGUF 模型文件需要提前下载。我用的是 Qwen2.5-1.5B-Instruct 的 Q4_K_M 量化版本,1.1 GB。

# 下载模型(这一步需要外网,在能联网的机器上做)
$ huggingface-cli download Qwen/Qwen2.5-1.5B-Instruct-GGUF \
    qwen2.5-1.5b-instruct-q4_k_m.gguf --local-dir ./models

$ ls -lh models/
-rw-r--r--  1.1G  qwen2.5-1.5b-instruct-q4_k_m.gguf

然后是最简单的调用:

public class JlamaQuickStart {

    public static void main(String[] args) throws Exception {
        Path modelPath = Path.of("./models/qwen2.5-1.5b-instruct-q4_k_m.gguf");

        AbstractModel model = ModelSupport.loadModel(
            modelPath,
            DType.F32,               // 计算精度
            DType.I8,                // KV cache 量化精度
            Optional.empty(),        // 不指定草稿模型
            (int) Runtime.getRuntime().availableProcessors()
        );

        PromptContext ctx;
        Scanner in = new Scanner(System.in);
        while (true) {
            System.out.print("> ");
            String line = in.nextLine();
            if ("exit".equals(line)) break;

            ctx = model.promptContext(InferenceArguments.builder()
                .temperature(0.3f)
                .maxTokens(512)
                .build());

            // 流式生成
            model.generate(ctx, line, (token, elapsed) -> {
                System.out.print(token);
                return true;         // 返回 false 可中断
            }, false);
            System.out.println();
        }
    }
}

跑起来第一感受是。在我的开发机(M2 Pro,16 核)上,1.5B 的 Q4 模型,生成速度约 14 tokens/s。作为对比,同样的模型用 llama.cpp 在同样机器上能到 48 tokens/s。

差距 3.4 倍,这个数字让我一度想放弃。但后来发现我犯了个配置错误。

关键:Vector API 有没有生效

Jlama 的性能几乎完全依赖 JDK 的 Vector API(JEP 469,JDK 22 转正,JDK 21 里是第八次预览)。如果 Vector API 没启用,它会退化到标量计算,性能差一个数量级。

我第一次跑的时候没加预览参数,所以走的是标量路径。加上之后:

java --enable-preview \
     --add-modules jdk.incubator.vector \
     -Djdk.incubator.vector.VECTOR_ACCESS_OOB_CHECK=0 \
     -Xmx6g -Xms6g \
     -jar myapp.jar

再测:

配置tokens/s(Qwen2.5-1.5B Q4_K_M)相对标量
标量(Vector API 未启用)4.11.0×
Vector API + 单线程9.82.4×
Vector API + 8 线程31.27.6×
Vector API + 16 线程38.69.4×
Vector API + 32 线程(超线程)39.19.5×
llama.cpp(同机,16 线程)48.3

从 4.1 到 38.6,差了 9.4 倍。 这个教训很实在:用 Jlama 之前第一件事就是确认 Vector API 生效了,否则跑出来的性能数据毫无意义。

怎么确认?加个启动检查:

@PostConstruct
void checkVectorApi() {
    int bits = IntVector.SPECIES_PREFERRED.vectorBitSize();
    log.info("Vector API enabled, preferred bits = {}", bits);
    if (bits < 256) {
        log.warn("""
            Vector API 未生效或位宽过低({} bits),Jlama 性能会严重下降。
            请确认启动参数包含 --add-modules jdk.incubator.vector
            """, bits);
    }
}

线程数超过物理核数之后基本不涨了(16 → 32 只涨了 1.3%),说明已经打满内存带宽了。这是个重要信号:推理是内存带宽密集型,不是计算密集型

和 llama.cpp 的完整对比

我在同一台机器上(AMD EPYC 7543,32 核,128G DDR4-3200)做了完整对比。测试用 Qwen2.5 系列三个尺寸,prompt 长度 512,生成 256 tokens。

模型 / 量化Jlama 0.8.4llama.cpp差距内存占用(Jlama)
Qwen2.5-0.5B Q4_K_M112 t/s148 t/s-24%堆外 680 MB
Qwen2.5-1.5B Q4_K_M46 t/s58 t/s-21%堆外 1.7 GB
Qwen2.5-7B Q4_K_M9.8 t/s13.1 t/s-25%堆外 5.2 GB
Qwen2.5-7B Q8_06.1 t/s8.4 t/s-27%堆外 8.6 GB

Jlama 大概是 llama.cpp 的 73~79%。这个差距比我预期的小。考虑到 llama.cpp 是高度优化的 C++ 实现,Jlama 作为纯 Java 能到这个水平已经不错。

还有几个数据:

指标Jlamallama.cpp(HTTP server)
冷启动(含模型加载)2.4s(1.5B)1.1s
常驻内存(1.5B)2.1 GB(含 JVM)1.4 GB
支持并发批处理有(continuous batching)
16 并发吞吐(1.5B)214 t/s276 t/s

内存这项要注意:JVM 自身占 400 MB 左右,加上堆外模型 1.7 GB,总内存 2.1 GB。llama.cpp 只要 1.4 GB。在内存受限的容器里,这 700 MB 的差距是要算进去的。

GGUF 支持的坑

不是所有 GGUF 都能直接加载。我试了 11 个模型,成功 9 个,失败 2 个。

模型结果说明
Qwen2.5 全系列成功0.5B / 1.5B / 7B 都 OK
Llama 3.2 1B / 3B成功
Mistral 7B v0.3成功
Gemma 2 2B成功需要额外配置 tokenizer
Phi-3.5 mini成功
DeepSeek-R1-Distill-Qwen-7B失败架构不支持,报 Unsupported model architecture
某社区微调的 Qwen(修改了 config)失败GGUF 元数据里的超参缺失

失败的那个 DeepSeek 蒸馏版挺可惜的,因为它正是我们想用的。报错是:

java.lang.IllegalArgumentException: Unsupported model architecture: deepseek2
    at com.github.tjake.jlama.model.ModelSupport.loadModel(ModelSupport.java:142)
    at ...

所以选型之前第一件事是确认你的目标模型在支持列表里。Jlama 的架构支持是硬编码的枚举,加一个新架构要改源码。

还有个量化格式的限制:只支持部分 GGUF 量化类型。Q4_K_M、Q5_K_M、Q6_K、Q8_0、F16 都行,但一些新的量化方法(比如 iq4_xs、q4_k_s 在某些版本)会失败。实测下来 Q4_K_M 兼容性最好,也是我们的默认选择。

生产集成:我们最终怎么用的

回到最初的需求:客户机房不能装 Python。最终方案是混合部署——不是所有推理都在 JVM 里做。

我们那个 Agent 有三类模型调用:

  1. 意图识别:输入短(<100 token),输出极短(<10 token),QPS 高(峰值 240)。→ Jlama 内嵌,0.5B 模型
  2. 文档摘要:输入长(2000~8000 token),输出中等,QPS 低(<5)。→ Jlama 内嵌,1.5B 模型
  3. 复杂推理:需要强模型,QPS 极低。→ 不内嵌,走客户已有的 GPU 推理服务

前两类用 Jlama 的理由很充分:它们量大、简单、且不允许有外部依赖。第三类用外部服务的理由是:7B 以上模型在 CPU 上跑不现实,客户机房有 GPU 机器,没必要硬扛。

集成代码,封装成一个统一的接口,两边可切换:

@Service
public class LocalInferenceService implements ChatModel {

    private final AbstractModel model;
    private final ExecutorService pool;
    private final Semaphore concurrency;      // 限并发,保护内存带宽

    public LocalInferenceService(@Value("${ai.local.model-path}") String path) {
        this.model = ModelSupport.loadModel(
            Path.of(path), DType.F32, DType.I8,
            Optional.empty(),
            Math.min(16, Runtime.getRuntime().availableProcessors()));

        this.pool = Executors.newFixedThreadPool(8);
        this.concurrency = new Semaphore(16);   // 超过 16 并发吞吐不再增长
    }

    @Override
    public String call(String prompt) {
        if (!concurrency.tryAcquire()) {
            throw new ModelBusyException("本地推理队列已满,请稍后重试");
        }
        try {
            PromptContext ctx = model.promptContext(
                InferenceArguments.builder()
                    .temperature(0.1f)
                    .maxTokens(256)
                    .build());

            StringBuilder out = new StringBuilder(512);
            model.generate(ctx, prompt, (token, elapsed) -> {
                out.append(token);
                return out.length() < 4096;     // 防止失控
            }, true);
            return out.toString();
        } finally {
            concurrency.release();
        }
    }
}

几个必须做的控制:

  • 并发信号量。前面测过了,超过物理核数吞吐不涨只涨延迟,所以限死在 16;
  • 输出长度上限。模型偶尔会「刹不住车」,我们在 callback 里判断长度主动中断;
  • KV cache 用 I8 量化DType.I8 相比 F32 能省 60% 的 KV cache 内存,长上下文场景差别巨大。实测 4096 上下文下,F32 的 KV cache 是 512 MB,I8 是 204 MB。

JVM 配置

这块踩了坑,单独说。

# 最终配置(1.5B 模型,容器 4C8G)
-Xms2g -Xmx2g                          # 堆要小!模型在堆外
-XX:MaxDirectMemorySize=4g             # 模型权重 + KV cache 都在堆外
-XX:+UseZGC                            # 堆小,用 ZGC 保证低延迟
-XX:MaxMetaspaceSize=256m
--add-modules jdk.incubator.vector
--enable-preview
-Djdk.incubator.vector.VECTOR_ACCESS_OOB_CHECK=0

堆只需要 2G,堆外要 4G。这个分配和传统 Java 服务完全相反,我一开始按老经验设了 -Xmx6g,结果堆外不够,加载模型时直接 OOM:

java.lang.OutOfMemoryError: Cannot reserve 1717986918 bytes of direct memory
    at java.base/java.nio.Bits.reserveMemory(Bits.java:178)
    at com.github.tjake.jlama.tensor.AbstractTensor.allocateDirect(...)

另外 VECTOR_ACCESS_OOB_CHECK=0 这个参数在 JDK 21 上有约 8% 的性能收益(跳过越界检查),但要注意它和 --enable-preview 一起用会打印警告,需要显式忽略:

WARNING: A restricted method in java.lang.foreign.Linker has been called
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning

适用场景判断

做了这一轮,我的判断是这样。

适合用 Jlama 的场景:

  1. 环境限制严格,不能装 Python 或 native 库。这是最强的理由,也是我们上它的唯一理由;
  2. 小模型 + 固定任务。意图识别、分类、抽取、短摘要这类,0.5B~3B 模型在 CPU 上完全够用,延迟 200ms 以内;
  3. 数据不能出进程。有些场景(比如处理敏感合同)模型调用也不能走网络,内嵌推理是唯一合规解;
  4. 已经在 JVM 技术栈上,团队没有 Python 运维能力。省掉一个技术栈的运维成本,价值不小。

不适合用 Jlama 的场景:

  1. 需要大模型。7B 以上在 CPU 上跑,10 t/s 的速度对交互式应用基本不可用;
  2. 有 GPU 且能装 Python。那 vLLM、SGLang 这些方案的吞吐是数量级的优势,没理由不用;
  3. 需要最新的模型架构。Jlama 的架构支持滞后,新模型经常要等几个月;
  4. 高并发在线服务。16 并发 214 t/s 的吞吐,撑不住 C 端流量。

一个数字化的边界:我们把「单次请求 < 500 token 输出、QPS < 50、允许 300ms 以上延迟」作为内嵌推理的适用范围。超过任何一条就走外部推理服务。

成本账

最后的成本核算。客户机房那套部署,对比「内嵌 Jlama」和「单独部署 llama.cpp 服务」两个方案:

内嵌 Jlamallama.cpp 服务
部署单元1 个 JAR1 个 JAR + 1 个二进制 + 1 个模型服务进程
进程数12(客户要求最少化进程)
内存总占用6.1 GB5.4 GB
运维复杂度和现有服务一致多一套监控和发布流程
性能基准 1.0×1.32×
实施人力5 人日9 人日(含客户环境评审)

性能上 llama.cpp 快 32%,但在客户那个受限环境里,多一个进程就要多一轮安全评审(他们那边一次评审 3 周)。我们宁可牺牲 32% 的性能,换掉 3 周的审批周期和一套额外的运维负担。

这个权衡在其他场景不一定成立。如果是在我们自己的云环境里,我会毫不犹豫选 llama.cpp。

下篇预告

这篇先把《Jlama:在纯 JVM 进程里跑大模型推理》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考