在过去一年多里,我陆续在多个项目里把大模型接进 Flowable 工作流,踩了一轮又一轮的坑。今天这篇就专门讲一件事:Flowable 工作流中如何接入大模型 LLM 节点。我会从场景设计、接入路线、JavaDelegate 落地代码、事务超时与重复调用这些高频翻车点出发,把整个过程完整拆给你看。
先说结论:Flowable 接 LLM 本身不难,难的是一开始没想清楚边界、没设计好异步机制、没考虑重试幂等。如果你正准备把合同的字段抽取、工单自动分类、审批意见辅助生成这类能力做成流程里的一个节点,这篇文章可以让你少走很多弯路。项目里从 0 到 1 接过的同学、正在做技术选型的架构师,或者刚接触 Flowable 想了解它能不能跟大模型结合的朋友,都可以参考。
1. 接 LLM 之前,先把“为什么要接”想清楚
很多人一上来就问“怎么在 Flowable 里加一个 AI 节点”,但真正该问的是:这个节点在业务上到底承担什么职责。LLM 节点接入流程,不只是写一个类调一下 API 那么简单,它意味着流程里出现了一个“不确定的输出源”,这会直接影响流程分支、人工确认和异常恢复的设计。
1.1 三种角色:判断型、提取型、生成型
我习惯把 LLM 节点按能力拆成三类,对应不同落地方案:
- 判断型:根据上下文给出分类、打分、结论。典型场景是工单自动分派、简历初筛、风险等级判断。这类节点的输出通常是结构化 JSON,比如
{"category": "投诉", "priority": "high"}。 - 提取型:从非结构文本里抽取结构化字段。典型场景是合同要素抽取、发票信息识别、案件要素归档。输出通常是固定字段的 JSON,要求格式完全可控。
- 生成型:产出建议、文案、报告。典型场景是审批意见草稿、周报摘要、法律文书初稿。这类节点输出的是自然语言文本,质量评估偏主观,更适合“AI 建议 + 人工确认”的组合模式。
为什么一开始要区分这三类?因为它们在流程里的地位完全不同。判断型和提取型可以做到结果可控、下游直接依赖;生成型天生是辅助角色,流程设计里必须给它留一个“人来看一下”的环节,否则出问题找不到人负责。
1.2 先画清边界:哪些环节不该放 LLM 节点
不是所有节点都值得交给大模型。我见过一个客户想把所有审批都塞给 LLM,理由是“规则太多了写不过来”。这种思路很危险。强合规的刚性规则,比如金额超过多少必须走特定分支、某个字段缺失直接驳回,这些应该交给规则引擎或 Java 代码,LLM 只适合处理规则覆盖不到、需要语义理解的部分。
另外,超低延迟的路径和高并发量极大的路径,也不太适合直接放 LLM 节点。一次普通模型调用耗时在 2 到 10 秒,如果这个节点每分钟要承载上千次调用,又同步跑在流程事务里,最后一定是把自己拖死。先把边界画好,哪些放规则引擎、哪些放 LLM、哪些必须人工,这个分野越清晰,后面的代码越简单。
2. 三条接入路线对比:JavaDelegate、外部任务与异步回调
Flowable 本身没有“LLM 节点”这个概念,它有的是 ServiceTask、UserTask、事件监听器、外部任务。接入 LLM 本质上是选一种机制把外部服务调用“挂”到流程节点上。我这边实际用过的方案有三条路线,特点差异很大。
2.1 路线 A:同步 JavaDelegate 直接调 LLM API
这是最直观的做法:写一个类实现JavaDelegate,在execute方法里调大模型 API,把结果写回流程变量,Flowable 自动推进流程。
优点是真的简单,配置一条serviceTask指向这个类就能跑。缺点是事务耦合很重,ServiceTask 执行期间整个流程实例被锁住,数据库连接也一直被占用。LLM 调用只要超过几秒,流程引擎的吞吐量立刻掉下来。
这条路线只适合:内部系统使用、并发量低、模型调用控制在几百毫秒到 1 秒以内的场景。配合较短的客户端超时和快速降级,问题不大。
2.2 路线 B:Flowable 外部任务机制
Flowable 有一个 ExternalTask 机制(对应 BPMN 里的<flowable:externalTask>、外部 worker 通过 REST API 拉取任务并 complete)。LLM 调用被放进一个独立部署的 worker 进程,worker 从 Flowable 引擎拉取外部任务,调完模型后把结果通过 complete 接口回传。
这条路线好处是职责分离:工作流引擎只负责流程推进,LLM 调用完全异步、可独立扩容,worker 挂了也不影响引擎本身。代价是要额外部署一套 worker 服务、处理外部任务的拉取频率和 complete 竞争,复杂度明显高于路线 A。
适合团队有独立服务能力、模型调用量大、或者希望 LLM 服务能弹性伸缩的场景。
2.3 路线 C:异步执行 + 回调恢复流程
严格说,这不是 Flowable 的专有特性,而是一种编排模式。在 ServiceTask 里发起一个异步 LLM 任务(丢进消息队列、或者交给独立的执行线程池),然后让流程实例停留在当前节点等待。等 LLM 结果回来后,通过 Flowable 的 RuntimeService API 触发流程继续推进(比如调用trigger或更新流程变量后推进节点)。
这条路线适合模型响应特别慢、又不想让 worker 反反复复做任务轮询的场景。代价是流程实例会“卡”在一个等待状态,需要自己管理超时、恢复、异常重试,对开发能力要求最高。
2.4 三条路线怎么选
| 对比维度 | 路线 A:同步 JavaDelegate | 路线 B:外部任务 worker | 路线 C:异步回调 |
|---|---|---|---|
| 实现复杂度 | 低 | 中 | 高 |
| 对流程引擎压力 | 高(事务锁、连接占用) | 低 | 中 |
| 并发支撑能力 | 弱 | 强 | 中 |
| 故障隔离 | 差(LLM 超时拖累引擎) | 好 | 好 |
| 适用场景 | 内部低频、快速降级可接受 | 大规模生产、独立 LLM 团队 | 超慢模型、特殊编排 |
我的建议是:不要一上来就追求架构最复杂的外挂式方案。多数项目从路线 A 起步,把它跑通、把监控和数据埋点做起来,等并发和稳定性要求上来了,再平滑迁到路线 B 或 C。下面我先重点讲路线 A 的完整落地,因为它覆盖了 80% 的细节问题。
3. JavaDelegate 路线的完整落地步骤
这一节你可以直接拿去抄。我用的是 Spring Boot 3 + Flowable 7 + Java 21,模型 API 用的是 OpenAI 兼容格式,实际对接通义、DeepSeek、智谱这些都大同小异。
3.1 环境准备与依赖
项目引入 Flowable 的 Spring Boot Starter,它会自动配置流程引擎、Spring 集成和自动部署,比自己拼flowable-engine+flowable-spring省太多事。
<dependency> <groupId>org.flowable</groupId> <artifactId>flowable-spring-boot-starter</artifactId> <version>7.0.1</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>数据库方面我直接用的 MySQL 8,配置好数据源和连接池。这里提醒一句:Flowable 7 对 Spring Boot 3.2+ 兼容性较好,别一个用 Boot 2.x 一个用 Flowable 7,会遇到一堆诡异的类加载问题。
3.2 封装 LLM 客户端:超时、重试、幂等
不要在生产代码里直接裸写HttpClient调模型接口,最好封装一个单独的客户端类。因为接入 LLM 节点后,所有对模型的行为管控——超时、重试、日志、降级——都要集中在一个地方,不能散落在各个 Delegate 里。
我写了一个LlmClient,核心逻辑是:
@Component public class LlmClient { private final RestTemplate restTemplate; public LlmClient() { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); // 关键:连接和读取超时都要收紧 factory.setConnectTimeout(1500); factory.setReadTimeout(3000); this.restTemplate = new RestTemplate(factory); } public String chatCompletion(String model, List<Map<String, String>> messages, String requestId) { Map<String, Object> req = new HashMap<>(); req.put("model", model); req.put("messages", messages); req.put("temperature", 0.2); req.put("user", requestId); // 幂等标识透传 HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.setBearerAuth(apiKey); HttpEntity<Map<String, Object>> entity = new HttpEntity<>(req, headers); long start = System.currentTimeMillis(); try { ResponseEntity<Map> resp = restTemplate.postForEntity("https://api.xxx.com/v1/chat/completions", entity, Map.class); // 记录耗时和 token 消耗到日志/监控 return extractContent(resp.getBody()); } catch (Exception e) { // 超时、连接失败统一包装 throw new LlmInvokeException("llm call failed, cost " + (System.currentTimeMillis() - start) + "ms", e); } } }几个细节值得说:
- 读取超时设定为 3 秒,这对绝大多数在线模型是够的;超过就直接失败走降级,不让流程事务被拖死。
user字段传入requestId,这是幂等设计的一部分,后面第 4 节详细展开。temperature设低一点(0.1 到 0.3),判断型和提取型任务需要确定性,不要给模型太多“创造性”。
如果你用的是 Spring WebFlux,也可以用WebClient配合 timeout,效果类似,核心思想是超时必须有兜底。
3.3 实现 JavaDelegate:流程变量进出
现在写核心的javaDelegate。这个类的职责很纯粹:读流程变量、拼接 prompt、调用 LLM、把结果写回流程变量。
@Component("llmExtractTask") public class LlmExtractTask implements JavaDelegate { private static final Logger log = LoggerFactory.getLogger(LlmExtractTask.class); private static final String PROMPT_TEMPLATE = "请从以下文本中提取合同关键字段,输出 JSON,字段包括:partyA, partyB, amount, signDate。\n文本:%s\n只输出 JSON。"; @Autowired private LlmClient llmClient; @Override public void execute(DelegateExecution execution) { String bizContent = (String) execution.getVariable("bizContent"); String requestId = execution.getProcessInstanceBusinessKey() + "-" + execution.getCurrentActivityId() + "-" + execution.getId(); String response = llmClient.chatCompletion("gpt-4o-mini", Collections.singletonList(Map.of("role", "user", "content", String.format(PROMPT_TEMPLATE, bizContent))), requestId); JsonNode parsed = parseResponse(response); // 写入结构化结果,后续节点直接读取判断 execution.setVariable("llmResult", parsed.toString()); execution.setVariable("llmStatus", "SUCCESS"); execution.setVariable("llmModel", "gpt-4o-mini"); execution.setVariable("llmCost", parsed.path("usage").path("total_tokens").asInt()); log.info("llm extract done, requestId={}, contentLen={}", requestId, bizContent.length()); } }这里有几个必须注意的点:
第一,requestId用“流程实例业务键 + 当前活动节点 ID + execution ID”拼接,为了唯一标识这一次节点执行。后面排查日志全靠它。
第二,不要把大段原文塞进流程变量再传出去。bizContent可能几万字,最好在流程发起时就放在业务表里,流程变量里只存业务主键,Delegate 里按需查询。
第三,所有模型调用相关的结果都要落成流程变量:模型名、耗时、状态、token 数。这不是拍脑袋,而是后期做成本分析和效果评估的唯一依据。没有这些变量,出了问题你只能干瞪眼。
第四,异常处理在 Delegate 里特别关键。捕获到模型异常时,不要盲目抛RuntimeException,而是定义业务异常码并记录错误信息,然后把流程变量llmStatus置为FAILED,让流程走上专门设计的异常分支。如果你直接抛异常,Flowable 会尝试重试整个命令,最终把流程实例标记为错误状态。
3.4 BPMN XML 里配置 ServiceTask
流程定义文件里,serviceTask的配置非常直接:
<process id="contractReview" name="合同审核流程"> <startEvent id="start" /> <serviceTask id="llmExtractTask" name="AI 抽取合同要素" flowable:class="com.xxx.LlmExtractTask" /> <serviceTask id="llmClassifyTask" name="AI 判断风险等级" flowable:class="com.xxx.LlmClassifyTask" /> <exclusiveGateway id="riskGateway" /> <sequenceFlow id="flow1" sourceRef="start" targetRef="llmExtractTask" /> <sequenceFlow id="flow2" sourceRef="llmExtractTask" targetRef="llmClassifyTask" /> <sequenceFlow id="flow3" sourceRef="llmClassifyTask" targetRef="riskGateway" /> <sequenceFlow id="flow4" sourceRef="riskGateway" targetRef="humanReview" name="高风险"> <conditionExpression xsi:type="tFormalExpression"> <![CDATA[${llmResult.risk == 'high'}]]> </conditionExpression> </sequenceFlow> ... </process>在 Flowable Modeler 里配置更简单:选中ServiceTask,在属性面板里把Implementation设为Java class,类名填com.xxx.LlmExtractTask,或填 Spring Bean 名llmExtractTask。两种方式都支持,Bean 名方式更推荐,方便 Delegate 里注入其它 Spring Bean。
这里有一个习惯要养成:每个 LLM 节点最好独立成一个 ServiceTask,不要在一个 Delegate 里又调模型又写复杂业务逻辑,也不要一个 Delegate 里串好几个模型调用。独立节点意味着每个模型调用都有独立的异常处理、独立的重试策略、独立的监控日志,排查时能精确到是哪个环节出的问题。
3.5 部署与验证
启动应用后,Flowable 会自动部署resources/processes/下的 BPMN 文件。验证时我习惯走一遍完整链路:
- 调用
RuntimeService.startProcessInstanceByKey("contractReview", businessKey, variables)发起流程。 - 观察日志里出现
llm extract done, requestId=...。 - 查看
act_ru_variable表,确认llmResult、llmStatus这些变量已经写入且值正确。
SELECT NAME_, TEXT_, LONG_ FROM act_ru_variable WHERE PROC_INST_ID_ = #{processInstanceId} AND NAME_ IN ('llmStatus','llmResult','llmModel');建议在测试环境里把所有流程分支都跑一遍,特别是失败分支。因为你永远不知道模型会在哪个 JSON 字段上抽风,提前设计好失败走向,比到时候在线上抢救强一百倍。
4. 真正容易翻车的三个坑:事务、超时和重复调用
这部分是全文最值钱的地方。下面这三个问题,我基本每个项目都遇到,而且都是上线后才发现,代价非常高。
4.1 坑一:LLM 慢调用把流程事务拖死
Flowable 的执行模型是:一个流程实例的推进被包装在一个Command里,整个命令走一个数据库事务。你在JavaDelegate里调 LLM,意味着这个外部调用发生在数据库事务内、并且持有流程实例相关的锁。
假设模型平均耗时 5 秒,这 5 秒内:
- 该流程实例对应的数据库行锁一直被持有;
- 连接池的一条连接被占用;
- 如果并发 200 个流程实例同时走到 LLM 节点,就会产生 200 个长事务,连接池瞬间被占满,其它所有流程都被阻塞;
- 数据库层出现大量行锁等待,最终表现为“流程引擎变慢、页面转圈、超时报警”。
这就是为什么文章开头说“难的不是调 API,而是事务和超时问题”。所有做过的人都明白:外部调用绝对不能长事务化。
我在生产环境里的底线是:模型调用客户端超时不超过 3 秒,超过就快速失败、走降级分支。如果你期望模型慢工出细活(比如深度推理、长文档分析),那就不该走路线 A,直接上异步方案。
4.2 坑二:重试的幂等设计与 Token 成本
Flowable 对于一个执行失败的命令,会基于JobExecutor的默认重试策略反复执行。听起来是好事,但在 LLM 场景下是灾难。
举个例子:模型调用成功但解析返回值时因为 JSON 格式不标准抛了异常,这个命令被标记为失败,然后 Flowable 自动重试。重试时JavaDelegate又被执行,模型 API 又被调了一次,Token 费用重复产生。如果你的业务量一天一万次,一个节点多调两次,成本直接翻三倍。
解决办法是把幂等做干净。我这里用两层:
内层,在模型请求里透传一个requestId(上一节里的user字段)。多数主流模型 API 允许传user,这是唯一的请求标识;哪怕重试,服务端也能看出来是同一笔请求的重复执行。
外层,在 Delegate 里加一个“结果缓存”。流程实例执行前先查一下act_ru_variable里有没有该节点的llmResult,有且状态是SUCCESS,直接跳过调用。
String cached = (String) execution.getVariable("llmResult_" + execution.getCurrentActivityId()); if (cached != null && "SUCCESS".equals(execution.getVariable("llmStatus_" + execution.getCurrentActivityId()))) { return; }实践中这两层配合可以把重复调用几乎清零。
4.3 坑三:异常抛出后的“隐性卡死”
第三个坑最隐蔽:Delegate 里模型调用失败,你如果不做任何处理直接让它冒泡,Flowable 会把整个命令标记为异常,流程实例停在当前节点,JobExecutor 会按重试计划反复尝试。你在前端看到的不是“流程失败”,而是“一直不动”,等你去查的时候已经重试了几十次,Token 烧了一大笔。
更可怕的是,噪音日志会淹没真正的错误信息。我在项目里就遇到过流程实例在act_ru_job里反复重试了 30 多次,每次日志都长一样,排查起来极度痛苦。
我的应对策略如下:
- Delegate 内部捕获模型异常,转换为业务异常码,如
LLM_TIMEOUT、LLM_JSON_PARSE_ERROR、LLM_RATE_LIMIT。 - 出错时记录
llmErrorCode、llmErrorMsg、llmRetryCount变量,并把llmStatus置为FAILED。 - 在 BPMN 里给 ServiceTask 挂边界错误事件,或直接把后续流程分支设计成按
llmStatus条件分流:成功走正常分支,失败走人工兜底分支。
<serviceTask id="llmExtractTask" name="AI 抽取合同要素" flowable:class="com.xxx.LlmExtractTask"> <boundaryEvent id="llmError" attachedToRef="llmExtractTask"> <errorEventDefinition errorRef="llmError" /> </boundaryEvent> </serviceTask>这样 LLM 失败后流程不会傻等,而是立即进入人工处理分支,由人看原始文本继续走。模型服务于业务,而不是业务迁就模型,这是设计原则。
5. 从能跑到跑得稳:异步化演进、多模型路由与人工兜底
路线 A 跑通只是起点。要让系统在生产环境稳定跑下去,还需要考虑演进路径。
5.1 把 LLM 节点外部化
当流程并发上来了,或者你希望 LLM 服务能独立扩容、独立发布,就把模型调用从引擎内部剥出去。最常见做法是消息队列中间层:ServiceTask 里只负责把待处理业务信息发到 MQ,然后返回、流程实例停在等待节点;一个独立的 LLM worker 消费消息、调模型、把结果写回某个表,再通过 Flowable REST API 或直接注入 RuntimeService 的方式把结果写回流程变量,并推进流程实例。
这里有个实现细节:等待节点用receiveTask(中间捕获事件)比较顺手,worker 完成后调用runtimeService.trigger(executionId, variables)让流程继续。触发方法里传入的variables就是模型输出的参数,落库后立刻在后续节点可见。
5.2 多模型路由与降级策略
生产环境里单一模型风险很大。我在项目里的标准做法是准备三档模型:
- 主力模型:效果好、价格适中,处理绝大多数请求;
- 备用模型:性能稍弱但稳定,主模型超时或报错时切换;
- 轻量模型:做判断、分类这类简单任务,成本最低。
路由策略不复杂,做一个配置类,按业务类型和失败次数切换。核心思路是“快速失败、快速降级”,绝不在一个模型上反复等待。
llm: routes: extract: primary: gpt-4o-mini fallback: deepseek-chat timeoutMs: 3000 classify: primary: deepseek-chat fallback: qwen-turbo timeoutMs: 2000降级要把状态记录下来(llmModel、llmFallbackTimes变量),方便后续成本分析和模型选择优化。
5.3 人工审批 + AI 建议的组合节点模式
我在企业项目里落地最多的,其实是“AI 建议 + 人工确认”的组合。流程走到 UserTask 之前,先有一个 LLM 节点生成建议;人工审批界面加载流程变量里的建议内容,审批人看到推荐意见,自己判断要不要采纳。
比如费用报销流程:LLM 节点先分析报销事由、金额、部门,输出{"suggestion": "建议加签财务复核", "reason": "金额超过部门权限"},审批人打开任务时直接看到这条建议,一键同意或者修改后提交。
这种模式的好处是保留人的最终决定权,合规性、可解释性都说得过去,业务部门也更容易接受。别一上来就搞全自动决策,人接受一个 AI 要时间,先给建议再慢慢来。
5.4 观测与效果评估:成本和业务指标
最后我要强调:接 LLM 节点一定要把“成本”和“效果”纳入日常监控。否则模型调用一次多少 token、月度成本涨了多少、某个节点成功率多少,全是糊涂账。
建议每个 LLM 节点输出这些指标:
- 节点执行次数、成功率、平均耗时、p95 耗时;
- token 消耗(输入/输出分别统计);
- 模型版本与降级次数;
- 业务侧指标:人工采纳率、后续流程异常率。
把这些指标输出到日志系统或 Prometheus + Grafana,两周后你会有非常清晰的数据来判断“这个 LLM 节点到底值不值得继续接入”。
在我实际做的项目里,最快见效的是把 LLM 节点的日志摘要和结果哈希落下来。出问题时,拿着requestId去日志系统里一搜,整个链路一目了然。接入大模型不是一锤子买卖,它是一个持续调优的过程——先小范围试点,把事务边界、成本监控、人工兜底跑顺,再横向扩展到更多流程,这条路是最稳妥的。