1. 为什么要在 Flowable 里塞进一个大模型节点
先说结论:把 LLM 当成一个普通的 BPMN 服务任务来用,是最省事也最稳的做法,但真正落地时你会发现坑不在"调用模型"这一步,而在"怎么让流程引擎和模型服务解耦、怎么保证超时可控、怎么让业务人员看得懂流程图"。
Flowable 是一套成熟的 BPMN 流程引擎,核心能力是把业务流转、人工审批、条件网关、定时任务这些东西编排起来。而 LLM 擅长的是非结构化理解、文本生成、意图识别、信息抽取。这两者结合的场景其实非常自然:合同审批流程里自动抽取关键条款、工单系统里自动分类并生成回复草稿、报销流程里自动校验发票描述是否合规、招聘流程里自动筛选简历摘要。这些环节过去要么靠人工,要么靠一堆正则和规则引擎硬扛,现在可以交给模型。
但问题来了。Flowable 的流程定义是静态的 XML(BPMN 2.0),而模型调用是动态的、有网络延迟的、可能失败的、还涉及密钥管理。你不可能把 API Key 写进 BPMN 文件里,也不可能让流程引擎线程傻等模型返回三十秒。所以真正要解决的是集成架构问题,而不是"怎么发一个 HTTP 请求"。
这篇文章面向的是已经用过 Flowable、想接入大模型能力的后端开发,也适合正在做 AI 工作流选型的技术负责人。我会从整体设计讲到具体实现,包括 External Worker 模式、Spring AI 集成、参数传递、异常兜底、密钥安全,以及我自己踩过的几个坑。看完你应该能直接照着搭一套能跑的原型。
2. 整体架构设计与方案选型
2.1 三种接入姿势的取舍
把 LLM 接进 Flowable,业内常见三条路,我逐个说清楚优劣。
第一种:Service Task + Java Delegate 直接调用。在 BPMN 里写一个 serviceTask,指定 delegateExpression 指向一个 Spring Bean,这个 Bean 里直接调模型。优点是简单,流程图和代码一一对应,调试直观。缺点是流程定义和模型调用强耦合,模型换了、Prompt 改了都得重新部署流程;而且模型调用是阻塞的,会占用流程引擎的异步执行线程池。
第二种:External Worker(外部任务)模式。BPMN 里用flowable:type="external"声明一个外部任务,流程引擎只负责把任务丢进ACT_RU_EXT_TASK表,由独立的 Worker 服务去 fetchAndLock、执行、complete。这是 Flowable 官方推荐的跨系统集成方式。模型调用这种"耗时、易失败、需要独立扩缩容"的操作,天然适合放在 Worker 里。流程引擎和 AI 服务彻底解耦,Worker 可以单独部署、单独限流、单独重试。
第三种:HTTP Task 直接调模型网关。BPMN 里配一个 httpTask,直接打模型服务的 REST 接口。看着最省事,但密钥会暴露在流程定义里,超时和重试策略也不好控制,生产环境基本不建议。
我的选择是第二种为主,第一种为辅。核心的、需要独立治理的模型调用走 External Worker;一些轻量的、确定性的、和流程强绑定的调用(比如简单的文本格式化)可以用 Java Delegate。下面重点讲 External Worker 这条路。
2.2 为什么 External Worker 是正解
External Worker 的本质是一个"拉取-执行-回写"的循环。流程走到外部任务节点时会挂起,任务进入待办表。Worker 通过 REST 或 Java API 去fetchAndLock一批任务,锁定后本地执行,执行完调complete把结果变量写回流程,流程继续往下走。
这个模型解决了几个关键问题。第一是解耦:流程定义里只有一个任务主题名(topic),比如llm-classify,具体用哪个模型、什么 Prompt,全在 Worker 侧配置,改这些不用动流程。第二是弹性:Worker 可以水平扩多个实例,按 topic 分流,模型调用慢就多开几个。第三是可靠性:fetchAndLock 有锁超时机制,Worker 挂了任务会自动释放被别的 Worker 捡走,天然支持故障转移。第四是超时可控:模型调用超时了,Worker 可以选择 complete 一个兜底结果,或者调handleFailure让流程走异常分支。
注意:External Worker 的锁超时(lockDuration)默认是 5 分钟,模型调用如果可能超过这个时间,要么调大 lockDuration,要么在 Worker 里做心跳续锁。我见过有人因为没续锁,任务被重复执行的案例。
2.3 整体链路长什么样
一条完整的链路是这样的:业务系统启动流程实例,流程走到 LLM 外部任务节点,引擎把任务写入待办表并带上输入变量(比如待分类的文本)。Worker 服务轮询拉取任务,组装 Prompt,通过 Spring AI 调用模型,拿到结果后做结构化解析,把结果作为流程变量 complete 回去。流程继续走网关判断,比如分类结果是"投诉"就走投诉分支,"咨询"就走咨询分支。
这里有个设计要点:输入输出变量要约定清楚。我习惯在 BPMN 里用flowable:field或者直接在启动流程时传入一个llmInput变量,Worker 读它;Worker 回写一个llmOutput变量,网关用${llmOutput.category == 'complaint'}这种表达式判断。变量名统一,流程和 Worker 才好对接。
3. 核心细节解析与实操要点
3.1 BPMN 里怎么声明一个 LLM 外部任务
先看 BPMN 片段。一个外部任务节点的核心是flowable:type="external"和topic:
<serviceTask id="llmClassifyTask" name="AI 工单分类" flowable:type="external" flowable:topic="llm-classify"> <extensionElements> <flowable:field name="promptTemplate"> <flowable:string>请对以下工单内容进行分类,只返回类别名称:${ticketContent}</flowable:string> </flowable:field> </extensionElements> </serviceTask>这里topic是 Worker 订阅的主题,flowable:field可以传一些静态配置。但注意,不要把 Prompt 模板写死在 BPMN 里,我上面只是演示。实际项目里 Prompt 应该放在 Worker 侧的配置中心或数据库,BPMN 只传业务数据。原因很简单:Prompt 迭代频率远高于流程变更频率,写死在流程里每次改都要重新部署流程定义,运维成本太高。
流程变量通过flowable:field的expression或者直接在启动时传入。比如启动流程时:
Map<String, Object> variables = new HashMap<>(); variables.put("ticketContent", "用户反馈:下单后三天没发货,客服也联系不上"); variables.put("ticketId", "T20240115001"); runtimeService.startProcessInstanceByKey("ticketProcess", variables);3.2 Worker 侧的任务拉取与锁定
Worker 用 Flowable 的 External Worker Client 来拉任务。依赖是flowable-external-worker-spring-boot-starter,配置好引擎地址和认证信息后,写一个订阅者:
@Component public class LlmClassifyWorker { @EventListener public void subscribe(FlowableExternalWorkerClientReadyEvent event) { ExternalWorkerClient client = event.getClient(); client.subscribe("llm-classify") .lockDuration(Duration.ofMinutes(10)) .handler(this::handleTask) .open(); } private void handleTask(ExternalWorkerTask task, ExternalWorkerClient client) { String content = (String) task.getVariable("ticketContent"); try { String category = llmService.classify(content); Map<String, Object> result = Map.of("llmOutput", Map.of("category", category)); client.complete(task, result); } catch (Exception e) { client.handleFailure(task, "LLM_CALL_FAILED", e.getMessage(), 3, Duration.ofSeconds(30)); } } }几个关键点。lockDuration我设成 10 分钟,因为模型调用可能慢,默认 5 分钟不够。handleFailure的第三个参数是重试次数,第四个是重试间隔,模型调用失败时让它自动重试 3 次,间隔 30 秒,避免瞬时抖动导致流程卡死。complete时传的变量会合并进流程变量,网关就能用了。
实操心得:
handleFailure的重试次数别设太大。模型服务如果整体挂了,重试 3 次还是失败,应该让流程走异常分支或者转人工,而不是无限重试把 Worker 线程占满。
3.3 用 Spring AI 封装模型调用
模型调用这层我用 Spring AI,因为它把不同厂商的模型抽象成了统一的ChatClient接口,换模型基本只改配置。依赖:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> </dependency>配置里放模型参数,密钥走环境变量或配置中心,绝不进代码仓库:
spring: ai: openai: api-key: ${LLM_API_KEY} base-url: ${LLM_BASE_URL} chat: options: model: ${LLM_MODEL} temperature: 0.1temperature设 0.1 是因为分类、抽取这类任务要的是稳定输出,不是创意。温度越高输出越发散,分类结果就越不可控。
封装一个分类服务:
@Service public class LlmService { private final ChatClient chatClient; public LlmService(ChatClient.Builder builder) { this.chatClient = builder .defaultSystem("你是一个工单分类助手,只输出类别名称,不要任何解释。") .build(); } public String classify(String content) { String result = chatClient.prompt() .user(u -> u.text("工单内容:{content}\n可选类别:投诉、咨询、建议、其他") .param("content", content)) .call() .content(); return normalize(result); } private String normalize(String raw) { if (raw == null) return "其他"; String trimmed = raw.trim(); for (String c : new String[]{"投诉", "咨询", "建议", "其他"}) { if (trimmed.contains(c)) return c; } return "其他"; } }normalize这步很关键。模型输出经常带标点、带解释、带换行,直接拿去网关判断会翻车。我习惯做一层白名单匹配,匹配不上就落到兜底类别,保证流程永远有确定的分支可走。
3.4 结构化输出的处理
如果模型返回的是 JSON,比如抽取合同条款,那就要做 JSON 解析和校验。Spring AI 支持entity()直接映射成对象:
public record ContractInfo(String party, String amount, String deadline) {} public ContractInfo extract(String contractText) { return chatClient.prompt() .user(u -> u.text("从合同文本中抽取甲方、金额、截止日期:{text}") .param("text", contractText)) .call() .entity(ContractInfo.class); }但别太信任模型的 JSON。实测下来,模型偶尔会返回带 markdown 代码块包裹的 JSON,或者字段名大小写不一致。稳妥做法是加一层容错:先尝试直接解析,失败就剥离代码块标记再解析,再失败就走兜底。这块的坑我在第 5 节细说。
4. 实操过程与核心环节实现
4.1 环境准备与依赖清单
先把环境列清楚,避免版本对不上。我用的是 Flowable 7.x + Spring Boot 3.x + Spring AI 1.0.x。核心依赖:
| 依赖 | 作用 | 备注 |
|---|---|---|
| flowable-spring-boot-starter | 流程引擎核心 | 含 REST 和自动配置 |
| flowable-external-worker-spring-boot-starter | 外部任务客户端 | Worker 侧必需 |
| spring-ai-openai-spring-boot-starter | 模型调用抽象 | 换厂商换 starter |
| spring-boot-starter-web | Web 容器 | Worker 需要 |
数据库用 MySQL 8,Flowable 会自动建表。生产环境记得把flowable.database-schema-update设成false,用脚本手动管理表结构,别让引擎自动改生产库。
4.2 完整流程定义示例
一个能跑的工单处理流程,包含启动、AI 分类、网关分支、人工处理:
<process id="ticketProcess" name="工单处理流程"> <startEvent id="start"/> <sequenceFlow sourceRef="start" targetRef="llmClassifyTask"/> <serviceTask id="llmClassifyTask" name="AI 分类" flowable:type="external" flowable:topic="llm-classify"/> <sequenceFlow sourceRef="llmClassifyTask" targetRef="categoryGateway"/> <exclusiveGateway id="categoryGateway"/> <sequenceFlow sourceRef="categoryGateway" targetRef="complaintTask"> <conditionExpression xsi:type="tFormalExpression"> ${llmOutput.category == '投诉'} </conditionExpression> </sequenceFlow> <sequenceFlow sourceRef="categoryGateway" targetRef="normalTask"> <conditionExpression xsi:type="tFormalExpression"> ${llmOutput.category != '投诉'} </conditionExpression> </sequenceFlow> <userTask id="complaintTask" name="投诉专员处理"/> <userTask id="normalTask" name="普通客服处理"/> </process>网关表达式里访问的是llmOutput.category,这要求 Worker complete 时传的变量结构是Map.of("llmOutput", Map.of("category", ...))。变量结构一定要和表达式对齐,否则流程会抛PropertyNotFoundException。
4.3 参数传递与变量作用域
Flowable 的变量有作用域概念。流程实例级变量全局可见,任务级变量只在任务内可见。LLM 任务的输入输出我建议都用流程实例级,因为网关和后续节点都要读。
传参时注意类型。Flowable 会把变量序列化存库,复杂对象要可序列化。我习惯只传基本类型和 Map/List,避免传自定义 POJO 导致反序列化问题。如果非要传对象,确保类实现了Serializable且版本一致。
还有一个细节:大文本别直接塞流程变量。模型输入可能是几千字的合同,存进ACT_RU_VARIABLE表会撑大数据库。我的做法是把大文本存到业务表或对象存储,流程变量里只存一个引用 ID,Worker 拿着 ID 去取原文。
4.4 超时、重试与兜底策略
模型调用必须假设它会失败。我的策略分三层。
第一层,Worker 内部超时。Spring AI 的调用加超时配置,比如 30 秒。超时就抛异常,进 handleFailure。
第二层,handleFailure 重试。设 3 次重试,间隔递增。瞬时网络抖动基本能扛过去。
第三层,流程级兜底。重试耗尽后,任务会变成 dead letter,或者我干脆在 Worker 里 catch 住所有异常,complete 一个兜底结果:
try { String category = llmService.classify(content); client.complete(task, Map.of("llmOutput", Map.of("category", category))); } catch (Exception e) { log.error("LLM 分类失败,走兜底", e); client.complete(task, Map.of("llmOutput", Map.of("category", "其他", "fallback", true))); }兜底成"其他"类别,流程继续走人工分支,不会卡死。这比让流程挂起等运维介入要友好得多。是否兜底取决于业务容忍度,涉及资金、合规的场景可能宁可挂起也不能猜。
4.5 密钥与鉴权信息的安全处理
这是热词里反复出现的关注点,我单独强调。API Key 绝对不能出现在 BPMN 文件、代码仓库、日志里。具体做法:
- 密钥放环境变量或配置中心(如 Nacos、Apollo),代码里只引用占位符。
- 日志脱敏,Spring AI 的请求日志默认可能打印 header,要配置过滤。
- Worker 和模型服务之间走内网或专线,减少暴露面。
- 定期轮换密钥,别一个 Key 用到天荒地老。
- 如果多租户,按租户隔离密钥,别共用。
注意:我见过有人在 BPMN 的
flowable:field里直接写 API Key,然后流程定义 XML 被导出、被分享、进了 Git 历史。这种泄露是灾难性的,一定要在代码评审阶段拦住。
5. 常见问题与排查技巧实录
5.1 任务被重复执行
现象:同一个外部任务被多个 Worker 执行,业务逻辑跑了两遍。
原因通常是锁超时。Worker 拉取任务后,如果执行时间超过lockDuration,锁会自动释放,别的 Worker 就能捡走。模型调用慢的时候特别容易触发。
解决:调大lockDuration,或者在长任务里做心跳续锁。Flowable 的 External Worker Client 支持extendLock,可以在处理过程中定期续锁。另外,业务逻辑本身要做幂等,用任务 ID 做去重键,这是最后一道防线。
5.2 模型返回格式不稳定
现象:分类任务偶尔返回"这个工单属于投诉类别"而不是"投诉",网关判断失败。
原因:模型是概率系统,即使 temperature 很低也不能保证 100% 格式一致。
解决:三层防护。Prompt 里明确要求"只输出类别名称";代码里做白名单匹配和归一化;网关表达式用容错写法,比如先判断是否包含关键词。别指望 Prompt 能解决所有问题,代码兜底才是可靠的。
5.3 流程变量反序列化失败
现象:流程走到网关时报Cannot deserialize或ClassNotFoundException。
原因:Worker 和流程引擎用的类版本不一致,或者传了不可序列化的对象。
解决:流程变量只用基本类型和标准集合。如果必须传对象,把类抽到公共模块,两边依赖同一个版本。升级时注意兼容性。
5.4 常见问题速查表
| 问题 | 可能原因 | 排查方向 |
|---|---|---|
| 任务一直挂起不执行 | Worker 没订阅对应 topic | 检查 topic 名拼写、Worker 是否启动 |
| 任务重复执行 | 锁超时 | 调大 lockDuration、加幂等 |
| 网关判断失败 | 变量结构或类型不对 | 打印流程变量、核对表达式 |
| 模型调用超时 | 网络或模型服务慢 | 加超时、重试、兜底 |
| 密钥泄露风险 | 硬编码 | 审计代码和 BPMN、走配置中心 |
| 流程变量表膨胀 | 存了大文本 | 大文本外置,变量只存引用 |
5.5 几个我踩过的坑
坑一:Prompt 写死在 BPMN。上线后业务要改 Prompt,结果发现要重新部署流程定义,还得处理运行中实例的兼容问题。后来全部挪到配置中心,流程只传数据。
坑二:没做输出归一化。模型返回带标点、带解释,网关判断全挂。加了归一化层之后稳定多了。
坑三:Worker 单实例。模型调用慢,单 Worker 吞吐上不去,任务堆积。后来按 topic 拆成多个 Worker 实例,水平扩展。
坑四:日志打印了完整请求。排查问题时把带密钥的请求头打进了日志,差点出事。后来统一加了日志脱敏过滤器。
坑五:忽略模型成本。每个工单都调一次模型,量大之后成本飙升。后来加了缓存和规则前置,简单工单用规则分类,复杂工单才走模型。
6. 性能优化与成本控制
6.1 批处理与并发控制
Worker 拉任务时可以一次拉一批,fetchAndLock支持maxTasks参数。但模型调用是 IO 密集型,并发太高会打爆模型服务的限流。我的做法是 Worker 内部用有界线程池,并发数按模型服务的 QPS 配额来定,比如配额是 10 QPS,线程池就设 10 左右,配合信号量限流。
6.2 缓存与规则前置
不是所有请求都值得调模型。我习惯在 Worker 里加一层规则前置:能靠关键词、正则、历史记录判断的,直接出结果,不走模型。只有规则覆盖不了的才调模型。这一层能砍掉相当一部分调用量,成本和延迟都降下来。
对于重复度高的输入,可以加结果缓存,key 是输入文本的哈希,value 是模型输出。工单分类这种场景,很多工单内容高度相似,缓存命中率不低。
6.3 模型选型的分层
不是所有任务都需要最强的模型。分类、抽取这类任务,小模型往往够用,成本低、延迟低。只有复杂的推理、生成任务才上大模型。我一般做分层:简单任务走小模型,复杂任务走大模型,按任务类型路由。Spring AI 的多模型配置支持这种路由。
7. 一些延伸思考
Flowable 接 LLM 只是起点。往深了做,可以引入 Agent 模式,让模型自己决定调用哪些工具、走哪些分支,这时候流程引擎的角色会从"编排者"变成"执行容器"。也可以把 RAG 接进来,让模型基于企业知识库回答,这在客服、售后场景特别有用。
但我的建议是别一上来就搞复杂。先把 External Worker 这条链路跑通,把超时、重试、兜底、密钥安全这些基础做扎实,再考虑 Agent 和 RAG。很多项目失败不是因为模型不够强,而是因为工程细节没做好,流程一卡就没人敢用了。
最后分享一个小技巧:在 Worker 里记录每次模型调用的耗时、token 数、成功失败,打到监控里。这些数据能帮你判断该不该换模型、该不该加缓存、瓶颈在哪。没有度量就没有优化,这话在 AI 工作流里同样成立。