工作流引擎里塞一个大模型节点听上去很科幻其实就是一件很务实的事。我最早被问到“Flowable 里怎么接 LLM”时第一反应是为什么要在流程引擎里去接模型后来想明白了——传统 BPM 管的是“流程怎么走”但没人管“走到某个节点时怎么判断怎么处理”。以前这些判断靠人工或者靠一堆烂代码硬编码规则。现在有了大模型最自然的一件事就是把那些需要语义理解、非结构化信息筛选、甚至“拍脑袋”的环节做成一个 LLM 节点放进流程里。比如贷款审批里让模型先做资料初筛、客服工单自动打标、简历海选时统一评分这些都能用同一个套路实现。这篇文章我会把从 BPMN 建模、委托类开发、变量设计到异步、多实例、外部任务处理以及我踩过的一堆坑都梳理一遍。你用 Flowable 6.x 还是 7.x 区别不大核心思路是一致的读完至少能动手跑通一个“大模型节点审批”的最小示例。1. 为什么要在 Flowable 里接入 LLM 节点1.1 流程引擎没长着“判断力”的脑子先想一个问题一个 BPMN 流程里请假超过 3 天要总监审批这个“3 天”的判断是谁做的本来应该是排他网关上的条件表达式是固化的规则。但如果今天不是“超过 3 天”而是“这个请假理由合不合理”传统表达式怎么写写不了。因为“合理”不是一个布尔值它是语义层面的判断。这种语义判断恰恰是 LLM 擅长的。Flowable 本身只负责流程状态流转、任务分发、历史留痕它不会做语义判断甚至也不该做。所以把一个 LLM 调用封装成一个 Service Task 类型的节点本质上就是给流程引擎外接一个“会思考的计算器”思考完把结果写回流程变量后续网关、人工任务、子流程再去消费这个变量。这就是我们常说的“规则 模型”混合工作流确定性强的环节用 Flowable 网关做规则判断模糊性强的环节交给大模型输出结构化结果两边不打架。1.2 接入 LLM 的三条主流技术路线不同团队接入 LLM 的方式不太一样我整理过三类各有适用场景接法实现方式适合什么情况注意点JavaDelegate Spring Bean在 Service Task 里通过delegateExpression指向一个 Spring BeanBean 内调用大模型 API团队以 Java 为主流程内部需要快速同步拿结果调用耗时会占住流程触发线程需要控制超时外部任务 ExternalTaskService Task 类型配成 “external”独立 Worker 程序fetchAndLock领取任务再上报结果AI 服务是异构语言或调用量大不想占引擎线程流程引擎要暴露 REST 接口Worker 要处理鉴权消息/事件驱动节点发消息给 AI 服务AI 处理完通过RuntimeService重新触发流程AI 服务和流程系统彻底解耦异步明显链路长流程状态跟踪麻烦我自己实际用的最多的是第一种原因是简单直接适合大多数内部管理系统。后面第 4 节我再单独展开外部任务和多实例这两种属于进阶玩法。2. 整体设计从流程模型到 LLM 节点的落地思路2.1 建模LLM 节点不是“新节点”是 Service TaskFlowable 并没有一个叫“大模型节点”的原生元素。你去新建 BPMN 模型时应该用的是 Service Task服务任务。这在设计上很重要——我们不是给 Flowable 加引擎特性而是利用它现成的扩展点“服务任务”去承载任意后端逻辑。服务任务本身很杂可以执行 Java 类、调用表达式、走 WebService、发消息。LLM 调用只是其中一种实现。从流程建模角度节点的id我用llmReviewname写成“大模型审核”或者“AI 初筛”这样业务方看流程图也能看懂不会问“这个 Service Task 是干嘛的”。serviceTask idllmReview name大模型审核 flowable:delegateExpression${llmReviewDelegate} /这段 XML 的核心是delegateExpression它指定运行时要调用的 Spring Bean 名称。Bean 是负责跟大模型通信的执行器。流程模型把“什么时候调用、调用后往哪走”管住Bean 把“怎么调、怎么解析、怎么回写”管住职责非常干净。2.2 用 delegateExpression 而不是 flowable:class有读者可能会问Flowable 服务任务还支持flowable:classcom.example.MyDelegate直接写类全路径为什么我更推荐delegateExpression理由从 Spring 容器说起。flowable:class是让 Flowable 自己实例化那个类但你在类里想Autowired注入一个RestTemplate、RedisTemplate、或者你们公司封装好的大模型客户端 SDKFlowable 可不知道去哪里找。虽然也能通过自定义SpringExpressionManager做一些补救但那属于大幅改造没有意义。flowable:delegateExpression${llmReviewDelegate}等于告诉 Flowable这个 Bean 你从 Spring 容器里按名字拿。于是你可以在 Delegate 类里放心注入一切需要的组件实现 IoC。而且 Bean 是容器管理的单例运行期没有反复创建的损耗。再补充一个小经验Bean 名和 Delegate 类名不要混。比如类名是LLMReviewDelegateBean 名可能是reviewDelegate、llmDelegate只要 XML 里的表达式跟你Component(...)的名字一一对应即可。很多新人卡在“明明 Spring Boot 能扫到 Bean流程却报找不到”就是因为 XML 里的名字和注解名字不一致。2.3 LLM 输出必须转成流程变量而不是让网关去读原文在设计流程时最容易犯的错是让排他网关直接对 LLM 返回的一串自然语言做条件判断。比如网关条件写成${llmResult.contains(同意)}这种写法极其脆弱。模型这次返回“同意”下次可能返回“我觉得可以同意”甚至前缀加个“不同意”分析一堆再反转说“最终结论同意”。所以我的经验是LLM 返回的原始文本绝不直接参与网关路由。我们要在 Delegate 里做一层“格式化输出”把模型自然语言结果映射成有限的、稳定的枚举或 JSON 结构再写入流程变量。推荐让 LLM 返回一个固定 JSON{ decision: APPROVE, riskLevel: LOW, summary: 材料齐全金额正常整体风险较低 }decision只枚举APPROVE、REJECT、MANUALriskLevel枚举LOW、MEDIUM、HIGH。然后用这四个或五个枚举控制网关走向。哪怕模型返回格式不规范我们也可以在一次调用后再加一层解析和兜底逻辑。这个细节才是流程稳定性的关键。3. 实操从 0 到 1 实现一个 LLM 审批节点3.1 环境准备与依赖假设你用的是 Spring Boot 技术栈。先引入 Flowable 的 starterdependency groupIdorg.flowable/groupId artifactIdflowable-spring-boot-starter/artifactId version6.8.0/version /dependency如果你用的 Java 8建议用 6.7.2 或 6.8.x 这个档位Java 17 且有精力折腾新版本可以上 Flowable 7。很多二开源码框架比如 JeecgBoot、芋道那些内部带的是 6.x直接升级到 7 可能有一堆配置不兼容建议先保持框架内置版本只把这块 LLM 逻辑做成一个独立的 Delegate 接入。另外需要包含 Spring Web 的依赖因为 Delegate 里要发 HTTP 请求dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency这一段不要觉得多余因为后面很多坑都和团队对 Flowable 版本认知不清楚有关。后面第 5 节我再细说版本兼容问题。3.2 定义 BPMN XML 流程文件在resources/processes目录下建一个llm-approval.bpmn20.xml这里给一个可运行的最小流程?xml version1.0 encodingUTF-8? definitions xmlnshttp://www.omg.org/spec/BPMN/20100524/MODEL xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xmlns:flowablehttp://flowable.org/bpmn targetNamespacehttp://flowable.org/bpmn process idllmApprovalProcess name大模型审批流程 isExecutabletrue startEvent idstartEvent / serviceTask idllmReview name大模型初筛 flowable:delegateExpression${llmReviewDelegate} / exclusiveGateway idgatewayDecision defaulttoManual / sequenceFlow idflow1 sourceRefstartEvent targetRefllmReview / sequenceFlow idflow2 sourceRefllmReview targetRefgatewayDecision / sequenceFlow idflowApproved name自动通过 sourceRefgatewayDecision targetRefendApproved conditionExpression xsi:typetFormalExpression ![CDATA[${decision APPROVE}]] /conditionExpression /sequenceFlow sequenceFlow idflowManual name转人工 sourceRefgatewayDecision targetRefmanualTask conditionExpression xsi:typetFormalExpression ![CDATA[${decision MANUAL || decision null}]] /conditionExpression /sequenceFlow userTask idmanualTask name人工复核 flowable:assigneemanager / endEvent idendApproved / endEvent idendManual / /process /definitions这里值得注意是排他网关的defaulttoManual。一旦前面 LLM 节点执行异常或没有写入decision变量流程会走进默认出口也就是人工兜底而不是直接卡死或报错。这算是我做这个改造时的一个硬性要求AI 环节必须“允许失败”失败后永远有一条人工兜底的路。3.3 写 LLM 委托类Spring Bean 的核心是实现org.flowable.engine.delegate.JavaDelegate接口重写execute()方法Component(llmReviewDelegate) public class LLMReviewDelegate implements JavaDelegate { private final RestTemplate restTemplate; public LLMReviewDelegate(RestTemplate restTemplate) { this.restTemplate restTemplate; } Override public void execute(DelegateExecution execution) { String applicantName (String) execution.getVariable(applicantName); BigDecimal amount (BigDecimal) execution.getVariable(amount); MapString, Object payload new HashMap(); payload.put(prompt, buildPrompt(applicantName, amount)); payload.put(temperature, 0.1); payload.put(response_format, json); HttpHeaders headers new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); HttpEntityMapString, Object request new HttpEntity(payload, headers); LLMResponse resp restTemplate.postForObject( http://ai-service/v1/chat, request, LLMResponse.class); execution.setVariable(decision, resp.getDecision()); execution.setVariable(riskLevel, resp.getRiskLevel()); execution.setVariable(llmSummary, resp.getSummary()); } private String buildPrompt(String name, BigDecimal amount) { return 你是贷款审核助手。用户姓名 name 申请金额 amount 。请判断是否通过只输出 JSON格式为 {\decision\:\APPROVE\|\REJECT\|\MANUAL\,\riskLevel\:\HIGH\|\MEDIUM\|\LOW\,\summary\:\理由\}; } }这里几个理解重点第一execution.setVariable()写入的变量会存到 Flowable 的变量表别的节点比如排他网关和人工任务都可以直接读取。变量虽然是存在ACT_RU_VARIABLE表里但你不必关心存储细节只需要知道流程实例级别的变量是跨节点共享的。第二RestTemplate建议在配置类里设置合理的超时时间。LLM 响应普遍比普通 HTTP 接口慢连接超时 3 秒、读超时 30 到 90 秒都是正常的。如果完全不加超时一旦模型服务端挂起你的流程实例就会被卡住。第三不要试图在 Delegate 里对自然语言返回做复杂正则解析。如果大模型服务端不支持response_formatjson那就在 Delegate 里先拿到原始字符串再用 Jackson 解析一次。解析失败时把decision显式设成MANUAL保证兜底路由能触发。3.4 超时、重试与幂等设计模型接口不是数据库事务它慢、可能抖动、可能收费。所以接入的时候一定不要把 LLM 调用当成普通 IO 来看必须设计超时、重试、幂等三层防线。超时已经在 RestTemplate 配好了。重试这块我推荐做“有限重试”而不是无限重试。可以用 Spring RetryRetryable(value {ResourceAccessException.class, TimeoutException.class}, maxAttempts 2, backoff Backoff(delay 1000)) public LLMResponse callLlm(String prompt) { return restTemplate.postForObject(http://ai-service/v1/chat, buildRequest(prompt), LLMResponse.class); }最多重试 1 次间隔 1 秒。不要三次四次地试大模型接口如果真的挂了你试十次也救不回来还会拖垮 Flowable 的工作线程。重试时的幂等很重要。有些模型服务是按 token 计费、带触发日志的。如果你重试时用同一个请求可能造成重复计费或重复写入业务侧。一个简单做法请求体里带上requestId用execution.getProcessInstanceId()生成payload.put(request_id, execution.getProcessInstanceId()); modelService为nullAI 服务端可以基于request_id做去重这样流程同一实例重试多少次都不会产生多条消费记录。这是我在做项目时加的一个约定强烈建议你提早在接口设计阶段定下来。3.5 用异步 ServiceTask 避免流程线程卡死上面的方案是同步调用如果模型服务响应时间需要 30 秒那你发起流程的那个 REST 请求线程就会挂起 30 秒。在管理后台这类场景还能忍但如果并发高一点你的应用线程池就很容易被打满。Flowable 原生支持异步 Service Task配置非常简单serviceTask idllmReview name大模型初筛 flowable:delegateExpression${llmReviewDelegate} flowable:asynctrue /加了flowable:asynctrue之后流程实例不会在 Service Task 这里同步执行 Delegate而是会往 Flowable 的 Job 表里插入一个异步 job由 JobExecutor 的线程池去执行。这样发起流程的 HTTP 请求很快就返回了。但这里有个新坑一旦异步你再也拿不到“这一次调用失败”的即时异常了。异常会记录在 job 的 exception stacktrace 里JobExecutor 默认会依据配置的重试次数反复跑。所以异步模式必须配套一个 Job 异常监听器或者人工定时去查ACT_RU_JOB表否则流程可能就在后台默默重试前台用户以为流程正常走到了人工节点其实是卡住了。这个经验我在后面第 5 节再展开。4. 进阶玩法多实例、监听器与外部任务模式4.1 多实例批量调用 LLM适合简历筛选和批量打标如果列表里 100 个候选人需要每一个都给模型打分这时候就可以把 Service Task 配成多实例。BPMN 多实例的概念很像编程里的 for/foreach核心是集合和元素变量。场景流程变量candidateNames是一个ListString我们让模型对每个名字尝试打分serviceTask idllmScoreTask name候选人模型评分 flowable:delegateExpression${llmScoreDelegate} multiInstanceLoopCharacteristics isSequentialtrue flowable:collection${candidateNames} flowable:elementVariablecandidateName / /serviceTask对应 Delegate 里直接读candidateNameComponent(llmScoreDelegate) public class LLMScoreDelegate implements JavaDelegate { Override public void execute(DelegateExecution execution) { String candidateName (String) execution.getVariable(candidateName); Integer score callLlmScore(candidateName); execution.setVariable(score_ candidateName, score); } }isSequentialtrue是串行执行100 个候选人就是模型调用 100 次耗时会很长。如果你的模型服务支持高并发也可以配成isSequentialfalseFlowable 会并发执行所有实例。但并发模式下写变量要小心因为多个实例同时setVariable会互相覆盖。稳妥做法是把每个实例的结果先放在一个局部变量里最后用多实例的loopCounter汇总或者直接用Map收集结果再一次性写入。4.2 监听器旁路记录日志和指标不要放重逻辑在 Service Task 前后我们可以挂监听器做模型调用的日志、耗时、异常监控。XML 写法serviceTask idllmReview flowable:delegateExpression${llmReviewDelegate} extensionElements flowable:executionListener eventstart delegateExpression${llmCallStartListener} / flowable:executionListener eventend delegateExpression${llmCallEndListener} / /extensionElements /serviceTask监听器的执行时机是节点执行前触发start执行完或抛出异常后触发end。在start监听器里记开始时间在end监听器里记结束时间和结果可以很轻松统计出模型的平均耗时、失败率。但我建议监听器里不要放复杂的业务逻辑。见过一些同事把 LLM 调用直接写在监听器里监听器能做这件事不代表该这么做。流程引擎对监听器的期望是“轻量、快速、可靠”你把它变成重计算点一旦异常连流程本身的状态都不可信了。而且很多监听器配置是全局生效的排查问题时你会面临“到底哪个监听器改了变量”这种灵魂拷问。4.3 外部任务模式把 LLM 调用抽离成独立 Worker如果你的团队里模型侧是 Python 服务或者 LLM 调用量特别大希望这部分计算不要占用 Flowable 引擎所在 JVM 的任何资源那就用外部任务模式。BPMN 建模时不再用delegateExpression而是serviceTask idllmReview name大模型初筛 flowable:typeexternal flowable:topicllmReviewTopic /外部任务的理解方式引擎把任务挂到一张“外部任务表”里标记主题llmReviewTopic谁订阅了谁处理。独立的 Worker 程序通过 Flowable REST API 去fetchAndLock领取任务调用大模型处理完后把结果complete回引擎流程继续往后续节点走。Worker 侧伪代码ExternalTaskClient client ExternalTaskClient.create() .baseUrl(http://localhost:8080/flowable-rest) .asyncResponseTimeout(10000) .build(); client.subscribe(llmReviewTopic) .handler((externalTask, externalTaskService) - { MapString, Object vars externalTask.getVariables(); String prompt vars.get(prompt).toString(); String result callLlm(prompt); MapString, Object completeVars new HashMap(); completeVars.put(decision, parseDecision(result)); completeVars.put(llmSummary, result); externalTaskService.complete(externalTask, completeVars); }) .open();外部任务优势很明显引擎只负责派发不关心模型怎么实现Worker 可以单独扩缩容内存、CPU 压力不会压到流程应用上。缺点是需要额外部署 Worker并且要维护一个订阅任务列表链路变长。如果团队不大我建议先别引入这套等 JavaDelegate 模式跑稳了再演进。5. 常见问题与排查技巧实录5.1 流程变量序列化失败变量里塞了不该塞的对象遇到过最多的报错是FlowableException: couldnt serialize variable或者variable type not supported。原因基本一致在 Delegate 里直接把RestTemplate、UserDetails、MultipartFile甚至某个Runnable对象setVariable塞进流程。Flowable 要把变量持久化到数据库不是所有 Java 对象都能序列化。解决办法有三个方向最小变量原则只把模型返回结果、必要业务标识写进变量。比如decision、riskLevel、summary。复杂对象转 JSON把对象序列化成字符串再存。绝对不要存大文本模型返回一万字说明也尽量截断或压缩。排查时可以用historyService.createHistoricVariableInstanceQuery().processInstanceId(pid).list()把所有变量打出来看谁的类型是反序列化不出来的。5.2 模型输出不稳定网关条件一个都不匹配这是接入 LLM 后最经典的翻车现场。模型没有按约定输出APPROVE/MANUAL而是输出一句话“根据材料我认为可以放款风险较低”。然后排他网关的decision APPROVE判定为 false又因为 default 兜底所有请求都会转人工。用户体验就是“模型节点形同虚设”。解决路径调用时尽量打开大模型服务的 JSON 输出模式不同服务商叫法不一样但基本都是response_format或json_mode。Delegate 里拿到原始文本后先做一次 JSON 解析不要直接信任 HTTP 层封装。解析失败时用一层简单的“关键词正则”提取兜底。比如判断是否包含decision:APPROVE或者提取挂着:APPROVE。再不行输出MANUAL转人工。我的经验是LLM 输出的兜底策略必须和流程路由设计成同一条链路上的环节。你可以在 Delegate 里写一个parseDecision(rawText)方法先尝试结构化解析再尝试正则最后返回默认值这样即使模型偶尔说胡话流程也不会中断。5.3 异步模式流程卡住Job 表里积压一堆重试如果你按 3.5 节加了flowable:asynctrue之后发现流程卡在 LLM 节点不动大概率是 Job 执行失败了。因为异步任务执行失败后Flowable 的 JobExecutor 会按retries配置反复重试但你没有地方看到异常信息只能查表。排查步骤查FLW_RU_JOB老版本叫ACT_RU_JOB表看RETRIES_是不是小于初始值。看EXCEPTION_MSG_和EXCEPTION_STACKTRACE_字段里面有异常堆栈。如果已经重试很多次不要再继续挂着了把 job 删除或手动执行jobService.setJobRetries(jobId, 0)让它稳定失败然后去查代码问题。另外异步 job 执行是有锁定的如果应用停了job 会处于等待状态不会自动消失。这不是 bug是设计。5.4 多实例 collection 为空节点直接跳过flowable:collection${candidateNames}如果取到的值是nullFlowable 默认会直接跳过多实例节点不会报错更不会执行 Delegate。很多新手以为“集合为空应该走进异常分支”其实它不是。如果你希望集合为空时走到人工兜底需要自己在 Delegate 之前的网关或前序节点里做判断比如conditionExpression xsi:typetFormalExpression ![CDATA[${candidateNames null || candidateNames.isEmpty()}]] /conditionExpression还有一个容易踩的坑是变量名大小写。Flowable 变量名区分大小写你在流程启动时runtimeService.startProcessInstanceByKey(..., Map.of(candidateNames, list))写的是candidateNamesXML 里写成了candidateName单复数对不上节点就会静默跳过。排查这种问题最直接的做法是在流程启动后马上查一次当前流程变量的key列表。5.5 版本和依赖坑Flowable 6.x 的包名是org.flowable到 7.x 这个没变但不少模块结构发生了变化而且flowable-spring-boot-starter版本最好和 Spring Boot 大版本匹配。我见过很多二开项目比如某些低代码平台内置的 Flowable 是 6.4.1你把版本强行升级到 6.8.0结果它内部很多自定义监听器和引擎扩展点全部编译不过。建议二开项目优先尊重内置版本。自己的独立项目用flowable-spring-boot-starter时先跑通最小示例再加业务。LLM 节点相关代码尽量做成一个独立模块跟流程引擎版本解耦。用的依赖越少版本冲突面越小。6. 两条实战建议6.1 先规则后模型不要把整个流程都交给大模型接入 LLM 节点时我倾向于保留排他网关里原有的硬规则。比如金额大于 100 万必须走人工这个用网关表达式判断稳定可靠没必要让模型去“理解”。只有那些硬规则覆盖不到的自由文本、图像、非结构化信息才交给模型。这样做的好处是系统行为可预期审计的时候也能说清楚“哪些是规则判的哪些是模型判的”。做过一段时间后你会慢慢找到平衡规则处理 80% 的确定性事件模型处理剩余 20% 的模糊事件人工只处理模型不敢拍板的极少数 case这是运营成本最低的组合。6.2 日志和指标必须保留token 成本同样是成本最后再提醒一个不算技术的点。模型调用每一次都有 token 消耗这是硬成本。我建议在 Delegate 里把 prompt 的长度、completion token 数量、响应耗时、决策结果都记录到日志或者 Metrics 里。你可以用 Micrometer 挂一个llm_call_total计数器按decision打 tag。这样一个月下来你能清楚知道流程里哪些环节天天在调模型、花了多少钱、失败率多少后续优化路由和缓存策略才有依据。这个小习惯比任何华丽的架构设计都实用。真正做了生产接入的人早晚会回来补这一课不如从一开始就做好。