☰
Flowable集成LLM实现语义决策中枢
2026/10/2 5:10:43 网站建设 项目流程

1. 项目概述:让工作流真正“思考”起来

Flowable 工作流引擎在企业级业务系统中跑了十几年,从审批单、采购流程到复杂的供应链协同,它稳得像台老式柴油机——可靠、可预测、逻辑清晰。但问题来了:当流程走到“需要判断客户投诉是否属于高危舆情”这一步时,传统规则引擎要么写死一堆 if-else,要么调个简单关键词匹配,结果是漏判率高、误判率高、规则维护成本爆炸。这时候你盯着 Flowable 的 BPMN 图发呆,心里清楚:不是流程引擎不行,是它缺了“理解”和“推理”的能力。而大模型 LLM 正好补上这块拼图——它不靠硬编码的规则,而是靠对语义的深度理解、上下文的动态推理、以及生成式响应来处理模糊边界问题。所谓“接入 LLM 节点”,本质不是给 Flowable 装个新插件,而是构建一个语义决策中枢:把流程中那些原本需要人工拍板、专家经验、或临时写脚本处理的“灰色地带”任务,交给 LLM 去完成。比如简历筛选工作流里,不是简单筛掉“学历非985”的硬条件,而是让 LLM 读完整份简历+JD+公司文化描述,输出“匹配度87%,优势在项目落地能力,建议进入二面并重点关注跨部门协作案例”;再比如客服工单流转中,LLM 实时分析对话原文+历史工单+产品文档,直接给出“应升级为P0级技术故障,转研发组,并附带复现路径建议”。这不是炫技,是把 LLM 从“聊天玩具”变成流程里的“智能执行单元”。我去年在一家保险科技公司落地这个方案时,把理赔审核环节的自动初审率从42%拉到79%,人工复核时间平均缩短63%。关键在于,整个过程完全跑在 Flowable 原有架构上,没动核心引擎,只加了两个轻量级服务模块。下面我就把从设计思路、节点封装、参数调优到避坑实录的全过程,掰开揉碎讲清楚。

2. 整体架构设计与核心思路拆解

2.1 为什么不能直接在 Flowable 中嵌入 LLM SDK?

这是新手最容易踩的第一个坑。看到 Flowable 支持 Java Delegate、Script Task,就想当然地在 delegate 类里 new 一个 OpenAI 客户端,然后调用 chat.completions.create()。实测下来,三分钟内就会遇到三个致命问题:第一,Flowable 的流程引擎线程池是固定大小的(默认20),而 LLM API 调用是网络 I/O 密集型操作,一次请求平均耗时800ms~2s,线程卡死导致后续流程全部阻塞;第二,Java Delegate 运行在 Flowable 的 JVM 内,所有 token、API Key、prompt 模板都硬编码在 class 文件里,安全审计直接亮红灯;第三,LLM 返回的 JSON 结构千变万化,Flowable 的变量序列化机制(默认用 Jackson)根本解析不了嵌套过深或含特殊字符的 response,流程直接抛出JsonMappingException异常中断。我试过用 Spring Boot 的 WebClient 异步封装,结果发现 Flowable 的异步任务调度器(AsyncExecutor)和 WebClient 的 Mono/Flux 线程模型根本对不上,回调永远收不到。所以结论很明确:LLM 必须作为独立服务存在,Flowable 只负责发指令、收结果,中间用标准协议桥接。这不仅是技术选型,更是架构分层的铁律。

2.2 三层解耦架构:Flowable + Adapter + LLM Service

