1. 为什么要在 Flowable 里“硬塞”一个 LLM 节点?——不是炫技,是解决真问题
Flowable 是企业级工作流引擎里少有的、真正能扛住高并发审批链和复杂业务规则的成熟方案。我去年在给一家省级医保信息平台做流程重构时,就踩过这个坑:他们原有系统里,所有“异常费用申诉审核”都卡在人工环节——医生上传材料、科室初审、医保办复核、最后还要法务介入。平均耗时 17.3 天,积压单子超 4200 件。当时团队第一反应是“加人、排班、搞 KPI”,但上线三个月后发现,92% 的申诉其实有明确规则可判:比如“单次住院超 3 次 CT 检查且无手术记录”“同一药品日剂量超说明书上限 2.5 倍”——这些全是结构化规则,Flowable 的ExclusiveGateway+Expression完全能跑通。可剩下那 8%,全是医生手写的“患者为晚期癌痛,需超量使用吗啡缓释片,附肿瘤科会诊意见(手写扫描件)”。这类非结构化文本,传统 BPM 引擎根本没法处理。
这时候,LLM 不是锦上添花,而是补上最后一块拼图。它不替代 Flowable 的核心能力——流程编排、状态持久化、事务一致性、历史追溯——而是作为动态决策增强器,嵌在流程的特定节点上。比如在“法务终审”前加一个LLMDecisionNode,把扫描件 OCR 后的文本、患者既往病史摘要、最新诊疗指南片段一起喂给模型,让它输出“建议通过/驳回/补充材料”,并附带依据段落引用。Flowable 只负责把这串 JSON 结果接住,再根据decision: "approve"这样的字段走不同分支。整个过程对流程引擎透明,不改一行 Flowable 源码,不碰数据库 schema,不增加运维负担。这才是 LLM 和 BPM 正确的相处姿势:LLM 处理“模糊”,Flowable 管理“确定”。
你可能听过 Coze 或 n8n 的“AI 工作流”,它们把 LLM 当成流程主干,适合轻量级自动化。但企业级流程不同——它必须满足审计要求、支持回滚、能对接 SAP/Oracle、要兼容国产信创环境。Flowable 的优势恰恰在于它的“笨重”:每个节点执行都有完整上下文快照,每次变量变更都落库可查,连TaskListener都能监听到“用户点击了‘同意’按钮”这种粒度。把 LLM 接进来,不是为了取代这套严谨性,而是让严谨的流程能处理以前不敢碰的模糊地带。所以标题里说的“接入 LLM 节点”,本质是在 Flowable 的确定性框架内,安全、可控、可审计地引入不确定性计算能力。关键词里反复出现的 “function节点”“条件”“监听器”,其实都在指向同一个事实:我们不是要造个新引擎,而是给现有引擎装一个可插拔的智能模块。
提示:别被“大模型”三个字吓住。实际落地时,你根本不需要自己训模型、搭 GPU 集群。一个 7B 参数的 Qwen2-Chat 模型,用 vLLM 推理服务部署在 2 卡 A10 上,TPS 稳定在 35+,足够支撑日均 5 万单的医保申诉流程。重点从来不是模型多大,而是怎么让 Flowable 信任它、调用它、容错它。
2. Flowable 原生不支持 LLM?那就用“伪装术”——三类节点改造方案深度对比
Flowable 官方文档里找不到LLMTask这种东西。它的节点类型是固定的:UserTask(人工)、ServiceTask(Java 服务)、ScriptTask(脚本)、CallActivity(子流程)。想接入 LLM,就得在这四类里选一个“马甲”,再注入智能逻辑。我实测过所有路径,结论很明确:ServiceTask是唯一生产可用的选择,其他都是 Demo 级陷阱。下面拆解每种方案的真实代价:
2.1 ScriptTask 方案:看似简单,实则埋雷最多
很多人第一反应是写 Groovy 脚本调用 OpenAI API:
def response = new URL("https://api.openai.com/v1/chat/completions").openConnection(); response.setRequestMethod("POST"); response.setRequestProperty("Authorization", "Bearer ${apiKey}"); response.setRequestProperty("Content-Type", "application/json"); def payload = [ model: "gpt-4-turbo", messages: [[role: "user", content: execution.getVariable("inputText")]] ]; response.outputStream << new groovy.json.JsonBuilder(payload).toString(); def result = new groovy.json.JsonSlurper().parseText(response.inputStream.text); execution.setVariable("llmResult", result.choices[0].message.content);表面看,5 行代码搞定。但上线第三天就崩了:Groovy 的URL.openConnection()默认超时是 30 秒,而 LLM 接口在高峰时段响应常达 45 秒;更致命的是,Groovy 脚本运行在 Flowable 的主线程里,一次超时就会卡死整个流程实例,导致后续所有审批单停滞。我们曾因此触发过一次省级医保系统的熔断机制。这不是理论风险,是血泪教训。
2.2 CallActivity 方案:过度设计,得不偿失
有人提议建个独立子流程,里面放ServiceTask调 LLM,主流程用CallActivity调用它。听起来模块化,实则制造了三重麻烦:第一,子流程需要单独部署、版本管理,和主流程耦合度反而更高;第二,CallActivity的变量传递是深拷贝,传入 10MB 的 OCR 文本会直接 OOM;第三,最要命的是错误处理——子流程里 LLM 调用失败,CallActivity只能返回“子流程失败”,你根本不知道是网络超时、token 超限还是模型拒答,日志里只有一行BpmnErrorEvent,排查成本翻倍。
2.3 ServiceTask 方案:唯一经得起压测的正解
ServiceTask的本质是调用 Spring Bean 方法。这意味着你能用 Spring 全家桶做任何事:用RestTemplate配置连接池和熔断,用@Async开异步线程防阻塞,用@Retryable实现指数退避重试。更重要的是,它天然支持 Flowable 的事务边界——如果 LLM 调用成功但后续节点失败,整个事务回滚,LLM 的调用记录也会被清除(前提是你的 LLM 服务也支持幂等)。我们最终采用的方案长这样:
@Component public class LlmDecisionService { private final RestTemplate restTemplate; private final CircuitBreaker circuitBreaker; // Resilience4j 熔断器 public LlmDecisionService(RestTemplate restTemplate, CircuitBreaker circuitBreaker) { this.restTemplate = restTemplate; this.circuitBreaker = circuitBreaker; } @Transactional // 关键!确保与 Flowable 事务一致 public LlmResponse execute(DelegateExecution execution) { // 1. 构建请求体(从 execution 获取变量) String inputText = (String) execution.getVariable("medicalRecordText"); String guidelines = loadLatestGuidelines(); // 从本地缓存读取最新诊疗指南 LlmRequest request = LlmRequest.builder() .model("qwen2-7b-chat") .messages(List.of( Map.of("role", "system", "content", "你是一名资深医保审核专家,请严格依据以下指南判断..."), Map.of("role", "user", "content", inputText + "\n\n参考指南:" + guidelines) )) .temperature(0.1) // 降低随机性,保证结果稳定 .build(); // 2. 熔断+重试调用 return circuitBreaker.executeSupplier(() -> { ResponseEntity<LlmResponse> response = restTemplate.postForEntity( "http://llm-service:8080/v1/chat/completions", new HttpEntity<>(request, createHeaders()), LlmResponse.class ); if (!response.getStatusCode().is2xxSuccessful()) { throw new RuntimeException("LLM service returned " + response.getStatusCode()); } return response.getBody(); }); } }然后在 BPMN XML 里声明:
<serviceTask id="llmDecision" name="LLM 医保审核" flowable:delegateExpression="${llmDecisionService}" />这个方案的威力在于:它把 LLM 调用彻底“Spring 化”了。你可以用@Value("${llm.timeout:5000}")动态配置超时,用@Scheduled定时刷新缓存的指南文本,甚至用@EventListener监听FlowableEngineEvents.PROCESS_COMPLETED事件,把每次 LLM 决策结果自动存入审计表。这才是企业级集成该有的样子——不是把 AI 塞进流程,而是让流程拥抱 AI。
注意:千万别在
ServiceTask里直接 new 对象。必须用 Spring 注入的 Bean,否则事务、AOP、配置管理全失效。我们曾因一个new LlmDecisionService()导致重试机制完全不生效,花了两天才定位到。
3. LLM 节点不是“黑箱”,而是“白盒决策单元”——输入、输出、校验的黄金三角
很多团队把 LLM 节点当成魔法盒子:扔进去一段文字,出来个 JSON,流程就往下走。结果上线后发现,30% 的决策结果格式错乱(比如返回纯文本而非 JSON),20% 的内容偏离指令(模型擅自添加“建议咨询上级医师”这种流程外动作),还有 15% 的响应包含敏感信息(患者身份证号被模型原样复述)。这不是模型的问题,是你没给它画好“牢笼”。真正的 LLM 节点必须由三部分构成:结构化输入(Input Schema)、强约束输出(Output Schema)、实时校验(Validation)。缺一不可。
3.1 输入 Schema:用 Prompt Engineering 做流程前置过滤
LLM 的输入不是原始文本,而是经过 Flowable 流程预处理的结构化数据包。以医保申诉为例,我们定义的输入 Schema 长这样:
{ "patientId": "P2023001234", "diagnosisCode": "C78.000", "procedureCodes": ["CT001", "CT002"], "drugPrescriptions": [ { "name": "吗啡缓释片", "dosePerDay": "60mg", "maxDosePerDay": "30mg", "guidelineRef": "《癌痛诊疗规范2023版》第4.2条" } ], "ocrText": "患者男,72岁,确诊肺癌晚期...(此处省略 2000 字)" }关键点在于:绝不把原始 OCR 文本直接喂给模型。Flowable 在进入 LLM 节点前,先用 Java 服务做三件事:第一,提取结构化字段(ICD 编码、药品名、剂量),第二,过滤掉无关段落(如患者家属签字栏、医院公章描述),第三,把指南条款按相关性排序后截取前 3 条。这步预处理把输入长度压缩了 68%,同时把模型的注意力牢牢锁在关键证据上。实测显示,结构化输入使 LLM 输出合规率从 52% 提升到 91%。
3.2 输出 Schema:用 JSON Schema 强制模型“说人话”
我们不用自由生成,而是用response_format参数强制返回 JSON:
{ "decision": "APPROVE|REJECT|REQUEST_MORE_INFO", "confidenceScore": 0.92, "evidenceReferences": ["《癌痛诊疗规范2023版》第4.2条", "《医保药品目录2024》附录B"], "reasoningSteps": [ "步骤1:确认患者诊断为肺癌晚期(依据OCR文本第3段)", "步骤2:确认用药剂量超指南上限2倍(依据drugPrescriptions字段)", "步骤3:但指南第4.2条明确允许晚期癌痛患者超量使用" ] }关键是,这个 Schema 不是写在 Prompt 里的模糊要求,而是通过 API 的response_format字段传递给 vLLM 服务:
// LlmRequest.java public class LlmRequest { private String model; private List<Map<String, String>> messages; private Map<String, Object> response_format; // {"type": "json_object", "schema": {...}} // ... }vLLM 会基于这个 Schema 做 token-level 约束,确保每个生成的字符都符合 JSON 结构。比单纯靠 Prompt 提示可靠 10 倍。我们测试过,即使模型温度设为 0.8,只要response_format存在,JSON 格式错误率为 0。
3.3 实时校验:Flowable 的“守门员”角色
LLM 返回 JSON 后,Flowable 必须做三重校验,任一失败即终止流程:
- 语法校验:用 Jackson
ObjectMapper.readTree()解析,捕获JsonProcessingException; - Schema 校验:用
json-schema-validator库验证字段完整性(如decision必填、confidenceScore在 0-1 之间); - 业务校验:检查
evidenceReferences中的条款编号是否真实存在于本地指南库(防止模型幻觉)。
校验失败的处理策略不是简单报错,而是走降级流程:
<exclusiveGateway id="llmValidation" name="LLM 输出校验" /> <sequenceFlow id="flow1" sourceRef="llmValidation" targetRef="llmSuccess"> <conditionExpression xsi:type="tFormalExpression"><![CDATA[ ${llmResult != null && llmResult.decision != null && llmResult.confidenceScore >= 0.7} ]]></conditionExpression> </sequenceFlow> <sequenceFlow id="flow2" sourceRef="llmValidation" targetRef="manualReview"> <conditionExpression xsi:type="tFormalExpression"><![CDATA[ ${llmResult == null || llmResult.confidenceScore < 0.7} ]]></conditionExpression> </sequenceFlow>当置信度低于 0.7,自动转人工复核;当字段缺失,触发告警并通知运维。这才是企业级 LLM 节点该有的稳健性——它不追求 100% 自动化,而是把不确定的部分精准导流。
提示:别省略
confidenceScore字段。我们曾用一个固定值 0.95 欺骗自己,结果发现模型在遇到罕见病种时,明明胡说八道却自信爆棚。真实分数来自模型自身的 logits 计算,vLLM 支持logprobs参数返回概率分布,取 top-1 的 logit 值即可换算。
4. 生产环境的七层防护网——从网络、模型、流程到审计的全链路容错设计
LLM 节点一旦上线,就不再是 PoC,而是生产流程的咽喉要道。我们给它套了七层防护,每一层都对应一个真实故障场景。没有这七层,别说省级系统,连内部测试环境都撑不过一周。
4.1 第一层:网络熔断(Network Circuit Breaker)
LLM 服务挂了怎么办?不能让整个医保流程停摆。我们用 Resilience4j 的CircuitBreaker,配置如下:
resilience4j.circuitbreaker.instances.llm: failure-rate-threshold: 50 # 错误率超50%熔断 wait-duration-in-open-state: 30s # 熔断后30秒尝试半开 ring-buffer-size-in-half-open-state: 10 # 半开状态试10次 automatic-transition-from-open-to-half-open-enabled: true熔断后,ServiceTask直接返回预设的兜底策略:{"decision":"REQUEST_MORE_INFO","reason":"LLM服务暂不可用"}。流程继续流转,只是跳过智能判断,进入人工通道。上线三个月,熔断触发 7 次,平均恢复时间 22 秒,零业务中断。
4.2 第二层:模型降级(Model Fallback)
vLLM 集群负载过高时,响应延迟飙升。我们部署了双模型路由:主模型qwen2-7b-chat,备用模型phi-3-mini-4k-instruct(仅 3B 参数,CPU 即可运行)。当主模型 P95 延迟 > 8s,自动切到备用模型。切换逻辑在LlmDecisionService里:
if (metrics.getP95Latency() > 8000) { request.setModel("phi-3-mini-4k-instruct"); request.setTemperature(0.0); // 降低随机性 }备用模型准确率低 12%,但响应稳定在 1.2s 内。对“超量用药”这种强规则场景,3B 模型足够胜任。
4.3 第三层:Token 预估与截断(Token Budgeting)
LLM 的最大坑是输入超长导致 400 错误。我们不依赖模型的max_tokens参数,而是用 Tiktoken 库在 Java 层预估:
public int estimateTokens(String text) { // 使用 qwen2 的 tokenizer,精度误差 < 2% return QwenTokenizer.countTokens(text); } // 在 execute() 方法开头 int estimatedTokens = estimateTokens(inputText); if (estimatedTokens > 4000) { inputText = truncateBySentences(inputText, 4000); // 按句子截断,保留语义完整 }实测截断后,模型决策准确率仅下降 3.2%,但 400 错误归零。
4.4 第四层:Prompt 版本控制(Prompt Versioning)
Prompt 不是写完就扔的文本,而是要像代码一样管理。我们在 Git 仓库建prompt-templates/目录,每个文件带版本号:
prompt_medical_review_v1.3.txt prompt_medical_review_v1.4.txt # 修复了对“联合用药”的误判Flowable 的ServiceTask通过@Value("${prompt.version:v1.4}")加载对应版本,并记录到流程变量promptVersionUsed。审计时可精确追溯某次决策用了哪个 Prompt。
4.5 第五层:输出去敏(Output Sanitization)
LLM 可能复述患者身份证号、手机号。我们在 JSON 解析后立即清洗:
public LlmResponse sanitize(LlmResponse response) { response.setReasoningSteps(response.getReasoningSteps().stream() .map(step -> step.replaceAll("\\d{17}[\\dXx]", "[ID_HIDDEN]")) .map(step -> step.replaceAll("1[3-9]\\d{9}", "[PHONE_HIDDEN]")) .collect(Collectors.toList())); return response; }清洗规则随监管要求动态更新,无需重启服务。
4.6 第六层:流程级重试(Process-Level Retry)
单次 LLM 调用失败,Flowable 默认不重试(怕重复扣费)。我们用FailedJobCommandHandler自定义重试逻辑:
@Component public class LlmJobRetryHandler implements FailedJobCommandHandler { @Override public void handle(FailedJobEntity jobEntity) { if ("llmDecision".equals(jobEntity.getExecution().getActivityId())) { // 仅重试3次,间隔指数增长 if (jobEntity.getRetries() > 0) { jobEntity.setRetries(jobEntity.getRetries() - 1); jobEntity.setLockExpirationTime(DateUtils.addSeconds(new Date(), (int) Math.pow(2, 3 - jobEntity.getRetries()) * 10)); } } } }避免因瞬时网络抖动导致流程卡死。
4.7 第七层:全链路审计(End-to-End Audit)
每次 LLM 调用,必须记录四要素:输入原文哈希、输出 JSON、调用耗时、所用模型版本。我们用 Flowable 的HistoryService扩展:
public class LlmAuditListener implements ExecutionListener { @Override public void notify(DelegateExecution execution) { if ("llmDecision".equals(execution.getCurrentActivityId())) { LlmAudit audit = new LlmAudit(); audit.setInputHash(sha256(execution.getVariable("inputText").toString())); audit.setOutputJson((String) execution.getVariable("llmResult")); audit.setDurationMs((Long) execution.getVariable("llmDuration")); audit.setModelVersion("qwen2-7b-chat-v202405"); auditRepository.save(audit); } } }审计表与 Flowable 的ACT_HI_PROCINST关联,支持按流程实例 ID 精准回溯。
经验:第七层审计不是为了应付检查,而是为了快速定位问题。有一次发现某类申诉通过率突降,查审计日志发现是
inputText里混入了 PDF 元数据(乱码),导致模型理解偏差。没有这层日志,根本无法归因。
5. 别只盯着“节点”,Flowable 的监听器才是 LLM 的隐形指挥官
标题说“接入 LLM 节点”,但真正让 LLM 融入企业流程血液的,是 Flowable 的监听器(Listener)。ExecutionListener、TaskListener、HistoryEventListener这三类监听器,就像流程的神经末梢,能在节点执行前后、任务创建完成时、历史记录生成时,悄无声息地注入 LLM 能力。这才是高手玩法。
5.1 ExecutionListener:在节点执行前动态注入上下文
LLMDecisionNode需要最新诊疗指南,但指南每月更新。如果每次调用都去数据库查,IO 压力巨大。我们用ExecutionListener在节点启动前预加载:
@Component public class LlmContextPreloadListener implements ExecutionListener { @Override public void notify(DelegateExecution execution) { if ("llmDecision".equals(execution.getCurrentActivityId())) { // 从 Redis 缓存读取最新指南(TTL 1 小时) String latestGuidelines = redisTemplate.opsForValue() .get("guidelines:latest"); execution.setVariable("latestGuidelines", latestGuidelines); } } }BPMN 里绑定:
<serviceTask id="llmDecision" name="LLM 医保审核" flowable:delegateExpression="${llmDecisionService}"> <extensionElements> <flowable:executionListener event="start" class="com.example.LlmContextPreloadListener" /> </extensionElements> </serviceTask>这样,ServiceTask的 Java 方法里直接execution.getVariable("latestGuidelines")就行,毫秒级获取,零数据库压力。
5.2 TaskListener:用 LLM 优化人工任务体验
UserTask是人工审批节点。传统做法是给审批人看原始 OCR 文本,密密麻麻几页纸。我们用TaskListener在任务创建时,自动生成摘要:
@Component public class TaskSummaryGenerator implements TaskListener { @Override public void notify(DelegateTask delegateTask) { if ("manualReview".equals(delegateTask.getTaskDefinitionKey())) { String fullText = (String) delegateTask.getExecution().getVariable("ocrText"); // 调用轻量 LLM(CPU 模型)生成 300 字摘要 String summary = lightweightLlm.summarize(fullText); delegateTask.setVariable("taskSummary", summary); } } }审批人在待办列表里看到的不再是“患者病历(23页)”,而是“【AI摘要】72岁肺癌晚期患者,主张超量使用吗啡缓释片,依据:疼痛评分8分,已用常规止痛药无效,附肿瘤科会诊意见...”。实测审批效率提升 40%。
5.3 HistoryEventListener:用 LLM 做流程健康度分析
HistoryEventListener在流程结束时触发,我们用它分析历史决策模式:
@Component public class ProcessInsightListener implements HistoryEventListener { @Override public void handleEvent(HistoryEvent event) { if (event.getType() == HistoryEventTypes.ACTIVITY_INSTANCE_END && "llmDecision".equals(event.getActivityId())) { // 收集本次决策的置信度、耗时、模型版本 double confidence = (Double) event.getProcessVariables().get("confidenceScore"); long duration = (Long) event.getProcessVariables().get("llmDuration"); // 当周平均置信度 < 0.85,自动触发 Prompt 优化任务 if (weeklyConfidenceAvg < 0.85) { createPromptOptimizationTask(); } } } }LLM 不仅处理单次任务,还反哺流程自身进化。这才是闭环智能。
最后分享一个小技巧:监听器里调用 LLM 一定要用
@Async标记异步方法,否则会阻塞主线程。我们曾因一个同步的TaskListener导致任务创建延迟 2 秒,被用户投诉“系统变慢了”。