我用 Java 21 虚拟线程重写了一个 RAG 平台:从架构设计到踩坑实录
项目地址:gitee.com/taisan/MaxK... | Gitee GVP 认证 | GPL v3
为什么 Java 需要 RAG 平台
先说个现实:2025 年了,你去搜「RAG 平台选型」,出来的结果清一色是 Dify(Python)、FastGPT(Node.js)、MaxKB(Python)、Coze(闭源)。Java 在这里几乎不存在。
但另一面是:国内企业级后端,Java 仍然是绝对主力。一个典型的 Java 团队要做知识库问答,面临的不是「选哪个 RAG 平台」的问题,而是「要不要为此引入一整套 Python/Node.js 技术栈」的问题。
这背后是真实的成本:
- 人力成本:招一个能搞定 LangChain + FastAPI 的 Python 工程师,或者一个熟悉 Node.js 的全栈,在二线城市月薪 15-25k。而你的 Java 团队本来就能干这事。
- 运维成本:多一套语言就多一套 CI/CD、多一套监控、多一套部署流水线。
- 集成成本:RAG 平台要和现有的 Spring Cloud 微服务、MyBatis 数据层、Redis 缓存打通,跨语言调用要么走 HTTP,要么走 gRPC,要么走消息队列------每多一跳就多一个故障点。
- 定制成本:想改 RAG 的检索策略?想加一个自定义的文档解析器?Python 代码对 Java 团队来说是黑盒。
MaxKB4j 就是冲着这个问题来的。 用 Java 生态原生的技术栈,把 RAG + 工作流 + Agent 这套东西重新实现一遍。
整体架构
先看架构全景:
scss
┌─────────────────────────────────────────────────────────┐
│ 前端 (Vue 3 + Element Plus) │
│ 工作流编排器 │ 知识库管理 │ 模型管理 │ 应用管理 │
└──────────────────────────┬──────────────────────────────┘
│ HTTP / SSE
┌──────────────────────────▼──────────────────────────────┐
│ API Gateway (Spring Boot 3.2) │
│ Spring Security │ 权限控制 │ API 鉴权 │
└──────────────────────────┬──────────────────────────────┘
│
┌──────────────────────────▼──────────────────────────────┐
│ 核心服务层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │
│ │ 工作流引擎 │ │ RAG 引擎 │ │ Agent引擎 │ │ 模型管理 │ │
│ │(DAG 调度) │ │(检索+生成) │ │(意图+工具) │ │(多模型) │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬────┘ │
│ │ │ │ │ │
│ ┌────▼──────────────▼──────────────▼──────────────▼────┐ │
│ │ LangChain4j 集成层 │ │
│ │ Embedding │ ChatModel │ ToolExecution │ RAG │ │
│ └──────────────────────┬──────────────────────────────┘ │
└─────────────────────────┼────────────────────────────────┘
│
┌─────────────────────────▼────────────────────────────────┐
│ 基础设施层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │
│ │PostgreSQL│ │ pgvector │ │ Redis │ │ MinIO │ │
│ │(业务数据) │ │(向量存储) │ │(缓存/会话)│ │(文件存储)│ │
│ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │
└─────────────────────────────────────────────────────────┘
几个关键设计决策:
1. 为什么选 LangChain4j 而不是自己造轮子?
LangChain4j 是 Java 生态里目前最成熟的 LLM 应用开发框架,提供了 ChatLanguageModel、EmbeddingModel、ContentRetriever、ToolProvider 等核心抽象。它做的事情和 Python 的 LangChain 类似,但 API 设计更符合 Java 的习惯------强类型、接口驱动、依赖注入友好。
MaxKB4j 在 LangChain4j 之上构建了三层能力:
- 模型适配层 :统一封装 OpenAI、通义千问、文心一言、智谱 GLM、DeepSeek 等 20+ 模型的调用差异,上层业务代码只面对一个
ChatLanguageModel接口 - RAG 管道层 :基于 LangChain4j 的
RetrievalAugmentor,实现了文档分段、向量化、检索、重排序的完整管道 - 工作流编排层:在 LangChain4j 的 AI Service 之上,自研了 DAG 工作流引擎
2. 为什么用 pgvector 而不是 Milvus/Weaviate?
早期版本支持多种向量数据库,但实际生产中我们发现:大多数 Java 团队的 RAG 场景,数据量在百万级文档段以内,pgvector 完全够用。而且它跑在 PostgreSQL 里,不需要额外部署一个独立的向量数据库服务,运维成本直接降一个量级。
当然,如果需要更专业的向量检索能力(十亿级、多模态向量),架构上也预留了切换到 Milvus 的接口。
虚拟线程 + Reactor:LLM 场景下的并发模型选择
这是整个项目里技术含量最高的部分,也是我踩坑最多的地方。
LLM 应用的并发特征
先分析一下 LLM 应用的 I/O 模型:
css
用户请求 → [意图识别 200ms] → [知识库检索 100ms] → [LLM 生成 2-10s] → 响应
↓
[向量检索 50ms]
[重排序 100ms]
特征很明显:
- 长连接 + 流式输出:LLM 生成一个回答可能要 2-10 秒,但用户期望看到逐字输出(SSE),不是等 10 秒后一次性返回
- 串行依赖链:意图识别 → 检索 → 生成,有严格的先后顺序
- 高并发 + 长等待:每个请求占用资源的时间很长(秒级),但大部分时间在等 I/O
- 混合 I/O 模型:既有阻塞式调用(JDBC 查数据库),也有非阻塞式调用(HTTP 调 LLM API + SSE 流式读取)
为什么不全用 WebFlux?
很多人第一反应是:既然 I/O 密集,直接上 WebFlux + Reactor 不就完了?
理论上可以,实际上坑很多:
坑 1:LangChain4j 和 JDBC 都是阻塞的
LangChain4j 的 ChatLanguageModel.call() 是同步阻塞的。pgvector 的查询走的是 JDBC,也是同步阻塞的。如果你在 Reactor 的 event loop 里调用这些,直接把线程池堵死。
java
// ❌ 错误示范:在 Reactor event loop 里做阻塞调用
webClient.post()
.uri("/chat")
.bodyValue(request)
.retrieve()
.bodyToFlux(String.class)
.flatMap(chunk -> {
// 这里调用 LangChain4j 的同步 API → 阻塞 event loop
String result = chatModel.call(prompt); // 💀
return Flux.just(result);
});
要解决这个问题,你得把所有阻塞调用都包一层 subscribeOn(Schedulers.boundedElastic()),代码复杂度直线上升,而且调试困难。
坑 2:调试体验差
Reactor 的调用栈是异步的,出了问题看堆栈跟看天书一样。对于 LLM 应用这种需要频繁调试 Prompt、检索策略、模型参数的场景,开发效率会大幅下降。
坑 3:团队学习成本
WebFlux 的编程模型(Mono/Flux/背压/调度器)对习惯了 Spring MVC 的 Java 开发者来说,学习曲线陡峭。一个 RAG 平台如果只有核心开发者能维护,开源社区就活不起来。
虚拟线程 + Reactor 的混合方案
MaxKB4j 最终采用的是 虚拟线程处理业务逻辑 + Reactor 处理流式输出 的混合模型:
java
// 虚拟线程处理完整的 RAG 请求链(阻塞式,但虚拟线程不消耗平台线程)
@Service
public class ChatService {
private final ExecutorService virtualExecutor =
Executors.newVirtualThreadPerTaskExecutor();
// 业务入口:在虚拟线程中执行完整的 RAG 管道
public SseEmitter chat(ChatRequest request) {
SseEmitter emitter = new SseEmitter(300_000L); // 5分钟超时
virtualExecutor.submit(() -> {
try {
// 1. 意图识别(同步调用 LLM API,虚拟线程自动挂起)
Intent intent = intentRecognizer.recognize(request.getMessage());
// 2. 知识库检索(同步 JDBC 查询 pgvector,虚拟线程自动挂起)
List<Document> docs = retriever.retrieve(
EmbeddingRequest.builder()
.text(request.getMessage())
.build()
);
// 3. 构建 Prompt + 调用 LLM 流式生成
Prompt prompt = promptBuilder.build(request, docs, intent);
// 4. 流式输出:这里切换到 Reactor 处理 SSE
chatModel.generateStream(prompt)
.doOnNext(token -> {
try {
emitter.send(SseEmitter.event()
.data(token));
} catch (IOException e) {
emitter.completeWithError(e);
}
})
.doOnComplete(emitter::complete)
.subscribe();
} catch (Exception e) {
emitter.completeWithError(e);
}
});
return emitter;
}
}
这个设计的核心思路是:
-
业务编排用虚拟线程:意图识别、检索、Prompt 构建、会话管理等业务逻辑,全部在虚拟线程中以同步方式编写。代码可读性高,调试方便,和传统 Spring MVC 开发体验一致。
-
流式输出用 Reactor :LLM 的 SSE 流式响应是天然的响应式场景,用 Reactor 的
Flux<String>处理最合适。背压控制、错误传播、超时处理都是现成的。 -
虚拟线程解决并发瓶颈:一个 4 核 8G 的服务器,传统线程池可能只能开 200 个线程,而虚拟线程可以轻松开到几万个。每个用户请求占一个虚拟线程,等 LLM API 返回时自动让出 CPU,不浪费任何平台线程。
性能数据
在我们的压测场景下(100 并发用户,每个请求触发一次 RAG 检索 + LLM 生成):
| 模型 | 平台线程数 | 内存占用 | P99 响应时间 | 吞吐量 |
|---|---|---|---|---|
| 传统线程池 (200线程) | 200 | 2.1 GB | 12.3s | 8 req/s |
| 虚拟线程 | 4 (carrier) | 1.4 GB | 8.7s | 23 req/s |
| WebFlux (全响应式) | 4 (event loop) | 1.2 GB | 7.9s | 26 req/s |
虚拟线程方案比传统线程池吞吐量提升 ~3 倍,和全响应式方案接近,但代码复杂度只有后者的 1/3。
RAG 引擎:不只是「向量检索 + 拼接 Prompt」
很多人对 RAG 的理解停留在「把文档向量化 → 检索相关段落 → 拼到 Prompt 里 → 让 LLM 回答」。这个理解没错,但生产环境的 RAG 远比这复杂。
文档处理管道
scss
原始文档 (PDF/Word/Markdown/HTML)
↓
文档解析器 (Apache Tika / 自定义解析)
↓
文本清洗 (去噪、去重、格式标准化)
↓
智能分段 (按语义边界切分,不是简单按字数切)
↓
向量化 (Embedding Model)
↓
存入 pgvector (带元数据:来源文档、页码、章节)
分段策略是 RAG 效果的关键。 简单的按 500 字切一刀,很可能把一个完整的逻辑段落切成两半,导致检索到的片段缺乏上下文。
MaxKB4j 实现了基于语义的分段策略:
- 优先按标题层级(H1/H2/H3)分段
- 其次按段落边界分段
- 超长段落按句子边界切分,保留上下文重叠区(overlap)
- 支持自定义分段规则(正则、分隔符、最大/最小长度)
混合检索策略
scss
用户查询
↓
┌─────────────┐
│ 查询改写 │ ← LLM 将用户口语化的问题改写为更适合检索的形式
└──────┬──────┘
↓
┌──────────────┬──────────────┐
│ 向量检索 │ 全文检索 │ ← pgvector + PostgreSQL tsvector
│ (语义相似度) │ (关键词匹配) │
└──────┬───────┴──────┬───────┘
↓ ↓
┌─────────────────────────────┐
│ RRF (Reciprocal Rank Fusion)│ ← 融合两路检索结果
└──────────────┬──────────────┘
↓
┌──────────────┐
│ 重排序 │ ← 用 Cross-Encoder 模型对 Top-K 结果精排
└──────────────┘
↓
最终结果
为什么需要混合检索?
纯向量检索有个经典问题:用户问「Java 21 的虚拟线程怎么用?」,向量检索可能返回一篇讲「Java 21 新特性概览」的文章(语义相似),但漏掉一篇标题就叫「Java 21 虚拟线程使用指南」的文章(关键词完全匹配但 embedding 距离稍远)。
全文检索恰好相反,擅长精确关键词匹配,但不理解语义。
MaxKB4j 的混合检索用 RRF(Reciprocal Rank Fusion)算法融合两路结果:
java
// RRF 融合算法核心逻辑
public List<SearchResult> hybridSearch(String query, int topK) {
// 向量检索
List<SearchResult> vectorResults = vectorSearch.search(
embeddingModel.embed(query), topK * 2
);
// 全文检索
List<SearchResult> fullTextResults = fullTextSearch.search(query, topK * 2);
// RRF 融合:score = Σ 1/(k + rank_i),k 通常取 60
Map<String, Double> rrfScores = new HashMap<>();
int k = 60;
for (int i = 0; i < vectorResults.size(); i++) {
String docId = vectorResults.get(i).getDocId();
rrfScores.merge(docId, 1.0 / (k + i + 1), Double::sum);
}
for (int i = 0; i < fullTextResults.size(); i++) {
String docId = fullTextResults.get(i).getDocId();
rrfScores.merge(docId, 1.0 / (k + i + 1), Double::sum);
}
return rrfScores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(topK)
.map(entry -> getDocument(entry.getKey()))
.collect(Collectors.toList());
}
工作流引擎:DAG 调度 + 节点可扩展
MaxKB4j 的工作流引擎是一个基于 DAG(有向无环图)的可视化编排系统。用户在前端拖拽节点、连线,定义 LLM 应用的执行流程。
节点类型
| 节点类型 | 功能 | 输入 | 输出 |
|---|---|---|---|
| LLM 节点 | 调用大模型生成文本 | Prompt 模板 + 变量 | 生成文本 |
| 知识库检索节点 | RAG 检索 | 查询文本 | 检索到的文档片段 |
| 条件判断节点 | if/else 分支 | 判断表达式 | 走不同分支 |
| 代码执行节点 | 运行自定义 Java/JS 代码 | 上下文变量 | 代码返回值 |
| HTTP 请求节点 | 调用外部 API | URL + 参数 | API 响应 |
| 变量赋值节点 | 设置/修改变量 | 键值对 | 更新后的上下文 |
| 开始/结束节点 | 流程控制 | - | - |
执行引擎设计
java
public class WorkflowExecutor {
private final Map<String, NodeHandler> nodeHandlers;
private final ExecutorService virtualExecutor;
/**
* 执行工作流
* 核心思路:拓扑排序 → 按层并行执行
*/
public WorkflowResult execute(Workflow workflow, Map<String, Object> input) {
// 1. 拓扑排序,确定执行层级
List<List<Node>> layers = topologicalSort(workflow.getNodes(), workflow.getEdges());
// 2. 共享上下文(节点间传递数据)
WorkflowContext context = new WorkflowContext(input);
// 3. 逐层执行,同层节点并行
for (List<Node> layer : layers) {
List<Future<?>> futures = layer.stream()
.map(node -> virtualExecutor.submit(() -> {
NodeHandler handler = nodeHandlers.get(node.getType());
NodeResult result = handler.execute(node, context);
context.setVariable(node.getId(), result);
}))
.collect(Collectors.toList());
// 等待当前层所有节点完成
awaitAll(futures);
}
return context.buildResult();
}
}
关键设计点:
- 同层并行:DAG 中没有依赖关系的节点在同一层并行执行(虚拟线程让并行成本几乎为零)
- 上下文传递 :每个节点的输出自动注入到工作流上下文,下游节点通过
${nodeId.output}引用 - 节点可扩展 :实现
NodeHandler接口就能自定义节点类型,用户可以通过代码执行节点写自己的逻辑
一个实际的工作流示例
css
[开始] → [意图识别(LLM)] → [条件判断]
├─ 知识问答 → [知识库检索] → [LLM生成] → [结束]
├─ 工具调用 → [HTTP请求] → [结果处理] → [结束]
└─ 闲聊 → [LLM闲聊回复] → [结束]
这个工作流实现了一个简单的智能路由:先让 LLM 判断用户意图,然后根据意图走不同的处理分支。在传统代码里这要写一堆 if-else,在工作流里就是拖几个节点、连几条线。
Agent 引擎:从 RAG 到自主决策
RAG 解决的是「基于知识回答问题」,Agent 解决的是「理解意图 + 选择工具 + 执行任务」。
MaxKB4j 的 Agent 引擎基于 LangChain4j 的 @Tool 注解和 AiServices 构建:
java
// 定义一个可以查询订单的 Agent
public interface OrderAgent {
@SystemMessage("你是一个订单查询助手,可以帮用户查询订单状态。")
String chat(@UserMessage String message);
}
// 定义工具
public class OrderTools {
@Tool("根据订单号查询订单状态")
public OrderStatus queryOrder(@P("订单号") String orderId) {
return orderService.getOrderStatus(orderId);
}
@Tool("根据用户ID查询该用户的所有订单")
public List<Order> queryUserOrders(@P("用户ID") String userId) {
return orderService.getOrdersByUserId(userId);
}
@Tool("申请退款")
public RefundResult applyRefund(
@P("订单号") String orderId,
@P("退款原因") String reason
) {
return refundService.apply(orderId, reason);
}
}
// 组装 Agent
OrderAgent agent = AiServices.builder(OrderAgent.class)
.chatLanguageModel(chatModel)
.tools(new OrderTools())
.contentRetriever(knowledgeBaseRetriever) // 同时具备 RAG 能力
.build();
用户说「帮我查一下订单 20250315001 的物流到哪了」,Agent 会:
- 理解意图 → 需要调用查询工具
- 提取参数 → orderId = "20250315001"
- 调用
queryOrder工具 - 将工具返回的结果组织成自然语言回复
Agent + RAG + 工具的三合一是 MaxKB4j 的核心卖点。很多平台要么只做 RAG,要么只做 Agent,能同时支持并且通过工作流编排组合的,Java 生态里目前只有 MaxKB4j。
MCP 协议支持
MaxKB4j 还支持了 MCP(Model Context Protocol),这是 Anthropic 提出的模型上下文协议,让 AI 能够感知代码仓库、文件系统等开发环境上下文。
这意味着 MaxKB4j 的 Agent 不只是一个聊天机器人,它可以:
- 读取和分析代码仓库
- 理解项目的上下文结构
- 基于代码上下文给出更精准的回答
对于「把 AI 集成到开发工具链」这个场景,MCP 协议的支持是一个重要的差异化能力。
踩坑实录
坑 1:虚拟线程 + synchronized 的死锁陷阱
虚拟线程遇到 synchronized 块时会「钉住」(pin)载体线程(carrier thread),不会像普通阻塞那样让出 CPU。如果大量虚拟线程同时竞争同一个锁,载体线程会被耗尽。
解决方案 :把所有 synchronized 替换为 ReentrantLock。
java
// ❌ 虚拟线程中避免使用
synchronized (lock) {
// 如果这里阻塞,载体线程被钉住
someBlockingOperation();
}
// ✅ 使用 ReentrantLock
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
someBlockingOperation();
} finally {
lock.unlock();
}
坑 2:SSE 连接超时
LLM 生成长文本时,一个 SSE 连接可能持续 30 秒以上。Nginx 默认的 proxy_read_timeout 是 60 秒,但某些云负载均衡器的超时更短。
解决方案:
- Nginx 层:
proxy_read_timeout 300s; - Spring 层:
SseEmitter设置合理的超时时间 - 前端层:实现自动重连机制
坑 3:Embedding 模型的 Token 限制
文档分段后,每段都要调用 Embedding 模型向量化。大多数 Embedding 模型有 Token 限制(如 text-embedding-3-small 限制 8191 tokens)。超长段落会被截断,导致信息丢失。
解决方案:分段时不仅控制字符数,还要估算 Token 数(中文约 1.5 字/token),超长段落强制二次切分。
和主流平台的客观对比
| 维度 | MaxKB4j | Dify | FastGPT | MaxKB |
|---|---|---|---|---|
| 语言 | Java 21 | Python | Node.js | Python |
| 核心框架 | Spring Boot 3 + LangChain4j | Flask + LangChain | Next.js | Django |
| RAG | ✅ 混合检索 + RRF | ✅ | ✅ | ✅ |
| 工作流 | ✅ DAG 引擎 | ✅ | ✅ | ✅ |
| Agent | ✅ Tool + RAG | ✅ | ✅ | ✅ |
| MCP 协议 | ✅ | ❌ | ❌ | ❌ |
| 虚拟线程 | ✅ | ❌ | ❌ | ❌ |
| 向量数据库 | pgvector | Weaviate/Qdrant | MongoDB+pgvector | Milvus |
| 模型支持 | 20+ 国内外模型 | 20+ | 10+ | 10+ |
| Java 团队集成 | 原生 | 需跨语言 | 需跨语言 | 需跨语言 |
| 社区规模 | 成长中 | 大 | 中 | 中 |
| 开源协议 | GPL v3 | Apache 2.0 | Apache 2.0 | GPL v3 |
不吹不黑: Dify 的社区和生态目前最大,FastGPT 的知识库体验最好,MaxKB 的 Gitee 生态最完善。MaxKB4j 的差异化在于 Java 生态原生 + 虚拟线程性能 + MCP 协议支持。如果你的团队是 Java 技术栈,这是目前唯一合理的选择。
写在最后
这个项目从 V1 到 V2.4,写了 1433+ 次提交,踩了无数的坑。最大的感悟是:Java 21 的虚拟线程彻底改变了 Java 在 I/O 密集型场景的竞争力。 以前 Java 做 LLM 应用,要么上 WebFlux 承受复杂的响应式编程,要么用传统线程池忍受低并发。虚拟线程让 Java 可以用最简单的同步代码,获得接近响应式编程的并发性能。
如果你是 Java 开发者,对 RAG / Agent / LLM 应用感兴趣,欢迎来 Gitee 看看代码、提 Issue、提 PR。Java 生态的 AI 工具链需要更多人一起建设。
🔗 项目地址:gitee.com/taisan/MaxK...
如果觉得有收获,欢迎点赞收藏。有问题可以在评论区讨论,我会逐一回复。