AI 应用层被 Python 卷成红海,为什么我偏要用 Java 造一个 RAG + 工作流引擎?

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 模块只放接口和值类型,不放实现
  • 外部模块要触发工作流,只能依赖 WorkflowFactoryIWorkFlowActuator 两个契约,永远不能 new 一个引擎类
  • 实体类也从 api 层下沉到了 impl 层(保包名),避免了"契约层泄漏 JPA/Lombok 耦合"的老问题。

这是个反直觉但很正确的决定。很多 Java 项目把 EntityService 混在 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 在此基础上多了 statusupNodeIdList(上游节点)、runtimeNodeIddetailanswerText 等执行态。有两个设计细节特别精巧:

① 确定性的运行时 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(所有单例就绪后再扫描注册),就避开了这个坑。

想新增一个节点类型?三步

  1. 写个 XxxNode POJO,标 @NodeCreatorType(NodeType.XXX)
  2. 写个 XxxNodeHandler 继承 AbsNodeHandler,标 @NodeHandlerType(NodeType.XXX)
  3. 完事。注册全自动,分发零成本。

模型供应商的注册(后面会讲)用的是完全相同的一套模式------这种"一个模式吃遍天"的一致性,是成熟工程的标志。

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 抢占成功

都过了才真正执行:onNodeStartrecordExecutionnodeHandler.execute().join() → 异常走 ExceptionResolverChain → 写 detail / 写 context → completeNode → 算出 nextNodes

4. 条件分支怎么裁剪?------分支断言

这是 DAG 执行里最巧妙的一笔。ConditionNodeHandler 执行后返回一个带 branchIdNodeResult。下一步 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推式回调onPartialResponseonCompleteResponse),而引擎调度是拉式 FutureCompletableFuture<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):把 errMessageerrorClasserrorTime 写进节点 detail,供前端展示和断点恢复。

ChatWorkflowHandler 还会额外往 sink 推一条错误消息,让用户实时看到"某节点执行失败"。

7. 循环节点:子工作流 + 上下文共享

LoopNodeHandler 是最复杂的处理器。支持数组遍历、定次循环、无限循环(封顶 1000 次)。每次迭代它会:

  1. NodeBuilder 把循环体子图物化成节点;
  2. 构造一个 ChatLoopWorkflow / KnowledgeLoopWorkflow复用父工作流的 WorkflowContextHistoryManager ,但自建一份 WorkflowConfiguration
  3. 递归调用 workFlowActuator.execute(loopWorkflow)
  4. 订阅子工作流的 sink,把消息转发出来,并把 runtimeNodeId_{index} 后缀以区分每次迭代;
  5. 监听 LOOP_BREAK / FORM / USER_SELECT 决定是否提前停止。

循环不是简单的 for 循环,而是"子工作流 + 共享上下文"的递归执行------这才是能支撑复杂业务编排的循环语义。

设计模式小结:模板方法(AbsNodeHandler.execute final 包裹 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 SkillsMcpNodeHandler 让工作流能调用任何 MCP 工具;支持本地代码函数工具、HTTP 接口工具、Claude SKILLS 技能。
  • 多模态:ASR / TTS / OCR(RapidOCR)/ 图像生成(Stable Diffusion),都有对应的工作流节点。
  • 权限 :基于 Sa-Token 的细粒度控制(应用 / 知识库 / 工具 / 模型四级),带审计日志。业务层通过 UserContext 解耦,不直接调 StpKit------又是分层洁癖的体现。
  • 沙箱:groovy-sandbox 隔离用户脚本节点,防止恶意代码。

七、说点实在的:它也不是完美的

一篇只夸的文章不值得看。读源码时我也注意到几个可以讨论的点:

  1. 部分节点类型声明了但还没实现NodeType 枚举里 DATABASEECHARTSCLASSIFICATION 三个有声明但暂无 handler。属于路线图上的占位,不是 bug。
  2. 34 种节点 + 20+ 模型供应商意味着学习曲线不短。但好在有可视化编辑器和数十种 Agent 模板,上手门槛被压低了。
  3. 项目由个人开发者维护,没有大厂背书。这也是为什么作者开放了赞助和企业合作通道。

这些都不是硬伤,而是"一个认真做产品的开源项目"该有的真实状态。


八、为什么值得一看?

市面不缺 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 支持一下独立开发者 ☕


相关推荐
wangray1997droid1 小时前
让 AI 拥有“真实记忆“:一次从碎片到叙事的记忆系统质变
人工智能
二十雨辰1 小时前
[Java]-Spring面试题
java·开发语言
Wz_z_z_z1 小时前
SpringBoot Actuator 泄露挖掘实战:从 /env 到 heapdump
后端
一次旅行1 小时前
AI 前沿日报 | 2026年08月08日 星期六
人工智能
9i编程1 小时前
工具是编程的铠甲(下篇):从文件对比、全文搜索到数据库设计
后端·openai·ai编程
老马历写记1 小时前
Maven POM 依赖管理总结
java·maven·system·pom·optional
hyuk的AI工坊1 小时前
Agent/Tool Calling 深度实战:LangChain4j 生产级工具设计
人工智能
古法安卓1 小时前
Android-车机 GNSS 定位数据接收问题排查
android·java·android studio
manyingAi1 小时前
AIGC 落地影视内容行业:漫映 AI 漫剧全链路工作流技术架构解析
人工智能·架构·aigc