我们最终采用的是严格分层的三段式架构,每层职责清晰、可独立部署、可灰度升级:

  • Flowable 层(决策发起者):保持原样,只做两件事——在 BPMN 流程图中定义一个 Service Task,配置其class属性指向自定义的LlmServiceDelegate;同时通过execution.setVariable("llm_input", inputMap)把结构化输入数据(如用户原始文本、业务上下文、约束条件)传进去。这里的关键是,Flowable 不关心 LLM 怎么算,只认一个约定好的输入格式和输出格式。

  • Adapter 层(协议翻译器):这是整个方案的“心脏”,一个独立的 Spring Boot 微服务(我们叫它flowable-llm-adapter)。它暴露/v1/llm/invokeREST 接口,接收 Flowable 发来的 POST 请求(JSON body),做三件事:① 校验输入合法性(比如检查prompt_template_id是否存在、max_tokens是否超限);② 根据model_name(如 "qwen2-7b" 或 "gpt-4o")路由到对应后端;③ 把 Flowable 传来的扁平化变量,按目标 LLM 的 API 规范组装成标准请求(OpenAI 格式 or Ollama 格式 or 自研模型 SDK 格式)。Adapter 层还内置熔断器(Resilience4j)、重试策略(最多2次)、结果缓存(Redis,key 为llm:${hash(input)},TTL 1h),避免重复调用。

  • LLM Service 层(能力提供者):完全与 Flowable 解耦。可以是云厂商 API(Azure OpenAI)、本地部署模型(Ollama + Qwen2)、或私有化大模型平台(如魔搭 ModelScope 上的 finetuned 模型)。这一层只管一件事:收到标准请求,返回标准响应(必须包含choices[0].message.content字段)。我们甚至用它对接过公司内部的 RAG 系统——Adapter 把用户问题+知识库 ID 传过去,LLM Service 先检索再生成,Flowable 拿到的还是纯文本结果,完全无感。

这种设计带来的实际好处是:当业务方说“下周要把 GPT-4 换成自家微调的 DeepSeek-V2”时,运维只需改 Adapter 的配置文件,Flowable 和前端流程图一动不动;当 LLM Service 因为 GPU 显存不足挂了,Adapter 的熔断器会自动降级返回预设的 fallback 文本(如“当前智能分析繁忙,请稍后重试”),流程不会中断,只是降级为人工处理。

2.3 节点类型选择:Service Task 是唯一正解

BPMN 规范里能调外部服务的节点有三种:Service Task、Send Task、Business Rule Task。很多人纠结选哪个,其实答案非常明确:必须用 Service Task。原因如下:

  • Send Task 设计初衷是发消息(如 JMS、Email),它的implementation属性只支持##webService或##other,没有 Java Delegate 扩展点,无法注入 Spring Bean,也就没法调用我们的 Adapter 客户端。

  • Business Rule Task 本质是规则引擎(Drools),它期望输入是事实(Fact)对象,输出是规则结果,而 LLM 的输入是 prompt,输出是自由文本,语义完全不匹配。强行塞进去会导致规则引擎解析失败。

  • Service Task 的class属性天然支持自定义 Java Delegate,且 Flowable 会自动注入DelegateExecution对象,让我们能自由读写流程变量、控制流程走向。更重要的是,Service Task 支持async属性(<serviceTask id="llmNode" flowable:async="true" ...>),开启后 Flowable 会把任务扔进异步队列,由独立线程池处理,彻底规避主线程阻塞。我们在生产环境把 async 线程池大小设为core=10, max=50,配合 Adapter 的熔断,即使 LLM 服务抖动,流程吞吐量也只下降15%,远优于同步调用的雪崩效应。

提示:不要试图用 Script Task(JavaScript 或 Groovy)调用 LLM。Groovy 的HttpBuilder在 Flowable 的沙箱环境里权限受限,HTTPS 证书验证经常失败;JavaScript 引擎(Nashorn)在 JDK15+ 已被移除,兼容性极差。这些坑我们都踩过。

3. 核心细节解析与实操要点

3.1 LLM 节点的输入设计:结构化 Prompt 工程

