☰
Flowable工作流中集成大模型:LLM节点落地实践与踩坑记录
2026/10/1 2:11:59 网站建设 项目流程

最近做流程平台改造,遇到最多的需求就是:能不能让大模型在工作流里当一环?比如工单自动分类、合同摘要、审批意见草拟,甚至风控初筛。与其在业务代码里到处硬编码调用接口,不如直接把大模型做成一个 Flowable 工作流里的“LLM 节点”,让流程引擎统一编排、统一变量、统一降级。这篇文章就把我自己在项目里接入的完整思路、踩过的坑和可复现的代码方案整理出来,适合已经在用 Flowable、想给流程补上 AI 能力的团队参考。

1. 整体方案与技术选型

1.1 为什么留在 Flowable 而不是换引擎

很多团队一提到“工作流 + 大模型”,第一反应是用 n8n、Coze 这类偏 AI 的编排工具。但如果你已经是 Flowable 的重度用户,流程图、审批链路、会签、驳回、会签、多实例都已经跑在 Flowable 上,这时候再去另起一套 AI 编排,等于把流程拆成两套系统来维护,变量要来回传,状态要来回对,成本远高于收益。

Flowable 本身是轻量的嵌入式工作流引擎,BPMN 2.0 的节点模型非常成熟,尤其是服务任务(Service Task)天生就适合做外部系统调用。连数据库、审批、定时器这些都能和外部 HTTP 接口串起来。它不像商业化 BPM 平台那么封闭,也不像纯代码状态机那么死板。把大模型封装成 BPMN 里的一个节点,本质上是把一个外部服务调用标准化,Flowable 帮你管理这个节点的状态、超时、异常分支和变量传递。

从社区活跃度、Spring Boot 集成成熟度、文档完整度来看,Flowable 在当前这几个主流开源引擎里属于最稳的一档。网上大量教程资源也意味着万一卡住了,基本都能搜到答案。

1.2 LLM 节点应该放在流程的哪个位置

BPMN 节点类型很多,用户任务、服务任务、业务规则任务、脚本任务、调用活动、子流程,哪一个适合放 LLM?我的结论是:优先用服务任务(Service Task)。

用户任务一般是给人操作的,但如果想要“创建待办后自动生成意见草稿”,可以用监听器在处理完后调用 LLM,然后把结果写入待办描述或表单字段。不过这种做法本质上是把 AI 调用挂到了事件回调上,排查问题时要多绕一层。大多数真正的 AI 节点,比如“智能分类”“内容摘要”“生成审批建议”,都应该是一个独立的服务任务:上一环节把流程变量传进来,LLM 节点处理完把结果写回变量,后续网关再根据变量走不同分支。

业务规则任务(Business Rule Task)不适合,因为 LLM 不是规则引擎,虽然可以当“黑盒判断”用,但 Flowable 对规则任务的默认处理方式是交给 DRD/DMN,和 LLM 的调用方式不匹配。自定义 BPMN 元素也能做,但工作量大,需要写解析器、画图编辑器扩展、引擎行为扩展,除非你的团队就是要做一套“可视化 LLM 节点产品”,否则不建议从底层造轮子。

1.3 技术栈:JavaDelegate + HTTP 对接大模型接口

Flowable 服务任务有两种落地方案:一是写一个 Java 类,实现JavaDelegate接口,然后在 BPMN 里指定flowable:class或flowable:delegateExpression;二是通过flowable:type="external"走远程 worker,类似 Camunda 的外部任务。对于单体应用,JavaDelegate 是最直接的。外部任务适合跨语言或高可用的场景,但会引入额外的传输层,成本高,我这里不展开。

LLM 接入方面,现在主流的大模型服务基本都提供 OpenAI 兼容的 HTTP 接口,或者本地部署的 Ollama、vLLM 也能拉一个兼容 endpoint 出来。所以我不建议把某个特定厂商的 SDK 直接写进流程引擎层,最好是封装一个LlmService,只暴露一个call(prompt, format)方法,内部用 HTTP 调用模型接口。这样换模型、换部署地址,只要改配置,不需要动流程类。

2. 核心细节:Prompt 构造、变量流转与异常处理

2.1 流程变量与 Prompt 模板的绑定

大模型不是凭空发挥的,你得把流程上下文塞给它。Flowable 里最核心的数据交互载体是流程变量(process variables)。启动流程时设定的参数、表单字段、上一步处理结果,都以变量形式挂在流程实例上,在execute(DelegateExecution execution)里可以通过execution.getVariable("xxx")拿到。

Prompt 构造最常见的坑是把模板写死在代码里。比如:

String prompt = "你是审批助手,金额是" + amount + ",类型是" + type + ",请给出意见。"

