1. 为什么要在 Flowable 里塞进一个大模型节点先说结论把 LLM 当成一个普通的 BPMN 服务任务来用是最省事也最稳的做法但真正落地时你会发现坑不在调用模型这一步而在怎么让流程引擎和模型服务解耦、怎么保证超时可控、怎么让业务人员看得懂流程图。Flowable 是一套成熟的 BPMN 流程引擎核心能力是把业务流转、人工审批、条件网关、定时任务这些东西编排起来。而 LLM 擅长的是非结构化理解、文本生成、意图识别、信息抽取。这两者结合的场景其实非常自然合同审批流程里自动抽取关键条款、工单系统里自动分类并生成回复草稿、报销流程里自动校验发票描述是否合规、招聘流程里自动筛选简历摘要。这些环节过去要么靠人工要么靠一堆正则和规则引擎硬扛现在可以交给模型。但问题来了。Flowable 的流程定义是静态的 XMLBPMN 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:typeexternal声明一个外部任务流程引擎只负责把任务丢进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:typeexternal和topicserviceTask idllmClassifyTask nameAI 工单分类 flowable:typeexternal flowable:topicllm-classify extensionElements flowable:field namepromptTemplate flowable:string请对以下工单内容进行分类只返回类别名称${ticketContent}/flowable:string /flowable:field /extensionElements /serviceTask这里topic是 Worker 订阅的主题flowable:field可以传一些静态配置。但注意不要把 Prompt 模板写死在 BPMN 里我上面只是演示。实际项目里 Prompt 应该放在 Worker 侧的配置中心或数据库BPMN 只传业务数据。原因很简单Prompt 迭代频率远高于流程变更频率写死在流程里每次改都要重新部署流程定义运维成本太高。流程变量通过flowable:field的expression或者直接在启动时传入。比如启动流程时MapString, 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); MapString, 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 groupIdorg.springframework.ai/groupId artifactIdspring-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模型调用抽象换厂商换 starterspring-boot-starter-webWeb 容器Worker 需要数据库用 MySQL 8Flowable 会自动建表。生产环境记得把flowable.database-schema-update设成false用脚本手动管理表结构别让引擎自动改生产库。4.2 完整流程定义示例一个能跑的工单处理流程包含启动、AI 分类、网关分支、人工处理process idticketProcess name工单处理流程 startEvent idstart/ sequenceFlow sourceRefstart targetRefllmClassifyTask/ serviceTask idllmClassifyTask nameAI 分类 flowable:typeexternal flowable:topicllm-classify/ sequenceFlow sourceRefllmClassifyTask targetRefcategoryGateway/ exclusiveGateway idcategoryGateway/ sequenceFlow sourceRefcategoryGateway targetRefcomplaintTask conditionExpression xsi:typetFormalExpression ${llmOutput.category 投诉} /conditionExpression /sequenceFlow sequenceFlow sourceRefcategoryGateway targetRefnormalTask conditionExpression xsi:typetFormalExpression ${llmOutput.category ! 投诉} /conditionExpression /sequenceFlow userTask idcomplaintTask name投诉专员处理/ userTask idnormalTask 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表会撑大数据库。我的做法是把大文本存到业务表或对象存储流程变量里只存一个引用 IDWorker 拿着 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 工作流里同样成立。