LLM 不是万能的,它需要精准的“指令”。在 Flowable 场景下,这个指令不能是随手写的自然语言,而必须是结构化、可版本化、可审计的 Prompt。我们设计了一套三层输入模型:

  • 基础层(Flowable 变量):流程启动时,业务系统通过RuntimeService.startProcessInstanceByKey()传入 Map,例如:

    Map<String, Object> variables = new HashMap<>(); variables.put("customer_complaint", "APP登录后一直闪退,iOS 17.5,iPhone 14 Pro"); variables.put("product_version", "v3.2.1"); variables.put("user_level", "VIP"); variables.put("history_tickets", "[{'type':'bug','status':'resolved'}, {'type':'feature','status':'pending'}]"); runtimeService.startProcessInstanceByKey("complaint_flow", variables);

    这些变量会被LlmServiceDelegate自动收集,作为 Prompt 的原始素材。

  • 模板层(Prompt Template):存放在数据库或配置中心(我们用 Apollo),每条模板有唯一 ID(如complaint_risk_v2)、版本号、生效时间。模板内容是 Jinja2 格式,例如:

    你是一名资深的APP质量分析师,请根据以下信息判断本次投诉的风险等级(高/中/低)并给出理由: - 用户设备:{{ device_info }} - APP版本:{{ product_version }} - 用户等级:{{ user_level }} - 历史工单:{{ history_tickets | default('无') }} - 当前投诉:{{ customer_complaint }} 输出格式必须严格为JSON,包含两个字段: {"risk_level": "高|中|低", "reason": "不超过50字的分析"}

    关键点:① 所有变量用{{ }}包裹,确保渲染安全;② 强制指定输出格式(JSON),避免 LLM 自由发挥;③ 加入角色设定(“资深APP质量分析师”),提升专业性。

  • 策略层(Dynamic Context):在LlmServiceDelegate中动态注入。比如检测到user_level == "VIP",就额外追加一条 context:

    if ("VIP".equals(execution.getVariable("user_level"))) { context.put("vip_rule", "VIP用户投诉需优先处理,风险等级自动+1档"); }

    这样模板里就能用{{ vip_rule }},实现业务规则与 Prompt 的解耦。

实测下来,这种结构化设计让 Prompt 维护效率提升3倍:运营人员只需改数据库里的模板文本,开发不用发版;审计时直接查模板ID和版本,就能追溯每次 LLM 判断的依据。

3.2 输出解析与变量映射:让 Flowable “读懂”LLM

LLM 返回的是一段字符串,而 Flowable 流程需要的是结构化变量(如risk_level用于后续网关判断)。如果用正则表达式硬匹配,会陷入“写一个正则,修十个 bug”的深渊。我们的解决方案是:强制 LLM 输出标准 JSON,并用 Schema 验证。

Adapter 层收到 LLM 响应后,先做三步校验:

  1. JSON 格式校验:用 Jackson 的ObjectMapper.readTree()尝试解析,捕获JsonProcessingException;
  2. Schema 匹配校验:每个 Prompt 模板关联一个 JSON Schema(存于数据库),例如:
    { "type": "object", "properties": { "risk_level": {"enum": ["高", "中", "低"]}, "reason": {"type": "string", "maxLength": 50} }, "required": ["risk_level", "reason"] }
    用json-schema-validator库验证,不通过则返回错误码LLM_OUTPUT_INVALID;
  3. 业务逻辑校验:比如risk_level为“高”时,reason字段必须包含关键词“崩溃”或“闪退”。

只有三重校验全通过,才把 JSON 解析为 Map,调用execution.setVariables(localVars)写回 Flowable。这样,后续的 Exclusive Gateway 就能直接用${risk_level == '高'}做分支判断,和普通变量毫无区别。

注意:绝对不要在 Flowable 的 BPMN 图里用 Expression 直接解析 LLM 返回的原始字符串。我们曾见过有人写${llm_response.contains('高风险') ? 'high' : 'low'},结果 LLM 一次返回“高风险已确认”,另一次返回“判定为高风险级别”,正则就失效了。结构化输出是底线。

3.3 Token 管控与成本控制:每个 token 都要精打细算

LLM 调用不是免费午餐,尤其在高频流程中。我们统计过,一个典型客服工单分析,平均消耗 1200 tokens(input 800 + output 400),按 GPT-4o 的价格($5/M input tokens),日均 10 万次调用就是 $600 成本。必须建立 token 预估和截断机制:

  • 输入预估:在LlmServiceDelegate中,用tiktoken(Python)或openai-java的TokenCount工具,对拼接后的 prompt 字符串做预估。公式很简单:estimated_tokens = (prompt_length * 1.3) / 4(英文字符按 1:1,中文按 1:1.3,再除以 4 得 token 数)。如果预估 >max_input_tokens(配置项,默认 2048),就触发截断策略——优先保留customer_complaint和history_tickets的最新3条,其余用省略号代替。

  • 输出截断:Adapter 层在调用 LLM API 时,强制设置max_tokens参数。但要注意:LLM 可能提前结束(如生成完 JSON 就停),也可能超限被截断。我们的做法是,在 JSON 解析前,先检查 response 字符串是否以}结尾,如果不是,就认为被截断,此时返回{"risk_level":"未知","reason":"响应不完整,请重试"},并记录告警。

  • 成本监控:Adapter 层每调用一次,就往 Prometheus Pushgateway 推送指标:

    llm_tokens_used{model="gpt-4o", template="complaint_risk_v2"} 1200 llm_cost_usd{model="gpt-4o"} 0.006

    Grafana 里就能看到各模板的 token 消耗 Top10,精准定位优化点。有个真实案例:把history_tickets从“全部展示”改为“仅展示未解决工单”,单次调用 token 从 1800 降到 650,成本直降 64%。

