「能不能不装 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.1 | 1.0× |
| Vector API + 单线程 | 9.8 | 2.4× |
| Vector API + 8 线程 | 31.2 | 7.6× |
| Vector API + 16 线程 | 38.6 | 9.4× |
| Vector API + 32 线程(超线程) | 39.1 | 9.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.4 | llama.cpp | 差距 | 内存占用(Jlama) |
|---|---|---|---|---|
| Qwen2.5-0.5B Q4_K_M | 112 t/s | 148 t/s | -24% | 堆外 680 MB |
| Qwen2.5-1.5B Q4_K_M | 46 t/s | 58 t/s | -21% | 堆外 1.7 GB |
| Qwen2.5-7B Q4_K_M | 9.8 t/s | 13.1 t/s | -25% | 堆外 5.2 GB |
| Qwen2.5-7B Q8_0 | 6.1 t/s | 8.4 t/s | -27% | 堆外 8.6 GB |
Jlama 大概是 llama.cpp 的 73~79%。这个差距比我预期的小。考虑到 llama.cpp 是高度优化的 C++ 实现,Jlama 作为纯 Java 能到这个水平已经不错。
还有几个数据:
| 指标 | Jlama | llama.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/s | 276 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 有三类模型调用:
- 意图识别:输入短(<100 token),输出极短(<10 token),QPS 高(峰值 240)。→ Jlama 内嵌,0.5B 模型;
- 文档摘要:输入长(2000~8000 token),输出中等,QPS 低(<5)。→ Jlama 内嵌,1.5B 模型;
- 复杂推理:需要强模型,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 的场景:
- 环境限制严格,不能装 Python 或 native 库。这是最强的理由,也是我们上它的唯一理由;
- 小模型 + 固定任务。意图识别、分类、抽取、短摘要这类,0.5B~3B 模型在 CPU 上完全够用,延迟 200ms 以内;
- 数据不能出进程。有些场景(比如处理敏感合同)模型调用也不能走网络,内嵌推理是唯一合规解;
- 已经在 JVM 技术栈上,团队没有 Python 运维能力。省掉一个技术栈的运维成本,价值不小。
不适合用 Jlama 的场景:
- 需要大模型。7B 以上在 CPU 上跑,10 t/s 的速度对交互式应用基本不可用;
- 有 GPU 且能装 Python。那 vLLM、SGLang 这些方案的吞吐是数量级的优势,没理由不用;
- 需要最新的模型架构。Jlama 的架构支持滞后,新模型经常要等几个月;
- 高并发在线服务。16 并发 214 t/s 的吞吐,撑不住 C 端流量。
一个数字化的边界:我们把「单次请求 < 500 token 输出、QPS < 50、允许 300ms 以上延迟」作为内嵌推理的适用范围。超过任何一条就走外部推理服务。
成本账
最后的成本核算。客户机房那套部署,对比「内嵌 Jlama」和「单独部署 llama.cpp 服务」两个方案:
| 项 | 内嵌 Jlama | llama.cpp 服务 |
|---|---|---|
| 部署单元 | 1 个 JAR | 1 个 JAR + 1 个二进制 + 1 个模型服务进程 |
| 进程数 | 1 | 2(客户要求最少化进程) |
| 内存总占用 | 6.1 GB | 5.4 GB |
| 运维复杂度 | 和现有服务一致 | 多一套监控和发布流程 |
| 性能 | 基准 1.0× | 1.32× |
| 实施人力 | 5 人日 | 9 人日(含客户环境评审) |
性能上 llama.cpp 快 32%,但在客户那个受限环境里,多一个进程就要多一轮安全评审(他们那边一次评审 3 周)。我们宁可牺牲 32% 的性能,换掉 3 周的审批周期和一套额外的运维负担。
这个权衡在其他场景不一定成立。如果是在我们自己的云环境里,我会毫不犹豫选 llama.cpp。
下篇预告
这篇先把《Jlama:在纯 JVM 进程里跑大模型推理》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。