Flowable 工作流中如何接入大模型 LLM 节点
最近在做一个内部审批系统的改造,需求很直接:原来的工作流只有“如果年龄大于60走A分支,否则走B分支”这类哑逻辑,碰到要“看一眼附件内容再判断”的场景完全抓瞎。比如合同审批里,业务员交上来一份扫描件,流程只能靠人工点按钮往下推,推完领导还得自己翻三页PDF找风险条款。
所以我们决定在 Flowable 工作流引擎里接入大模型,让流程节点具备“读了内容后做判断”的能力。这个需求在圈子里越来越常见,很多人想在 BPM 流程里加一个“AI节点”,但不知道从哪下手。我这次把完整方案跑了一遍,踩了不少坑,把能直接用的东西整理出来,希望能帮到正打算做这件事的人。
Flowable 本身是 Java 生态里很有代表性的开源工作流引擎,支持 BPMN 2.0 规范。它可以帮我们管理流程状态、任务分发、权限控制这些事,但它不关心业务数据的内容。而大模型 LLM 恰好相反,它擅长理解文本、摘要、分类、抽取和生成,但对“走到哪一步了”“该谁审了”完全没有概念。把两者接起来,本质上是一个朴素的想法:让工作流里的每个节点,既能按规则流转,又能理解规则之外的非结构化信息。
1. 为什么要把大模型节点搬进 Flowable,而不是换一个“AI工作流工具”
先说清楚这个问题,很多团队一听到“AI工作流”,第一反应是上 Dify、n8n、Coze 这类现成的编排工具。这些工具确实好用,拖拖拽拽就能搭一个带模型的流程,页面还漂亮。但它们解决的是“内容处理管线”的问题,不是“企业业务流程”的问题。
举一个我遇到的实际场景:差旅报销单,员工上传了发票 PDF,系统需要判断“这张发票是不是本次差旅产生的、金额是否超出部门预算”,然后再走财务复审。如果用 Dify 搭,消息进来跑一轮模型、抽个结果、返回给用户,这件事就结束了。但企业里面,这单需要先给直属主管批、再到财务备案、最后出纳打款,每一步都有角色权限、时限提醒、历史留痕,还要和公司现有的 OA 组织架构打通。这些是典型的工作流引擎职责,换成 AI 编排工具来做,等于把业务系统重写一遍。
所以我的结论是:不是谁替代谁的问题,而是 Flowable 管流程、LLM 管内容理解,两个东西是正交的。企业实际的合理路径是——流程依然是 Flowable 在跑,大模型作为一个“会思考的节点”嵌入到流程的某个环节里。
在 Flowable 里,这件事的技术底座并不复杂。核心就是把“调用 LLM”的动作封装成一个服务任务(Service Task),流程走到这个节点时,引擎自动触发 Java 代码,代码负责拼接 prompt、调用模型接口、拿回结果、写回流程变量,然后流程继续往下走。剩下的,就是处理好异步、超时、异常这些生产环境必须面对的问题。
顺带说一句,如果你还没接触过 Flowable,建议先花半天时间过一遍它的核心概念:流程定义(process definition)、流程实例(process instance)、任务(task)、执行实例(execution)、监听器(listener)、服务任务(service task)。这篇文章默认你至少有这个基础。
2. 接入前先把 Flowable 的扩展点摸清楚:Service Task、Listener 还是 External Task
Flowable 给了很多种方式让我们插入自定义代码,但“接入 LLM”这个场景并不是每种都合适。我这里把能用的几个位置挨个说一遍,你选型的时候可以直接对照。
2.1 Service Task(服务任务):承载 LLM 调用的主力节点
Service Task 是 BPMN 里专门用来“自动执行逻辑”的节点。它可以指定一个 Java 委托类,流程引擎在进入该节点时,自动实例化并调用类的 execute 方法。这个时机、触发方式、事务边界都非常明确,非常适合承载一次 LLM 调用。
看一个最简配置:
<serviceTask id="aiReviewTask" name="AI 审批初筛" flowable:delegateExpression="${llmReviewDelegate}" />对应的委托类只要实现 JavaDelegate 接口:
@Component("llmReviewDelegate") public class LlmReviewDelegate implements JavaDelegate { @Override public void execute(DelegateExecution execution) { String content = (String) execution.getVariable("applyContent"); String suggestion = callLlm("你是审批助手...", content); execution.setVariable("aiSuggestion", suggestion); } }delegateExpression用的是 Spring Bean 名称,Flowable 会从 Spring 容器里找到这个 Bean 并调用。这里有个小坑,如果你用的是flowable:class方式指定全限定类名,类的实例化和生命周期管理就不归 Spring 管,想注入别的 Service 会比较别扭。所以我推荐用delegateExpression,和 Spring Boot 配合更顺。
2.2 Execution Listener(执行监听器):适合轻量级旁路逻辑
Flowable 里还有监听器机制,可以在流程节点(或整个流程)进入、离开、结束时挂一段代码。相比 Service Task,监听器更像“切面”,它不改变流程元素本身,只是给某个节点追加行为。
举个例子,你不想修改原有 BPMN 文件,只想在“提交申请”节点之后自动调用大模型生成一个摘要字段,这时就可以挂一个 ExecutionListener。
不过监听器不适合做核心的 LLM 决策节点。因为它的语义是“监听并响应事件”,不是一个独立的处理环节。如果逻辑复杂,维护起来比较绕,而且不好在流程图上直观表达“这里有一个 AI 环节”。一般人看到流程图上的 Service Task,一眼就知道这是自动处理节点;但看到一条监听器配置,通常要翻代码才明白发生了什么。
2.3 External Task(外部任务):把调用交给独立服务
Flowable 还支持外部任务模式,流程引擎把任务发布到外部队列,由其他服务拉取后处理,再回传结果。这种方式的好处是把“流程引擎”和“LLM 调用服务”彻底解耦,适合团队里做微服务拆分时用。
但这个模式也带来额外的运维成本。你需要额外部署一个 Worker 进程,处理队列入站、轮询、结果提交、超时续约这些事。对大多数中后台系统来说,并没有必要为一次 LLM 调用就拆一个服务出来。
三种方式我用一张表总结一下:
| 接入方式 | 触发语义 | 适合场景 | 复杂度 |
|---|---|---|---|
| Service Task + JavaDelegate | 到达节点自动执行 | 流程主链路上的 AI 判断、摘要、分类 | 低,推荐 |
| Execution Listener | 节点或流程事件触发 | 旁路增强、自动填充字段 | 低,但语义不直观 |
| External Task | 引擎分发到外部队列 | 团队有独立 AI 服务、需要跨语言 | 中高,按需使用 |
所以对于绝大多数“在 Flowable 流程里加 AI 能力”的需求,直接用 Service Task 就是最稳的选型。后面的内容也围绕这条主线展开。
3. 从零实现一个 LLM Service Task 的完整路径
到了实际编码环节。我先给出一套 Spring Boot 项目里的完整代码骨架,然后讲解每一步的设计思路。这里以调用 OpenAI 兼容接口为例,这套写法同样适配国内主流的模型服务商,因为它们的接口格式基本对齐 OpenAI。
3.1 环境准备与依赖引入
假设你有一个 Spring Boot 3.x 项目,需要引入 Flowable 的 Starter:
<dependency> <groupId>org.flowable</groupId> <artifactId>flowable-spring-boot-starter</artifactId> <version>7.1.0</version> </dependency>再准备一个 HTTP 客户端,可以用 Spring 自带的 RestClient(Spring Boot 3.2+)或者 OkHttp。直接拿 JDK 自带 HttpClient 也行,我这里用 RestClient,简洁一点:
@Service public class LlmGateway { private final RestClient restClient = RestClient.builder() .baseUrl("https://api.example.com/v1") .defaultHeader("Authorization", "Bearer " + System.getenv("LLM_API_KEY")) .build(); public String chat(String systemPrompt, String userContent, double temperature) { Map<String, Object> payload = new HashMap<>(); payload.put("model", "gpt-4o-mini"); payload.put("messages", List.of( Map.of("role", "system", "content", systemPrompt), Map.of("role", "user", "content", userContent) )); payload.put("temperature", temperature); Map<String, Object> resp = restClient.post() .uri("/chat/completions") .body(payload) .retrieve() .body(new ParameterizedTypeReference<Map<String, Object>>() {}); // 解析 choices[0].message.content List<?> choices = (List<?>) resp.get("choices"); Map<?, ?> first = (Map<?, ?>) choices.get(0); Map<?, ?> message = (Map<?, ?>) first.get("message"); return (String) message.get("content"); } }提示:API Key 千万别写死在代码里。用环境变量或者配置中心管理,这算是一个基础但经常有人踩的规范问题。
在实际项目里,我更推荐用 Spring AI Alibaba 或 LangChain4j 这类框架来封装模型调用,它们把接口调用、JSON 解析、函数调用的细节都统一处理了,代码能少写一半。但如果只是做一次简单接入,自己封装一个 RestClient 反而更直观,出问题时更好排查。
3.2 定义 BPMN 流程:一个带 AI 节点的审批流程示例
下面是一个简化的流程定义。它表示:用户提交申请 → AI 自动初筛 → 判断初筛结果是否通过 → 通过则自动归档,否则转人工复审。
<?xml version="1.0" encoding="UTF-8"?> <definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:flowable="http://flowable.org/bpmn"> <process id="aiApprovalProcess" name="AI 辅助审批流程" isExecutable="true"> <startEvent id="start" /> <sequenceFlow id="flow1" sourceRef="start" targetRef="aiReview" /> <serviceTask id="aiReview" name="AI 初筛" flowable:delegateExpression="${llmReviewDelegate}" /> <sequenceFlow id="flow2" sourceRef="aiReview" targetRef="preJudgement" /> <exclusiveGateway id="preJudgement" /> <sequenceFlow id="flowPass" sourceRef="preJudgement" targetRef="autoArchive"> <conditionExpression xsi:type="tFormalExpression"> ${aiSuggestedPass == true} </conditionExpression> </sequenceFlow> <sequenceFlow id="flowReject" sourceRef="preJudgement" targetRef="manualReview"> <conditionExpression xsi:type="tFormalExpression"> ${aiSuggestedPass == false} </conditionExpression> </sequenceFlow> <serviceTask id="autoArchive" name="自动归档" flowable:delegateExpression="${archiveDelegate}" /> <userTask id="manualReview" name="人工复审" flowable:assignee="${reviewer}" /> <endEvent id="end" /> </process> </definitions>这个流程里最关键的节点就是aiReview。它执行完以后,往流程变量里写入aiSuggestedPass,网关根据这个变量决定走向。
3.3 实现 JavaDelegate:把流程变量变成 prompt,再把结果写回流程
委托类是核心中的核心。它的任务是:
- 从当前执行上下文里取出业务数据;
- 构造一个清晰的 prompt;
- 调用模型;
- 把结果转换、校验、回写到 execution 中。
直接看代码:
@Component("llmReviewDelegate") public class LlmReviewDelegate implements JavaDelegate { private final LlmGateway llmGateway; public LlmReviewDelegate(LlmGateway llmGateway) { this.llmGateway = llmGateway; } @Override public void execute(DelegateExecution execution) { // 1. 读取流程变量 String applicant = (String) execution.getVariable("applicant"); String applyContent = (String) execution.getVariable("applyContent"); BigDecimal amount = (BigDecimal) execution.getVariable("amount"); // 2. 构造 system prompt String systemPrompt = """ 你是公司内部的审批助手。你会收到一份业务申请材料,需要你判断是否建议通过。 请严格按以下 JSON 格式输出结果,不要输出任何解释: {"pass": true 或 false, "reason": "一句话理由", "riskPoints": ["风险1", "风险2"]} 注意:金额超过 10000 元时,pass 必须为 false。 """; // 3. 拼接用户内容 String userContent = "申请人:" + applicant + "\n申请金额:" + amount + "\n申请事由:" + applyContent; // 4. 调用模型 String rawResult = llmGateway.chat(systemPrompt, userContent, 0.2); // 5. 解析 JSON 并校验 LlmReviewResult result = parseJson(rawResult); // 6. 写回流程变量 execution.setVariable("aiSuggestedPass", result.isPass()); execution.setVariable("aiReviewReason", result.getReason()); execution.setVariable("aiRiskPoints", String.join(",", result.getRiskPoints())); } }这里有个容易被忽略的细节:流程变量名和模型返回的字段名要提前约定好。因为在网关表达式里写的就是${aiSuggestedPass},一旦变量名改掉,整个流程路由就失效了,而且这种错误通常不会在部署时报错,只会在运行时表现为“流程走了错误分支”。
3.4 Prompt 设计:三个维度的信息边界
我最近在处理几个 LLM 接入项目时,一直在用一套三段式 prompt 组织思路,和你分享:
- Key(我是谁):系统提示词里定义好模型角色、职责边界、输出格式约束;
- Query(我在找什么):把当前节点的业务目标写清楚,也就是“这次调用要模型产出什么决策”;
- Value(我能提供什么):把流程上下文(表单数据、历史审批意见、人员信息)作为价值内容打包提供给模型。
对应到上面的代码:
Key(系统角色):你是公司内部审批助手... Query(任务目标):判断是否建议通过,按 JSON 输出... Value(上下文材料):申请人 + 金额 + 申请事由...这个结构帮我想清楚了很多问题。之前经常出现 prompt 里塞了一堆不相关的字段,模型反而被带偏。如果每次写 prompt 前都能问自己“我是谁、我要找什么、我能提供什么”,上下文污染的问题会少很多。
3.5 部署启动与跑通流程
代码写完之后,启动 Spring Boot 应用,Flowable 会自动扫描并部署 classpath 下的processes/*.bpmn20.xml文件。启动过程中,它会校验 XML 语法、检查委托类是否存在。
跑通流程的测试代码可以这样写:
runtimeService.startProcessInstanceByKey("aiApprovalProcess", Map.of( "applicant", "张三", "applyContent", "申请购买一台测试服务器,用于搭建预发布环境", "amount", BigDecimal.valueOf(8000) ));启动后观察日志,正常情况下会看到:
flowable - Processing service task aiReviewTask然后查看流程变量:
List<HistoricVariableInstance> vars = historyService .createHistoricVariableInstanceQuery() .processInstanceId(processInstanceId) .list();如果走到这一步,说明模型返回结果已经成功回写到流程变量里,后续网关分支也能正确路由了。
4. 同步调调用是最大的坑:异步服务和错误兜底怎么设计
第一次把 LLM 节点部署上去,大概率会遇到一个打脸的场景:同步调模型,超时。模型服务不像数据库,耗时通常在两秒到十几秒不等,如果流量一大,几十个流程同时卡在 AI 节点上,数据库连接池会被长时间占用,整个系统都可能被拖垮。
这个问题的解法就是 Flowable 的异步执行能力。
4.1 让 Service Task 异步执行
在 BPMN XML 的<serviceTask>上加一句:
<serviceTask id="aiReview" name="AI 初筛" flowable:delegateExpression="${llmReviewDelegate}" flowable:async="true" />加上flowable:async="true"以后,Flowable 引擎执行到这个节点时,不会同步在当前线程里调用委托类,而是把任务挂到异步执行器的队列中。这个异步执行器是引擎自带的一个线程池,可以在配置里调整:
flowable.async-executor-activate=true flowable.async-executor.core-pool-size=10 flowable.async-executor.max-pool-size=20 flowable.async-executor.default-async-job-acquire-wait-time=10它带来的两个直接好处:
- 流程实例的推进和 LLM 调用解耦。即使模型服务慢或不可用,当前请求线程也能及时返回,后续逻辑由后台线程继续执行;
- 失败重试变成默认行为。引擎对异步任务有默认重试机制,默认重试次数是 3 次,每次重试的时间间隔由
default-retry-time-cycle控制。
这里需要特别提醒一句:如果 LLM 服务不是你自己的,务必配置一下重试次数,因为模型服务的瞬时故障太多了,重试能救回相当一部分流程。
flowable.async-executor.retry-times=34.2 事务边界:不要在数据库事务里打外部接口
Flowable 的 Service Task 默认和流程引擎的事务绑定。在同步模式下,委托类里的所有业务逻辑都在同一个事务里,如果委托类中调了外部模型接口,事务会长时间挂着,锁定资源。
所以我的建议是:LLM 调用节点统一走异步模式。异步模式下,Flowable 会在内部做事务分段——任务入队是一个事务,执行又是一个事务。即使模型调用失败,也不会影响主流程已经完成的数据库操作。
如果你因为需求原因只能同步调用(比如必须在用户请求内返回结果),那建议至少给模型接口做一层本地超时:
HttpClient client = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(3)) .build();并且把读取超时设为 5 秒,超过就抛异常。千万不要让 HTTP 请求无限等下去。
4.3 模型调用失败的三种常见情况与对策
在 Flowable 的日志里,最常看到的守护任务失败日志指向三种情况:
第一种:超时。模型服务端排队时间长,或网络不稳定。对策是重试。可以专门捕获超时异常,并给委托类标记一个重试次数,达到上限后直接把流程推到人工节点。
第二种:token 超限。输入材料太长,超出模型上下文窗口。对策是先在外部做文本截断或摘要,把长文本压缩。我们在实际项目里最稳妥的做法是,调用模型前先对“申请事由”做超长截断,保证交给模型的文本不超过 3000 字。
第三种:返回格式非预期。模型偶尔会“发挥”,返回的 JSON 里多了注释、少了字段,或者直接变成了一句话。解析失败后有两种处理:一是让模型重试一次,用“输出格式必须是合法 JSON,没有其他内容”来纠正;二是解析失败就默认走人工兜底。
基于这些情况,一个健壮的 Service Task 设计应该是这样:
@Override public void execute(DelegateExecution execution) { try { LlmReviewResult result = doCallAndParse(execution); execution.setVariable("aiSuggestedPass", result.isPass()); execution.setVariable("aiReviewReason", result.getReason()); execution.setVariable("aiStatus", "SUCCESS"); } catch (Exception e) { execution.setVariable("aiStatus", "FAILED"); execution.setVariable("aiError", e.getMessage()); // 不抛异常,让流程继续走,由网关判断 aiStatus 决定是否转人工 } }这里的关键设计是:AI 节点失败不应该让整个流程卡死。捕获异常后把状态写入变量,由后续网关分支决定是走人工补救还是终止流程。这比把异常往上抛、让引擎死等更符合生产要求。
<exclusiveGateway id="aiResultGateway" /> <sequenceFlow sourceRef="aiResultGateway" targetRef="manualReview"> <conditionExpression>${aiStatus == 'FAILED'}</conditionExpression> </sequenceFlow> <sequenceFlow sourceRef="aiResultGateway" targetRef="preJudgement"> <conditionExpression>${aiStatus == 'SUCCESS'}</conditionExpression> </sequenceFlow>4.4 幂等性:重试导致重复调用的风险
Flowable 异步任务的自动重试是个强力特性,但也带来了一个副作用:模型调用可能在同一流程里执行多次。虽然大模型调用不像转账接口那样有资金风险,但 token 是按调用次数计费的,重复调用等于烧钱。
处理方式有两种:
一种是通过流程变量做幂等标记。执行开始时先检查变量,如果存在就不重复计算:
if (execution.getVariable("aiAlreadyCalled") != null) { return; } execution.setVariable("aiAlreadyCalled", true);另一种是控制重试粒度。如果模型调用彻底失败,我们其实希望流程走人工兜底,而不是反复重试。可以在委托类里对特定类型的异常(如校验失败)直接标记失败,只有网络超时类异常才抛出给 Flowable 去重试。
这个细节在模型接入项目里很值得做——一方面是费用,另一方面是避免同一次审批里模型给出两个不同结果,影响人工评审的判断。
5. 进阶玩法:让 LLM 节点真正参与流程决策与人工辅助
最后聊几组我们验证过的进阶用法,这些事情做完,AI 节点就不只是“调用一下模型返回个字符串”,而是真正融入到业务流程里。
5.1 两段式调用:先摘要,再决策,结果更可控
一次调用让模型直接做决策,容易因为上下文太长把模型“绕晕”。我建议把节点拆成两步:
第一步,把申请的全文、附件文本做提炼,生成一个结构化的摘要(关键要素、金额、周期、风险摘要);第二步,基于这份摘要再做是否通过的判断。
比如 Delegate 里先调用一次summaryLlm(),拿到摘要;再调用一次decisionLlm(),传入摘要返回判断。
虽然多了一层调用,但实际效果往往比单次调用更稳定。
String summary = llmGateway.chat("你是信息抽取助手,提炼以下内容的要素", content, 0.3); String decision = llmGateway.chat("基于摘要判断是否通过,输出 JSON", summary, 0.2);关键原因是:大模型在处理“先浓缩再判断”的链路时,注意力更集中。尤其是采购、合同、报销这种动辄几千字的输入,一上来就让模型直接决策,很容易漏掉藏在后面的限制条款。
5.2 接入人工审批场景:AI 预审意见先写进任务表单
不是所有流程都要让 AI 直接拍板。很多场景里,AI 只是给审批人一个“初筛意见”,最终拍板还是人。
做法是:在调用完 LLM 后,往流程变量写 AI 的结论和建议;然后在人工审批节点的任务表单里,把 AI 意见展示给审批人。
比如 User Task 的候选人看到表单时,额外渲染几个字段:
- AI 初审结论:建议通过 / 建议拒绝
- AI 理由:xxx
- AI 风险提示:xxx
这个实现非常简单,就是在流程变量里存好:
execution.setVariable("aiReviewComment", "建议通过,理由:申请金额在团队预算范围内,资源规格合理。");然后在表单里读取展示即可。审批人不用从头看一遍材料,先看 AI 摘要,再决定是否认可,效率能提高一大截。
5.3 用 LLM 返回结果驱动网关:从“规则路由”到“语义路由”
传统工作流的网关分支靠的是硬编码条件,比如${amount > 10000}。但这种规则只能处理结构化数据,处理不了语义。
有了 LLM 节点之后,网关条件可以基于模型输出扩展。
比如在工单分配流程里,我们希望根据用户反馈的内容自动判断工单类型(网络故障、账号问题、支付失败、其他),流程再走不同的处理队列。用 LLM 节点做分类,把文本映射到预定义类别,然后把类别名存成流程变量:
execution.setVariable("ticketCategory", classifiedValue);网关条件:
<sequenceFlow sourceRef="gateway" targetRef="networkTeam"> <conditionExpression>${ticketCategory == 'NETWORK'}</conditionExpression> </sequenceFlow> <sequenceFlow sourceRef="gateway" targetRef="accountTeam"> <conditionExpression>${ticketCategory == 'ACCOUNT'}</conditionExpression> </sequenceFlow>这就是“语义路由”——流程的路由条件不再依赖数字大小,而是依赖内容的理解结果,应用面一下子宽了很多。
5.4 LLM 节点的成本控制与审计
最后讲一个容易被忽略的点:在正式把 LLM 节点上线前,一定要把费用模型搞清楚。
我的建议是给 LLM 节点做统一封装,记录三件事:调用的模型名称、消耗的 token 数、调用耗时。把这些数据写入数据库日志表,或者至少输出到日志文件。团队里没有成本意识的话,一个早上可能就跑掉几百块的 token 费用,而且前端没有任何感知。
另一个是审计要求。如果流程涉及财务、合同、合规,AI 的原始返回内容、prompt 版本、调用时间都需要留档,方便事后回溯。万一 AI 给出错误建议导致流程走错,至少能查清楚是哪一版 prompt 哪次调用出的问题。
我在项目里习惯直接建一张ai_invoke_log表,字段包括流程实例 ID、节点 ID、模型名、输入摘要、完整输出、token 数、耗时、调用结果。这张表既是审计依据,也是后面调 prompt 的样本数据来源。
6. 一些个人体会与后续打算
这次把 LLM 接进 Flowable,整体思路总结下来就是:用 Service Task 承载 AI 能力,用流程变量传递输入输出,用异步模式保稳定性,用失败兜底保证流程不中断。它没有特别炫技的地方,但每个环节都需要踩过坑才能体会到设计的重要性。
我印象最深的一点是:在做这种集成时,不要急着先写代码,先把“AI 在这个流程里到底要解决什么问题”想清楚。是分类、是抽取、是摘要,还是决策?这个答案直接决定了 prompt 结构、节点位置和后续的路由设计。
后续我计划在这个基础上再扩展两个方向:一个是在 User Task 上通过 Listener 实现 AI 辅助的自动归档建议,另一个是把多轮 LLM 调用编排成一个独立的 AI 服务,Flowable 侧只通过 HTTP 调用这个服务,进一步降低耦合。到时候有落地经验,再继续分享。