4. 实操过程与核心环节实现

4.1 开发 LlmServiceDelegate:Flowable 的“LLM 驱动器”

这是 Flowable 侧最核心的代码,必须做到零依赖、高稳定、易调试。我们摒弃了 Spring 的RestTemplate(太重),用 OkHttp 手写 HTTP 客户端:

@Component("llmServiceDelegate") public class LlmServiceDelegate implements JavaDelegate { private static final Logger log = LoggerFactory.getLogger(LlmServiceDelegate.class); private final OkHttpClient httpClient = new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .build(); @Override public void execute(DelegateExecution execution) throws Exception { // 1. 构建输入Map:取流程变量 + 动态上下文 Map<String, Object> inputMap = buildInputMap(execution); // 2. 调用Adapter服务 String adapterUrl = "http://flowable-llm-adapter:8080/v1/llm/invoke"; RequestBody body = RequestBody.create( MediaType.parse("application/json"), new ObjectMapper().writeValueAsString(inputMap) ); Request request = new Request.Builder() .url(adapterUrl) .post(body) .build(); try (Response response = httpClient.newCall(request).execute()) { if (!response.isSuccessful()) { throw new RuntimeException("Adapter call failed: " + response.code()); } String responseBody = response.body().string(); // 3. 解析JSON响应并写回流程变量 JsonNode result = new ObjectMapper().readTree(responseBody); Map<String, Object> outputVars = new HashMap<>(); outputVars.put("llm_result", result); outputVars.put("risk_level", result.path("risk_level").asText()); outputVars.put("llm_reason", result.path("reason").asText()); execution.setVariables(outputVars); } catch (IOException e) { log.error("LLM invoke failed for process {}", execution.getProcessInstanceId(), e); throw new BpmnError("LLM_INVOKE_FAILED", "LLM service unavailable"); } } private Map<String, Object> buildInputMap(DelegateExecution execution) { Map<String, Object> inputMap = new HashMap<>(); // 取所有流程变量 execution.getVariables().forEach((k, v) -> { if (v != null && !(v instanceof byte[])) { // 过滤二进制变量 inputMap.put(k, v.toString()); } }); // 注入动态上下文 injectDynamicContext(inputMap, execution); return inputMap; } private void injectDynamicContext(Map<String, Object> inputMap, DelegateExecution execution) { // 示例:VIP用户加权 if ("VIP".equals(inputMap.get("user_level"))) { inputMap.put("vip_weight", 1.5); } // 示例:根据产品线注入不同知识库ID String productLine = (String) inputMap.get("product_line"); if ("finance".equals(productLine)) { inputMap.put("kb_id", "kb_finance_2024"); } } }

关键细节说明:

