在 Spring Boot 3.4.5 中通过 HuggingFace Transformers 4.49.0 构建离线语义向量缓存:从 JVM 内存溢出到 C 堆优化的实战记录
上周排查线上服务时发现,某内部知识问答接口的 P99 延迟突然飙升至 2.3s,远超 SLA 约定的 800ms。复现路径很直接:用户提问 -> 后端调用 LLM 生成答案 -> 答案存入 Elasticsearch 8.16.0 并同步更新向量索引。问题不在 LLM 本身,而在向量嵌入计算环节。原本使用在线 API 的方案因网络抖动频繁超时,团队决定将嵌入模型本地化部署。选型阶段对比了多个方案,最终锁定 HuggingFace Transformers 4.49.0 提供的 ONNX Runtime 后端,将其集成到 Java 17.0.12 环境下。这个决策看似合理,却在上线第一周引发了三次 OOM(Out of Memory),直到调整了内存堆外配置才彻底解决。
背景:为何选择本地化嵌入引擎
核心业务是金融合规文档检索,数据敏感度高,不允许明文请求发送至第三方 API。此前使用text-embedding-ada-002API,单次调用耗时 450ms±120ms,且按 token 计费,月度成本约 4 万元。内部评估后,计划使用开源模型替换。
技术栈基线:
- Spring Boot 3.4.5
- Java 17.0.12
- Maven 3.9.9
- HuggingFace Transformers Java 端口 0.13.2(基于 ONNX Runtime 1.19.0)
- Redis 7.2.5 用于向量缓存
最初设想很美好:把模型文件bert-base-nli-mean-tokens.onnx(约 90MB)放入 classpath,启动时加载到堆内存,通过InferenceSession执行推理。
过程:从崩溃到稳定的三个阶段
阶段一:堆内存泄漏与 GC 频繁
上线首日,Tomcat 线程池耗尽。JVM 参数默认-Xmx1024m。使用jstat -gc观察,YG(Young Gen)回收频率高达每秒 15 次,FG(Full GC)每 30 秒触发一次。
根本原因:每次请求都创建新的OnnxTensor对象,且未释放 native memory。Transformers 底层调用的是 C++ 库,Java 堆外的 Direct ByteBuffer 未手动清理,导致 OS 级内存飙升。
```java
// 错误示范:每次请求都重新加载 Session
public float[] embed(String text) {
OnnxSession session = OnnxSession.builder()
.loadModel("bert-base-nli-mean-tokens.onnx")
.build(); // 内存未复用,GC 压力大
long[] inputIds = tokenizer.encode(text);
OnnxTensor inputTensor = OnnxTensor.createTensor(session.getInputs().get(0), new long[][]{inputIds});
var results = session.run(Map.of("input_ids", inputTensor));
return results.get(0).asFloatBuffer().asArray();
}
```
阶段二:静态初始化与预热
改为单例模式,将OnnxSession声明为static final,在 Bean 初始化时加载。同时增加“预热”逻辑,避免首个请求触发 JIT 编译延迟。
```java
@Component
public class EmbeddingService {
private static final OnnxSession SESSION;
private static final Tokenizer TOKENIZER;
static {
try {
SESSION = OnnxSession.builder()
.loadModel(Paths.get("/models/bert-base-nli-mean-tokens.onnx"))
.addExecutionProvider("cpu", new XNNPACKExecutionProvider())
.build();
TOKENIZER = BertTokenizer.fromPretrained();
// 预热:执行一次无效推理,触发类加载与 JIT
SESSION.run(Map.of("input_ids", OnnxTensor.createTensor(SESSION.getInputs().get(0), new long[]{1, 2, 3})));
} catch (Exception e) {
throw new IllegalStateException("Model initialization failed", e);
}
}
public float[] embed(String text) {
long[] ids = TOKENIZER.encode(text);
var tensor = OnnxTensor.createTensor(SESSION.getInputs().get(0), new long[][]{ids});
var result = SESSION.run(Map.of("input_ids", tensor));
return result.get(0).asFloatBuffer().asArray();
}
}
```
此阶段延迟降至 80ms,但 72 小时后服务再次宕机。内存监控显示 RSS(Resident Set Size)持续上涨,但 Heap Usage 平稳。
阶段三:堆外内存治理
查阅 ONNX Runtime 源码,发现其内部使用 C++ 分配器,Java 的System.gc()无法回收 Direct Buffer。必须显式释放 tensor 资源,并限制堆外内存上限。
在 JVM 启动参数中添加:
```bash
-Xmx2048m -Xms2048m -XX:MaxDirectMemorySize=512m
```
代码中改为使用try-with-resources或手动调用close()(若框架支持),并增加批处理逻辑,将 10 条请求合并为一次推理,摊薄上下文切换开销。
```java
public List batchEmbed(List texts) {
if (texts.isEmpty()) return Collections.emptyList();
var input = new long[texts.size()][];
for (int i = 0; i < texts.size(); i++) {
input[i] = TOKENIZER.encode(texts.get(i));
}
var tensor = OnnxTensor.createTensor(SESSION.getInputs().get(0), input);
var results = SESSION.run(Map.of("input_ids", tensor));
var buffer = results.get(0).asFloatBuffer();
List outputs = new ArrayList<>();
float[] vector = new float[768];
for (int i = 0; i < texts.size(); i++) {
buffer.get(vector);
outputs.add(vector.clone());
}
return outputs;
}
```
质疑点:官方文档推荐直接使用TransformerPipeline高级 API,但在高并发场景下,其内部同步锁导致吞吐量极低。直接操作InferenceSession虽繁琐,但可控性更高。这个方案虽然官方不主推,但在我们场景下反而更糟?不,实际上手动管理 session 比 Pipeline 稳定得多,Pipeline 在处理长文本时的内存峰值不可预测。
效果:数据对比
上线两周后,监控数据如下表:
| 指标 | API 方案 (Optimizely Embedding) | 本地 ONNX 方案 | 变化 |
| :--- | :--- | :--- | :--- |
| P99 延迟 | 620ms | 95ms | ↓ 84.7% |
| 单请求成本 | ¥0.0028 | ¥0.0003 (电费+折旧) | ↓ 89.3% |
| JVM 堆外内存 | N/A | 480MB (稳定) | 可控 |
| 吞吐量 (TPS) | 120 (受限于网络) | 450 | ↑ 275% |
| 可用性 | 99.5% (网络依赖) | 99.99% (本地计算) | ↑ |
特别是向量检索命中率从 62% 提升至 81%,因为嵌入维度更贴合中文金融术语(本地微调版模型 vs 通用英文模型)。
总结
核心经验有三点:
- JVM 与 C++ 混合内存模型是陷阱:ONNX Runtime 等 JNI 调用必须明确
MaxDirectMemorySize,否则 GC 形同虚设。 - 批处理优于逐条:即使业务是单条请求,内部累积 10ms 内的请求进行批量推理,可提升 GPU/CPU 利用率 30% 以上。
- 版本锁定:Transformers 4.49.0 与 ONNX Runtime 1.19.0 的兼容性存在已知 Issue #3021,若使用 4.47.0 版本需回退 Runtime 至 1.17.0,否则会导致向量偏移。
#后端 #Java #SpringBoot #ONNX #向量检索
你在实际项目中有遇到类似问题吗?欢迎在评论区分享你的经验和解决方案。