企业里做 AI 应用落地,最难受的不是模型效果差,而是改流程比训练模型还慢。业务方说这里要加一个人工确认节点,那个分支要换一套 Prompt,你一看又得改代码、发版本、等测试,一个需求排两周,业务早就不想等了。所以我看到 LangChain4j 和 LangGraph4j 在 Java 生态里逐步成熟时,第一反应就是这两个东西结合起来,非常合适做一套低代码工作流通用智能体平台。这套平台的定位很明确:把大模型调用、知识库检索、外部 API、人工审批这些能力封装成可拖拽的节点,让业务人员通过配置而不是敲代码来搭建一个智能体流程。
这篇文章聊聊我基于这个组合做的平台架构设计,包括为什么这么选型、工作流模型怎么定义、底层执行引擎怎么和 LangGraph4j 结合、以及实测踩过的坑。适合准备在 Java 项目里引入智能体编排、或者正在对比 Spring AI 和 LangGraph4j 做技术取舍的团队参考。全文不涉及具体公司信息,只讲思路和落地细节。
1. 平台定位与整体设计思路
1.1 为什么还要自己做一套"低代码+智能体"平台
市面上的低代码工作流平台已经很多了,n8n、Dify、Coze 都做得不错,界面漂亮、节点丰富,但落到企业内部 Java 技术栈时,问题立刻暴露出来。
第一是数据安全。流程节点要访问内网数据库、ERP 系统、企业微信审批接口,外部 SaaS 平台很难打通。第二是扩展成本。你确实可以调用它们的 API,但每对接一个内部系统都要写一堆 glue 代码,最终变成另一个需要维护的中间层。第三是部署形态。很多企业要求私有化部署,甚至要在离线环境运行,这就把所有依赖云服务的编排平台直接排除了。
所以我们需要在 Java 技术栈里做一套自己的平台。LangChain4j 解决了模型接入、Prompt 管理、工具调用、RAG 这些基础能力;LangGraph4j 恰好弥补了 LangChain4j 在复杂流程编排上的短板——条件分支、循环、多轮对话状态、人工介入。这两个库组合起来,正好覆盖一个工作流智能体平台所需要的全部底层能力。
相比直接用现成的智能体框架,自研平台最终省下的是"流程变更"的时间成本。业务方自己拖拽、测试、发布,我们只需要维护好节点能力和平台稳定性,这才是低代码的核心价值。
1.2 选型分析:为什么是 LangChain4j + LangGraph4j,而不是 Spring AI
这里要先说一个背景:我做这个架构之前,也认真对比过 Spring AI。Spring AI 的好处是生态统一、自动配置方便,尤其是快速调用大模型写个 Chat 接口,十分钟就能跑起来。但做"工作流"时,问题就来了:工作流天然是图结构,需要节点间共享状态、支持循环、支持阻塞等待人工审批,而 Spring AI 在这方面目前还没有成熟的图执行引擎。
LangGraph4j 的核心思想,是把智能体执行过程定义成一张有向图。每个节点是一个计算步骤,边决定了下一步怎么走,一个状态对象贯穿整个图。这个模型和我们低代码流程设计器里的"节点 + 连线 + 变量"几乎一一对应。所以用 LangGraph4j 做执行内核,LangChain4j 做模型和工具层,是一个很自然的组合。
另一个现实原因是团队熟悉度。LangChain4j 的 API 风格贴近原版 LangChain,中文社区资料多,普通 Java 开发看几天就能上手。LangGraph4j 虽然是社区移植版,但基本概念和 Python 版一致,文档里的例子也足够多。我们在架构设计阶段做了 5 个 PoC 验证,包括构建图、条件跳转、状态持久化、工具调用,确认这条路走得通才定下来的。
1.3 整体架构分层与模块边界
整个平台从上层往下分为五层,每层职责单一,依赖关系清晰。
- 表现层:Web 可视化流程设计器,基于 React + Ant Design 开发,使用拖拽库提供节点面板、画布、连线、配置表单。
- API 层:提供流程定义的 CRUD、发布、执行、实例查询、运行日志等 REST API,用 Spring Boot 3 + Spring Security 做权限控制。
- 编排层:流程解析器把 JSON 定义翻译成 LangGraph4j 的 StateGraph;执行引擎负责启动、暂停、继续、终止流程实例;调度器处理定时触发和并发控制。
- 能力层:封装模型调用、向量检索、工具执行、人工任务、脚本执行等节点能力,这一层是平台的可扩展点。
- 持久层:MySQL 存流程定义、工作流实例、运行日志;Redis 存分布式锁和缓存;对象存储存流程运行中产生的文件。
需要特别说明的是,编排层是核心,但也是最容易踩坑的地方。LangGraph4j 提供的 StateGraph 是单机内存执行模型,我们必须在它外面包一层持久化和调度能力,否则流程一重启就全部丢失。后面第 3 节我会详细讲状态管理与持久化方案。
2. 核心模块与工作流模型设计
2.1 工作流图的数据结构:一份可落地的 JSON 设计
低代码平台的第一步是把工作流定义数据结构化。我们使用 JSON Schema 来定义流程定义,简化后的结构是这样的:
{ "id": "lead_qualification", "name": "销售线索质检", "version": 3, "startNodeId": "start", "nodes": [ { "id": "start", "type": "start", "next": "classify" }, { "id": "classify", "type": "llm", "config": { "model": "qwen-plus", "promptTemplate": "你是销售线索质检助手。用户提交线索如下:{{input}},请判断线索是否有效,并输出JSON:{\"valid\": boolean, \"reason\": string}", "outputVariable": "classifyResult" }, "next": "branch" }, { "id": "branch", "type": "condition", "config": { "expression": "classifyResult.valid == true", "nextIfTrue": "score", "nextIfFalse": "end" } }, { "id": "score", "type": "api", "config": { "url": "https://internal-scoring/score", "method": "POST", "timeout": 5000, "body": "{\"company\": \"{{classifyResult.company}}\", \"contact\": \"{{classifyResult.contact}}\"}", "outputVariable": "scoreResult" }, "next": "human_review" }, { "id": "human_review", "type": "human", "config": { "assignee": "sales_admin", "title": "请确认高潜力线索", "contentTemplate": "线索:{{scoreResult.company}},评分:{{scoreResult.score}}" }, "next": "end" }, { "id": "end", "type": "end" } ], "variables": { "input": "string", "classifyResult": "object", "scoreResult": "object" } }这个 JSON 结构参考了 Camunda BPMN 的思路,但为了智能体场景做了大幅精简。每个节点都有一个全局唯一的 id、类型、配置、出边。条件节点用显式的nextIfTrue/nextIfFalse,避免执行引擎去解析复杂的布尔表达式,提高可读性和可靠性。
在设计时我们定了一个约定:所有节点输出都写入同一个顶层状态对象,节点之间通过变量名引用。条件表达式使用 SpEL(Spring Expression Language)来取值,因为 Java 团队熟悉、可控,而且可以通过StandardEvaluationContext限制只读变量,避免表达式注入。实际实现里,我们会把流程定义中所有变量先做一轮静态校验,确保引用的变量名在前面某个节点中被声明过,否则直接拒绝发布。
2.2 低代码设计器到 LangGraph4j 的映射流程
设计器里画出来的"节点 + 箭头",最终要变成可执行的图。LangGraph4j 的构建方式是addNode+addEdge+addConditionalEdge,关键代码大致如下:
StateGraph<AppState> graph = new StateGraph<>(AppState::new); graph.addNode("classify", ctx -> classifierNode.invoke(ctx)); graph.addNode("score", ctx -> scoringNode.invoke(ctx)); graph.addNode("human_review", ctx -> humanTaskNode.invoke(ctx)); graph.addNode("end", ctx -> null); graph.setEntryPoint("start"); graph.addEdge("start", "classify"); graph.addConditionalEdge("classify", ctx -> isTrue(ctx.state().getClassifyResult()) ? "score" : "end", Map.of("score", "score", "end", "end")); graph.addEdge("score", "human_review"); graph.addEdge("human_review", "end");但这里有个关键的工程问题:低代码平台的工作流是配置驱动的,不可能为每个流程写一个静态图。所以我们不会在代码里硬编码节点类,而是维护一个NodeRegistry。每种节点类型对应一个实现了WorkflowNode接口的 Spring Bean:
public interface WorkflowNode { void invoke(NodeExecutionContext context); }流程解析器读 JSON 定义时,先遍历节点数组,根据type从NodeRegistry拿到对应的 Bean,然后用graph.addNode(nodeId, ctx -> node.invoke(ctx))动态注册;再遍历边关系,调用addEdge或addConditionalEdge。这样一来,新增一种节点类型只需要写实现类和配置表单,引擎代码完全不用动。这才是"低代码"在技术层面真正站得住脚的地方。
2.3 智能体节点能力池:平台真正要沉淀的东西
平台好不好用,最终看节点能力够不够。我们一开始预设了以下几类节点:
| 节点类型 | 作用 | 关键配置项 |
|---|---|---|
| start | 流程入口 | 输入参数定义、触发方式 |
| end | 流程结束 | 输出结果定义 |
| llm | 调用大模型 | 模型、Base URL、API Key、Prompt 模板、输出解析 |
| rag | 知识库检索 | Embedding 模型、向量库、TopK、重排策略 |
| api | 调用外部 HTTP 接口 | URL、Method、Headers、参数映射、超时、重试 |
| condition | 条件分支 | SpEL 表达式、目标节点 |
| human | 人工审批/输入 | 审批人、任务标题、任务内容模板、超时提醒 |
| script | 数据转换 | Groovy 或 Java 表达式 |
| loop | 循环处理 | 循环变量、内部子流程 |
| parallel | 并行分支 | 分支列表、汇合策略 |
以 LLM 节点为例,它并不是简单调用一次chatModel.generate()就结束。我们支持两种输出模式:一种是将 LLM 的回复作为整体写入变量;另一种是要求模型返回 JSON,并自动做 JSON 解析和字段映射。后者在实际业务中更常用,所以我们在 LLM 节点配置里增加了一个outputSchema字段,运行时会把它拼接进 Prompt,要求模型严格按 Schema 输出,再用 Jackson 反序列化。如果解析失败,节点可以走一条专门的"解析失败"分支,让流程设计者决定是重试还是转人工。
RAG 节点这里多说一句:LangChain4j 本身提供了EmbeddingStoreIngestor和检索工具,可以直接在 Java 里做知识库入库与召回。但我们的平台里更常见的设计是,把 RAG 封装成一个"检索服务"并通过 API 节点调用,而不是每个流程都直接引入向量库连接。原因很简单:核心知识库通常由专门团队维护,权限和版本控制都在那边,工作流只需要拿到检索结果。当然,如果平台本身就是知识库的所有者,那直接用 LangChain4j 的 RAG 组件会更省事。
3. 关键机制实现
3.1 状态管理与工作流实例持久化
LangGraph4j 的核心是状态对象。我们定义的状态类大致是这样的:
@Data public class AppState { private String workflowInstanceId; private String sessionId; private Map<String, Object> data; private String currentNodeId; private Integer attempt; private Boolean finished; }每个节点执行前从data读取输入,执行后把结果写回data。这个设计本身不复杂,复杂在于"低代码平台必须支持暂停和恢复"。
比如人工审批节点,流程发起后要等待审批人点击通过或驳回,可能一等就是几小时。如果进程重启,流程必须能从上次停留的节点继续跑。LangGraph4j 自带的持久化机制在 Java 版里还不够成熟,所以我们做了两层持久化。
第一层是状态快照:每执行完一个节点,就把AppState整个序列化成 JSON,保存到wf_instance_state表,同时记录当前节点 ID。序列化只支持基础类型、List、Map,禁止自定义业务对象直接放进去——这是为了避免反序列化时的类版本问题。第二层是人工任务表:把待审批事项单独存到wf_task表,绑定流程实例 ID、节点 ID、审批状态。审批回调时,通过流程实例 ID 恢复AppState,然后找到"继续执行"的边,重新进入 LangGraph4j 图。
这里有一个优化细节:状态快照不要每个节点都全量写,否则高并发下数据库压力会很大。我们的做法是配置一个checkpointInterval,默认 3 个节点写一次;但对于有人工节点的分支路径,人工节点之前一定强制做一次快照,因为那里是最可能发生长等待的位置。
3.2 工具调用与函数注册机制
在实际工作流里,大模型经常需要调用外部工具,比如查订单、算折扣、发邮件。LangChain4j 对工具调用的支持相当完善,我们可以把 Java 方法直接暴露出给模型,但工程上不能把任何方法都给模型,要有明确的注册边界。
我们的做法是定义一套"平台工具"注解:
@PlatformTool( name = "queryOrder", description = "根据订单号查询订单信息", params = { @Param(name = "orderId", type = "string", description = "订单号,必填") } ) public Order queryOrder(String orderId) { // ... }平台在启动时通过反射扫描带注解的 Bean,生成ToolSpecification列表。LLM 节点运行时会从上下文拿到当前流程允许使用的工具 ID 列表,从平台工具注册表里筛选出对应的ToolSpecification,传给模型。模型输出工具调用请求后,由 LangChain4j 的ToolExecutor执行,并把结果写回状态。
这个设计要特别注意一点:工具的入参一定要做白名单校验。比如一个"发送邮件"工具,参数的to字段必须符合邮箱格式,content要经过模板渲染,不能让模型自由拼接任意文本。我们曾经遇到过大模型把业务参数和一些额外字符拼接在一起导致下游接口报错的问题,后来所有工具入参统一过一层ParameterValidator,按声明类型和约束强制校验,错误信息再返回给模型让它修正,效果好了很多。
3.3 执行引擎的并行、超时与重试策略
工作流引擎如果只是顺序调用,实现很简单;但真实场景里并行分支、API 超时、多实例并发这些问题躲不掉。
并行节点我们这样实现:把并行子分支放进ExecutorService,用CompletableFuture聚合结果。为了不占用太多线程,我们在线程池参数上做过测算。节点执行以 IO 等待为主,不是 CPU 密集,所以线程池核心线程数设置为2 * CPU 核数,最大线程数设置为8 * CPU 核数,队列大小控制在 200,拒绝策略是CallerRunsPolicy——如果任务实在太多,就由调用线程执行,避免直接丢弃业务请求。并行分支全部完成后,可以配置两种汇合策略:一种是"全部成功才继续",一种是"至少一个成功就继续"。第二种适合"多个模型结果投票"的场景,实现时只需要对CompletableFuture的anyOf和allOf做选择。
超时与重试是所有 API 类节点的标配。重试策略采用指数退避加随机抖动:delay = base * 2^attempt + random(0, 500ms)。基础值默认 1 秒,最多重试 3 次。为什么加随机抖动?因为很多下游系统在故障恢复时同时收到大量重试请求,没有抖动会把系统打挂。对于 LLM 节点,我们一般不会自动重试整个 Prompt 调用,因为大模型调用成本较高且响应时间长,通常是节点配置里让用户选择"失败后进入人工处理"还是"重试当前节点"。
分布式锁也是必要的一环。同一个流程实例如果被重复触发,必须先拿到实例级锁。我们用 Redis + Redisson 实现,锁粒度是wf:instance:{id},锁的超时时间根据流程预估耗时设置,一般 10 分钟,到期自动释放。这样能避免定时触发和人工重试同时把同一个实例跑成两份。
4. 实操过程:从一个"销售线索质检"工作流看落地
4.1 场景梳理:把业务痛点翻译成流程图
销售团队每天会收到大量线上线索,需要判断是否有效、是否值得跟进。原来全靠商务手动一条条看,人均每天处理 200 条就饱和了,而且判断标准不统一,有人只看公司名,有人还要查官网。
我们和业务一起梳理了一个流程:先用大模型从线索文本里抽取结构化信息,包括公司名、行业、规模、联系人;再调用企业内部评分服务,根据行业权重和关键词匹配打一个 0-100 的分数;分数大于 60 的进入人工确认,小于等于 60 的直接淘汰。整个过程可以用平台里的 LLM 节点、API 节点、条件分支节点、人工审批节点串起来。
4.2 在低代码设计器里配置流程
配置流程的过程,业务人员自己就能完成大半。
第一步,新建流程,输入流程名称和描述,提交后系统自动生成流程 ID。第二步,从左侧节点面板拖入一个 LLM 节点,给它命名"信息抽取";在配置表单里选择模型供应商,填写 Prompt 模板,模板里我们用{{input}}引用流程入参。第三步,拖入 API 节点,配置内部评分服务的 URL,把 LLM 节点输出的字段映射到请求体里。第四步,拖入条件节点,填表达式scoreResult.score > 60,把"是"的方向连到人工审批节点,"否"的方向连到结束节点。第五步,人工审批节点配置审批人为销售主管,审批通过后走"通知销售"的脚本节点,驳回则直接结束。
整个配置过程大概 20 分钟。配完之后点击"试运行",平台会针对当前流程定义生成一个临时实例,填入测试数据,跑一遍,并把每个节点的输入输出都记录下来。试运行通过后再点击"发布",流程定义生成一个新的版本号,后续新的流程实例都基于版本 3 执行。
4.3 LangGraph4j 执行器代码骨架
为了让读者更清楚动态构建图的过程,我贴一段简化后的执行器代码:
@Service public class WorkflowExecutor { private final NodeRegistry nodeRegistry; public WorkflowExecutionResult execute(String definitionId, Map<String, Object> inputData) { WorkflowDefinition def = workflowDefinitionRepository.load(definitionId); StateGraph<AppState> graph = buildGraph(def); AppState initialState = new AppState(); initialState.setWorkflowInstanceId(UUID.randomUUID().toString()); initialState.setData(new HashMap<>(inputData)); CompiledGraph<AppState> compiledGraph = graph.compile(); AppState finalState = compiledGraph.invoke(initialState); saveInstance(finalState); return mapResult(finalState); } private StateGraph<AppState> buildGraph(WorkflowDefinition def) { StateGraph<AppState> graph = new StateGraph<>(AppState::new); for (WorkflowNodeDef nodeDef : def.getNodes()) { WorkflowNode node = nodeRegistry.getNode(nodeDef.getType()); graph.addNode(nodeDef.getId(), ctx -> node.invoke(ctx)); } for (WorkflowEdgeDef edge : def.getEdges()) { graph.addEdge(edge.getSource(), edge.getTarget()); } for (WorkflowConditionDef condition : def.getConditions()) { graph.addConditionalEdge(condition.getSource(), ctx -> evaluate(condition, ctx.state()), condition.getTargetMap()); } graph.setEntryPoint(def.getStartNodeId()); return graph; } }这段代码的核心是buildGraph:工作流定义 JSON 被解析成WorkflowDefinition对象,然后循环注册节点、注册边和条件边。节点执行时统一接收NodeExecutionContext,里面包含了当前AppState、流程定义、节点配置等,具体某个节点做什么,由实现类决定。
实际项目中,我们还会在compiledGraph.invoke前后做状态快照、日志采集、异常捕获。注意invoke是同步阻塞的,如果某个节点是异步任务(比如发消息等待回调),会在节点内部实现为"挂起",直接返回一个"等待中"状态,而不是真的阻塞线程。这部分设计比较绕,简单说就是:只有像人工审批这样的长等待节点才需要挂起,普通 API 节点还是同步等待结果。
4.4 上线效果与优化过程
这个流程上线两周,实际效果超过预期。以前商务手动处理线索,人均每天 200 条封顶,现在只需要处理系统筛出来的高潜力线索,日均 40 条左右,判断标准也统一了。流程处理耗时中位数从原来的 18 分钟(因为要排队等人工)降到了 1.2 分钟,因为大部分线索在到达人工节点前已经被自动淘汰。
但中间也暴露了一个问题:大模型抽取字段的准确率一开始只有 85%,主要是部分线索文本很简短,比如只有一行"某某科技有限公司 张经理 138xxxx",模型会漏掉行业信息。我们做了两个优化:一是在 Prompt 里加了 few-shot 示例,模型能参考完整样例理解抽取模式;二是在"信息抽取"节点后面加了一个"字段完整性检查"的脚本节点,如果发现必填字段为空,就进入一个"再次抽取"的 LLM 节点,用更直白的追问式 Prompt 重新抽一次。这个"重试子流程"在低代码平台里配置非常方便,最终准确率提到了 94%。
5. 常见问题与排查技巧实录
5.1 工作流引擎高频问题速查表
把团队在开发和压测阶段遇到的高频问题整理成一个速查表,方便读者排查:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| LangGraph4j 条件边不生效,总是走默认分支 | SpEL 表达式解析结果不是布尔值,比如拿到了字符串"true" | 在表达式前加#并确保类型为 boolean,或在脚本节点里强制转换 |
| LLM 返回的 JSON 解析失败 | Prompt 约束不够,模型输出了额外解释文字 | 使用outputSchema并在 Prompt 中明确“只输出 JSON 代码块”,解析时先提取代码块内容 |
| 流程暂停后恢复,状态数据丢失 | 只保存了部分变量,或者自定义对象序列化后反序列化失败 | 状态快照里只存基础类型和 JSON 结构,业务对象不直接放入状态 |
| 并行节点偶尔结果缺失 | 某个分支异常时CompletableFuture未做异常捕获 | 每个分支捕获异常写入该分支结果字段,汇合时先检查所有分支状态 |
| API 节点重试导致下游重复提交 | 接口不是幂等的 | 在节点配置中增加“幂等键”,比如根据流程实例 ID + 节点 ID 生成request_id,下游用这个做去重 |
| 动态构建图时报节点 ID 冲突 | 用户在设计器里复制粘贴节点时未重新生成 ID | 节点 ID 用 UUID 作为内部标识,用户看到的名称单独存储 |
5.2 独家避坑心得:动态节点注册的类型安全
我们最初把所有节点统一设计成Node<R>,想通过泛型来约束输入输出,结果在动态构建图时频繁出现ClassCastException。因为 LangGraph4j 的节点执行接口拿到的是同一个AppState,泛型参数在运行时被擦除了,类型判断根本不可靠。
后来我们调整了设计:每个节点实现一个无泛型的WorkflowNode接口,节点自己的配置类独立定义,并在注册时绑定一个"配置解析器"。执行时,流程引擎从 JSON 节点定义里把config字段读出来,用配置解析器转成具体的配置对象。节点内部自行从AppState.data读取变量,并写入新变量。这样避免了网关处的类型判断,同时也让节点逻辑更内聚。
5.3 流程解析器不要用递归实现
还有一个经验:低代码流程引擎的执行器不要试图自己用递归深度优先来遍历图。我们的流程里存在循环边(比如"抽取失败再抽一次"),递归遍历很容易栈溢出。即便没有循环,深路径也会浪费大量栈帧。
正确做法是直接用 LangGraph4j 的图执行器,或者自己通过队列实现状态机遍历。我们选择前者,因为 LangGraph4j 已经处理了环、自环、多入口等复杂情况,只要把节点注册正确,执行逻辑是可信的。
6. 这套架构的边界与后续演进
最后聊一点边界认识。这套低代码智能体平台并不是万能的。如果团队只需要一个"聊天机器人+知识库问答",那直接用 LangChain4j 原生写几个类就够了,完全不需要工作流引擎。低代码平台的价值在"多条路径、多类节点、跨部门协作"的场景里才会被放大。
后续我们规划了几个方向:一是把更多常用模型接入做成开箱即用的插件,二是把流程版本回滚和灰度发布能力做完整,三是将节点执行日志结构化,方便业务方自己在后台查看。这些方向都依赖当前这套"图定义 + 节点注册中心 + LangGraph4j 执行引擎"的底座,所以底座稳定,扩展才可能。
从个人实际体会看,这类平台最忌讳一上来就想着覆盖所有节点。先固化两三类高频路径,跑通端到端,看业务方真实使用情况,再一点点增加节点类型。平台的价值不在于功能花哨,而在于让业务方形成"改流程不用求人"的习惯。一旦这种习惯建立,后续的需求沟通就会顺畅很多。