ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java低代码智能体平台:LangChain4j与LangGraph4j架构实践

Java低代码智能体平台:LangChain4j与LangGraph4j架构实践 直接聊正题。过去大半年我一直在折腾一件事在 Java 技术栈里把 LangChain4j 和 LangGraph4j 揉进一个低代码工作流平台做成给业务运营人员直接拖拽使用的智能体平台。这篇文章不是产品发布会是我从选型、架构、落地到踩坑的完整记录。如果你也在纠结Spring AI 还是 LangChain4j工作流引擎怎么跟大模型结合怎么让不会写代码的人也能编排智能体这篇应该能帮你省下不少弯路。先说结论LangChain4j 负责搞定大模型接入、工具调用、RAG 和记忆LangGraph4j 负责把流程变成有状态、可分支、可循环的图低代码层负责把图变成业务人员看得懂的画布。这三层各司其职整个平台的架构才有可能是清晰的。下面我把每一层为什么这么做、怎么做、坑在哪逐个拆开讲。1. 先想清楚为什么用 LangChain4j LangGraph4j 搭低代码智能体平台1.1 LangChain4j 不是什么Java 版 LangChain这么简单很多团队一上来就问LangChain4j 是不是就是把 Python 的 LangChain 翻译成 Java真不是。LangChain4j 是一个完全为 JVM 生态重新设计的框架它解决的核心问题是Java 项目里怎么标准地、可维护地接入大模型而不是每个人各写一套 HTTP 调用。它的核心抽象我用了这么长时间最值钱的是三个。第一个是ChatLanguageModel统一接口不管底层是 OpenAI、通义、文心还是本地部署的模型只要实现了这个接口上层代码完全不用改。第二个是AiServices它把模型调用 工具注册 记忆管理打包成一个有类型的服务接口你在接口上写个方法签名它就自动帮你完成 Agent 的 tool calling 循环。第三个是MessageWindowChatMemory这类记忆实现多轮对话不用自己拼历史消息数组。这三个抽象对低代码平台的意义非常大。因为平台要面对的不是一个固定场景而是要动态地组合模型、工具和记忆策略。如果每个场景都硬编码调 API平台根本无法做到通用。用 LangChain4j 之后我可以把模型配置、工具列表、记忆窗口大小全部变成工作流节点上的配置项运行时动态生成对应的 Agent。1.2 LangGraph4j 补上的关键一块状态化图编排LangChain4j 解决的是单个 Agent 怎么工作但它默认的 chain 是线性的一问一答没问题一遇到先检索、再判断、再调用工具、再生成、不满意就重来这种循环和分支就很别扭。这时候需要 LangGraph4j。LangGraph4j 是把 Python LangGraph 移植到 Java 的开源项目核心概念是 StateGraph你把流程拆成一个个 Node用 Edge 和 ConditionalEdge 把它们连成图。图可以走循环可以有分支更重要的是整张图是带状态的节点之间通过 Channel 传递数据。比如我现在平台里最常用的一个场景客服工单助手。节点分别是意图识别信息补齐调用工单系统生成回复质检复核。如果质检觉得回答不行通过 conditional edge 直接回流到生成回复节点再生成一次。这种流程在传统流程引擎里也能做但你得自己管理上下文传递、循环结束条件、模型响应和历史消息工作量巨大。LangGraph4j 把这些都变成了图的一部分状态是显式的循环是图的自然属性。1.3 对比 Dify、n8n、Coze自建平台的底气在哪这个东西市面上不是没有Dify、Coze、n8n 都做得挺好。那为什么还要自研我当时的判断有三个。第一是技术栈统一。公司核心业务全是 Spring BootDify 是 Python 服务要接进来等于多维护一套异构系统权限体系、部署监控、日志链路全都要单独搞。第二是深度定制。Dify 的工作流节点是它定死的我想加一个企业内部 API 节点让运营拖一个节点就能调内部系统接口这类定制在开源版里要改 Python 源码后续升级全是冲突。第三是数据和合规。业务数据要留在内网模型要接私有网关Dify 私有化部署虽然也支持但底层细节很多不可控。我整理过一张选型对比表放在架构评审会上用维度Difyn8nCoze自建 LangGraph4j 平台技术栈PythonTypeScriptSaaSJava/Spring Boot智能体语义支持较强弱强强且可自定义企业系统集成一般强弱完全可控二次开发成本改 Python改 TS不可Java 团队即可状态化循环编排支持但封闭有限支持图定义自由支持循环私有化部署支持支持不支持天然支持自建的底气不在于功能做得比 Dify 多而在于它是长在自己业务体系里的。Dify 是一个好产品但产品是别人的解决方案平台是自己的基础设施。这个定位想清楚了后面的架构取舍就都有依据了。2. 整体架构设计五层各司其职别把逻辑全塞进引擎里2.1 五层架构总览我见过的失败架构有一个共同点所有逻辑全塞在工作流引擎里DSL 里写满各种业务条件最后 DSL 变得比代码还难维护。所以我们的第一原则是分层而且每一层只允许干一件事。整个平台从上到下分五层展示层可视化画布负责拖拽节点、连线、配参数产出工作流 JSON。编排层负责 DSL 的解析、校验、版本管理把 JSON 翻译成可执行的图定义。执行层基于 LangGraph4j 运行图管理节点调度、状态流转、超时重试。智能体层基于 LangChain4j 封装模型、工具、RAG、记忆是真正和大模型打交道的地方。基础层LLM 网关、向量库、对象存储、外部系统 API 网关。这里最容易被忽视的是编排层和执行层之间的边界。一开始我图省事让前端直接把 JSON 传进执行引擎结果前端一个字段命名调整后端解析逻辑就得跟着改。后来在编排层做了一个中间步骤前端产出的 JSON 是画布结构经过编排层转换成执行结构两者是完全不同的 schema。画布结构关心节点坐标、连线关系执行结构关心节点类型、入参出参、条件分支策略。这个转换逻辑就像是编译器的中间表示隔离了两边的变化。2.2 工作流 DSL 设计画布结构与执行结构分开画布结构长什么样我不多说了就是常规的 nodes edges。重点说执行结构。执行结构的设计我参考了 LangGraph 的图模型因为最终它要映射到 LangGraph4j 上不如从一开始就对齐。一个最小执行结构大概是这样的{ graphId: customer_service_v3, version: 3, channels: { user_query: STRING, intent: STRING, collected_info: MAP, final_reply: STRING }, nodes: [ { id: n1, type: llm, model: default-chat-model, promptTemplate: 你是客服助手请判断用户意图..., outputMapping: { intent: $.intent } }, { id: n2, type: condition, expression: intent need_human, trueTarget: n5, falseTarget: n3 }, { id: n3, type: tool, toolId: ticket_system_create, inputMapping: { customerId: $.collected_info.customer_id } } ], edges: [ { from: n1, to: n2 }, { from: n3, to: n4 } ] }有几个设计细节我想强调一下。第一channels必须显式声明。LangGraph4j 的 State 本质是 Channel 集合提前声明可以避免运行时才发现字段拼错。第二每个节点只通过inputMapping和outputMapping跟全局状态交互节点内部不允许直接操作状态对象。这样做的目的是让节点可复用同一个调用工单系统的节点在不同工作流里通过不同的 mapping 就能适配。第三condition 节点不单独拉一条边而是用trueTarget/falseTarget显式指向避免画布上条件分支线一大片看不清。2.3 从 DSL 到 StateGraph 的映射一张表搞定节点类型DSL 设计好了接下来就是映射。我们约定了一张映射表每种 DSL 节点类型对应一种 LangGraph4j 节点实现DSL 节点类型对应 Graph 节点实现说明startLangGraph4j 的 START入口一般只做参数初始化llmLlmNode基于 LangChain4j 做模型调用、流式输出toolToolNode调用注册过的业务工具或外部 APIconditionConditionNode返回路由 key配合 ConditionalEdge 分支ragRagNode向量检索 上下文组装 模型回答codeGroovyNode / JsNode轻量脚本节点用沙箱执行简单计算human_confirmHumanConfirmNode人工审批挂起等待回调endLangGraph4j 的 END汇总输出映射过程在编排层完成遍历 DSL nodes按 type 找到对应的 node factory把 DSL 上的参数promptTemplate、model、toolId、表达式等塞进 factory 生成节点实例再按 edges 和 condition 的 target 关系连边。有一点要特别注意LangGraph4j 的 ConditionalEdge 需要节点返回一个路由 key然后通过一个 Map 把 key 映射到目标节点。所以 ConditionNode 的统一返回值就是一个字符串 key表达式算了什么自己知道外部只认 key。2.4 状态与数据流把 Channel 当成工作流的数据总线状态设计是整个平台最容易翻车的地方。LangGraph4j 里每个节点都接收 State、返回 StateState 里的 Channel 就是跨节点的数据总线。我踩过的第一个坑是把全量状态直接塞给每个节点。刚开始图省事State 里就放一个大 Map节点随便读随便写。结果到了第二个节点就分不清某个 key 是谁写的、格式是什么排查一个数据错乱问题花了一整天。后来强制规范Channel 分三类。输入 Channel 在工作流启动时由调用方写入中间 Channel 是节点间传递的产物必须在 DSL channels 里声明输出 Channel 是结束节点要返回给调用方的。节点代码里不能直接 new 一个没声明的 key 写进 State编排层的校验器会在 DSL 发布时就把这种问题拦下来。第二个经验是关于大对象的。不要把大文本、图片 base64 这类东西放进 Channel 里流转内存会爆序列化也会卡。我们的做法是 Channel 里放引用比如fileId或者vectorStoreQuery的查询条件大内容放对象存储或者 Redis节点真正需要时再取。3. 核心实现把 DSL 跑成真正的智能体流程3.1 工程骨架与依赖引入项目是标准的 Spring Boot 多模块工程。核心模块我分了三个workflow-dslDSL 定义、校验、解析。workflow-engineLangGraph4j 图构建与执行调度。agent-runtimeLangChain4j 智能体封装、工具注册、RAG 服务。Maven 依赖核心就这些我贴一个精简版properties langchain4j.version1.0.0-beta2/langchain4j.version langgraph4j.version1.0.0/langgraph4j.version /properties dependencies dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version${langchain4j.version}/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId version${langchain4j.version}/version /dependency dependency groupIdorg.bsc.langgraph4j/groupId artifactIdlanggraph4j-core/artifactId version${langgraph4j.version}/version /dependency dependency groupIdorg.bsc.langgraph4j/groupId artifactIdlanggraph4j-langchain4j/artifactId version${langgraph4j.version}/version /dependency /dependencies提醒一句LangChain4j 和 LangGraph4j 的版本迭代都比较快API 有过调整。上面这组是我当时验证过的组合你上手时以官方文档当前版本为准。langgraph4j-langchain4j这个集成包很重要它提供了 LangChain4j 的ChatLanguageModel和 LangGraph4j 节点之间的适配层省去了自己写接口转换的功夫。3.2 DSL 解释器与图构建器图构建是我觉得整个平台里最核心的一段代码。它的职责很单纯吃一份执行结构 JSON吐一个可执行的StateGraph。核心逻辑大概是这样的Component public class WorkflowGraphBuilder { private final MapString, NodeFactory nodeFactories; public StateGraphWorkflowState build(WorkflowDefinition definition) { WorkflowStateSchema schema definition.channels(); StateGraphWorkflowState graph new StateGraph( WorkflowState::new, schema.toChannels()); // 1. 注册所有节点 for (WorkflowNodeDefinition nodeDef : definition.nodes()) { NodeFactory factory nodeFactories.get(nodeDef.type()); if (factory null) { throw new DslValidationException(未知节点类型: nodeDef.type()); } graph.addNode(nodeDef.id(), factory.create(nodeDef, runtimeContext)); } // 2. 连边 for (EdgeDefinition edge : definition.edges()) { graph.addEdge(edge.from(), edge.to()); } // 3. 条件分支 for (WorkflowNodeDefinition nodeDef : definition.nodes()) { if (condition.equals(nodeDef.type())) { MapString, String routeMap nodeDef.condition().routeMap(); graph.addConditionalEdges( nodeDef.id(), state - state.value(nodeDef.id() .routeKey).toString(), routeMap ); } } // 4. 入口出口 graph.setEntryPoint(definition.startNodeId()); return graph; } }这段代码里最容易出错的是条件分支的 routeKey 存储。LangGraph4j 的 ConditionalEdge 回调需要读取节点的路由结果所以 ConditionNode 在执行时要把结果写进 Statekey 用节点 ID .routeKey这样的命名。如果直接用裸的 routeKey工作流里有两个 condition 节点就会互相覆盖。3.3 智能体节点基于 AiServices 的动态封装LLM 节点是整个平台里最灵活也最复杂的节点。我最终选择了基于 LangChain4j 的AiServices来做动态封装而不是直接调用chatLanguageModel.chat()。原因很简单真实业务场景里LLM 节点往往不是一句话问答而是要带上下文、要能调工具、要能引用知识库。这些能力AiServices都封装好了public class LlmNode implements NodeWorkflowState { private final LlmNodeConfig config; private final ChatLanguageModel model; private final ListObject tools; private final ChatMemory chatMemory; Override public WorkflowState apply(WorkflowState state) { AgentService agent AiServices.builder(AgentService.class) .chatLanguageModel(model) .chatMemory(chatMemory) .tools(tools.toArray()) .build(); String reply agent.chat(buildUserMessage(state, config.inputMapping())); MapString, Object output parseStructuredReply(reply); for (Map.EntryString, String entry : config.outputMapping().entrySet()) { state.update(entry.getKey(), mapValue(output, entry.getValue())); } return state; } }这里有一个取舍要说清楚。平台上的智能体节点我用的是无状态 Agent设计每个节点执行时创建自己的AiServices工作流的状态流转由 LangGraph4j 负责Agent 本身不跨节点维护记忆。为什么这样做因为工作流的记忆本质是流程的状态不是Model 的历史把记忆放在 Agent 内部会导致图的状态和 Agent 的状态互相打架非常难调试。那多轮对话怎么办答案是对话历史作为 Channel 在图中流转LLM 节点通过 prompt 模板把历史拼进去或者使用 Workflow 级别的 ChatMemory把 memoryId 作为参数传入。这两者的区别我会在第 4 节详细讲。3.4 把工作流发布成 API平台能力的出口低代码平台做得再漂亮最终还是要被业务系统调用。所以工作流必须能一键发布成 API。我们参考了 Dify 的发布为 API思路给每个已发布版本生成一个独立的调用端点POST /api/v1/workflows/{graphId}/invoke调用方传参直接对应 DSL 里的输入 Channel{ user_query: 我要查一下上周的工单处理情况, customer_id: C20240015 }平台内部的处理链路是Restful API 入口 - DSL 版本校验 - 构建图实例 - 注入输入 State - 执行引擎运行 - 汇总输出 Channel - 返回结果。执行引擎这里要注意一个基础问题StateGraph实例被构建出来以后能不能复用我们的结论是图结构可以缓存复用但每次执行必须用新的状态快照。LangGraph4j 本身支持图的复用但执行时如果共享可变 State 会出并发问题。我们的做法是维护一个CompiledGraph缓存key 是 graphId version每次请求进来通过 factory 从缓存图创建新的 execution保证并发安全。3.5 流式输出SSE 方案要注意的两件事智能体平台的体验很大程度上取决于是不是打字机效果。尤其 LLM 节点用户看到逐字输出耐心会高很多。流式我们选了 SSE因为实现简单Spring WebMVC 原生支持。LangChain4j 的流式接口是StreamingChatLanguageModel关键是把它和 LangGraph4j 的执行结合起来。因为图里可能既有 LLM 节点又有工具节点流式输出只对 LLM 节点有意义。我们的方案是LLM 节点执行时把流式 token 推到一个SseEmitters注册表key 是 executionId外部 API 的响应通过注册表找到对应的 SseEmitter 实时推送。两个具体的坑。第一个是 SSE 的编码。Spring 对text/event-stream的输出编码有时默认不是 UTF-8中文会乱码。网上不少人踩过解决办法是配置SseEmitter时显式指定字符编码或者用ResponseBodyEmitter并设置MediaType.TEXT_EVENT_STREAM加上 UTF-8 参数。第二个是连接超时。LLM 生成时间可能超过默认的 30 秒SseEmitter 需要根据模型超时时间动态设置或者做心跳机制否则前端会看到连接断掉。4. 实战踩坑与排查技巧4.1 状态序列化Channel 类型声明不能偷懒LangGraph4j 的状态在跨节点传递时会涉及序列化尤其是把图执行挂起、恢复比如人工确认节点的时候。我们遇到过一个典型问题DSL 里声明collected_info是 MAP但节点里写入的是某个自定义对象序列化后读出来类型对不上直接 ClassCastException。后来我们把所有 Channel 的可选类型收敛为几类基础类型STRING、INTEGER、BOOLEAN、MAP、LIST、LIST_STRING自定义对象一律塞进 MAP 里。虽然看起来不面向对象但工作流状态本来就是半结构化的数据流基础类型反而最稳定。这个约束还要下沉到 DSL 校验器发布时发现不支持的 Channel 类型直接拒绝。4.2 条件分支表达式别搞成万能脚本ConditionNode 是业务最爱的节点也是平台最容易被玩坏的节点。一开始我把表达式设计成 SpEL 全套支持结果运营同事写出了几百行的表达式完全没法维护。后来我把条件节点分成两种。一种是简单比较表达式语法收敛到字段 操作符 值操作符只有等于、不等于、包含、大于、小于这几个翻译成代码就一个简单的 switch。另一种是复杂分支这种情况我不让运营写表达式了而是建议他们把它拆成 code 节点由开发在 sandbox 里写脚本。这样做的目的不是限制能力而是让谁负责什么变得清晰运营负责配置开发负责逻辑。排查条件分支问题时最有效的工具是给 ConditionNode 加 trace 日志把表达式、字段值、计算结果、路由 key 全部打出来。这个日志在测试环境默认全量开生产环境可以通过节点配置按需开。4.3 多轮记忆跨请求的会话状态要放对位置工作流一次执行结束不等于对话结束。用户第二句话进来工作流重新被调用这时候怎么带上上一轮的上下文我最开始的做法是把历史消息全部塞进输入 Channel每次重新传给 LLM。看起来简单但有个问题历史会无限膨胀而且如果中间有工具调用记录历史里全是工具返回的大段 JSON很快就把上下文窗口撑爆。后来我引入ChatMemoryStore用 memoryId 维度管理会话历史。工作流入口节点会从请求参数里解析 memoryIdLLM 节点通过它加载对应的MessageWindowChatMemory。LangChain4j 提供了PersistentChatMemoryStore的扩展点我们把它接在 Redis 上TTL 设置成 24 小时。这个方案有三个好处历史可以在图外复用支持同一个会话跨多个工作流共享记忆上下文窗口可控因为配置了MessageWindowChatMemory.withMaxMessages(20)排查问题方便Redis 里直接能看到某次会话的历史消息。4.4 RRF 召回融合默认实现的去重缺陷要自己补聊一个比较深的坑也是在搜索热词里不少人提到的LangChain 和 LangChain4j 里默认的 RRF 实现去重逻辑存在缺陷。RRF 是告诉你想搜相似资料又要关键词命中时的融合方案。假设向量检索返回了文档 A、B、C关键词检索返回了 B、D。RRF 的基本公式是给每篇文档算融合分score sum(1/(k rank))k 一般取 60。问题在于融合前需要按键去重合并如果两个列表里同一篇文档的 ID 表示不一致一个是 docId一个是文档内容 hash默认实现就可能把同一篇文档当成两条记录分数被重复累加排名就错了。我们的修正方案是在融合前对文档做归一化。所有检索源统一返回结构(docId, contentHash, rank, score)融合器按键用docIddocId缺失时用contentHashkey 相同则合并 rank 列表再统一计算 RRF 分。这个逻辑不复杂但没有统一入口做归一化每个检索源各自实现就一定会出 bug。顺带说一句RAG 节点里检索融合的分数和 LLM 生成质量之间的关系不是分数越高答案越好。我见过团队花大力气调融合权重但真正影响回答质量的是送进 prompt 的上下文是否干净、是否去重、是否按相关性截断。融合只是排序排序之后的上下文组装策略才值得花更多精力。4.5 常见问题速查表把这段时间遇到的高频问题整理成一张表方便后来人对照排查现象可能原因解决思路图执行到一半报 ClassCastExceptionChannel 类型声明与实际写入类型不一致收敛 Channel 类型为 MAP/基础类型发布时强校验条件分支总是走默认分支routeKey 被另一个 condition 节点覆盖routeKey 按节点 ID 命名隔离SSE 中文乱码编码不是 UTF-8显式设置 SSE 编码 UTF-8 参数流式输出中断模型响应超时SseEmitter 过期按模型超时设置 emitter加心跳多轮对话历史丢失memoryId 未贯穿工作流请求入口节点解析 memoryIdLLM 节点统一加载LLM 节点重复调用工具Agent 没有正确结束 tool calling 循环检查工具返回格式确认 ToolExecutor 正确注册RAG 结果排名不对RRF 融合没有正确去重统一 docId/contentHash 归一化后再融合并发执行状态串了同一个 StateGraph 实例被多个请求共享状态图缓存复用State 每次新建实例DSL 改字段后旧流程报错线上流程还在用旧版本 DSL做好版本管理执行按版本走长时间任务前端一直转圈缺少执行进度推送执行引擎发进度事件通过 SSE 推给前端这里头的并发执行状态串了值得再强调一次。如果新建 execution 的时候图里注册的节点还持有上一次的上下文对象等于状态泄漏。节点最好是无状态的上下文一律从 State 里读实在需要持有临时对象的用ThreadLocal包裹并在执行结束 finally 里清理。5. 个人体会与后续扩展平台从第一个可用的版本到现在我最大的体会是低代码智能体平台最难的不是 AI 部分而是工程化部分。LangChain4j 和 LangGraph4j 都已经帮你把模型调用和流程编排的基础做好了剩下的工作量全在 DSL 设计、状态管理、版本兼容、权限控制、监控告警这些不性感的地方。这些地方做不好模型再强平台也撑不过两个月的真实业务考验。还有一个体会是不要试图用一套 DSL 通吃所有场景。我们平台现在跑得最稳的是两类工作流一类是结构化比较强的客服工单审批辅助另一类是内容生成类的报告撰写营销文案。前者用条件节点和工具节点居多后者用 LLM 节点和 RAG 节点居多。真正复杂的自主规划式 Agent目前只在小范围试因为它的行为不可控不适合直接交给业务方。后续的扩展方向我目前在看三个一是把 LangGraph4j 的 checkpoint 机制用起来做工作流执行的可回溯和人工干预二是把 code 节点从 Groovy 换成 GraalVM 的 JS 沙箱隔离性更好三是增加工作流执行轨迹的回放功能业务方可以直观看到每个节点消耗了多少 token、耗时多少、在哪里走的分支这对运营调优比日志好用得多。最后分享一个小技巧无论架构设计得多完美先挑一个真实的小场景把它完整跑通哪怕它只需要三个节点。我第一版平台就是先用工单摘要生成这个极简流程打通了 DSL 解析、图构建、LLM 调用、API 发布、SSE 推送这一整条链路后面的所有复杂功能都是在这条链路上长出来的。从最小闭环起步比从完整架构起步要快得多也稳得多。
返回列表