1. 为什么要在 Java 生态里造一个低代码智能体工作流平台
这两年做 Java 后端的同行应该都有同感:AI 能力接入这件事,从“调个 HTTP 接口”迅速演变成了“要编排一整套带记忆、带工具调用、带分支判断的智能体流程”。我最早是在一个内部客服工单系统里尝试接大模型,最开始就是写个 Service 拼 prompt,调一次接口返回结果。但业务方很快提了新需求:先判断工单类型,再决定要不要查知识库,查完知识库还要根据置信度决定是直接回复还是转人工,转人工之前还得生成一段摘要。这套逻辑用 if-else 硬编码,两周就变成了一坨没人敢动的面条代码。
后来我陆续试过几种方案。Python 侧的 LangChain 生态确实成熟,但我们的主栈是 Spring Boot,团队里没人愿意为了一个功能模块去维护一套 Python 服务,跨语言调用的序列化、超时、链路追踪全是坑。也看过一些可视化编排平台,拖拽是挺爽,但一旦要接公司内部的鉴权体系、要复用已有的 Java Bean,就发现扩展点根本不够用,最后还是要写代码,那还不如一开始就用代码写。
真正让我下定决心做这套架构的,是LangChain4j和LangGraph4j这两个库的成熟。LangChain4j 把 Java 侧的模型接入、RAG、工具调用这些基础能力封装得足够干净,而 LangGraph4j 把“图”这个概念引入了工作流编排——节点是执行单元,边是流转条件,状态在节点之间传递。这两者一结合,我脑子里那个“低代码工作流通用智能体平台”的轮廓就清晰了:用图来描述流程,用配置来定义节点,用 LangChain4j 来提供智能体能力,让业务人员能在可视化界面上拼出一个能跑的智能体,同时开发人员还能在关键节点插入自定义 Java 逻辑。
这套东西适合谁?如果你是一个 Java 团队的技术负责人,正在被业务方催着要“AI 能力”但又不想把架构搞乱;如果你是一个后端开发,想搞清楚智能体工作流到底该怎么落地而不是停留在 demo 阶段;如果你已经在用 LangChain4j 但觉得每次加个分支都要改代码太痛苦——那这套架构设计思路应该能给你省不少试错时间。下面我把整个设计拆开讲,包括选型理由、核心抽象、实操步骤,以及我踩过的那些坑。
2. 整体架构设计与技术选型背后的取舍
2.1 为什么是 LangChain4j 而不是 Spring AI
这个选择我纠结了挺久。Spring AI 的优势在于和 Spring 生态无缝集成,依赖注入、配置管理都是现成的,如果你只是要做一个简单的问答接口,Spring AI 确实更省事。但问题在于,Spring AI 的抽象层次偏高,它把很多决策权收走了。比如你想自定义一个带条件分支的 Agent 执行循环,Spring AI 的 Advisor 机制虽然能实现,但写起来很别扭,本质上是在框架的缝隙里塞逻辑。
LangChain4j 则更像一套“积木”。它的ChatLanguageModel、EmbeddingModel、ToolSpecification这些接口定义得很清晰,你可以自由组合。更重要的是,LangChain4j 对 RAG 的支持非常完整——文档加载、切分、向量化、检索、重排序,每一步都有对应的接口,而且允许你替换任意环节。我们平台里有个“知识库问答”节点,就是直接复用了 LangChain4j 的EmbeddingStoreContentRetriever,只改了检索后的过滤逻辑,其他全用默认实现。
还有一个现实因素:LangGraph4j 本身就是基于 LangChain4j 的生态构建的,两者在状态管理和消息传递上的设计理念一致。如果选 Spring AI,就得自己写一层适配把 LangGraph4j 的图执行和 Spring AI 的模型调用桥接起来,这个适配层的维护成本不低。
提示:如果你的团队已经在深度使用 Spring 生态且 AI 需求很简单,Spring AI 不是不能选。但只要涉及多步骤、带分支的智能体流程,LangChain4j + LangGraph4j 的组合在灵活性和可控性上优势明显。
2.2 LangGraph4j 的图模型到底解决了什么问题
传统的工作流引擎(比如 Camunda、Activiti)是面向“人工审批”设计的,节点是人工任务,流转靠表单和网关。但智能体工作流的节点是“模型调用”“工具执行”“条件判断”,它的执行时间不确定、输出不确定、甚至下一步走哪条边都不确定。用 BPMN 那套东西来表达智能体逻辑,就像用 Excel 做视频剪辑——不是不行,是别扭。
LangGraph4j 的核心抽象是StateGraph。你定义一个状态类型(通常是一个继承自AgentState的类,里面放消息列表、上下文变量、中间结果),然后往图里加节点和边。节点是一个函数,接收状态返回状态;边可以是固定的,也可以是条件边——根据状态里的某个字段决定下一步去哪个节点。这个模型天然适合智能体:“判断意图”是一个节点,“调用工具”是一个节点,“生成回复”是一个节点,条件边根据意图判断结果决定走哪条路。
我特别喜欢它的一个设计是“检查点”(Checkpoint)。每执行完一个节点,状态可以被持久化。这意味着如果流程跑到一半模型接口超时了,可以从上一个检查点恢复,不用从头再来。对于长流程的智能体(比如一个要调用五六个工具的调研任务),这个特性太重要了。
2.3 低代码层的设计边界:什么该拖拽,什么该写代码
“低代码”这个词很容易让人产生不切实际的期望。我的原则是:流程结构可视化,节点实现代码化,参数配置表单化。
流程结构可视化,指的是节点之间的连接关系、条件分支的走向,这些在画布上拖拽完成。业务人员能看懂“先查知识库,如果没查到就转人工”这个逻辑,他们可以在画布上调整这个顺序。
节点实现代码化,指的是每个节点具体干什么——比如“查知识库”这个节点内部怎么调 embedding、怎么检索、怎么拼 prompt——这些还是得开发人员写。低代码不是让业务人员写代码,而是让他们编排开发人员已经写好的能力。
参数配置表单化,指的是每个节点暴露出来的参数(比如检索的 topK、相似度阈值、模型温度),在界面上生成表单让业务人员填。开发人员在定义节点时声明这些参数的类型和默认值,前端自动渲染。
这个边界划清楚之后,整个平台的架构就清晰了:底层是 LangChain4j + LangGraph4j 的执行引擎,中间是节点定义和注册机制,上层是可视化编排界面和参数配置表单。
3. 核心模块拆解与关键实现细节
3.1 节点抽象:一切皆 Node
整个平台最核心的抽象就是Node。我定义了一个接口:
public interface WorkflowNode { String getType(); String getName(); List<NodeParameter> getParameters(); AgentState execute(AgentState state, Map<String, Object> config); }getType()返回节点类型标识,比如llm_call、knowledge_retrieval、tool_invocation、condition_branch。getParameters()返回这个节点需要配置的参数列表,前端根据这个列表渲染表单。execute()是实际执行逻辑,接收当前状态和配置,返回更新后的状态。
这里有个设计决策:状态是不可变的还是可变的?LangGraph4j 默认的状态传递是覆盖式的,节点返回一个新的状态对象。我一开始想用可变状态减少对象创建开销,但后来发现不可变状态在调试时太香了——每个节点的输入输出都能完整记录,出问题了直接对比前后状态就知道哪个节点改坏了。性能方面,状态对象本身不大(主要是消息列表和几个上下文变量),这点开销可以接受。
节点的注册用了一个简单的工厂模式。启动时扫描所有实现了WorkflowNode接口的 Bean,按getType()注册到一个 Map 里。前端请求节点列表时,把这个 Map 里的节点元信息返回去。新增一种节点类型,只需要写一个类加上@Component注解,重启后自动出现在画布上。
3.2 状态设计:AgentState 里到底放什么
状态设计是智能体工作流的灵魂。放少了,节点之间没法传递信息;放多了,状态膨胀得没法维护。我最终的设计是分三层:
第一层是消息历史(messages)。这是 LangChain4j 的ChatMessage列表,记录了用户输入、模型回复、工具调用结果。所有需要“对话上下文”的节点都从这里读。
第二层是流程变量(variables)。一个Map<String, Object>,存放节点产生的中间结果。比如“意图识别”节点会把识别出的意图类型写进variables.intent,“知识检索”节点会把检索到的文档列表写进variables.retrievedDocs。条件边就是读这些变量来决定走向。
第三层是执行元数据(metadata)。包括当前节点 ID、执行时间戳、重试次数等。这些不参与业务逻辑,但用于监控和调试。
public class AgentState { private List<ChatMessage> messages; private Map<String, Object> variables; private Map<String, Object> metadata; public AgentState copy() { // 深拷贝,确保节点间状态隔离 } }注意:
variables里的值一定要可序列化。因为检查点机制需要把状态持久化到数据库或 Redis,如果塞了一个不可序列化的对象进去,恢复的时候直接报错。我踩过这个坑,当时往 variables 里放了一个InputStream,调试了半天才发现是序列化问题。
3.3 条件边的实现:让流程会“拐弯”
条件边是低代码平台里最体现“智能”的部分。在 LangGraph4j 里,条件边是一个函数,接收状态返回下一个节点的名称。但在低代码场景下,不能让业务人员写这个函数,得把它配置化。
我的做法是定义一个ConditionRule结构:
{ "sourceNodeId": "intent_check", "rules": [ { "expression": "variables.intent == 'faq'", "targetNodeId": "knowledge_retrieval" }, { "expression": "variables.intent == 'complaint'", "targetNodeId": "transfer_to_human" } ], "defaultTargetNodeId": "fallback_reply" }表达式的解析我用了一个轻量级的规则引擎(基于 SpEL 做了封装),支持==、!=、>、<、contains、startsWith这些常用操作。业务人员在界面上通过下拉框选择变量、选择操作符、填写值,前端生成表达式字符串。
这里有个性能考量:表达式不要每次执行都重新解析。SpEL 的ExpressionParser解析一次之后可以缓存Expression对象,执行时直接getValue()。我在节点初始化时就把所有条件表达式预编译好,执行时只做求值,实测下来单次条件判断在微秒级别,完全不是瓶颈。
3.4 工具调用的动态注册:让智能体会用“新工具”
智能体要能干活,就得能调工具。LangChain4j 的工具调用机制是通过ToolSpecification描述的,每个工具需要定义名称、描述、参数 schema。在低代码平台里,如果每加一个工具都要改代码重新部署,那就谈不上“低代码”了。
我的方案是工具的动态注册。平台启动时,除了扫描代码里定义的@Tool注解方法,还会从数据库加载用户通过界面上传的工具定义。工具的执行方式支持两种:一种是 HTTP 调用(配置 URL、方法、请求头、参数映射),一种是脚本执行(支持 Groovy 脚本,在沙箱里跑)。
public class DynamicTool { private String name; private String description; private String executionType; // HTTP or SCRIPT private Map<String, Object> executionConfig; public ToolSpecification toToolSpecification() { // 把数据库里的定义转换成 LangChain4j 的 ToolSpecification } }HTTP 类型的工具特别实用。我们内部有很多微服务已经暴露了 REST 接口,业务人员只需要在界面上填一下接口地址和参数映射,就能让智能体调用这些服务。比如“查询订单状态”这个工具,就是配置了一个 GET 请求,参数从状态变量里取。
提示:HTTP 工具一定要加超时和重试配置。模型有时候会生成奇怪的参数导致接口返回 500,如果没有超时,整个工作流就卡死了。我默认设置的是连接超时 3 秒、读取超时 10 秒、重试 1 次。
4. 从零搭建一个智能体工作流的完整实操
4.1 环境准备与依赖引入
先说一下基础环境。JDK 17 是底线,LangChain4j 和 LangGraph4j 都用到了 record 和 sealed class 这些新特性。构建工具用 Maven 就行,核心依赖就两个:
<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>0.35.0</version> </dependency> <dependency> <groupId>org.bsc.langgraph4j</groupId> <artifactId>langgraph4j-core</artifactId> <version>1.0.0</version> </dependency>模型接入方面,我用的是 OpenAI 兼容的接口,所以还需要加上langchain4j-open-ai。如果你用的是国内模型,找对应的适配包就行,LangChain4j 的社区适配已经覆盖了主流模型。
数据库方面,我用 PostgreSQL 存工作流定义和执行记录,Redis 做检查点缓存。向量库用的是 Milvus,LangChain4j 有现成的MilvusEmbeddingStore。
4.2 定义一个“客服工单处理”工作流
拿一个真实场景来演示:用户提交一个工单,系统需要先判断工单类型,如果是咨询类就去知识库找答案,如果是投诉类就转人工,如果是技术问题就调用内部 API 查系统状态。
第一步,定义状态结构。这个工作流的状态需要存消息历史、工单内容、意图类型、检索结果、最终回复。
第二步,在画布上拖出节点。从左侧节点面板拖入:一个“意图识别”节点(LLM 调用类型)、一个“知识检索”节点、一个“API 调用”节点、一个“转人工”节点、一个“回复生成”节点。
第三步,连线并配置条件。意图识别节点后面接一个条件分支,三条规则分别指向知识检索、API 调用、转人工。知识检索和 API 调用的输出都汇入回复生成节点。
第四步,配置每个节点的参数。意图识别节点需要配置模型名称、温度(设 0.1,因为要稳定分类)、prompt 模板。知识检索节点需要配置向量库连接、topK(设 5)、相似度阈值(设 0.75)。API 调用节点需要配置接口地址和参数映射。
第五步,保存并发布。平台会把画布上的图结构序列化成 JSON 存到数据库,同时生成一个唯一的 workflowId。调用时通过这个 ID 加载定义,构建 LangGraph4j 的StateGraph并执行。
4.3 工作流执行引擎的核心代码
执行引擎的入口是一个WorkflowExecutor:
public class WorkflowExecutor { private final NodeRegistry nodeRegistry; private final CheckpointManager checkpointManager; public AgentState execute(String workflowId, AgentState initialState) { WorkflowDefinition definition = loadDefinition(workflowId); StateGraph<AgentState> graph = buildGraph(definition); CompiledGraph<AgentState> compiled = graph.compile(); return compiled.invoke(initialState, config -> config.checkpointManager(checkpointManager)); } private StateGraph<AgentState> buildGraph(WorkflowDefinition definition) { StateGraph<AgentState> graph = new StateGraph<>(AgentState::new); for (NodeDef nodeDef : definition.getNodes()) { WorkflowNode node = nodeRegistry.get(nodeDef.getType()); graph.addNode(nodeDef.getId(), state -> node.execute(state, nodeDef.getConfig())); } for (EdgeDef edge : definition.getEdges()) { if (edge.isConditional()) { graph.addConditionalEdges(edge.getSourceId(), state -> evaluateCondition(edge, state), edge.getTargetMap()); } else { graph.addEdge(edge.getSourceId(), edge.getTargetId()); } } return graph; } }这段代码的关键在于buildGraph方法——它把数据库里的 JSON 定义翻译成了 LangGraph4j 的内存图结构。每次执行都重新构建图,而不是缓存编译后的图,是因为工作流定义可能随时被修改。如果你追求极致性能,可以加一层缓存,用 workflowId + version 作为 key。
4.4 检查点与断点恢复的实操
检查点的价值在长流程里体现得最明显。我有个客户的工作流要调用六个外部系统,总耗时可能超过两分钟。如果中间某个接口挂了,没有检查点就得从头再来,用户体验极差。
LangGraph4j 的检查点机制是通过CheckpointManager实现的。我实现了一个基于 Redis 的版本:
public class RedisCheckpointManager implements CheckpointManager { private final RedisTemplate<String, byte[]> redisTemplate; @Override public void save(String threadId, String nodeId, AgentState state) { String key = "checkpoint:" + threadId + ":" + nodeId; byte[] serialized = serialize(state); redisTemplate.opsForValue().set(key, serialized, Duration.ofHours(24)); } @Override public AgentState load(String threadId, String nodeId) { String key = "checkpoint:" + threadId + ":" + nodeId; byte[] data = redisTemplate.opsForValue().get(key); return data != null ? deserialize(data) : null; } }恢复的时候,传入相同的 threadId,引擎会从最后一个成功的检查点继续执行。这里有个细节:检查点的粒度是节点级别,不是边级别。也就是说,如果一个节点执行成功了但边判断失败了,恢复时会从该节点之后重新判断边,而不是重新执行节点。这个设计是合理的,因为节点通常比边昂贵得多。
注意:检查点里的状态必须和当前工作流定义的版本匹配。如果工作流定义改了(比如删了一个节点),旧的检查点可能无法恢复。我的做法是在检查点里存一个 definitionVersion,恢复时先校验版本,不匹配就提示用户重新发起。
5. 踩坑记录与常见问题排查
5.1 模型输出格式不稳定导致条件边判断失败
这是最常见的问题。条件边依赖variables.intent的值来判断走向,但模型有时候会输出“意图是咨询”而不是纯粹的“咨询”,导致==判断失败。
我的解决方案是在 LLM 调用节点后面加一个“输出解析”节点。这个节点的作用是把模型的自然语言输出规范化成结构化数据。具体做法是让模型输出 JSON 格式,然后用 LangChain4j 的JsonOutputParser解析。如果解析失败,走一个兜底分支,用规则匹配提取关键词。
public class OutputParserNode implements WorkflowNode { @Override public AgentState execute(AgentState state, Map<String, Object> config) { String rawOutput = (String) state.getVariables().get("lastLlmOutput"); try { Map<String, Object> parsed = Json.fromJson(rawOutput, Map.class); state.getVariables().putAll(parsed); } catch (Exception e) { // 兜底:用正则提取 String intent = extractByRegex(rawOutput); state.getVariables().put("intent", intent); } return state; } }5.2 工具调用参数类型不匹配
LangChain4j 在生成工具调用参数时,会根据ToolSpecification里的 schema 来生成。如果 schema 定义的是integer,但模型生成了一个字符串"5",调用时就会报类型转换错误。
我踩过这个坑之后,在工具执行层加了一层参数强制转换。对于数字类型的参数,先尝试Integer.parseInt,失败再尝试Double.parseDouble;对于布尔类型,把"true"、"yes"、"1"都转成true。这层转换虽然看起来不优雅,但确实能挡住大部分模型生成的小毛病。
5.3 工作流死循环
条件边如果配置不当,可能形成环。比如 A 节点判断失败后指向 B,B 执行完又指回 A,而条件永远不满足退出条件,就死循环了。
我在执行引擎里加了一个最大步数限制,默认 50 步。超过之后强制终止并返回错误。同时在前端画布上,如果检测到环,会高亮提示但不会阻止保存——因为有些场景确实需要循环(比如重试),但必须配合明确的退出条件。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 条件边不走预期分支 | 变量值格式不匹配 | 打印 state.variables 看实际值 | 加输出解析节点规范化 |
| 工具调用报参数错误 | 模型生成类型不对 | 看工具调用的原始参数 | 加参数强制转换层 |
| 工作流卡死 | 死循环或接口超时 | 看执行日志最后停在哪个节点 | 加最大步数限制和超时 |
| 检查点恢复失败 | 状态不可序列化 | 看序列化异常堆栈 | 确保 variables 里都是可序列化对象 |
| 模型回复慢 | 上下文太长 | 看 token 消耗量 | 加消息历史截断策略 |
5.4 消息历史膨胀拖慢执行
多轮对话场景下,messages列表会越来越长,每次调模型都要把全部历史传过去,token 消耗大且响应慢。我的策略是滑动窗口 + 摘要:保留最近 10 条消息,更早的消息用一个小模型生成摘要,把摘要作为一条 system 消息放在最前面。这样既保留了上下文,又控制了 token 量。
这个摘要节点也是可配置的,业务人员可以选择“不摘要”“按条数摘要”“按 token 数摘要”三种模式。实测下来,一个跑了 50 轮的对话,用摘要模式后 token 消耗降低了 70%,响应时间从 8 秒降到 3 秒左右。
6. 平台扩展性与后续演进方向
6.1 多智能体协作的图结构
现在的工作流本质上是单智能体——一个状态在节点间流转。但有些场景需要多个智能体各自维护状态、互相通信。LangGraph4j 支持子图(SubGraph),可以把一个完整的工作流作为一个节点嵌入到更大的图里。
我目前的设计是:每个子图有独立的状态空间,父子图之间通过输入输出映射传递数据。比如一个“调研智能体”子图负责收集信息,完成后把结果写到一个约定的变量里,父图的条件边读取这个变量决定下一步。这个模式在 LangGraph4j 里实现起来很自然,因为图本身就是可组合的。
6.2 人工介入节点
有些流程需要人工确认才能继续,比如“转人工”节点实际上应该是一个暂停点,等待人工处理完再恢复。这个用检查点机制可以实现:执行到人工节点时,保存检查点并返回一个“等待中”状态。人工处理完后,通过一个恢复接口传入处理结果,从检查点继续执行。
这个模式我们已经在用了,效果很好。人工处理的结果会作为一个变量注入状态,后续节点可以读取。
6.3 工作流版本管理与灰度发布
生产环境里,工作流定义是会频繁修改的。直接改线上定义风险太大,我的做法是版本化:每次发布生成一个新版本,旧版本继续可用。调用时可以指定版本号,不指定就用最新版。灰度发布则是通过一个路由层,按比例把流量分到不同版本。
这个机制在 LangGraph4j 层面没有直接支持,是在平台层实现的。核心思路是WorkflowDefinition带一个 version 字段,执行引擎根据 version 加载对应的图定义。
6.4 可观测性建设
智能体工作流的调试比传统接口麻烦得多,因为中间状态多、模型输出不确定。我在每个节点执行前后都打了结构化日志,包括节点 ID、输入状态摘要、输出状态摘要、耗时。这些日志进 Elasticsearch,配合 Kibana 可以做链路追踪。
另外还加了一个“回放”功能:把一次执行的完整状态序列存下来,可以在界面上逐步回放,看每个节点做了什么决策。这个功能在排查“为什么走了这条分支”这类问题时特别有用。
7. 一些实操心得与选型建议
先说一个我反复验证过的结论:不要试图用低代码平台覆盖所有场景。我一开始想的是让业务人员能拖拽出任何流程,后来发现复杂逻辑(比如嵌套循环、动态节点生成)在画布上表达起来极其别扭。现在的策略是:80% 的常规流程用画布拖拽,20% 的复杂逻辑封装成自定义节点,业务人员在画布上引用这个节点就行。
关于 LangChain4j 和 LangGraph4j 的版本选择,我的建议是锁定版本,不要追新。这两个库都在快速迭代,API 偶尔会有 breaking change。我吃过一次亏,升级了一个小版本号,结果StateGraph的泛型签名变了,编译报了一堆错。现在是在 pom 里写死版本,升级前先在测试环境跑一遍全量工作流。
性能方面,最大的瓶颈永远是模型调用,不是图执行。我实测过,一个包含 10 个节点的图,纯图执行耗时在 50 毫秒以内,而一次模型调用动辄两三秒。所以优化重点应该放在减少模型调用次数、缓存模型结果、用更小的模型做简单判断上。比如意图识别这种任务,用 7B 的小模型就够了,没必要上大模型。
最后分享一个配置管理的小技巧:把节点的 prompt 模板也做成可配置的。我一开始把 prompt 硬编码在节点实现里,后来发现业务方经常要微调措辞,每次都要改代码发版。现在 prompt 模板存在数据库里,界面上可以直接编辑,改完立即生效。这个改动虽然小,但省了大量的沟通和发版成本。
这套架构目前在我们内部跑了半年多,支撑了十几个智能体工作流,从客服工单到内部知识问答到销售线索筛选都有覆盖。最深的体会是:低代码的价值不在于让非技术人员写代码,而在于让技术人员写一次代码,业务人员能复用无数次。节点库越丰富,平台的杠杆效应越明显。