  • OkHttp 连接池复用:OkHttpClient是线程安全的,全局单例,避免频繁创建连接;
  • 异常分类处理:HTTP 错误(4xx/5xx)抛BpmnError,让 Flowable 进入错误边界事件;网络异常(IOException)则抛运行时异常,触发重试;
  • 变量过滤:跳过byte[]类型变量(如上传的附件),防止 JSON 序列化失败;
  • 动态上下文注入:把业务规则逻辑放在 delegate 里,而不是硬编码在 prompt 模板中,便于 A/B 测试。

4.2 Adapter 服务开发:协议转换与熔断保障

Adapter 是 Spring Boot 应用,核心是LlmInvokeController:

@RestController @RequestMapping("/v1/llm") public class LlmInvokeController { @Autowired private LlmClientFactory clientFactory; // 根据model_name返回不同Client @PostMapping("/invoke") @CircuitBreaker(name = "llmInvoke", fallbackMethod = "fallbackInvoke") @Retryable(value = {IOException.class}, maxAttempts = 2, backoff = @Backoff(delay = 1000)) public ResponseEntity<Map<String, Object>> invoke(@RequestBody Map<String, Object> inputMap) throws Exception { // 1. 校验必填字段 if (!inputMap.containsKey("prompt_template_id") || !inputMap.containsKey("model_name")) { throw new IllegalArgumentException("Missing required fields"); } // 2. 获取Prompt模板(从DB或Cache) PromptTemplate template = templateService.findById((String) inputMap.get("prompt_template_id")); if (template == null) { throw new IllegalArgumentException("Template not found"); } // 3. 渲染Prompt(Jinja2) String renderedPrompt = jinja2Engine.render(template.getContent(), inputMap); // 4. 构建LLM请求 LlmRequest request = LlmRequest.builder() .model((String) inputMap.get("model_name")) .messages(List.of(new Message("user", renderedPrompt))) .maxTokens(Integer.parseInt(String.valueOf(inputMap.getOrDefault("max_tokens", 512)))) .build(); // 5. 调用具体LLM Client LlmResponse response = clientFactory.getClient(request.getModel()).invoke(request); // 6. JSON Schema校验 JsonNode resultNode = objectMapper.readTree(response.getContent()); if (!schemaValidator.validate(template.getSchema(), resultNode)) { throw new ValidationException("LLM output does not match schema"); } return ResponseEntity.ok(objectMapper.convertValue(resultNode, Map.class)); } // 熔断降级方法 public ResponseEntity<Map<String, Object>> fallbackInvoke( Map<String, Object> inputMap, Throwable t) { log.warn("LLM invoke fallback triggered", t); Map<String, Object> fallback = new HashMap<>(); fallback.put("risk_level", "未知"); fallback.put("reason", "智能分析服务暂时不可用,请稍后重试"); return ResponseEntity.ok(fallback); } }

关键组件说明:

  • LlmClientFactory:工厂模式,根据model_name返回不同实现:
    • OpenAiClient:封装 OpenAI REST API;
    • OllamaClient:调用本地 Ollama 的/api/chat接口;
    • CustomModelClient:对接公司私有模型平台的 gRPC 服务。
  • @CircuitBreaker:用 Resilience4j,失败率 >50% 持续30秒,就打开熔断器,后续请求直接走fallbackInvoke;
  • @Retryable:网络抖动时自动重试,避免单次失败影响流程;
  • Schema 校验:schemaValidator是封装的json-schema-validator,确保 LLM 输出始终可控。

4.3 BPMN 流程图实战:从草图到上线

以“简历筛选工作流”为例,展示如何在 Flowable Modeler 中配置 LLM 节点:

  1. 绘制主流程:Start Event → Service Task(LLM 节点)→ Exclusive Gateway → [High Match] → Human Task(HR 面试)→ End;[Medium Match] → Service Task(发送测评链接)→ End;[Low Match] → End。

  2. 配置 Service Task:

    • ID:llm_resume_analyze
    • Name:LLM 简历分析
    • Implementation:llmServiceDelegate(即我们写的 delegate bean 名)
    • Async:勾选 ✅(关键!)
    • Field Injection:添加两个字段
      • promptTemplateId=resume_screening_v3
      • modelName=qwen2-7b
  3. 配置 Exclusive Gateway:

    • Condition 1:${llm_result.risk_level == '高'}
    • Condition 2:${llm_result.risk_level == '中'}
    • Default Flow:指向 Low Match 分支
  4. 测试技巧:

