AI 应用层被 Python 卷成红海,为什么我偏要用 Java 造一个 RAG + 工作流引擎?
拆解 MaxKB4j:一个纯 Java 21 + 虚拟线程 + LangChain4j 的企业级 AI 应用底座,看它如何把"工作流引擎"做成教科书级的设计。
一、一个有点"轴"的技术选型
如果你今天要做一个 RAG 知识库问答 + LLM 工作流编排的平台,闭着眼睛猜技术栈:Python(Django / FastAPI / Flask)或者 TypeScript(Node.js)。Dify、MaxKB、FastGPT、RAGFlow......叫得上名字的开源方案,几乎全是 Python 或 TS。
这没什么问题------AI 生态的工具链、模型 SDK、文档解析库,Python 确实最全。但对企业里那批主力用 Java 的团队来说,这意味着:
- 想上线一个智能客服,得引入一整套 Python 运维栈;
- 出了问题,Java 团队不会调,Python 团队又不懂业务;
- 招人、培训、对接现有 Spring Cloud 中间件,全是成本。
MaxKB4j (Max Knowledge Brain for Java)干了一件有点"轴"的事:把一整套 RAG + LLM 工作流引擎,用纯 Java 重写了一遍。
Java 21 + Spring Boot 3 + 虚拟线程(Project Loom)+ LangChain4j 1.x,零 Python / Node 依赖。
这不是套个壳调 Python 服务,而是从向量存储、文档解析、模型对接,到 DAG 工作流引擎、多 Agent 协作,全部 Java 原生实现。
更重要的是------它不是"能跑就行"的玩具。读完源码后,我最想说的一句话是:
它的工作流引擎设计,放在任何一本讲设计模式的书里都不过分。
这篇文章就带你拆解它的内核。所有结论都能在源码里逐一验证。
二、先看地基:五层模块,契约与实现分离的"洁癖"
MaxKB4j 是一个标准的多模块 Maven 工程,依赖方向自上而下、单向不回头:
sql
maxkb4j-start 启动入口、配置、打包
└─ maxkb4j-service 业务实现(application/chat/knowledge/model/
│ oss/system/tool/trigger/workflow 共 9 个子模块)
└─ maxkb4j-service-api 对外契约:接口、DTO、VO(8 个 *-api 子模块)
└─ maxkb4j-core 核心抽象(assistant / guardrail / listener)
└─ maxkb4j-common 通用工具
这套分层最值得称道的一点是**"契约闭包"**:
*-api模块只放接口和值类型,不放实现;- 外部模块要触发工作流,只能依赖
WorkflowFactory和IWorkFlowActuator两个契约,永远不能 new 一个引擎类; - 实体类也从 api 层下沉到了 impl 层(保包名),避免了"契约层泄漏 JPA/Lombok 耦合"的老问题。
这是个反直觉但很正确的决定。很多 Java 项目把 Entity 和 Service 混在 api 包里对外暴露,结果改一列字段半个系统跟着编译。MaxKB4j 主动把 entity 迁到 impl,让 api 层保持"纯契约"------这是敢上生产的前提。
技术选型也全是 Java 企业团队熟悉的主流栈,没有一个"炫技式"冷门依赖:
| 组件 | 作用 |
|---|---|
| Java 21 + 虚拟线程 | 轻量级并发,单机扛数千并发 |
| Spring Boot 3.5 + Reactor | 响应式、流式输出 |
| LangChain4j 1.17.2 | Java 生态最活跃的 LLM 编排框架 |
| Sa-Token 1.39 | 鉴权 / SSO |
| MyBatis-Plus 3.5 | ORM |
| PostgreSQL + pgvector | 一套库同时承载业务与向量 |
| MongoDB 6 | 全文检索 |
| Docling | 复杂版式 / 表格文档解析 |
| Caffeine | 多级缓存 |
一句话:可维护、可招聘、可接手。
三、重头戏:工作流引擎是怎么把 DAG 跑起来的?
这是整个项目的技术核心,也是最值得拆的部分。
1. 图怎么存?------前端节点 + 运行时节点双模型
工作流的"图"来自前端 LogicFlow 编辑器,落库为两类节点描述:
LfNode:前端节点,{id, type, properties};LfEdge:边,关键是sourceAnchorId字段------它承载了分支 id,这是后面条件分支裁剪的依据:
java
// maxkb4j-workflow-api/.../workflow/logic/LfEdge.java
class LfEdge {
String sourceNodeId, targetNodeId;
String sourceAnchorId, targetAnchorId; // sourceAnchorId 携带分支标识
JSONObject properties;
}
运行时节点 AbsNode 在此基础上多了 status、upNodeIdList(上游节点)、runtimeNodeId、detail、answerText 等执行态。有两个设计细节特别精巧:
① 确定性的运行时 ID(支持断点续跑)
java
// NodeIdGenerator.generateRuntimeNodeId
String input = Arrays.toString(safeList.toArray()) + nodeId; // 上游路径 + 节点id
return bytesToHex(MessageDigest.getInstance("SHA-1").digest(input.getBytes(UTF_8)));
同一个节点经由不同路径到达,会得到不同的 runtimeNodeId。这意味着中断后重跑,能精确恢复到上次的执行位置------不是简单的"从 start 重新跑",而是"接着上次断点跑"。
② 无锁抢占(CAS)解决菱形汇合的并发去重
当 DAG 出现"菱形"结构(A→B, A→C, B→D, C→D),节点 D 会被两条上游路径同时触发。怎么保证只执行一次?用 AtomicReferenceFieldUpdater 做状态 CAS:
java
// maxkb4j-workflow-api/.../workflow/node/AbsNode.java
private static final AtomicReferenceFieldUpdater<AbsNode, Integer> STATUS_UPDATER =
AtomicReferenceFieldUpdater.newUpdater(AbsNode.class, Integer.class, "status");
public boolean tryClaimRunning() {
Integer current = this.status;
if (NodeStatus.READY.getStatus() == current || NodeStatus.INTERRUPT.getStatus() == current)
return STATUS_UPDATER.compareAndSet(this, current, NodeStatus.STARTED.getStatus());
return false;
}
谁先 CAS 成功谁执行,后来的直接返回空。没有 synchronized,没有锁竞争------这在高并发工作流场景下是关键的性能决策。
2. 节点怎么扩展?------注解驱动的自注册 SPI
工作流有 34 种节点类型:开始、条件分支、循环、LLM、知识库检索、重排序、HTTP、MCP、NL2SQL、图片理解、语音、表单、直接回复......
这么多节点怎么注册和分发?MaxKB4j 的做法是一套统一的"注解 + 注册表 + 自动注册器"三件套:
java
// 节点处理器注解,支持一个处理器服务多种节点类型
@Target(TYPE) @Retention(RUNTIME)
public @interface NodeHandlerType { NodeType[] value(); }
// 节点 POJO 注解(非 Bean)
@Target(TYPE) @Retention(RUNTIME)
public @interface NodeCreatorType { NodeType value(); }
注册中心 NodeCenter 用两个 ConcurrentHashMap 持有 creators 和 handlers,运行时分发就是一次 map 查找:
java
public INodeHandler getHandler(String nodeType) {
INodeHandler handler = handlers.get(nodeType);
if (handler == null)
throw new IllegalStateException("No handler found for node type: " + nodeType);
return handler;
}
一个值得细品的细节 :自动注册用的是 SmartInitializingSingleton,而不是常见的 BeanPostProcessor。源码注释写得很直白------
与工作流节点处理器的注册保持一致,避免在创建期提前触发其它 BeanPostProcessor 的副作用。
BeanPostProcessor 会在 Bean 创建期就介入,可能强制提前实例化 AspectJ AutoProxyCreator、Sa-Token 的鉴权 Advisor,引发启动顺序问题。改用 SmartInitializingSingleton(所有单例就绪后再扫描注册),就避开了这个坑。
想新增一个节点类型?三步:
- 写个
XxxNodePOJO,标@NodeCreatorType(NodeType.XXX); - 写个
XxxNodeHandler继承AbsNodeHandler,标@NodeHandlerType(NodeType.XXX); - 完事。注册全自动,分发零成本。
模型供应商的注册(后面会讲)用的是完全相同的一套模式------这种"一个模式吃遍天"的一致性,是成熟工程的标志。
3. DAG 怎么执行?------模板方法 + CompletableFuture 调度
执行入口是一个策略选择器 WorkFlowActuator:
java
handlers.stream().filter(h -> h.canHandle(workflow)).findFirst()
.ifPresentOrElse(h -> h.execute(workflow),
() -> { throw new IllegalStateException("No handler found"); });
AbsWorkflowHandler.runChainNodes() 是调度核心。对每个节点,根据 handler.isAsync() 分两条路:
- 同步节点 :丢到
workflowTaskExecutor(虚拟线程池)上跑CompletableFuture; - 异步流式节点 (如 LLM):直接挂载 handler 自己返回的 future,不占工作线程------流式 token 输出全程不阻塞 worker。
每个节点执行前要过两道关:
scss
dependenciesExecuted(node) // 所有上游都已 SUCCESS/SKIP
&& node.tryClaimRunning() // CAS 抢占成功
都过了才真正执行:onNodeStart → recordExecution → nodeHandler.execute().join() → 异常走 ExceptionResolverChain → 写 detail / 写 context → completeNode → 算出 nextNodes。
4. 条件分支怎么裁剪?------分支断言
这是 DAG 执行里最巧妙的一笔。ConditionNodeHandler 执行后返回一个带 branchId 的 NodeResult。下一步 nextNodes 时:
java
// WorkflowExecutionAccessor
if (currentNodeResult.isAssertionResult()) { // 结果里带 branchId
List<AbsNode> targetNodes = buildNodes(targetNodeIds, currentNode);
targetNodes.forEach(node -> {
if (!isAssertionNode(node.getId(), currentNodeResult, sourceEdges)) {
if (isSkipNode(node, currentNode.getId()))
node.setStatus(NodeStatus.SKIP.getStatus()); // 兄弟分支直接跳过
}
});
return targetNodes;
}
isAssertionNode 的判断依据是边的 sourceAnchorId == "{sourceNodeId}_{branchId}_right"------前端画布上的连线锚点,直接成了运行时的分支路由信号。选了 if 分支,else 分支整棵子树被标 SKIP 向下传播,不再执行。
不需要预先做静态图分析,运行时按结果动态裁剪。优雅。
5. 流式输出:把"推"式 TokenStream 桥接成"拉"式 Future
LLM 节点的流式输出是个老大难:LangChain4j 的 TokenStream 是推式回调 (onPartialResponse、onCompleteResponse),而引擎调度是拉式 Future (CompletableFuture<NodeResult>)。两者怎么统一?
AbstractChatStreamNodeHandler.writeContextStreamAsync 做了这个桥接:
java
tokenStream
.onPartialThinking(t -> { if (isResult && options.reasoningContentEnable()) emitMessage(...); })
.onPartialResponse(c -> { if (isResult) { emitMessage(...); answerTexts.add(c); } })
.onCompleteResponse(r -> resultFuture.complete(
handleChatResponse(r, String.join("", answerTexts), node, err.get())))
.onError(e -> resultFuture.completeExceptionally(e))
.start();
return resultFuture;
推式回调把 token 边产生边推给前端的 Sinks.Many<ChatMessageVO>,同时累积完整答案;流结束时 resultFuture.complete(...) 把控制权交还给引擎的 future 调度链。两种编程模型在一个节点里无缝协作。
6. 异常处理:责任链模式
节点抛异常不会让整个工作流崩。ExceptionResolverChain 按优先级跑一组 NodeExceptionResolver:
LoggingExceptionResolver(order=1):记日志;DetailRecordingResolver(order=2):把errMessage、errorClass、errorTime写进节点 detail,供前端展示和断点恢复。
ChatWorkflowHandler 还会额外往 sink 推一条错误消息,让用户实时看到"某节点执行失败"。
7. 循环节点:子工作流 + 上下文共享
LoopNodeHandler 是最复杂的处理器。支持数组遍历、定次循环、无限循环(封顶 1000 次)。每次迭代它会:
- 用
NodeBuilder把循环体子图物化成节点; - 构造一个
ChatLoopWorkflow/KnowledgeLoopWorkflow,复用父工作流的WorkflowContext和HistoryManager,但自建一份WorkflowConfiguration; - 递归调用
workFlowActuator.execute(loopWorkflow); - 订阅子工作流的 sink,把消息转发出来,并把
runtimeNodeId加_{index}后缀以区分每次迭代; - 监听
LOOP_BREAK/FORM/USER_SELECT决定是否提前停止。
循环不是简单的 for 循环,而是"子工作流 + 共享上下文"的递归执行------这才是能支撑复杂业务编排的循环语义。
设计模式小结:模板方法(
AbsNodeHandler.executefinal 包裹doExecute)、策略(WorkFlowActuator选 handler)、责任链(异常)、建造者(ChatWorkflowBuilder)、门面 + 分层访问器(AbstractWorkflow暴露context()/execution()/output())、注解驱动 SPI、无锁 CAS、响应式流桥接。一个引擎,把 GoF 设计模式用了个遍,而且每个都用在刀刃上。
四、模型中立:一套 SPI 接住所有大模型
支持几十种模型是 AI 平台的标配,但怎么组织这几十种模型才见工程功力。MaxKB4j 的做法和工作流引擎一脉相承:
java
// 模型供应商注解
@Target(TYPE) @Retention(RUNTIME)
public @interface ModelProviderType {
String provider(); // 供应商唯一标识
String name(); // 展示名
String icon(); // 图标
}
抽象基类 AbsModelProvider 是个典型的模板方法 + 默认禁用基类:
java
public abstract class AbsModelProvider {
// 子类按需覆写,不支持的类型返回 Disabled*
public StreamingChatModel buildStreamingChatModel(...) { return new DisabledStreamingChatModel(); }
public EmbeddingModel buildEmbeddingModel(...) { return new DisabledEmbeddingModel(); }
public ImageModel buildImageModel(...) { return new DisabledImageModel(); }
public ScoringModel buildScoringModel(...) { throw new ModelDisabledException(...); }
// ...
}
每种供应商只需继承它、覆写自己支持的那几个 build* 方法:
java
@Component
@ModelProviderType(provider = "OpenAI", name = "OpenAI", icon = "openai_icon.svg")
public class OpenAiModelProvider extends AbsModelProvider { ... }
注册表 ModelProviderRegistry + ModelProviderAutoRegistrar(同样是 SmartInitializingSingleton)在启动期把所有带注解的供应商扫进一个 LinkedHashMap(保声明顺序)。运行时 registry.get(provider) 一次查找。
目前内置 20+ 供应商:OpenAI、Anthropic Claude、Gemini、DeepSeek、通义千问、腾讯混元、字节豆包、百度文心、智谱 GLM、Kimi、讯飞、MinMax、SiliconFlow、Ollama、Xorbits、LocalAI、Azure......
两个细节值得单独点出来:
① 共享的 HttpClientBuilder + 虚拟线程
java
protected synchronized HttpClientBuilder getHttpClientBuilder() {
if (springRestClientBuilder == null) {
HttpComponentsClientHttpRequestFactory requestFactory = new HttpComponentsClientHttpRequestFactory();
requestFactory.setConnectTimeout(60_000);
requestFactory.setReadTimeout(600_000); // LLM 调用动辄几十秒,10 分钟读超时
this.springRestClientBuilder = SpringRestClient.builder()
.restClientBuilder(RestClient.builder().requestFactory(requestFactory))
.streamingRequestExecutor(new VirtualThreadTaskExecutor()); // 流式请求跑在虚拟线程上
}
return springRestClientBuilder;
}
流式请求的执行器是 VirtualThreadTaskExecutor------LLM 的长连接流式输出,天然适合虚拟线程:一个请求一个虚拟线程,几乎零开销,单机能撑大量并发流式对话。这是 Java 21 相对 Python async / Node 事件循环的硬优势。
② 保存模型时自检 modelIsValid
配模型时先实测一把:"only say ok",流式调用 blockLast() 同步等待,异常能在当前请求线程冒泡被全局异常处理器捕获。配错的模型存不进库,而不是等用户提问时才发现 401。
五、RAG 检索:双路召回 + 多模式后端
RAG 是这类平台的立身之本。MaxKB4j 的检索链路是一条清晰的管道:
arduino
IRetrieveService
└─ ParagraphRetriever // 组装入参,回填段落详情
└─ DataRetriever // 按 searchMode 选 store(vector / fullText / composite)
└─ SearchOrchestrator // 双路召回编排:段落路 + 问题路
└─ IDataStore // 纯持久化端口(pgvector / MongoDB / 复合)
1. 三种检索模式,一套 store 接口
DataRetriever 用 @Qualifier 注入三个 store 实现,按 searchMode 切换:
java
private IDataStore getStore(String searchMode) {
return switch (searchMode) {
case SearchType.EMBEDDING -> vectorStore; // pgvector 向量检索
case SearchType.FULL_TEXT -> fullTextStore; // MongoDB 全文检索
case SearchType.HYBRID -> compositeStore; // 混合召回
default -> throw new IllegalArgumentException("Unknown search mode: " + searchMode);
};
}
向量检索 SearchMode.VECTOR、全文检索 FULL_TEXT、混合检索 HYBRID------支持多路召回,再配合工作流里的 RerankerNodeHandler(调用 ScoringModel.scoreAll 做 topN 精排),这就是所谓的 Advanced RAG。
2. 双路召回:段落路 + 问题路
SearchOrchestrator 是检索的编排大脑。它最有意思的设计是双路召回:
java
public List<TextChunkVO> search(IDataStore store, SearchRequest request) {
resolveExcludeParagraphIds(request); // 先汇总要排除的段落
List<TextChunkVO> results = new ArrayList<>(
store.searchBySource(request, SourceType.PARAGRAPH)); // ① 段落路:直接匹配段落向量
List<TextChunkVO> problemHits = store.searchBySource(request, SourceType.PROBLEM);
results.addAll(mapProblemsToParagraphs(problemHits)); // ② 问题路:匹配"问题"向量,再映射回段落
return dedupAndRank(results, request.getTopK()); // 去重 + 排序 + 截断
}
为什么要两条路?因为知识库里除了文档段落本身,还可以维护一批"问题-段落"对(FAQ 式标注)。用户提问时:
- 段落路:问题和段落内容做向量相似度;
- 问题路 :问题和预置的"标准问题"做向量相似度,命中后通过
problem_paragraph映射表反查回段落。
两路结果合并、按 paragraphId 去重、按相似度降序排序、截断到 topK。这个 dedupAndRank 还有个小心机:同分时用 paragraphId 的累计总分做 tiebreaker,让"被多路同时命中的段落"排名更靠前。
注释里有一句话点明了重构动机:store 层因此退化为纯持久化端口,不再依赖任何 service,原构造期循环依赖随之消除。------把检索策略从 store 里抽出来上移到编排器,既消除了循环依赖,又让 store 可独立测试。这是干净分层带来的副产品。
3. pgvector 向量后端:按维度缓存 + 分批重试
PgVectorEmbeddingStoreImpl 有几个生产级细节:
按 embedding 维度缓存 store 实例(不同模型维度不同,1500 维和 1024 维的表是分开的):
java
private final Map<Integer, EmbeddingStore<TextSegment>> stores = new ConcurrentHashMap<>();
public EmbeddingStore<TextSegment> get(int dimension) {
return stores.computeIfAbsent(dimension, this::build); // 原子初始化 + 缓存命中
}
注释提到原实现用 public static HashMap + getOrDefault,既线程不安全又每次新建、从未真正缓存。改成 ConcurrentHashMap.computeIfAbsent 后才算"真缓存"。这种对老代码的精准重构,比新写代码更见功力。
写入分批 + 指数退避重试:
java
private boolean backoff(int attempt) {
Thread.sleep(retryDelayMs * (1L << (attempt - 1))); // 1000 * 2^(n-1) 指数退避
}
批量向量化写入失败时,按指数退避重试,避免瞬时故障导致整批数据索引失败。
得分归一化 :向量库的余弦相似度是 [-1,1],全文检索得分是 [0,1],混排前统一归一化到同一量纲,否则混合检索的分数根本没法比。
六、不止这些
受篇幅所限,还有几块没法展开细说,但都值得一探:
- 触发器(Trigger):Cron 定时任务 + Webhook 事件回调,让 Agent 能"无人值守"自动化(每日生成数据报告、CRM 来线索自动触发画像分析)。
- Multi-Agent 协作:每个 Agent 独立角色、知识库、工具集,支持动态任务分发和上下文感知路由。
- MCP 协议 + Claude Skills :
McpNodeHandler让工作流能调用任何 MCP 工具;支持本地代码函数工具、HTTP 接口工具、Claude SKILLS 技能。 - 多模态:ASR / TTS / OCR(RapidOCR)/ 图像生成(Stable Diffusion),都有对应的工作流节点。
- 权限 :基于 Sa-Token 的细粒度控制(应用 / 知识库 / 工具 / 模型四级),带审计日志。业务层通过
UserContext解耦,不直接调StpKit------又是分层洁癖的体现。 - 沙箱:groovy-sandbox 隔离用户脚本节点,防止恶意代码。
七、说点实在的:它也不是完美的
一篇只夸的文章不值得看。读源码时我也注意到几个可以讨论的点:
- 部分节点类型声明了但还没实现 :
NodeType枚举里DATABASE、ECHARTS、CLASSIFICATION三个有声明但暂无 handler。属于路线图上的占位,不是 bug。 - 34 种节点 + 20+ 模型供应商意味着学习曲线不短。但好在有可视化编辑器和数十种 Agent 模板,上手门槛被压低了。
- 项目由个人开发者维护,没有大厂背书。这也是为什么作者开放了赞助和企业合作通道。
这些都不是硬伤,而是"一个认真做产品的开源项目"该有的真实状态。
八、为什么值得一看?
市面不缺 RAG 平台,但 MaxKB4j 的稀缺性在于:
它让 Java 团队在不引入 Python/Node 生态的前提下,拿到了一张 AI 应用层的入场券。
而它的工程品质------五层契约分层、注解驱动 SPI、CAS 无锁并发、推拉桥接的流式模型、双路召回的 RAG 编排------即使抛开"纯 Java"的标签,也是一份值得学习的工程范本。
如果你是以下读者,建议 star 一份:
- Java 企业团队负责人:要在现有 Spring 栈里嵌 AI 能力,这是目前最对口的方案;
- 架构师 / 中高级 Java 开发:想看一套"设计模式用在刀刃上"的真实生产代码;
- AI 应用工程师:想搞懂 RAG + 工作流引擎的内部机理,而不只是当黑盒调 API。
上手只要三步
bash
git clone https://github.com/taishan666/MaxKB4j.git
cd MaxKB4j
docker-compose up -d
打开 http://localhost:8080,默认账号 admin / tarzan@123456。或直接体验在线 Demo(账号 demo / demo@123456)。
AI 应用层的下半场,比的不是谁的模型大,而是谁能把模型变成可靠的业务系统。 Python 不是唯一答案,Java 也该有一席之地。
🌟 项目地址:github.com/taishan666/... · 觉得有帮助,点个 Star 支持一下独立开发者 ☕