
前阵子手上有个内部的费用报销审批流程要做智能化改造业务方提了个需求系统要在审批阶段自动判断一笔报销单的风险等级并顺手生成一段处理建议。难点在于判断依据不只是金额和类别这些结构化字段还包括报销说明这种没法用硬规则处理的非结构化文本。当时第一反应是写一堆 if/else 规则或者搞一张决策表放到 Flowable 的条件表达式里。但很快就发现这条路行不通——“外出参加行业会议对方开具的发票金额与会议日程不符”这种描述规则引擎根本没法给出合理结论。真正能处理这种内容的东西只有大模型。于是问题变成了一个非常具体的工程问题如何在 Flowable 工作流里接入一个能自动执行的大模型节点。两个多星期折腾下来从 BPMN 建模、JavaDelegate 编写到异步执行、异常处理踩了不少坑也把一套可复用的接入模式跑通了。这篇就把完整思路、代码和排错过程整理出来给同样在 Flowable 项目里做 AI 改造的同学做个参考。1. 为什么要在 Flowable 里塞一个“AI 智能审核”节点场景与路线选型先明确一点Flowable 本身是一个状态机 任务调度的引擎它没有任何“智能”成分。我们说的“LLM 节点”本质上是在流程定义的某个位置挂一个由代码实现的自动步骤这个步骤负责调用大模型接口再把模型输出转化成后续路由需要的数据。1.1 这类节点在真实项目里能干什么从我接触过的项目来看LLM 节点最常见的落地场景有这么几类审批建议生成读取单据内容让模型输出风险等级、通过建议、需补充的材料清单。这是最常用的场景改造成本低、效果最直观。智能分单根据工单描述自动确定负责人团队替代原来靠人肉选择或者简单关键词匹配的逻辑。简历初筛把候选人简历文本和岗位要求一起喂给模型让模型输出匹配度评分和不匹配点。条款风险提取读取合同段落提取可能的风险条款给法务人员附上原文定位。动态条件判断表单里没有现成字段支撑网关分支条件时用模型输出一个枚举值作为排他网关的路由依据。这些场景有一个共同特点输入是文本输出是结构化或半结构化结果而且结果直接参与流程走向。也就是说这不是锦上添花的“智能问答”而是流程内部的一个真实环节它的稳定性会直接影响流程能否正确跑完。1.2 三条可选技术路线为什么我选了 Service Task聊方案的时候团队里出现过三种声音这里把对比列出来路线做法优点缺点外部回调AI 服务在 Flowable 外部运行把结果通过 REST API 回写流程变量流程引擎改动最小需要额外开发回调接口AI 执行节点在流程图上不可见业务没法直观看到“这里有个智能判断”事件监听器用 ExecutionListener 或 TaskListener 在节点触发时调用 LLM可以复用 Flowable 生命周期事件监听器逻辑分散在多个节点事件的回调里不方便做成“一个粒度的 AI 动作”测试和排查都比较绕Service Task JavaDelegate把 LLM 调用封装成 Flowable 的服务任务在 BPMN 里作为一个独立节点节点可视化、可复用、可实现异步、可用流程变量天然传递数据需要写 Java 代码对建模工具有一点要求我最后选的是第三条路线。原因很实际Service Task 是 BPMN 规范里标准的自动执行节点Flowable 对它支持得最好既能同步执行也能启用异步出错后引擎会负责重试而且节点画在流程图上是可见的业务方评审流程图时能直观看到“这里有一个 AI 审核动作”而不是去代码里猜哪条监听器做了什么事。后来项目里又加了其他流程这个 LLM Service Task 被直接复用类似功能在多个流程里只要拖一个节点进来就行。如果当时用了监听器每个流程都得单独维护一套监听逻辑想想就头疼。2. 扩展点梳理Service Task 为什么是接入 LLM 的正解要把 LLM 节点做对先得理解 Flowable 给自定义自动节点提供的几种实现方式以及它们各自的适用边界。这块值得单独说清楚因为选错实现方式后面维护会很难受。2.1 BPMN 里的 Task 类型乱花眼自动执行其实只有一个标准入口BPMN 2.0 里和“任务”相关的东西很多User Task人工任务、Service Task服务任务、Script Task脚本任务、Send Task发送任务、Receive Task接收任务等。对一个 LLM 节点来说它要做的是读取数据、调用外部 API、写回结果这个模式就是锁定 Service Task。Script Task 虽然也能做这件事但要在脚本里拼 HTTP 请求、处理 JSON、管理密钥既不安全也不好调试Send Task 更多用于消息发送模式不适合承载业务逻辑。所以结论很简单Service Task 是接入 LLM 的正解没有之一。2.2 Service Task 的三种实现方式要分清“绑定类”和“绑定表达式”Flowable 的 Service Task 有几种绑定方式官方文档里都列了但实际项目里最容易混淆的是这三种flowable:class直接指定一个实现JavaDelegate的类全限定名。引擎会自行实例化这个类不经过 Spring 容器。flowable:expression写一个表达式比如${myService.doWork()}引擎直接调用这个表达式返回结果。flowable:delegateExpression也是表达式但表达式的值的需要是一个实现了JavaDelegate的对象。最常见写法是${myDelegateBean}其中myDelegateBean是 Spring 容器里的一个 Bean。先看一个容易踩的坑如果用flowable:class引擎是通过反射创建类的这个类不会注入 Spring 依赖里面的Autowired全是 null。很多刚接触 Flowable 的人在这里碰壁包括我。如果有 Spring Boot 环境强烈建议用delegateExpression指向一个注册成 Bean 的 Delegate 实现类这样可以正常使用依赖注入、读取配置、走 AOP 日志。2.3 为什么我选 Delegate Expression 而不是直接写死在类上除了依赖注入的问题delegateExpression还有一个好处可以在部署不同流程版本时通过配置切换不同的节点实现。举个实际例子我们在做 LLM 节点时先接了一个通用对话模型做验证后来切换成更便宜的专用分类模型。如果类名硬编码在 BPMN 里就得改流程图重新部署但用delegateExpression的话可以在 Spring 配置里控制哪个 Bean 生效流程图不用动切模型就像改了个配置项。另外配合 Spring 的Primary、Qualifier或者 Profile 机制还可以实现测试环境用 Mock 实现、生产环境用真实模型这对工作流自动化测试特别有用。后面讲测试的时候我会再展开。3. 把 LLM 节点写成可用代码从依赖到 BPMN 配置下面进入正题直接上一套可以在 Spring Boot Flowable 环境里跑起来的代码。这套代码我按生产标准写过不是 demo。3.1 工程准备Flowable 版本和依赖选择项目用的是 Spring Boot 2.7Flowable 7.1.0。这里有个经验Flowable 7.x 对 Spring Boot 版本有对应关系不要随意混搭。可以直接看官方文档的版本兼容矩阵或者干脆用 Flowable 提供的 BOM 来管理版本省心不少。Maven 依赖核心就两个dependency groupIdorg.flowable/groupId artifactIdflowable-spring-boot-starter/artifactId version7.1.0/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependencyHTTP 客户端我直接用 Spring 的RestTemplate设置好超时时间即可。如果项目里已有 OkHttp 或者 WebClient也没问题只要封装在 LLM Client 里就行。这里不推荐 service task 里直接写裸的HttpClient代码后面要加超时、重试、日志都会很零散。3.2 核心实现LlmServiceDelegateFlowable 的自定义节点逻辑核心是实现org.flowable.engine.delegate.JavaDelegate接口。我在一个报销审批流程里定义了一个 LLM 节点它的做法是这样的从流程变量中读取报销类别、金额、报销说明组装提示词调用大模型解析返回的 JSON最后把风险等级和处理建议写回流程变量。Component(llmReviewDelegate) public class LlmReviewDelegate implements JavaDelegate { private final LlmClient llmClient; public LlmReviewDelegate(LlmClient llmClient) { this.llmClient llmClient; } Override public void execute(DelegateExecution execution) { String category execution.getVariable(category, String.class); BigDecimal amount execution.getVariable(amount, BigDecimal.class); String description execution.getVariable(description, String.class); String prompt 你是财务报销审核助手。根据以下信息判断该报销单的风险等级。 类别%s 金额%s 报销说明%s 只输出如下 JSON 格式不要输出多余文字 {riskLevel:LOW|MEDIUM|HIGH,suggestion:处理建议,reason:判断理由} .formatted(category, amount, description); LlmResponse response llmClient.chat(prompt); // 解析模型输出注意要做容错处理 RiskResult riskResult RiskResultParser.parse(response.getContent()); // 把结果写回流程变量后续网关和人工任务都能读取 execution.setVariable(riskLevel, riskResult.getRiskLevel()); execution.setVariable(riskSuggestion, riskResult.getSuggestion()); execution.setVariable(riskReason, riskResult.getReason()); } }这段代码有几个容易忽略的点提一下变量命名规范写回的变量如果后续要在网关条件里使用建议保持纯小写驼峰避免在表达式里和 JavaBean 属性解析混淆。模型输出解析不能假设一定成功大模型偶尔会返回多出前缀的文本建议在解析前做一次清理后面我会单独讲这个坑。DelegateExecution 是线程安全的吗每个流程实例有独立的 execution 实例但 Delegate Bean 是单例的里面不要存跟具体请求相关的状态所有数据都放 execution 变量。3.3 LLM Client多模型适配的关键封装代码里的LlmClient是一个接口我没有直接把某个厂商的 SDK 硬编码到 Delegate 里。理由很简单大模型领域变化太快今天用这个模型明天可能要换成别的接口隔离一下Delegate 代码就不用动了。public interface LlmClient { LlmResponse chat(String prompt); }对于大模型服务我建议优先找支持 OpenAI 兼容接口的服务。因为现在私有化部署方案、国内外主流云厂商绝大部分都提供 OpenAI 兼容的/chat/completions接口。这样我们只需要写一个实现Component public class OpenAiCompatibleClient implements LlmClient { Value(${llm.api-url}) private String apiUrl; Value(${llm.api-key}) private String apiKey; Value(${llm.model}) private String model; private final RestTemplate restTemplate; public OpenAiCompatibleClient(RestTemplate restTemplate) { this.restTemplate restTemplate; } Override public LlmResponse chat(String prompt) { MapString, Object body new HashMap(); body.put(model, model); body.put(messages, List.of( Map.of(role, system, content, 你是企业流程自动化助手总是输出严格要求的格式。), Map.of(role, user, content, prompt) )); body.put(temperature, 0.1); HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.setBearerAuth(apiKey); HttpEntityMapString, Object entity new HttpEntity(body, headers); ResponseEntityJsonNode resp restTemplate.exchange(apiUrl, HttpMethod.POST, entity, JsonNode.class); String content resp.getBody().path(choices).path(0).path(message).path(content).asText(); return new LlmResponse(content); } }这里有两个细节值得注意temperature 要调低工作流里的模型输出直接影响流程走向我不希望它“太有创造力”通常设 0.1 或者 0尽量稳定输出。超时设置必须在 RestTemplate 层面做否则默认是不超时的一旦模型接口卡住整个服务可能被拖垮。超时配置后面单独说。3.4 BPMN 里怎么配这个节点代码写好了接下来要把节点画到流程图上。这里有个经验Flowable 官方自带的 Modeler 也能画但很多生产项目其实是拿 Camunda Modeler 画图再导出 BPMN 文件给 Flowable 部署因为 Camunda Modeler 是独立桌面端画起来顺手版本兼容性也好。两个工具生成的 BPMN XML 都是标准格式Flowable 能直接识别。在 Camunda Modeler 里拖一个 Service Task然后在右侧属性面板找到Implementation相关设置填入flowable:delegateExpression${llmReviewDelegate}。对应生成的 XML 片段长这样serviceTask idllmReviewTask nameAI 智能审核 flowable:delegateExpression${llmReviewDelegate} flowable:asynctrue /serviceTask注意flowable:asynctrue这个属性这是异步执行开关我后面专门讲为什么建议打开。部署流程还是在代码里最直观SpringBootTest class ProcessDeployTest { Autowired private RepositoryService repositoryService; Test void deployProcess() { repositoryService.createDeployment() .name(报销审批流程) .addClasspathResource(processes/expense-approval.bpmn20.xml) .deploy(); } }部署完成后去 Flowable 的 ACT_RE_PROCDEF 表里查一下流程定义是否正常注册只要状态正常节点就挂上去了。4. 数据流转和异步保护别让一次 API 调用拖垮整个流程实例LLM 节点和其他 Service Task 最大的不同在于它的执行时间可能很长。普通服务任务几十毫秒就结束了LLM 调用动辄三五秒高峰期甚至二三十秒。如果不做异步和超时保护整个流程引擎都会被拖垮。这一章把变量传递和异步执行的实操讲透。4.1 流程变量作用域getVariable 和 setVariable 到底写在哪儿先解释一个容易懵的点DelegateExecution.getVariable()和setVariable()操作的是当前执行实例可见的变量但 Flowable 的变量不是全平铺在一个大 Map 里。一个流程实例启动时会创建一个根执行实例Process Instance Execution。排他网关、并行网关会从它分支形成子执行实例。在子执行实例里调用getVariable()会沿着执行树的父级向上查找而setVariable()默认会写到当前执行实例的父级也就是流程实例级别除非你在多实例节点里。这个机制绝大多数时候是够用的但有一个场景必须注意并行网关。如果流程里有并行分支两条分支同时跑其中一条分支的 LLM 节点setVariable(riskLevel, ...)另一条分支如果也操作同名变量就会出现互相覆盖的竞态问题。解决办法是尽量让写变量发生在合并后的节点上或者在分支里用带前缀的变量名区分比如leftRiskLevel和rightRiskLevel。多实例节点会签、或签里更要注意每个实例有独立的 executiongetVariable能取到共享变量但如果你setVariable想改共享变量默认行为可能会造成局部变量和全局变量不一致。建议多实例场景下用execution.getParent().setVariable()显式指定作用域或者用execution.setVariable()后针对多实例结果做getVariable(nrOfCompletedInstances)汇总判断。4.2 同步执行会让流程引擎线程池寸步难行Flowable 默认情况下Engine 会在发起流程的线程里直接执行 Service Task。如果你在接口线程里启动流程实例而这个流程里有一个同步的 LLM 节点这个线程就会在restTemplate.exchange()上阻塞好几秒。我实测过一个场景压测并发 30 个报销单启动流程每个 LLM 调用平均 4 秒后面的流程全部排队看起来就像线程池被“卡死”了。试想生产环境高峰期几十个工单同时在跑系统响应时间直接上天。解决办法就是给 Service Task 开异步执行serviceTask idllmReviewTask ... flowable:asynctrue开了这个属性Flowable 会把该节点的执行封装成一个 Job放入异步执行器的队列中由 AsyncExecutor 的后台线程去消费。这样启动流程的接口线程立刻返回流程实例在那个节点处“暂停”等异步线程把 LLM 调用完成后流程再从该节点继续往下走。Flowable 在 Spring Boot 环境下默认会启动 AsyncExecutor可以在配置里调整线程池大小和队列长度flowable: async-executor-activate: true async: async-executor-core-pool-size: 8 async-executor-max-pool-size: 16 async-executor-queue-capacity: 100这里注意一点并发量特别大、模型接口又慢的时候队列容量填太大会导致流程实例长期停留在“未完成”状态业务上要注意超时提醒。容量太小又会触发RejectedExecutionExceptionJob 会按重试策略反复补偿。4.3 超时和重试一张表说清楚配置思路LLM 调用失败是常态网络抖动、模型服务过载、返回格式不对都可能导致节点执行失败。Flowable 对异步 Job 有默认重试机制但重试次数和间隔要按场景调。RestTemplate 超时配置Bean public RestTemplate llmRestTemplate() { HttpComponentsClientHttpRequestFactory factory new HttpComponentsClientHttpRequestFactory(); factory.setConnectTimeout(5_000); factory.setReadTimeout(30_000); return new RestTemplate(factory); }场景建议配置原因连接超时5 秒模型服务地址不可达时要快速失败读取超时30 秒大模型生成可能较慢但超过 30 秒基本就是出问题了Flowable Job 重试次数3 次次数太少抖动时误失败太多会积压消息队列重试间隔指数递增从 2 秒开始给模型服务恢复时间Flowable 异步 Job 重试配置在process-engine-configflowable: async: async-executor-activate: true default-timer-job-acquire-wait-time: 10000 async-job-retry-wait-time: 2000 async-job-retry-max-retries: 3重试有一个隐藏问题LLM 调用如果已经成功写入了流程变量只是因为解析或网络异常抛了错重试会把同一个请求再发一次。所以要在节点里做幂等处理。我在代码里加了一个简单判断if (execution.getVariable(riskLevel) ! null) { return; }这是在流程变量中写入标记重试时发现已有结果就直接跳过避免重复调模型浪费成本。5. 真实踩坑记录变量丢失、线程阻塞、返回解析的排查全过程这章把我在接入过程中遇到的最典型的四个问题按排查链路写出来。看答案没意思看排查思路才有价值以后换个坑也能自己定位。5.1 坑一Service Task 里 getVariable 拿到的全是 null现象流程流转到 LLM 节点日志打印出来 category、description 全是 null。排查过程先怀疑流程变量没传对去 ACT_RU_VARIABLE 表查发现变量明明存在。再怀疑节点 ID 写错BPMN 图看了一遍没错。最后进调试模式把execution.getVariables()全部打印发现能拿到变量但getVariable(category)返回 null因为变量名在数据库里存的是category和Category之类的差异大小写不一致。定位到原因流程启动时用Map.of(Category, ...)传入而 Delegate 里读取的是category。Flowable 变量名是大小写敏感的而且我踩过的是Map.of构造出来的 key 是Category我以为是category。解决办法统一变量名。我在项目里定了一条规范所有流程变量统一小驼峰命名启动流程和 Delegate 读取必须用同一个常量类引用。后来再没出过这类问题。5.2 坑二同步调用把流程服务线程池整个占满现象压测时从第 20 个请求开始接口响应从 200ms 涨到 15 秒日志里出现Acquire async job和大量阻塞线程。排查过程先看数据库 ACT_RU_EXECUTION发现大量流程实例停在 LLM 节点。再用jstack抓线程发现线程都停留在RestTemplate的 socketRead0。确认是同步 Service Task 耗死线程。这个坑我之前已经讲了解决办法就是开flowable:asynctrue。还有一个隐蔽点即使开了异步如果 AsyncExecutor 线程池太小或者队列容量太高同样会出现隐性问题。我最后把核心线程数设为 8最大 16队列 100压测下来吞吐没问题。5.3 坑三模型返回的 JSON 里带了 markdown 代码块标记直接解析失败现象生产环境偶发报JsonParseException有时候同一个输入这次成功下次失败。排查过程打点日志记录模型原始返回。果然模型偶尔会在 JSON 外面包上 json 代码块标记。这确实是很多模型的“种族天赋”你让它输出 JSON它非要给你加装饰。解决办法是写一个健壮的解析函数public class RiskResultParser { public static RiskResult parse(String raw) { String content raw.trim(); // 去掉可能的 markdown 代码块标记 content content.replaceAll(^json\\s*, ).replaceAll(\\s*$, ); JsonNode node new ObjectMapper().readTree(content); String level node.path(riskLevel).asText(); if (!Set.of(LOW, MEDIUM, HIGH).contains(level)) { throw new IllegalStateException(riskLevel 不在合法枚举内: level); } return new RiskResult(level, node.path(suggestion).asText(), node.path(reason).asText()); } }5.4 坑四Flowable 和项目里 Jackson 版本冲突启动直接报错现象接入 Flowable 后Spring Boot 启动时报JsonMappingException或者NoSuchMethodError指向 Jackson 的某个类。排查过程mvn dependency:tree查 Jackson 依赖发现 Flowable 7.x 传递依赖 Jackon 2.15而项目里被一个旧依赖压成了 2.13。典型错误是InvalidDefinitionExceptionJackson 2.13 和 2.15 在时间日期序列化上行为不同。解决办法在 pom 里统一 Jackson 版本到 Flowable 认可的版本。如果不想动全局版本也可以在 flowable-spring-boot-starter 里把 jackson 排除显式声明和项目一致的版本。这个问题的通用排查思路就一句话报NoSuchMethodError先查依赖树别急着定位业务代码。6. 可以从这三点继续扩展动态路由、结果校验、成本缓存LLM 节点跑通只是第一步实际生产要考虑的还有不少。但把这三个方向做好体系就比较完整了。6.1 动态路由用模型输出当排他网关的路由条件LLM 节点输出的riskLevel可以直接作为排他网关的判断依据在 BPMN XML 里就是这样exclusiveGateway idriskGateway name风险等级网关 / sequenceFlow idflowLow sourceRefriskGateway targetRefautoApproveTask conditionExpression xsi:typetFormalExpression ![CDATA[${riskLevel LOW}]] /conditionExpression /sequenceFlow sequenceFlow idflowHigh sourceRefriskGateway targetRefmanualAuditTask conditionExpression xsi:typetFormalExpression ![CDATA[${riskLevel ! LOW}]] /conditionExpression /sequenceFlow这里有一个我在表达式中踩过的细节Flowable 的 EL 表达式里字符串比较要用而不是equals它用的是 Spring EL 的语法。如果你写成${riskLevel.equals(LOW)}也能跑但格式风格不统一会容易出错。另外条件表达式里不能直接依赖riskSuggestion这种中文字符串做精确匹配常有换行和空格问题建议以枚举值为准。6.2 结果校验给模型输出上保险模型输出不像接口文档那么可靠。这里说的校验有两个层面格式校验必须能被解析成合法 JSON且字段完整。解析失败就走重试或降级。业务语义校验riskLevel必须在合法枚举内金额大但风险等级为 LOW 这种结果建议校验通过后再放行。我在自定义的RiskResultParser里做了第一道校验在 Delegate 里还可以加入规则联动if (LOW.equals(riskResult.getRiskLevel()) riskResult.getReason() null || riskResult.getReason().isBlank()) { riskResult RiskResult.fallbackHigh(模型输出缺少理由按高风险人工处理); }这个降级策略很重要宁可让模型保守一点走人工也不要因为一个失误把不该通过的单子直接放行了。LLM 节点先做好异常兜底才敢真正驱动业务决策。6.3 成本缓存同样的输入别反复调模型LLM API 按 token 计费工作流里的节点调用频率还蛮高的同一个工单因为流程回退、重试可能被反复触达。我给 LLM 节点加了简单缓存以“提示词内容的 SHA-256 Hash”为 key带上流程的业务 ID 一起存Component public class LlmCacheService { private final CacheString, LlmResponse cache Caffeine.newBuilder() .maximumSize(500) .expireAfterWrite(30, TimeUnit.MINUTES) .build(); public LlmResponse get(String prompt) { String key DigestUtils.sha256Hex(prompt); return cache.getIfPresent(key); } public void put(String prompt, LlmResponse response) { String key DigestUtils.sha256Hex(prompt); cache.put(key, response); } }注意缓存 key 只包含提示词不包含流程实例 ID这样两个审批单如果描述完全一样就能命中。但如果你的业务场景对时效性要求高缓存时间要调短如果每次都希望模型重新判断就不要加缓存。这个看业务取舍。6.4 日志与观测记录 token 消耗和模型耗时LLM 节点进生产以后运维上最实用的就是记录每次调用的模型、耗时、token 消耗、原始输出、流程实例 ID。建议在LlmServiceDelegate里用 MDC 塞上流程实例 IDMDC.put(processInstanceId, execution.getProcessInstanceId()); try { LlmResponse response llmClient.chat(prompt); log.info(LLM 调用成功, 耗时{}ms, token{}, 输出{}, costMs, response.getUsage(), response.getContent()); } finally { MDC.remove(processInstanceId); }这套日志做好了以后出问题可以按流程实例 ID 直接拉出这个节点的完整调用链路排查效率提升不是一点半点。最后分享个我个人的习惯在做 Flowable 和 LLM 集成这类偏探索性的工作时先把主链路用通用模型跑通再慢慢加缓存、校验、降级这些“防倒”机制。这里面的顺序很重要。另外真正让这个方案稳定的是把“模型输出”当成一个不信任的输入源来对待所有下游逻辑都要默认它可能是错的、慢的、格式不对的。把这一点想明白了踩坑的速度会慢一半。