    • 在 Flowable Admin 中,用“启动流程实例”功能,手动输入变量 JSON:
      { "candidate_name": "张三", "resume_text": "5年Java开发,主导过电商秒杀系统重构,熟悉Spring Cloud、Redis集群...", "job_description": "招聘高级Java工程师,要求:1. 3年以上分布式系统经验;2. 有高并发场景实战;3. 熟悉DDD..." }
    • 查看流程实例日志,确认llm_result变量是否正确写入;
    • 在 Gateway 处设置断点,验证分支是否按预期走向。

实操心得:第一次部署时,务必在 Gateway 前加一个 Script Task,内容为console.log("LLM result: " + execution.getVariable("llm_result"));。这样能在日志里直接看到 LLM 返回的原始 JSON,比查数据库快十倍。等流程跑通后再删掉。

5. 常见问题与排查技巧实录

5.1 典型问题速查表

问题现象根本原因排查步骤解决方案
流程卡在 LLM 节点,日志显示AsyncExecutor线程耗尽LLM API 响应慢(>30s),async 线程池被占满① 查flowable-default-async-executor日志;② 用jstack看线程堆栈;③ 检查 Adapter 的readTimeout调大 Adapter 的readTimeout(如 60s),并增加 async 线程池maxPoolSize
LLM 返回 JSON,但 Flowable 报Cannot construct instance of java.util.LinkedHashMapJackson 反序列化时,LLM 返回的字段名含空格或特殊字符(如"risk level")① 在LlmServiceDelegate中打印原始 response 字符串;② 用在线 JSON 校验工具检查在 Prompt 模板中强制字段名用下划线(risk_level),并在 Adapter 层做字段名标准化
同一输入,LLM 有时返回高风险,有时返回中风险Prompt 中未固定随机种子(seed),LLM 生成具有随机性① 对比两次调用的 prompt 字符串(MD5);② 检查 LLM API 是否传了seed参数在LlmRequest中统一设置seed=42,确保相同输入必得相同输出
Adapter 日志报Connection refused,但 LLM 服务明明在运行Docker 网络配置错误,Adapter 容器无法访问 LLM 容器① 进入 Adapter 容器docker exec -it adapter sh;②ping ollama或curl http://ollama:11434在 docker-compose.yml 中将两个服务放在同一 network,并用 service name 互访

5.2 独家避坑技巧

  • Prompt 版本灰度发布技巧:不要直接替换线上模板。我们在数据库模板表里加了status字段(DRAFT/ACTIVE/OBSOLETE)和weight字段(0~100)。新模板先设status=DRAFT, weight=10,意味着 10% 的流量走新模板,90% 走旧模板。通过对比两组的risk_level准确率(人工抽检),确认效果达标后再把 weight 调到 100。这招让我们上线resume_screening_v3时,0 故障。

  • LLM 响应延迟的“感知优化”:用户提交后,流程页面显示“智能分析中...(预计15秒)”,其实是前端轮询 Flowable 的taskAPI。但若 LLM 真的卡住,用户等30秒会焦虑。我们的解法是:在LlmServiceDelegate中,调用 Adapter 前,先写一个llm_status变量为"pending";Adapter 收到请求后,立即返回{"status":"accepted"};前端看到llm_status=="pending"就显示倒计时,看到llm_status=="done"就刷新结果。这样用户感知的等待时间,从“不确定”变成了“确定的15秒”。

  • 敏感信息脱敏前置:LLM 节点处理的文本常含手机号、身份证号。我们不在 Prompt 里写“请忽略手机号”,而是在buildInputMap()方法里,用正则预处理:

    String text = (String) inputMap.get("customer_complaint"); text = text.replaceAll("(?<!\\d)1[3-9]\\d{9}(?!\\d)", "[PHONE]"); text = text.replaceAll("\\d{17}[0-9Xx]", "[ID_CARD]"); inputMap.put("customer_complaint", text);

    这样既保护隐私,又不影响 LLM 理解语义(“[PHONE]”仍能提示这是联系信息)。

  • Fallback 的终极保险:当熔断器打开,fallbackInvoke返回“服务不可用”,但业务不能停。我们在 Gateway 后加了一个兜底分支:如果llm_result.risk_level == '未知',就触发DefaultRuleEvaluatordelegate,用硬编码规则做降级判断(如“VIP用户+投诉含‘崩溃’字眼 → 高风险”)。这保证了 LLM 服务完全宕机时,流程依然能走通,只是精度略低。

最后分享一个小技巧:在 Adapter 的/actuator/prometheus端点里,我们暴露了llm_cache_hit_ratio指标。当发现某模板的缓存命中率长期低于 20%,就说明这个模板的输入太个性化(如含大量时间戳、随机ID),不适合缓存。这时我们会把它从缓存策略里移除,避免无效缓存占用内存。这个指标成了我们优化 Prompt 设计的黄金罗盘。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询