先聊一个很实际的问题:你手上已经有一套跑得好好的 Flowable 流程,流程里全是人工审批、条件判断、外部服务调用,现在突然要求“让大模型参与审批”,怎么办?
最省事的办法当然是在流程里加一个“服务节点”,然后在 Java 代码里硬调大模型 API。流程引擎不需要知道什么是 token、什么是 prompt,它只需要知道:这个节点的输入是流程变量,输出是流程变量,中间发生了什么,由节点自己负责。Flowable 正好天然支持这种扩展,Service Task 加一个自定义 Delegate 类,前后都能接到流程上下文。就这么一个组合,足以把绝大多数 LLM 能力嵌进审批流、工单流、报表流里。
这篇内容适合做流程平台开发的工程师,也适合正在做“AI 化系统改造”的架构师。我直接以一个“报销单 AI 预审”的实际案例来拆解,从 BPMN 建模到 JavaDelegate 实现,到超时降级和失败重试,全都走一遍。里面包含的是我踩过坑之后的工程取舍,不是官方文档的复述。
1. 需求拆解与方案选型:别一上来就写代码
1.1 这个 LLM 节点到底要解决什么问题
先别急着选技术方案,把业务问题掰开。
传统工作流擅长编排“确定性规则”,比如金额大于 5000 走总监审批、部门是财务走财务复核,这些用 Flowable 的表达式和网关就能做得很好。但有一类流程环节,过去只能靠人判断,比如报销摘要里写“客户招待”,到底算业务招待费还是差旅费;合同条款里有没有明显的风险表述;工单描述里提到了“紧急”“宕机”是不是需要升级处理。这些都属于“非确定性判断”。
大模型节点的价值,就是把这类“需要语言理解和归纳”的环节,从人工变成自动化。但它不是替代审批人,而是做“预审助理”。我在实际项目里定位是:LLM 节点只产出“判断建议 + 风险结构化数据”,最终审批结论仍然由人或者人工规则决定。这个定位非常重要,它能让你绕开一堆合规和责任归属的争议。
所以接入 LLM 节点前,先要把以下三个问题想清楚:
- 节点的输入是什么:哪些流程变量可以被拼进 Prompt,哪些字段涉及隐私不能出库;
- 节点的输出是什么:是纯文本结论,还是 JSON 结构化结果;
- 节点失败怎么办:LLM 服务超时、返回格式不对、甚至内容涉敏拦截,流程不能卡死。
1.2 方案选型:为什么是自定义 JavaDelegate
实现一个 LLM 节点,并不是只有一条路。我把主流做法列一下,方便你对比:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 嵌入式 JavaDelegate 直接调 LLM API | 事务一致、变量传递顺、调试方便 | 节点内网络延时阻塞事务,需要额外做超时和重试 |
| 先走 Service Task 发 HTTP 回调外部 AI 服务 | 调用链更清晰,适合已有独立 AI 服务 | 多一次网络跳转,回调结果更难写回流程变量 |
| 直接把 LLM 能力做成 Flowable 外部 Rest API | Flowable 本身改动小 | 要求有额外的流程平台 API,适合大型中台但不适合单项目落地 |
| 换一个原生支持 AI 的 BPM 引擎 | 功能更“智能” | 迁移成本高,现有流程资产几乎都要重做 |
我的建议是选第一个:在 Flowable 里定义 Service Task,通过 Delegate 表达式指向一个 Spring Bean,然后在这个 Bean 的 execute 方法里调用大模型。原因是这套方式和现有 Flowable 事务模型结合得最紧,流程变量天然可用,不需要额外设计“外部系统回调写流程变量”这套机制。
自定义 JavaDelegate 的核心逻辑很简单:读流程变量,拼 Prompt,调模型,把返回的 JSON 解析后写回流程变量。但在生产环境里,真正的难点不是“调通接口”,而是“怎么让这个节点在流量进来时不拖垮事务、不重复扣费、不阻塞业务流程”。
2. BPMN 建模与核心代码:一个 AI 预审节点是怎么跑起来的
2.1 流程设计:serviceTask 放在哪个位置
我用一个报销审批流程做示例。原始流程是:员工提交报销单 -> 部门经理审批 -> 财务复核 -> 出纳打款。现在要在“部门经理审批”之前插入一个“AI 预审节点”,让 AI 先看看报销摘要、金额、票据信息,输出一个风险等级和合规建议,部门经理在审批面板上直接看到这些信息。
对应的 BPMN XML 片段大概是这样:
<process id="expenseApproval" name="报销审批流程" isExecutable="true"> <startEvent id="startEvent" /> <serviceTask id="aiPreAudit" name="AI 预审" flowable:delegateExpression="${llmDelegate}" /> <userTask id="managerApprove" name="部门经理审批" flowable:assignee="${manager}" /> <sequenceFlow sourceRef="startEvent" targetRef="aiPreAudit" /> <sequenceFlow sourceRef="aiPreAudit" targetRef="managerApprove" /> </process>这里最关键的一行是flowable:delegateExpression="${llmDelegate}"。它表示:当流程流转到这个节点时,Spring 容器里找一个名为llmDelegate的 Bean,执行它。用 Delegate 表达式而不是flowable:class,最大的好处是可以注入 Spring 管理的组件,比如 Redis、配置中心、HTTP 客户端,后面的降级和限流都好做。
放在这个位置的考虑是:AI 预审不应该阻断主流程太久,而且如果预审失败,我希望它能自动降级为“不预审”,让流程继续走到人工节点。所以我不建议在 AI 节点两边加排他网关来“强依赖结果”,更合理的做法是让 AI 节点永远返回一个“可用/不可用”的结构化标记,由后续网关决定分支。
2.2 封装一个 OpenAI 兼容的 LLM 客户端
大模型厂商很多,各家 API 细节有差异,但大部分都兼容 OpenAI 的 Chat Completions 协议。只要封装一个通用的客户端,就同时适配了国内多家合规大模型服务商的接口,也能适配很多私有化部署的模型。这样做的收益很明显:后面换模型供应商,只改配置,流程节点代码一行不用动。
下面这段代码用 Spring Boot 的 RestClient 实现了一个最小可用客户端,没有引入额外 SDK,方便你直接抄:
@Component public class LlmClient { private final RestClient restClient; public LlmClient(@Value("${llm.api-url}") String apiUrl, @Value("${llm.api-key}") String apiKey) { this.restClient = RestClient.builder() .baseUrl(apiUrl) .defaultHeader("Authorization", "Bearer " + apiKey) .defaultHeader("Content-Type", "application/json") .build(); } public LlmResult chat(String systemPrompt, String userPrompt, int maxTokens) { Map<String, Object> body = new HashMap<>(); body.put("model", "your-model-name"); body.put("messages", List.of( Map.of("role", "system", "content", systemPrompt), Map.of("role", "user", "content", userPrompt) )); body.put("temperature", 0.2); body.put("max_tokens", maxTokens); body.put("response_format", Map.of("type", "json_object")); Map<String, Object> resp = restClient.post() .uri("/chat/completions") .body(body) .retrieve() .body(new ParameterizedTypeReference<Map<String, Object>>() {}); return parseResponse(resp); } }几个细节我想单独拎出来说,因为它们都是实际运行之后才能发现的问题:
temperature调到 0.2 左右。审批预审这类场景要的是稳定判断,而不是创意表达,温度过高会导致同样的输入产生不一样的结果;max_tokens一定要控制。报销预审输出几百字足够,不限制的话容易被模型“自由发挥”,Token 费用也会失控;response_format用它约束 JSON 输出。现在主流服务商都支持这个参数,务必使用,后面解析字段会省很多事。
2.3 JavaDelegate 实现:读变量、拼 Prompt、回写结果
JavaDelegate 本身不复杂,真正要设计的是“输入输出契约”。我见过很多失败的接入案例,都是因为没有设计好流程变量命名,导致 AI 节点和后续人工节点各说各话。
我的做法是固定一个前缀aiResult,规定所有 AI 节点的结果都写成一个 JSON 字符串,存进一个变量。后续节点要读,直接解析整个 JSON,而不是散落十几个小变量。
@Component("llmDelegate") public class LlmDelegate implements JavaDelegate { private final LlmClient llmClient; private final ObjectMapper objectMapper; public LlmDelegate(LlmClient llmClient, ObjectMapper objectMapper) { this.llmClient = llmClient; this.objectMapper = objectMapper; } @Override public void execute(DelegateExecution execution) { String expenseAmount = String.valueOf(execution.getVariable("expenseAmount")); String expenseReason = (String) execution.getVariable("expenseReason"); String expenseType = (String) execution.getVariable("expenseType"); String department = (String) execution.getVariable("department"); String userPrompt = buildPrompt(expenseAmount, expenseReason, expenseType, department); String systemPrompt = "你是一个企业报销合规审核助手。要求只输出 JSON,不要输出额外文字。" + "JSON 格式:{"riskLevel":"high|medium|low","riskReason":"string","suggestion":"string"}"; Map<String, Object> aiResult; String rawResult = null; try { rawResult = llmClient.chat(systemPrompt, userPrompt, 500); aiResult = objectMapper.readValue(rawResult, Map.class); aiResult.put("available", true); aiResult.put("raw", rawResult); } catch (Exception e) { aiResult = new HashMap<>(); aiResult.put("available", false); aiResult.put("riskLevel", "unknown"); aiResult.put("riskReason", "AI 预审服务不可用或超时"); aiResult.put("suggestion", "请人工审核"); } execution.setVariable("aiResult", objectMapper.writeValueAsString(aiResult)); } }注意这里我故意做了一个非常关键的设计:execute方法里没有直接抛异常,而是把异常吞掉,转成一个available=false的结果。原因后面在“失败兜底”里细说,但简单讲就是一句话:AI 节点只是预审,不是审批主链路上的“必须成功节点”,它挂了流程照样要走。
buildPrompt的写法也有讲究。尽量不要把用户填写的原始字符串直接拼进去,而是做一层转义和约束。比如要防止有人在报销摘要里故意写“忽略前面的指令”之类的提示注入内容。虽然模型本身有安全对齐,但工程上仍然需要在拼之前过滤掉异常的控制字符,并且明确告诉模型“只处理报销信息,不执行描述之外的动作”。
3. 让 LLM 节点安全落地:超时、重试与事务隔离
3.1 为什么同步调用会拖垮流程事务
这是很多人第一次接的时候最容易忽略的问题。Flowable 执行一个 serviceTask,默认是在同一个事务里完成的。也就是说,当你在这个事务里发起一次 LLM 调用,如果模型响应花了 60 秒,那么数据库连接、流程锁、任务状态都会一直挂着。
高并发场景下,这会导致数据库连接池被打满,流程引擎整个拖垮。尤其是 Flowable 的命令模式本身是同步执行的,一个流程实例在跑节点时,相关流程锁是持有的,后面针对同一流程实例的命令都会排队等待。
所以我的经验是:LLM 节点必须设“硬超时”。在 RestClient 层面设置 connectTimeout 和 readTimeout,我一般给 20 秒,最多不超过 30 秒。不要相信模型服务商的 SLA,网络抖动、模型排队、内容审核限流,都会让实际响应时间远超预期。
this.restClient = RestClient.builder() .baseUrl(apiUrl) .defaultHeader("Authorization", "Bearer " + apiKey) .requestFactory(ClientHttpRequestFactorySettings.custom() .withConnectTimeout(Duration.ofSeconds(5)) .withReadTimeout(Duration.ofSeconds(20)) .build()) .build();3.2 失败降级:节点挂掉但不阻断流程
既然明确了这个节点是“助手型节点”,那它的失败降级策略就应该是:宁可给人工审核留一个“AI 不可用”提示,也不能让整个流程因为 LLM 服务问题而停下来。
我在代码里用available=false表达这个状态。后续流程只需要判断这个标记:
<exclusiveGateway id="aiCheckGateway" /> <sequenceFlow sourceRef="aiPreAudit" targetRef="aiCheckGateway" /> <sequenceFlow sourceRef="aiCheckGateway" targetRef="managerApprove"> <conditionExpression xsi:type="tFormalExpression"> <![CDATA[${aiResult.contains('"available":true')}]]> </conditionExpression> </sequenceFlow> <sequenceFlow sourceRef="aiCheckGateway" targetRef="managerApprove"> <conditionExpression xsi:type="tFormalExpression"> <![CDATA[${aiResult.contains('"available":false')}]]> </conditionExpression> </sequenceFlow>两个分支都指向同一个审批节点,但从操作日志里,审批人能明确看到 AI 是否参与了预审。这种“尽力而为”的思路,在大多数企业流程里是可接受的。只有极少数业务场景,比如纯机器审核链路上不能有人工兜底,才需要把 LLM 失败做成 hard fail。
3.3 重试的幂等性问题:小心重复扣费
Flowable 对 serviceTask 抛出的异常默认会触发 Job 重试。这个机制本来是很好的,但如果你的 Delegate 已经在执行过程中完成了 LLM 调用,只是因为解析结果时报错,Flowable 会重跑整个节点,等于多花一次模型调用的钱。
解决这个问题有两种方式:
第一种,把“结果写入”和“后续依赖”做原子化。在 Delegate 里一旦拿到模型响应,立刻把原始结果也存到流程变量里,这样即使后续解析异常,重试时可以先用一个“是否已调用过”标记判断,跳过重复调用。
第二种,使用业务幂等键。在调用模型时传入一个request_id,大部分大模型服务商收到相同 request_id 会返回同一个结果,不会重复计费。我在实际项目里用的是“流程实例ID + 节点ID”拼出来的唯一标识。
String requestId = execution.getProcessInstanceId() + ":" + execution.getCurrentActivityId(); body.put("request_id", requestId);需要注意的是,并不是所有模型服务商都支持 request_id 幂等,接入前先确认。如果不支持,就只能靠“先写变量,再决定是否重调”的方式自己做幂等。
3.4 超时兜底脚本:BPMN 定时边界事件
除了代码层面的超时,还可以在 BPMN 层面加一道保险:给 serviceTask 挂一个 Timer Boundary Event,比如 30 秒后如果节点还没执行完,就中断并走人工兜底分支。
<serviceTask id="aiPreAudit" name="AI 预审" flowable:delegateExpression="${llmDelegate}"> <boundaryEvent id="timerBoundary" attachedToRef="aiPreAudit"> <timerEventDefinition> <timeDuration>PT30S</timeDuration> </timerEventDefinition> </boundaryEvent> </serviceTask>这个方案的好处是保险措施不依赖代码逻辑,模型服务再慢也有个“物理上限”。缺点是要注意边界事件触发后,Flowable 会中断节点的执行,此时 Delegate 方法可能还在跑,最终两个路径都会执行。所以代码里仍然要把降级写干净,不能让模型结果覆盖掉超时兜底变量。我的建议是双保险都用:代码里设置 20 秒超时,BPMN 边界事件给 35 秒,后者只作为最终防线。
4. 再进一步:让 LLM 节点做“智能路由”
4.1 从预审到自动分单:节点输出的第二层价值
上面讲的 AI 预审只是把模型结果人工参考。但 LLM 节点的真正价值,是能把非结构化文本转化成结构化路由条件。
举个例子:客户提交了一个工单,描述是“系统登录后页面一直转圈,重启了几次还是不行,怀疑是认证服务有大面积故障”。传统规则路由很难从这段自然语言中提取“高优先级”“属于技术故障”“影响面可能较大”这几个标签。用 LLM 节点处理后,输出一个 JSON:
{ "category": "系统故障", "priority": "P1", "impactScope": "large", "needOncall": true }然后 Flowable 排他网关直接根据这些字段做路由,把工单分配给不同的工程师队列,甚至可以通过变量动态指定 assignee。这样“理解文本 -> 分发”就完全自动化了。
4.2 动态从流程变量中构造 Prompt 的模板引擎
现实中的案件描述、合同条款、报销摘要,长度和格式五花八门。最好别在 Java 代码里用字符串拼接写死 Prompt,而是做一个模板文件,用 FreeMarker 或者 Thymeleaf 渲染。
我建议把 Prompt 模板放在resources/prompt/目录下,一个业务场景一个模板。比如报销预审的模板:
请对以下报销单进行合规预审。 部门:${department} 金额:${expenseAmount} 元 报销类型:${expenseType} 报销摘要:${expenseReason} 请按 JSON 格式输出风险等级、风险原因和审核建议。这样运维和业务人员可以在不修改 Java 代码的前提下调整 Prompt。模板文件放到配置中心后,线上可以动态优化提示词,不用重新发版。我在项目里就是这么做的,效果比改代码强很多,尤其当业务方反复提需求的时候,非常省事。
4.3 给 LLM 节点加一层“降级开关”
接 LLM 节点最大的隐患,不是技术不会,而是模型输出不稳定。有些模型在同一个问题上的判断偏差很大,尤其涉及金额、合规、法务场景时,如果自动路由到一个完全错误的部门,比不用 AI 还麻烦。
所以我在所有 LLM 节点里都会加一个开关变量。流程定义里,AI 节点前面接一个排他网关,先判断一个叫aiEnabled的流程变量,如果为 true 才走 LLM 节点,否则直接跳人工。这个开关可以在流程启动时传入,也可以由管理员在运行时通过 REST API 修改全局开关。凡是涉及财务、法务、供应商管理的流程,我默认都先把开关关掉,灰度跑一段时间,效果稳定后再打开。
5. 接入过程中的高频坑和排查方法
5.1 流程变量太大导致序列化报错
一次模型调用返回的原始 JSON 可能非常大,特别是模型喜欢额外输出解释时,一个节点的结果就可能超过几十 KB。Flowable 默认的变量序列化对长度有限制,如果你把原始响应整个塞进流程变量,很容易出现VariableValueIntegrityViolated之类的异常。
我的经验是最多保留两层:最终结构化结果放进aiResult,原始完整响应不放进流程变量,只记录在日志里,或者经过截断后再存。如果确实要留完整记录,建议把原始响应落到独立的日志表或者 ES,流程变量只存一个摘要字段。
5.2 delegateExpression 找不到 Bean
这是刚上手最容易遇到的问题。flowable:delegateExpression="${llmDelegate}"依赖 Spring 容器里有对应的 Bean。如果你只写了一个@Component类,没指定名字,Spring 默认名是类名首字母小写,比如llmDelegate没问题。但如果类名比较长,比如AiPreAuditDelegate,默认 Bean 名就是aiPreAuditDelegate,表达式里必须保持一致,否则启动流程时直接报找不到表达式目标。
还有个容易埋坑的点:Bean 必须放在能被 Spring 扫描的包路径下。Flowable 本身是独立引擎,如果你把 Delegate 类放在了流程引擎模块之外的某个扫描不到的位置,同样会报Unknown property used in expression。排查时先看 Spring 容器里到底有没有这个 Bean,我一般用 Spring Boot 的/actuator/beans端点直接查一遍。
5.3 LLM 返回内容被解析成 JSON 失败
即使你设置了response_format,模型仍有可能在极端情况下返回一段 Markdown 或额外解释。所以解析逻辑不能太脆弱,建议按下面这个顺序处理:
- 直接用正则在返回文本里查找第一个
{到最后一个}之间的内容; - 对这个片段做 JSON 解析;
- 解析失败时,把整个返回文本原样写入降级结果,并在日志里记录一句“LLM 返回格式异常”。
不推荐用“如果包含json就截取”这种粗暴逻辑,因为模型输出里经常带前后缀。正则提取花括号片段是当前兼容性最高的做法。
5.4 并发调用导致限流和超时
模型服务商的 API 都有 QPS 限制。流程引擎的并发能力很强,一旦多个流程实例同时进入 LLM 节点,瞬间几十个请求打过去,很容易触发限流。这个问题的解法有两个:
第一,在 Delegate 内部做信号量限流。比如用Semaphore(5)控制最多同时有 5 个请求在途,超出就快速失败,走降级路径。这样相当于给流程引擎和模型服务之间加了一个缓冲。
第二,给不同业务节点配置不同的 API Key 或单独的模型实例。核心流程用一个高配额 Key,非核心流程用低配额 Key,互不挤兑。
5.5 模型结果不可解释,审计困难
企业流程最后一定会被审计。传统规则引擎你还能跟审计解释“金额大于 5000 自动转总监”,但 LLM 的判断是个黑盒,很难讲清楚“为什么判定为高风控”。
我在实际项目中做了一个很土但有效的办法:把每次调用 LLM 节点的输入 Prompt、输出原始结果、解析后的结构化结果、模型版本、响应耗时,全部落一张流水表。审批界面只展示结构化结果,审计后台可以翻原始流水。这样至少做到了“有据可查”,虽然不能完全解释模型推理过程,但比什么都没有强得多。
6. 一些我做这个改造后的体会
回到最初那个问题:Flowable 里接入 LLM 节点,到底是技术问题还是工程问题?我现在的回答是,代码只占了很小一部分,真正花时间的全是在设计“节点边界”:哪些输入能出库、失败怎么降级、成本怎么控制、结果怎么被信任。
我自己调试这个功能的时候,最深的感受是:不要试图让大模型替你做流程决策,而是让它帮你做“信息提取和预判”。Flowable 的价值在于流程的确定性和可追踪性,LLM 的价值在于非结构化理解。两者加起来,不是替代关系,而是分工关系。
如果你现在正准备在现有流程里接大模型节点,我建议从小场景做起,先挑一个“判断简单、人工兜底容易”的节点做验证,跑通之后再逐步扩展到自动路由和自动审批。别一上来就做全自动审批,不是模型能力不够,而是流程治理体系和模型的协同机制还没建好。
最后分享一个小技巧:在 Flowable 的流程设计器里,给 LLM 节点加一个自定义属性,比如ai-scene=expense_preaudit,然后在 Delegate 里通过execution.getCurrentActivityId()和扩展属性去动态选择 Prompt 模板。这样以后新增场景,只需要画一个新节点和配置一个模板,完全不用改 Delegate 代码。我实测下来,这个设计的扩展成本是最低的,强烈建议你也在项目里用起来。