第一次会运行,但业务人员改一句提示词,你就得改代码、重新发版。我不推荐这种写法。更好的做法是把 Prompt 模板作为流程变量传进来,或者统一存在配置中心、数据库中,代码里只做变量替换和拼接。

比如流程变量里定义:

promptTemplate = 你是业务审批助手,请根据以下信息给出审批建议。 申请类型:{type} 申请金额:{amount} 申请人部门:{dept} 请只返回 JSON,格式为 {"suggestion":"同意或驳回","reason":"一句话理由"}

在 JavaDelegate 里做模板渲染:

String template = (String) execution.getVariable("promptTemplate"); String type = (String) execution.getVariable("type"); String amount = String.valueOf(execution.getVariable("amount")); String dept = (String) execution.getVariable("dept"); String prompt = template .replace("{type}", type) .replace("{amount}", amount) .replace("{dept}", dept);

这段逻辑虽然简单,但帮我把“提示词变更”从发版列表里摘出来了。业务人员改流程定义中的一个变量值,就能调整 AI 行为,Flowable 流程定义本身具备版本管理能力,每次改变量模板还能对应到流程版本,比代码里的散装字符串好维护得多。

这里有个很容易踩的细节:Flowable 变量名区分大小写。你 setVariable 的时候写Amount,getVariable 写amount,返回 null,Prompt 就会变成“金额是null”。真要排查这种问题,经常浪费半天。建议团队内部约定流程变量一律小驼峰,启动流程前用一个校验方法检查 required 变量是否存在。

2.2 大模型返回值怎么回写流程变量

大模型返回的是一个文本字符串,但在流程里你往往需要结构化结果,比如“建议是否通过”“风险等级”“置信度”。所以第一步是把模型的输出约定成 JSON 格式,而不是让它输出“同意,因为……”。在 System Prompt 里明确要求:

只输出 JSON,不要输出任何解释。

模型兼容 OpenAI 的response_format时,调用时可以附带response_format={"type":"json_object"},能明显提升输出稳定性。

拿到模型响应后,在 JavaDelegate 里解析 JSON,再拆成多个 Flowable 变量:

public class LlmNodeDelegate implements JavaDelegate { @Autowired private LlmService llmService; @Override public void execute(DelegateExecution execution) { // 1. 取变量并构造 prompt String prompt = buildPrompt(execution); // 2. 调用大模型 LlmResult llmResult = llmService.call(prompt, LlmFormat.JSON); // 3. 解析结果并回写变量 execution.setVariable("llmSuggestion", llmResult.getSuggestion()); execution.setVariable("llmReason", llmResult.getReason()); execution.setVariable("llmConfidence", llmResult.getConfidence()); execution.setVariable("llmRaw", llmResult.getRaw()); } }

为什么要拆成多个变量?因为后续网关条件需要用上,比如:

<conditionExpression xsi:type="tFormalExpression"> <![CDATA[${llmSuggestion == '同意'}]]> </conditionExpression>

如果只塞一个llmResult字符串,网关判断就得写代码解析,麻烦且容易出错。把判断条件需要的字段拆出来,排他网关的表达式就会非常干净。

2.3 超时、重试与兜底设计

大模型调用本质是外部 HTTP 调用,一定会有超时、限流、服务不可用的问题。而且 Flowable 默认同步执行服务任务,如果一个 LLM 节点卡住,整个流程实例就卡住,后续用户任务全部会被阻塞。

所以我在 LLM 节点上做了三层保护:

第一层,HTTP 客户端超时。RestTemplate 或 WebClient 的 readTimeout 必须设置,默认有时候是无限等待,非常危险。建议 connectTimeout 3 秒,readTimeout 10 秒。第二层,业务层用 Future 再兜一个超时,防止某些模型接口吞超时。第三层,在 BPMN 图上给服务任务加边界错误事件,LLM 节点抛业务异常后,流程流向人工处理节点,而不是当场挂掉。

异常时的降级策略也要提前想好。我的经验是:如果 LLM 是辅助性节点,比如生成审批意见草稿,失败了就把标志位llmSkipped置为 true,流程继续走;如果是核心决策节点,比如自动分类,失败了就需要走人工分类。这个决策不能在代码里硬编码,最好也是通过流程变量配置。

3. 实战:从零在 Flowable 中接入一个 LLM 节点

3.1 环境准备与依赖配置

项目基于 Spring Boot,加上 Flowable 的 starter。我用的是 Flowable 7。

<dependency> <groupId>org.flowable</groupId> <artifactId>flowable-spring-boot-starter</artifactId> <version>7.0.0</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency>

本地快速 demo 时,可以把数据库指向 H2 内存库,启动即初始化引擎表结构。正式项目切换到 MySQL 或 PostgreSQL 也基本不需要改代码,只是要提前把数据库的变量表容量考虑好,因为 LLM 节点的 Raw 输出可能会比较大。

如果你的流程已经复杂到需要历史数据、定时任务、监听器,建议单独建一个flowable数据库,不要和业务库混在一起,否则后续备份恢复都会互相影响。

3.2 编写通用 LLM Delegate

先写一个LlmService做模型接口调用:

@Service public class LlmService { @Value("${llm.api-url}") private String apiUrl; @Value("${llm.api-key:}") private String apiKey; @Value("${llm.model}") private String model; @Value("${llm.timeout-ms:10000}") private int timeoutMs; public LlmResult call(String prompt, String format) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); if (StringUtils.hasText(apiKey)) { headers.setBearerAuth(apiKey); } Map<String, Object> body = new HashMap<>(); body.put("model", model); body.put("messages", List.of(Map.of( "role", "user", "content", prompt ))); body.put("temperature", 0.2d); body.put("max_tokens", 512); if ("json".equals(format)) { body.put("response_format", Map.of("type", "json_object")); } HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers); SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(3000); factory.setReadTimeout(timeoutMs); RestTemplate restTemplate = new RestTemplate(factory); String response = restTemplate.postForObject(apiUrl, entity, String.class); return parse(response); } private LlmResult parse(String response) { // 用 Jackson 解析 choices[0].message.content } }

LlmResult里只保留我们关心的字段:

public class LlmResult { private String suggestion; private String reason; private BigDecimal confidence; private String raw; // getter/setter }

然后写LlmNodeDelegate,注意注册成 Spring Bean,这样可以在里面注入 LlmService:

@Component("llmNodeDelegate") public class LlmNodeDelegate implements JavaDelegate { @Autowired private LlmService llmService; @Override public void execute(DelegateExecution execution) { // 这里使用模板和变量,构造 prompt String template = (String) execution.getVariable("promptTemplate"); String type = (String) execution.getVariable("type"); String amount = String.valueOf(execution.getVariable("amount")); String prompt = template .replace("{type}", type) .replace("{amount}", amount); LlmResult result = llmService.call(prompt, "json"); execution.setVariable("llmSuggestion", result.getSuggestion()); execution.setVariable("llmReason", result.getReason()); execution.setVariable("llmConfidence", result.getConfidence()); execution.setVariable("llmRaw", result.getRaw()); } }

如果业务很复杂,同一个流程里可能有多个 LLM 节点做不同的事,我不建议每个节点写一个 Delegate 类,那样代码会爆炸。更好的做法是抽出公共的BaseLlmDelegate,子类只需要返回 Prompt 模板标识和输出变量映射。

3.3 在 BPMN 中配置服务任务

这里推荐使用delegateExpression而不是class。flowable:class需要全限定类名,Spring 容器管理不到,你没法注入服务;delegateExpression可以直接指向 Spring Bean 的名称:

<serviceTask id="llmNode" name="生成审批意见" flowable:delegateExpression="${llmNodeDelegate}" />

如果需要在 LLM 调用失败时走向人工处理,可以给这个服务任务加一个错误边界事件。在 BPMN 里定义一个业务异常:

<error id="llmError" errorCode="LLM_ERROR" />

然后服务任务上挂边界事件:

<boundaryEvent id="llmErrorBoundary" attachedToRef="llmNode" errorRef="llmError"> <sequenceFlow sourceRef="llmErrorBoundary" targetRef="manualHandle" /> </boundaryEvent>

JavaDelegate 里抛异常:

throw new BpmnError("LLM_ERROR", "LLM 调用异常");

这样比直接抛 RuntimeException 更可控,Flowable 能识别 BpmnError 并触发边界事件,而不是直接把整个流程带入异常终态。

3.4 完整测试:用流程实例跑通 LLM 节点

启动流程时,把 Prompt 模板和上下文变量传进去:

Map<String, Object> variables = new HashMap<>(); variables.put("promptTemplate", "你是业务审批助手。申请类型:{type},申请金额:{amount}。请只输出 JSON,格式:" + "{\"suggestion\":\"同意/驳回\",\"reason\":\"简短理由\",\"confidence\":0.95}"); variables.put("type", "采购"); variables.put("amount", "50000"); ProcessInstance processInstance = runtimeService .startProcessInstanceByKey("llmDemo", variables);

流程从开始节点走到服务任务,执行完 LLM 节点后,查看流程变量:

Map<String, Object> vars = runtimeService.getVariables(processInstance.getId()); System.out.println(vars.get("llmSuggestion"));

跑通后,你可以在排他网关上做分支判断。比如llmSuggestion为“同意”时走审批通过节点,否则走驳回节点。这套链路验证通过后,整个 LLM 节点就真正变成了一个可以被流程编排的“业务动作”,后续加再多的 AI 能力,都是一个套路。

4. 常见问题与排查技巧实录

4.1 大模型请求超时导致流程卡住

这是最常见的线上事故。原因通常是 HTTP 客户端没设超时,或者 LLM 服务端响应本来就慢。表现是 Flowable 的流程实例停留在服务任务,不往后走,日志里也没有报错,只是线程一直占着。

解决方向有两个:一是给 RestTemplate 或 WebClient 显式设置 connect/read timeout;二是在 JavaDelegate 内部包一层ExecutorService,用Future.get(15, TimeUnit.SECONDS)做超时控制,超时后直接抛 BpmnError 走降级分支。

我个人更推荐第二种,因为即使 HTTP 层设了超时,如果对端返回了 200 但 body 迟迟不完整,某些实现仍然会吞掉超时。Future 是最后一道保险。

4.2 大模型返回的 JSON 格式不稳定

同样的 Prompt,模型今天输出{"suggestion":"同意"},明天可能输出Response: {"suggestion":"同意"},甚至外面包一层 markdown 的 ```json 代码块。如果直接用 Jackson 解析肯定失败。

我在解析前加了两个预处理:

  1. 找到第一个{和最后一个},截取中间内容,去掉外层说明文字。
  2. 如果截取后仍然解析失败,就重新调用一次模型,同时把处理失败信息补到 Prompt 里:“上次输出无法解析,请严格只输出 JSON”。

第二个方法实测效果好,因为模型能“知错就改”。但要注意重试次数不能太多,最多两次,不然成本和时间都扛不住。

4.3 流程变量过大和敏感数据泄露问题

Flowable 的流程变量默认会持久化到ACT_RU_VARIABLE/ACT_HI_VARINST表。如果直接把大模型返回的整段长文本塞进raw变量,再叠加多实例场景,表体量会增长得很快。

我的建议是:只保留结构化结果,比如suggestion、confidence、reason,不要全量存 Raw 内容。如果确实需要审计,把 Raw 写到外部对象存储或日志系统,不要长期留在 Flowable 变量表里。

另外,Prompt 中不要无脑塞入所有流程变量。比如申请人的身份证号、手机号、银行账号,这些字段一旦进 Prompt,就会被发送到模型服务端。即使你用的是私有化部署模型,也最好遵循最小暴露原则。排查和审计时,把“哪些变量不能进 LLM”做成一份清单,在构造 Prompt 前强制过滤。

下面这张表是我自己项目里常用的排查速查表:

症状可能原因解决方向
流程实例卡住不动LLM 调用超时或异常未捕获设置超时,加边界错误事件,做降级
变量 return null变量名大小写不匹配统一小驼峰,启动前校验
JSON 解析异常模型输出包裹 markdown 或说明文字截取花括号内容,重试一次
任务表迅速膨胀大字段写入流程变量精简变量,Raw 内容外用存储
模型服务限流并发过高加信号量/Redis 令牌桶限流

4.4 高并发下的限流与成本控制

大模型接口有成本,也有 QPS 限制。工作流如果有多个并发实例同时过 LLM 节点,很容易把模型服务压垮。我常用的一个方案是给LlmService内部加一个简单的信号量:

@Override public LlmResult call(String prompt, String format) { if (!semaphore.tryAcquire(10, TimeUnit.SECONDS)) { throw new BpmnError("LLM_BUSY", "模型服务繁忙"); } try { return doCall(prompt, format); } finally { semaphore.release(); } }

这样能保证并发请求量可控,超出限制直接走降级分支,而不是让所有线程都堆在模型接口上。对于更精细的限流,可以按用户维度或部门维度做令牌桶,但这个要看业务场景,不是每个项目都需要一上来就搞。

成本控制方面,我在调用后从响应里解析 usage 字段,把 prompt_tokens / completion_tokens 写进日志或单独一张llm_call_log表。做了一段时间后,你就会知道哪条流程最烧 token,哪类 Prompt 模板超长,方便定向优化。

最后一点经验

接入 LLM 节点的工作,真正难的不是“调一个接口”,而是怎么把模型的随机性塞进原本确定性的流程里。我踩过几次坑之后最大的体会是:Prompt 模板、变量设计、异常降级这些工程细节,决定了你这个 LLM 节点是偶尔灵光的玩具,还是稳定跑在生产环境的流程节点。建议新项目先用本地小模型或兼容接口跑通最简单的服务任务,再把分类、摘要、审批建议一个个迭代上去,每一步都能看到实际效果,比一开始就设计一个万能 LLM 节点靠谱得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询