Flowable 的老项目,多数是在跑审批、跑单据流转、跑订单处理,只要不涉及“阅读理解”类的工作,它都挺省心。可一旦需求变成“合同进来以后先自动分析一下哪些条款有风险”“工单描述特别长的先自动分个类”“客户情绪差的先转人工”,靠传统规则就非常痛苦。于是所有人不约而同想到了同一件事:把大模型(LLM)作为工作流里的一个特殊节点接进去。
这个想法听起来像画了个新功能,但真正落地时,争论最多的往往不是模型选哪个,而是“LLM 节点到底算什么东西”。它既不是人工任务,也不是数据库操作,本质上就是一个外部服务调用。而 Flowable 的 BPMN 原生就支持 Service Task,所以最稳的姿势很简单:把 LLM 调用封装成一个 Service Task,和流程变量、网关、人工审批配合起来。这篇我就直接从实操讲:怎么把 LLM 包装成可复用的 Flowable 服务节点,怎么处理失败和重试,怎么让它的输出驱动流程分支,以及那些 demo 里永远见不到的生产环境坑。
1. 为什么是 Service Task:LLM 在 BPMN 里的正确身份
1.1 传统流程引擎处理“非结构化信息”时的无力感
客户合同、工单、简历这些文本进系统时,以前能做的自动处理非常有限。最简单的做法是用关键词匹配,或者写一堆 if-else 规则。中级的可以训分类模型,但要样本、要持续迭代。一旦判断维度变多,规则就会变成一团乱麻。
举个例子,工单分类“设备故障 / 网络问题 / 账户问题 / 其他”。关键词规则很容易误判,“我的网很慢但是能开网页”会被分到“设备故障”而不是“网络问题”。规则写得再细,也防不住用户换着花样表达。这种场景本质上是非结构化文本的语义理解问题,传统工作流引擎根本不擅长。
1.2 接入 LLM 后的第一原则:BPMN 语义不变
很多人一开始把 LLM 想象成一种全新的节点类型,觉得要像“人工任务”那样给引擎加一个“AI 任务”。其实完全不用。LLM 调用在 BPMN 里就是一个 Service Task,跟调用财务系统的接口没有本质区别:输入流程变量,执行外部计算或模型调用,把结果写回流程变量。
一旦认清这一点,后面的设计就顺畅了。Flowable 的持久化、审计、超时控制、重试机制这些能力全部保留,LLM 只是让服务节点拥有了“语义理解”能力。这也是工作流接入大模型最稳定的姿势:引擎不改造,逻辑不侵入,收益却很大。
1.3 什么样的业务才值得接
接之前先判断业务是否真的需要。适合 LLM 节点的场景一般有三个特征:
- 输入是非结构化文本,靠规则做不稳,靠人做太贵。
- 判断结果要流向后续流程节点,而不是一次性问答。
- 判断出错可以接受,但必须有人工兜底或复核环节。
典型的比如合同风险提示、客户投诉分类、简历初筛摘要、工单自动分派、审批意见生成。如果只是“聊天机器人”式的问答,那没必要硬塞进 Flowable,直接挂在门户或客服系统里更合适。
2. 接入路线对比:内置 HTTP 任务与自定义 Service Task 的取舍
2.1 HTTP 任务:零 Java 代码的捷径,但只适合简单调用
Flowable 支持在 Service Task 里直接发起 HTTP 调用,配置 URL、请求方法、请求头、请求体,再把响应解析出来存成流程变量。初看对 LLM 这种 REST API 很契合,也确实是一条能跑通的捷径。
适合用 HTTP 任务的情况是:模型 endpoint 固定,提示词基本写死,返回只要从 JSON 里取一个字段。比如“把用户输入翻译成英文”这种场景,在流程模型里埋几个配置项就够。
但实际用起来会遇到三道坎:
- 提示词如果依赖上一步流程变量,HTTP 任务的请求体就要在 XML 里拼出一大段表达式,维护难度直接拉满。
- 响应解析如果只取一层 JSON 字段还算简单,一旦要处理错误格式、超时、多候选结果、上下文追加,没有任何编程逻辑可以承接。
- 统一日志、埋点、重试策略都做不了,每个流程都要单独配置,散落各处。
2.2 自定义 Service Task:把复杂度收敛在一个地方
我更推荐第二种:写一个 JavaDelegate 统一负责提示词渲染、模型调用、结果解析和失败兜底,然后通过delegateExpression挂到任意流程节点上。
这么做优势很明显:
- 逻辑可以单元测试,不像 XML 配置一多就难以验证。
- 多个流程复同一套调用逻辑,换模型不用改流程模型。
- 能按流程变量切换模型、Prompt 模板,灵活性高。
- 可观测性容易做,日志和链路追踪集中在唯一入口。
有一个要避开的反面模式:每个流程单独建一个 delegate bean。那样不出三个月就会出现几十个 delegate,每个只是改了 prompt,代码重复严重。正确做法是做一个通用 LLM 节点,把“Prompt 模板”和“输入数据”作为流程变量交给它,节点本身保持可复用。
2.3 怎么选:先跑通还是做平台
我现在的判断标准很简单:如果只是某个项目里一次性的简单调用,用 HTTP 任务,快;如果公司要把 AI 能力变成流程平台的一部分,未来多个流程都会用,直接上自定义 Service Task。绝大多数业务走着走着都会落到第二种方案,因为 LLM 调用根本不是“一次请求”的事,它涉及提示词管理、结果校验、成本统计和风险兜底。
3. 核心实现:把 LLM 包装成可复用的流程节点
3.1 先用变量协议把接口定清楚
任何涉及多节点协作的功能,第一步就是规定变量协议。我习惯定义下面这套变量:
| 变量 | 方向 | 说明 |
|---|---|---|
llmPromptTemplate | 入 | 提示词模板,可以用{var}占位 |
llmInput | 入 | 要分析的业务文本或 JSON 对象 |
llmModel | 入 | 模型名,如gpt-4o、qwen-plus,空则走默认 |
llmOutput | 出 | LLM 返回的文本或 JSON 字符串 |
llmError | 出 | 失败原因,为空表示成功 |
llmStatus | 出 | success或failed |
这里的设计思路借鉴了函数接口:调用方约定好输入,节点返回状态和结果。后续流程不关心具体调的是哪家模型,只看llmOutput和llmStatus。这种解耦在将来切换模型时尤其重要。
3.2 JavaDelegate 骨架和 XML 绑定
Java 端核心代码非常简单,重点是 renderPrompt 和结果写回的约定:
@Component("llmProcessor") public class LlmProcessorDelegate implements JavaDelegate { private final LlmGateway llmGateway; public LlmProcessorDelegate(LlmGateway llmGateway) { this.llmGateway = llmGateway; } @Override public void execute(DelegateExecution execution) { String prompt = renderPrompt(execution); LlmConfig config = resolveConfig(execution); try { LlmResult result = llmGateway.complete(prompt, config); execution.setVariable("llmStatus", "success"); execution.setVariable("llmOutput", result.getContent()); execution.setVariable("llmError", null); } catch (Exception e) { execution.setVariable("llmStatus", "failed"); execution.setVariable("llmError", e.getMessage()); } } }renderPrompt内部从执行环境读llmPromptTemplate和llmInput,替换占位符后返回完整提示词。resolveConfig负责模型名、温度、超时等参数。
BPMN 侧绑定也很直接:
<serviceTask id="llmTask" name="调用LLM分析"> <extensionElements> <flowable:delegateExpression expression="${llmProcessor}"/> </extensionElements> </serviceTask>这里注意,提示词不是强行塞进 XML 里的,而是通过流程模型上游设置的变量传入。这样流程设计器里只体现“调用 LLM 分析”,具体提示词归提示词管理模块管,职责清晰。
3.3 失败策略:不要让异常直接卡死流程
工作流里最让人紧张的状态就是“挂了就挂”。LLM 服务不是 100% 可用,超时、限流、返回格式非法都常见。所以 LLM 节点的失败必须被显式设计,而不是让异常直接炸掉整个流程实例。
我一般用两层兜底:
- 第一层在 delegate 内部:捕获异常后写入
llmStatus和llmError,流程继续往下走。 - 第二层在 BPMN 模型上:用一个排他网关判断
llmStatus,成功走自动分支,失败走人工兜底分支。
如果想用 BPMN 边界错误事件也可以,但实战下来,变量加网关的方式更直观,也更容易在测试里断言。
注意:不要设置无限重试。LLM 调用失败大多不是“多试几次就能成功”的,尤其限流时无限重试只会把故障时间拉长。指数退避重试一两次是合理上限。
3.4 结果要写全局变量,别写局部变量
有一个非常常见的坑,就是图省事用setVariableLocal写结果。局部变量只存在于当前执行分支的 scope,一旦流程走到排他网关或者并行网关的另一个分支,读取时很容易拿到 null,流程就莫名其妙走到了默认分支。
我在项目里真实见过一次:并行分支里的服务任务用setVariableLocal写结果,后面排他网关读llmStatus时一直为空,明明日志里 delegate 执行成功了,流程却绕到人工兜底去了。排查了半天才发现是局部变量作用域的问题。
所以凡是需要跨越节点或网关使用的变量,统一用execution.setVariable写入。如果确实想保留局部信息,那就通过命名区分,比如aiResult_local,并确保后续真的不会跨作用域读取。
4. 引入人工复核后,“AI 自动判断”才真正可上生产
4.1 LLM 结果本身不能全对,必须有“人工兜底”
我不太建议直接把 LLM 的输出当成事实直接驱动后续业务动作。模型幻觉、格式不对、理解偏差都是概率事件,生产环境必须有一个人工复核关口。
在 Flowable 里这很自然:LLM 节点之后接一个用户任务。用户任务里展示三块信息:原始输入、LLM 的分析结果、模型给出的理由。然后让用户提交一个审批结论,比如“同意”“改为人工处理”“重新生成”。这个结论存成流程变量humanDecision,后面排他网关根据它往下走。
这套设计的好处是,AI 只负责提效,最终的业务责任还是落在人身上。这在审批类、法务类、财务类场景里尤其重要。
4.2 “重新生成”其实就是一个小循环
用户对 LLM 结果不满意时,最常见诉求是让它重新分析。实现上不复杂:排他网关里判断humanDecision == "regenerate",让流程回到之前的 LLM 节点再跑一次。
但这里有一个优化点:重跑之前,应当把用户的反馈或上次的结果拼进新的提示词里。比如“用户认为上次分类不准确,请重点检查是否有退款相关的语义”。如果只是原样重跑,大概率拿到相同的结果,既浪费 token 又让用户觉得 AI 没用。
4.3 重复执行的副作用要防
正因为有循环分支,也有引擎异步重试机制,LLM 节点很可能对同一份输入执行多次。模型本身不具备幂等性,同一个提示词在不同时间可能给出不同结果。
处理经验是:如果 LLM 节点只是做只读分析,那重复执行问题不大,最多费点 token;如果节点后面要触发外部副作用,比如发短信、创建工单、改外部系统状态,那就必须在业务参数里带上唯一请求 ID,或者在流程变量里标记“该输入已处理过”,重跑时直接复用已有结果。
5. 用 LLM 结果驱动流程分支:结构化输出与网关的组合
5.1 不要让模型自由发挥,要求结构化 JSON
很多人第一步只让 LLM “给个判断”,结果输出一大段散文。后续要驱动流程分支时,解析散文非常痛苦。正确做法是要求模型按 JSON 格式返回。
以客户投诉分类为例,提示词明确要求模型输出如下结构:
{ "category": "payment", "priority": "high", "summary": "用户无法完成支付" }模型返回后,delegate 里用 Jackson 解析,把category和priority抽出来存成独立变量:
JsonNode node = objectMapper.readTree(llmOutput); execution.setVariable("aiCategory", node.get("category").asText()); execution.setVariable("aiPriority", node.get("priority").asText());这一步很关键:在 Java 侧完成字段结构化,后续 BPMN 网关表达式直接引用普通 String 变量,简洁又可靠。
5.2 网关条件直接引用流程变量
有了aiCategory变量,排他网关的条件就非常直白:
${aiCategory == 'payment'} ${aiCategory == 'network'} ${aiCategory == 'account'}这比在网关里直接解析 JSON 字符串要可维护得多。Flowable 的 SpEL 表达式对普通字符串变量的兼容性也最好,调试时一眼就能看出问题。
5.3 多分支必须留 default 兜底
LLM 最让人头疼的就是不守规矩。你明明限制了 enum,它还是可能返回一个你从没见过的值。所以排他网关只要用了 LLM 结果驱动,就必须写 default 分支,default 落到人工处理或通用兜底节点。
千万不要觉得“只要提示词写得好,模型就一定听话”。实测下来,提示词约束能降低错误率,但永远到不了 100%。default 分支不是多余,而是保险丝。
6. 生产环境最容易翻车的四个细节,以及我的处理方式
6.1 delegate bean 是单例,保存实例字段状态会炸
LlmProcessorDelegate注册成 Spring bean 后,默认是单例,所有流程实例共享同一个对象。这意味着 delegate 里不能放“当前流程的临时状态”。
我见过有人把流程数据缓存在 delegate 的实例 Map 里,结果流程 A 和流程 B 的数据串了线,而且是偶发性的,特别难排查。正确的做法是:所有状态都通过DelegateExecution的变量读写,delegate 本身保持无状态。
6.2 调用 LLM 的线程池要认真设计
Flowable 执行服务任务时,如果节点没有配置异步执行,LLM 调用会阻塞当前请求的处理线程。并发量一上来,处理线程全被模型请求占住,其他流程实例也一起卡住。
合理的处理方式有几个方向:
- 给 LLM 服务任务设置
flowable:async="true",让模型调用放到异步 JobExecutor 里执行,避免阻塞前端流程线程。 - 异步执行时要关注 Job 的锁超时时间。LLM 调用如果超过锁时间,另一个执行器可能把任务抢走,导致重复执行。
- 给每次模型调用设置严格的超时上限,比如 30 秒或 60 秒,宁可失败走人工兜底,也不能无限等。
6.3 记录 Prompt 版本、Token 和耗时
LLM 是不可控的,无法预测,所以在生产环境里更需要可观测性。每次调用至少记录:流程实例 ID、模型名、Prompt 模板版本、输入长度、输出 Token 数、耗时、是否重试。
这些数据不只是用于排查问题,更能用来做成本核算和模型效果对比。换提示词后效果有没有变好,不能靠感觉,要看线上统计。我一般把日志直接打到结构化日志里,带上processInstanceId和llmRequestId两个贯穿字段,事后查询非常方便。
6.4 模型调用层要做适配隔离
虽然标题讲的是 Flowable,但我要多说一句:不要在主流程逻辑里直接写死某一家模型的 SDK。封装一个LlmGateway接口,内部按模型名路由到不同供应商的实现,这样做有两个实际好处:一是公司以后换模型时业务代码不用动;二是可以方便地在网关层统一加缓存、重试和限流。
如果团队里资源紧张,也可以先用 n8n、Dify 这类工具把模型调用包装成标准 API,Flowable 里只做 HTTP 调用。这种方式能快速上线,但后续要做到精细的提示词和成本管理时,还是逃不掉要维护一层自建网关。
最后分享一个小技巧
我做了几个项目后的最大体会是:LLM 节点的变量命名越固定越好。把所有模型的输入输出都统一成llmPromptTemplate、llmInput、llmOutput、llmStatus这一套“约定大于配置”,团队里任何人接手新流程时都能马上看懂。
再补充一个细节:renderPrompt里拼提示词时,一定把业务文本截断到合理长度,比如 8000 字以内,超出的部分先做摘要再传给模型。别让用户输入一个几十万字的大文件直接把模型接口打爆,这种事在真实环境里一定会发生。