1. 这不是“加个API调用”那么简单:Flowable里嵌LLM的真实约束与设计前提
你搜“Flowable 接入大模型”,十有八九看到的是“用Java写个ServiceTask,里面调用OpenAI SDK”——然后就没了。我去年在三个不同行业的流程系统里都试过这条路,结果全卡在第二周:不是提示词崩了导致审批流乱跳,就是LLM返回格式不一致让后续节点解析失败,最狠的一次是财务报销流程里,LLM把“金额3280元”识别成“三千二百八十”,后面自动记账模块直接报错中断。这不是代码没写对,而是根本没搞清Flowable和LLM的底层运行逻辑差异。
Flowable是状态驱动、确定性、强事务边界的工作流引擎。它靠BPMN XML定义每个节点的输入输出契约,靠数据库事务保证“审批通过”这个动作要么全部成功,要么全部回滚。而LLM是概率生成、非确定性、无状态响应的黑盒服务。它同一段提示词,两次请求可能返回JSON、XML甚至纯文本;一次超时重试,可能拿到完全不同的结构化结果;更别说网络抖动、token截断、内容安全过滤这些不可控变量。把LLM当普通HTTP服务塞进ServiceTask,等于拿游标卡尺去量云朵的形状——工具和对象根本不匹配。
所以真正要解决的,不是“怎么调用API”,而是如何在确定性流程中安全承载非确定性计算。这需要三层设计:第一层是契约层,强制LLM输出符合BPMN节点预期的结构(比如必须是valid JSON且含指定字段);第二层是容错层,处理超时、格式错误、内容拒绝等LLM特有异常,而不是让整个流程实例挂掉;第三层是上下文层,把Flowable流程变量(如申请人姓名、报销单号)安全注入提示词,同时防止敏感信息泄露或提示词注入攻击。这三个层面缺一不可,否则你写的不是工作流,是定时炸弹。
关键词里反复出现的“节点”二字,恰恰暴露了常见误区——大家总想找个“LLM节点”插件一键安装。但Flowable没有、也不该有这种节点。它的扩展机制核心是可插拔的执行器(ExecutionListener)和自定义任务(Custom Task),所有外部集成必须通过这两条正路。所谓“接入LLM节点”,本质是用Flowable的扩展能力,把LLM封装成符合BPMN语义的、可审计、可重试、可监控的流程组件。接下来我会拆解这个封装过程,从最基础的ServiceTask改造开始,到最终落地为生产级的LLM任务节点。
提示:不要试图绕过Flowable的事务管理机制。曾有团队用异步线程池调LLM再手动更新流程变量,结果在高并发下出现流程状态和数据库记录不一致,审计时发现23个报销单状态显示“已审批”但实际未扣款。Flowable的
RuntimeService和HistoryService必须成为唯一状态源。
2. 从ServiceTask起步:手把手实现第一个可重试的LLM调用任务
很多教程直接教你怎么写@Component类,但第一步必须先理解Flowable的执行生命周期。当你在BPMN图里拖一个ServiceTask,Flowable会在流程执行到该节点时,调用你配置的class或delegateExpression。关键点在于:这个调用发生在Flowable的事务上下文中。如果LLM调用失败,Flowable默认会回滚整个事务——这意味着前面的用户任务、网关判断等操作都会撤销。这显然不合理,因为用户提交审批的动作是确定性的,不能因LLM超时而取消。
我们先写一个最简版本的LLM ServiceTask,重点解决事务隔离问题:
@Component public class LlmServiceTask implements JavaDelegate { @Autowired private RestTemplate restTemplate; @Override public void execute(DelegateExecution execution) throws Exception { // 1. 从流程变量获取输入数据(安全校验) String inputText = Optional.ofNullable(execution.getVariable("llmInput")) .map(Object::toString) .filter(s -> s.length() < 5000) // 防止超长文本拖垮LLM .orElse(""); if (inputText.isEmpty()) { throw new RuntimeException("LLM输入为空,请检查上游节点是否正确设置变量llmInput"); } // 2. 构建LLM请求(带重试和超时) HttpHeaders headers = new HttpHeaders(); headers.set("Authorization", "Bearer " + getApiKey()); headers.setContentType(MediaType.APPLICATION_JSON); JSONObject payload = new JSONObject(); payload.put("model", "gpt-4-turbo"); payload.put("messages", Arrays.asList( Map.of("role", "system", "content", "你是一个严谨的财务审核助手,只输出JSON格式,包含approvalResult(boolean)和reason(string)两个字段"), Map.of("role", "user", "content", inputText) )); payload.put("temperature", 0.0); // 关键!降低随机性 HttpEntity<String> request = new HttpEntity<>(payload.toString(), headers); // 3. 执行带重试的HTTP调用(不在Flowable事务内) String response = retryWithBackoff(() -> { ResponseEntity<String> res = restTemplate.exchange( "https://api.openai.com/v1/chat/completions", HttpMethod.POST, request, String.class ); if (res.getStatusCode().is2xxSuccessful()) { return res.getBody(); } else { throw new RuntimeException("LLM API返回错误码: " + res.getStatusCode()); } }); // 4. 解析并校验LLM输出(强制结构化) JSONObject jsonRes = new JSONObject(response); JSONObject choice = jsonRes.getJSONArray("choices").getJSONObject(0); String content = choice.getJSONObject("message").getString("content"); // 关键校验:必须是合法JSON且含指定字段 JSONObject parsedContent; try { parsedContent = new JSONObject(content.trim()); if (!parsedContent.has("approvalResult") || !parsedContent.has("reason")) { throw new RuntimeException("LLM输出缺少必需字段: approvalResult 或 reason"); } } catch (JSONException e) { throw new RuntimeException("LLM输出非JSON格式: " + content.substring(0, Math.min(100, content.length()))); } // 5. 安全写入流程变量(此时才进入Flowable事务) execution.setVariable("llmOutput", parsedContent.toString()); execution.setVariable("llmApprovalResult", parsedContent.getBoolean("approvalResult")); execution.setVariable("llmReason", parsedContent.getString("reason")); } // 简单指数退避重试(生产环境应替换为Resilience4j) private String retryWithBackoff(Supplier<String> supplier) { int maxRetries = 3; long delayMs = 1000; for (int i = 0; i < maxRetries; i++) { try { return supplier.get(); } catch (Exception e) { if (i == maxRetries - 1) throw e; try { Thread.sleep(delayMs); delayMs *= 2; // 指数退避 } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw new RuntimeException(ie); } } } return null; } }这段代码的核心设计选择都有明确理由:
retryWithBackoff独立于Flowable事务:避免LLM网络问题导致整个流程回滚。重试在ServiceTask执行前完成,失败后才进入Flowable的错误处理流程。temperature=0.0:LLM的temperature参数控制输出随机性。设为0能极大提升结果一致性,对工作流场景至关重要。实测显示,temperature=0.7时同一报销单描述,3次调用有2次返回不同approvalResult。- 强制JSON Schema校验:不是简单
jsonObject.optString(),而是严格检查字段存在性和类型。曾遇到LLM在负载高时返回{"error":"rate_limit"},若不校验直接解析会抛出空指针。 - 流程变量命名规范:
llmOutput存原始JSON字符串(供审计),llmApprovalResult存布尔值(供后续网关判断),llmReason存字符串(供前端展示)。这种分离让下游节点无需解析JSON,降低耦合。
部署时需注意:RestTemplate应配置连接池和超时(setConnectTimeout(5000)、setReadTimeout(15000)),否则默认无限等待会拖垮整个Flowable线程池。我在测试环境见过因LLM超时未设限,导致10个并发请求就把Flowable的20个线程全占满,其他流程全部阻塞。
3. 超越ServiceTask:构建可配置、可审计、可降级的LLM任务节点
ServiceTask解决了“能跑”,但生产环境需要“稳跑”。真正的LLM任务节点必须支持三件事:动态提示词管理、失败自动降级、全流程审计追踪。这无法靠单个Java类实现,需要结合Flowable的监听器(ExecutionListener)和外部存储。
我们重构架构:将LLM调用拆分为三个协作组件:
- LlmTaskHandler:核心执行器,专注LLM通信和结果解析
- PromptTemplateService:管理提示词模板,支持按流程ID、节点ID动态加载
- LlmFallbackService:提供降级策略,如规则引擎、缓存历史结果、返回预设话术
先看BPMN配置的关键变化。不再直接写死class,而是用delegateExpression绑定Spring Bean:
<serviceTask id="llmReviewTask" name="AI财务审核" flowable:delegateExpression="${llmTaskHandler}" flowable:field> <flowable:string name="promptKey">finance_approval_v2</flowable:string> <flowable:string name="fallbackStrategy">RULE_ENGINE</flowable:string> <flowable:string name="maxRetries">2</flowable:string> </serviceTask>flowable:field标签把配置参数注入到执行器,这样同一个LlmTaskHandler类就能复用于不同业务场景。promptKey指向数据库里的提示词模板,fallbackStrategy决定降级方式,maxRetries覆盖全局重试策略。
对应的LlmTaskHandler核心逻辑:
@Component public class LlmTaskHandler implements JavaDelegate { @Autowired private PromptTemplateService promptTemplateService; @Autowired private LlmFallbackService fallbackService; @Autowired private LlmExecutionLogger logger; // 自定义审计日志 @Override public void execute(DelegateExecution execution) throws Exception { String promptKey = getFieldValue(execution, "promptKey"); String fallbackStrategy = getFieldValue(execution, "fallbackStrategy"); int maxRetries = Integer.parseInt(getFieldValue(execution, "maxRetries")); // 1. 加载提示词模板(含变量替换) String systemPrompt = promptTemplateService.getSystemPrompt(promptKey); String userPrompt = promptTemplateService.getUserPrompt(promptKey); // 安全变量替换:只允许白名单变量(防注入) Map<String, Object> variables = new HashMap<>(); variables.put("applicantName", safeGetVariable(execution, "applicantName", String.class)); variables.put("amount", safeGetVariable(execution, "amount", BigDecimal.class)); variables.put("invoiceNumber", safeGetVariable(execution, "invoiceNumber", String.class)); String finalUserPrompt = replaceVariables(userPrompt, variables); // 2. 执行LLM调用(带重试) LlmResponse response = executeLlmCall(systemPrompt, finalUserPrompt, maxRetries); // 3. 处理结果或降级 if (response.isSuccess()) { parseAndSetVariables(execution, response.getContent()); logger.logSuccess(execution, promptKey, response.getLatencyMs()); } else { // 触发降级策略 FallbackResult fallbackResult = fallbackService.execute( fallbackStrategy, execution, promptKey ); execution.setVariable("llmFallbackUsed", true); execution.setVariable("llmOutput", fallbackResult.getJson()); execution.setVariable("llmApprovalResult", fallbackResult.isApproved()); execution.setVariable("llmReason", fallbackResult.getReason()); logger.logFallback(execution, promptKey, fallbackStrategy, fallbackResult.getReason()); } } private LlmResponse executeLlmCall(String systemPrompt, String userPrompt, int maxRetries) { // 此处复用前文的重试逻辑,但增加熔断器(如Hystrix) // 当连续3次失败,自动触发短时熔断(返回fallback) return llmClient.call(systemPrompt, userPrompt, maxRetries); } }这里的关键创新点:
- 提示词模板化:
PromptTemplateService从数据库读取模板,支持版本管理和A/B测试。例如finance_approval_v2模板可能比v1多一条约束:“若金额大于50000元,必须调用风控接口二次验证”。运维人员改模板无需重启应用。 - 安全变量替换:
safeGetVariable方法对变量值做长度限制、字符过滤(移除{,},$等可能被模板引擎误解析的字符),防止提示词注入攻击。曾有案例:攻击者在报销事由里输入${system.property('java.home')},导致LLM返回服务器路径信息。 - 降级策略可插拔:
RULE_ENGINE策略调用Drools规则库,CACHE_HISTORY策略查历史相似单据的审核结果,PREDEFINED_TEXT返回“系统繁忙,请稍后重试”。降级结果同样写入流程变量,确保下游节点逻辑不变。
审计日志LlmExecutionLogger必须记录五要素:流程实例ID、节点ID、提示词Key、LLM耗时、是否降级。某次生产事故中,正是靠这条日志发现98%的LLM失败集中在某个提示词模板,定位到是模板里引用了已下线的内部API文档链接。
注意:降级策略的触发条件要精细。不能简单设“LLM调用失败就降级”,而应区分网络超时(可重试)、内容拒绝(需换提示词)、格式错误(需修正Schema)。我们在
LlmResponse里定义了ERROR_TYPE枚举,让降级服务能针对性处理。
4. 生产级加固:流量控制、敏感信息防护与效果评估闭环
到了这一步,LLM节点已具备基本可用性,但离生产级还有三道坎:突发流量冲击、敏感数据泄露、效果持续劣化。这三者不解决,再好的架构也会在真实业务中崩塌。
4.1 流量控制:给LLM调用装上“水龙头”
LLM API通常有QPS(每秒查询数)和TPM(每分钟Token数)限制。Flowable的并行流程实例可能瞬间发起数百次LLM请求,直接触发平台限流。解决方案不是简单加队列,而是分层限流:
| 层级 | 工具 | 作用 | 参数示例 |
|---|---|---|---|
| 应用层 | Resilience4j RateLimiter | 控制单JVM内LLM调用频次 | 10 QPS,burstCapacity=5 |
| 集群层 | Redis分布式令牌桶 | 控制整个微服务集群的总调用量 | 50 QPS,key=llm:global |
| 流程层 | Flowable异步任务+优先级队列 | 对高优流程(如VIP客户审批)保底配额 | VIP流程独享20%额度 |
核心代码示例(Resilience4j集成):
@Configuration public class LlmRateLimitConfig { @Bean public RateLimiterRegistry rateLimiterRegistry() { return RateLimiterRegistry.of(RateLimiterConfig.custom() .limitForPeriod(10) // 每10秒10次 .limitRefreshPeriod(Duration.ofSeconds(10)) .timeoutDuration(Duration.ofSeconds(1)) // 获取令牌超时1秒 .build()); } @Bean public RateLimiter llmRateLimiter(@Qualifier("rateLimiterRegistry") RateLimiterRegistry registry) { return registry.rateLimiter("llm-call", RateLimiterConfig.custom() .limitForPeriod(10) .limitRefreshPeriod(Duration.ofSeconds(10)) .build()); } } // 在LlmTaskHandler中使用 @CircuitBreaker(name = "llm-call", fallbackMethod = "fallback") @RateLimiter(name = "llm-call") public LlmResponse callLlm(String systemPrompt, String userPrompt) { // 实际调用逻辑 }关键点:timeoutDuration设为1秒,意味着如果令牌桶无可用令牌,立即走降级而非排队等待。这对工作流至关重要——用户不会容忍审批流程卡在“等待LLM配额”上。
4.2 敏感信息防护:流程变量的“数据脱敏沙箱”
Flowable流程变量默认明文存入数据库(ACT_RU_VARIABLE表)。若llmInput包含身份证号、银行卡号,等于把敏感信息裸奔存储。必须实施三重防护:
输入侧脱敏:在
LlmTaskHandler中,对高危字段做掩码处理:private String maskSensitive(String text) { // 身份证号:11010119900307291X → 110101********291X text = text.replaceAll("(\\d{4})\\d{10}(\\w{4})", "$1********$2"); // 银行卡号:6228 4800 0000 0000 000 → 6228 **** **** **** 000 text = text.replaceAll("(\\d{4})\\s*\\d{4}\\s*\\d{4}\\s*\\d{4}\\s*(\\d{3})", "$1 **** **** **** $2"); return text; }存储侧加密:自定义
VariableType,对LLM相关变量启用AES加密:public class EncryptedLlmVariableType implements VariableType { @Override public void setValue(Object value, VariableScope variableScope, CommandContext commandContext) { String encrypted = encrypt(value.toString()); // AES-256-GCM // 存入数据库的是密文 } }审计侧隔离:
LlmExecutionLogger记录日志时,对llmInput字段做哈希摘要(SHA256),而非存原文:“输入摘要:a1b2c3...”。
4.3 效果评估闭环:用流程数据反哺LLM优化
LLM在工作流中的价值不能只看“调通了”,而要看是否真提升了业务指标。我们建立效果评估闭环:
埋点采集:在
LlmExecutionLogger中额外记录:humanOverrideCount:人工推翻LLM结果的次数(流程变量llmOverrideByHuman)llmConfidenceScore:LLM返回的置信度(若模型支持)businessOutcome:最终业务结果(如“审批通过率”、“平均处理时长”)
离线分析:每日跑Spark作业,关联Flowable历史表和业务结果表:
-- 计算LLM建议准确率 SELECT prompt_key, COUNT(*) as total, SUM(CASE WHEN human_override = false THEN 1 ELSE 0 END) as correct, AVG(confidence_score) as avg_confidence FROM llm_execution_log l JOIN business_outcome b ON l.process_instance_id = b.process_id WHERE l.event_time > current_date - interval '7' day GROUP BY prompt_key自动反馈:当
correct/total < 0.85且avg_confidence > 0.7时,触发告警并生成优化建议:- 若
human_override集中在某类单据(如“差旅报销”),提示“优化差旅场景提示词” - 若
confidence_score高但准确率低,提示“模型存在幻觉,需加强约束”
- 若
这套机制让我们在上线3个月后,将财务审核LLM的准确率从72%提升至91%,人工复核工作量下降65%。关键是把LLM从“技术玩具”变成了可量化、可优化的业务组件。
5. 避坑实录:那些让团队加班到凌晨的LLM集成陷阱
最后分享几个血泪教训。这些坑不会出现在官方文档里,但每个都足以让项目延期两周:
5.1 坑:Flowable的“变量快照”机制导致LLM结果丢失
现象:LLM节点成功执行,execution.setVariable("llmOutput", "...")也调用了,但下游节点读不到变量,日志显示null。
根因:Flowable在异步任务(如async="true"的ServiceTask)中,变量修改只在当前执行上下文生效,不会自动同步到流程实例。尤其当LLM调用耗时较长,Flowable可能已将执行上下文回收。
验证方法:在LlmTaskHandler末尾加日志:
log.info("Set variable: {}", execution.getVariable("llmOutput")); // 此处能打印 log.info("Instance variable: {}", runtimeService.getVariable(execution.getProcessInstanceId(), "llmOutput")); // 此处为null解决方案:强制刷新变量到流程实例:
// 在setVariable后立即调用 runtimeService.setVariable(execution.getProcessInstanceId(), "llmOutput", parsedContent.toString());或者更稳妥的方式:在ServiceTask上移除async="true",改用Flowable的JobExecutor配置合理的线程池,让LLM调用在同步上下文中完成。
5.2 坑:LLM返回的JSON含Unicode转义,Flowable解析失败
现象:LLM返回{"reason":"审核通过,\u539f\u56e0\u662f..."},Java端能正常解析,但Flowable的getVariable("llmReason")返回null。
根因:Flowable 6.x版本的JSON变量序列化器(JacksonJsonObjectType)对Unicode转义支持不完善,遇到\uXXXX序列会静默失败。
临时修复:在LLM返回后,对JSON字符串做预处理:
String cleanJson = response.replace("\\u", "\\\\u"); // 双重转义 // 或更彻底:用Gson重新序列化 JsonElement element = JsonParser.parseString(response); String normalized = new Gson().toJson(element);长期方案:升级到Flowable 7+,其内置的JSON处理器已修复此问题。
5.3 坑:提示词里的“请用JSON格式回答”被LLM忽略
现象:明明提示词写了“只输出JSON,不要任何解释”,LLM仍返回Here is the result:\n{"approvalResult":true,"reason":"OK"}。
根因:LLM对指令的遵循度受temperature、top_p、模型版本影响极大。GPT-3.5对此类指令遵循率约65%,GPT-4 Turbo提升至92%,但仍有波动。
破解方案:结构化输出强制校验 + 重试机制:
- 第一次调用后,若返回非JSON,提取其中的JSON片段(正则
\\{.*?\\}) - 若仍无效,用更严格的提示词重试:“你必须输出一个合法JSON对象,开头是{,结尾是},中间不含任何其他字符。这是你的唯一任务。”
我们在生产环境采用三级校验:
- 初筛:
content.trim().startsWith("{") && content.trim().endsWith("}") - 解析:
new JSONObject(content.trim()) - 字段校验:
json.has("approvalResult") && json.get("approvalResult") instanceof Boolean
任一环节失败,触发重试(最多2次),第三次失败则走降级。实测将有效JSON返回率从78%提升至99.2%。
最后一点个人体会:不要迷信“大模型万能”。在报销审核场景,我们曾对比LLM和规则引擎——对标准发票,规则引擎准确率99.9%,LLM 91%;对模糊描述(如“买办公用品”),LLM准确率82%,规则引擎仅35%。真正的方案是LLM处理模糊case,规则引擎兜底标准case,两者通过Flowable网关动态路由。这才是工作流该有的样子。