干流程开发这些年我几乎每一轮迭代都会遇到同一个问题业务方拿着一个“需要用人工经验判断”的环节问我能不能自动化。以前遇到这种需求就比较棘手——规则的 else 分支写多了就成了一套不太能维护的假智能不写吧流程又得断在人肉审核这一环。直到把大模型LLM作为一个工作流节点接进 Flowable这个僵局才算真正打破。Flowable 本身是 BPMN 引擎擅长把人工审批、任务分配、条件网关这些流程状态管得明明白白但要它在流程里自动读懂一段工单描述、一封客服邮件、一段质检对话原生能力是办不到的。我的做法很简单在流程图上放一个 Service Task写一个 JavaDelegate 委托类在节点执行时把流程变量拼成 Prompt调用 LLM API再把模型返回的结果写回流程变量后续网关、人工任务、抄送逻辑都可以基于这个结果继续往下走。这篇文章我会从整体设计、BPMN 配置、Java 代码、异步与重试、常见坑这五个层面把 Flowable 接入 LLM 节点的完整方案讲清楚。如果你正好在 Flowable 和 AI 能力的交界处犹豫这篇应该能帮你少走不少弯路。1. 为什么非要给 Flowable 接一个 LLM 节点1.1 传统工作流的“规则判断”天花板Flowable 里最常规的业务判断无非三种排他网关写条件表达式、Java 委托类里写 if/else、事件监听里做数据校验。这三样东西对付“金额是否超 5000”“申请人是不是经理”“日期是否逾期”都挺好使但一遇到“这段用户描述属于什么故障类型”“这封邮件是不是投诉倾向”“这份合同有没有明显风险条款”就傻眼了。原因很简单这些判断没有确定性的公式靠的是理解自然语言而自然语言恰恰是规则引擎最大的盲区。以前解决这类需求只有两条路。第一条是让流程停下来等人工处理典型做法是 User Task 挂一个审批人第二条是把判断逻辑塞到某个业务服务里让服务内部接关键词词表、甚至训练一个简单的分类模型。但第一条拖慢时效第二条维护成本极高词表迭代全靠业务提需求再发版等词表上线新的表达方式又冒出来了。LLM 节点的定位刚好卡在中间它既不替代人工终审也不背负全量业务规则而是把“只需要初步理解、可以交给模型完成”的中间判断环节接过来。在 Flowable 里接入之后流程就能自动读取文本类流程变量生成结构化结果再交给原有的网关和人工节点去决定下一步。本质上是把 BPMN 的流程编排能力和 LLM 的语义理解能力缝合在一起而不是把整个流程体系推倒重来。1.2 和 Dify / Coze / n8n 等 AI 工作流平台的取舍近期有不少团队拿 Dify、Coze、n8n 这类工作流工具来做 AI 编排。这些平台编排 Prompt、调用模型、管理知识库确实上手快几分钟就能拉出一条“判断 → 提取 → 回答”的链路。但放到企业存量系统里它们往往替代不了 Flowable 的位置。我说几个实际对比。Flowable 提供了 BPMN 全套建模能力比如人工任务池、会签、驳回、子流程、信号事件、到期提醒这些都是企业审批流刚需而外部 AI 工作流平台更偏线性的自动化管道不太擅长表达“多人依次审批、某一个人不同意就打回上一节点”这类状态流转。另外 Flowable 和业务库可以共享同一个事务和命令上下文流程走到一半逻辑出错可以回滚重来这在资金、合同类场景里非常关键。外部平台一般靠 API 回调同步结果和内部事务的一致性较难保证。数据方面Flowable 内嵌方案可以让文本和模型返回结果全部留在企业内网只在必要时把 Prompt 发送给模型服务安全边界更可控。这不是说外部 AI 工作流平台不好而是它们解决的是“从一个空流程开始快速搭 AI 自动化”的问题Flowable 接 LLM 节点解决的是“已有的、跑了两三年的核心流程如何升级为智能判断”的问题。后者不换引擎只是在原流程定义里加一个节点风险小很多。1.3 适合用 LLM 节点承接的几类场景根据我接过的实际需求比较典型的场景大概有四类工单/客服文本分类用户提交一段描述LLM 节点输出工单类别后续网关自动路由到对应处理团队。文档内容提取与结构化把合同、简历、发票等非结构化文本喂给 LLM输出 JSON 字段后续节点校验、入库。人工客服质检预筛把客服会话摘要交给模型打标签态度、合规、需复核命中风险的走复核任务。审批辅助建议流程触发前先生成一段审批摘要和风险提示随 User Task 的附件表单一起呈现给审批人。这一类场景有两个共同点结果不需要 100% 权威通常只需要一个较可靠的第一判断输入几乎都是自然语言规则引擎无从下手。只要满足这两点把 LLM 接到 Flowable 里就是合算的。拿工单分类来说模型返回的“网络”这个结论不需要具有法律效力它只是一个路由信号真正拍板的人还是后端服务或人工节点。2. 方案选型理解 Service Task 的扩展机制2.1 Service Task 在 Flowable 中扮演什么角色Flowable 中一个 Service Task服务任务就是流程图上那个带齿轮的小方框。它本身不干具体的活儿只是声明“走到这里时要触发一段外部逻辑”。触发方式有好几种flowable:class指定一个 Java 类的全限定名flowable:delegateExpression指定一个 Spring Bean 表达式flowable:expression直接写一份表达式甚至还可以调 WebService。对 LLM 来说最常用的就是前两种。很多人第一次写 Flowable 会困惑既然 Flowable 本身就支持 Java 类那直接在流程到达这个节点时调模型不就行了吗对就这么简单。Service Task 就是 Flowable 给开发者预留的自定义逻辑插槽。它执行的核心是JavaDelegate接口接口只有一个方法execute(DelegateExecution execution)。这个方法里面你能拿到整个流程的执行上下文包括流程变量、当前节点、流程实例 ID 等做完业务逻辑后可以把结果写回 execution后续节点就能继续使用。2.2 用 flowable:class 还是 delegateExpressionflowable:class写的是类全限定名Flowable 启动时会用这个类名反射创建实例。如果这个类不需要依赖 Spring 组件直接这么写最省事可一旦要访问配置中心、数据库 Mapper 或者模型服务就会面临“这个类怎么拿到 Spring Bean”的尴尬。flowable:delegateExpression写的是表达式常见写法是${llmDelegate}并且这个 Bean 必须在 Spring 容器里存在。两者从执行效果上看差不多但工程差异很大。我的项目里基本都是用delegateExpression因为工作流工程里几乎所有委托类都需要注入配置中心、模型服务、Redis 或者数据库 Mapper。直接用 Spring 管生命周期既能Autowired也可以放心利用 Spring 的代理、事务和 AOP。有一个容易踩的坑如果用了delegateExpression但 Bean 没有注册到 Spring 容器Flowable 部署流程的时候不会报错只有流程真正执行到那个节点时才会抛表达式解析异常异常信息还比较绕。建议在启动阶段做一个冒烟测试或者先用flowable:class做一个最简版本验证链路再迁到delegateExpression。2.3 模型名、提示词这些参数怎么配置Service Task 本身没有内置“模型名”“temperature”这些属性需要借助extensionElements扩展元素。在 BPMN 文件里这种自定义属性用field声明每个 field 有name和值流程部署时这些配置会被解析并绑定到当前节点上。运行时代码可以从execution.getCurrentFlowElement()拿到当前 FlowElement如果是 ServiceTask 类型就读取它的getFieldExtensions()遍历出我们定义的modelName、promptTemplate、temperature等。不过把配置写死在 BPMN 的方案只能满足固定模型、固定 Prompt 的场景。实际项目里经常需要按流程变量动态指定模型比如流程变量model用的是qwen-turbo另一个流程节点要换成qwen-max那可以在委托类里按“节点属性优先、流程变量兜底、代码默认值最后”的顺序去解析灵活性一下子就起来了。这块我在后面的实操代码里会写清楚。3. 整体设计LLM 节点该具备哪些能力3.1 先列一张能力清单在动手写代码前我习惯把 LLM 节点拆成一张能力清单避免做着做着变成“只要能调一个模型就行”的草台方案。常规设计至少包含以下四块入参处理能读取一个或多个流程变量作为模型输入能解析节点级静态配置模型、温度、最大 token支持把流程变量渲染进 Prompt 模板。模型调用支持至少一家主流模型 API最好兼容 OpenAI Chat Completions 的请求协议设置连接超时、读取超时输出结果能解析为字符串或 JSON。出参处理把模型输出写入流程变量命名要规范如llmResult、llmCategory、llmExtractedData必要时写日志和落库方便复盘。异常隔离模型服务不可用时不能把整个流程砸穿能降级、能重试、能记录失败原因并把流程引到兜底节点。这张清单是我后面三章设计的地基。其中最容易在初期被忽略的是第四块。不少团队第一次测试时模型服务是好的等上了生产模型 API 偶尔 5xx流程就整片积压最后才反过来补救。与其等事故不如第一次就把异常路径画进流程图。3.2 调用层设计协议、超时与重试我在代码里统一封装了一个LLMService所有委托类只依赖这个服务不直接碰 HTTP。原因有两个一是以后换模型供应商只需改一个类二是重试、超时、Token 统计这些横切逻辑可以集中维护不用每个 Delegate 都写一遍。调用协议建议优先找兼容 OpenAI Chat Completions 的服务。目前国内主流大模型厂商基本都支持这类协议统一用 HTTPS JSON 就能搞定代码不需要绑死某一家 SDK。请求结构也很简单model、messages、temperature、max_tokens。返回值里choices[0].message.content就是要的结果。超时参数要根据业务容忍度来配。一般 LLM 生成几十到几百 token1 到 3 秒很正常但复杂 Prompt 可能到 10 秒以上。我通常把连接超时设在 5 秒读取超时设到 60 秒同时在 Flowable 侧做成异步节点避免同步卡住 REST 请求线程。重试策略一般是二次退避第一次失败等 1 秒第二次失败等 3 秒最多三次模型返回 429/5xx 才会重试4xx 属于参数问题重试没有意义。3.3 Prompt 如何拿到流程上下文LLM 节点要“懂得”当前这条流程在经历什么关键在 Prompt 模板和流程变量的绑定。最简单的做法是字符串替换把 Prompt 里的占位符用流程变量替换掉。这种方法在变量少、结构简单时可以跑但变量一多就容易出现模板写错、空格处理不一致的问题。更规范的做法是引入模板引擎。Spring 工程里可以直接用 Freemarker把 BPMN 属性里声明的promptTemplate当作模板文本然后传入一个 MapMap 的 key 是流程变量名value 是流程变量值。Freemarker 还允许在模板里写条件判断和 list 循环应付“把最近三条历史操作记录拼进上下文”这类需求也不吃力。注意模板引擎表达式语法和 Flowable 网关表达式${expression}是两套体系别混用。4. 实操用 Service Task 实现一个工单智能分类流程4.1 Maven 依赖与工程结构我这里假设你用的是 Spring Boot Flowable 6.x7.x 思路一致只是部分 API 包名可能调整Maven 至少要加这几项依赖flowable-spring-boot-starter、spring-boot-starter-webHTTP 客户端我用的是 JDK 原生java.net.http.HttpClient不需要额外依赖如果走 Freemarker 模板再加spring-boot-starter-freemarker。数据库用 H2 做本地测试即可生产环境按团队习惯换成 MySQL/PostgreSQL 都行。工程结构上我会把 LLM 相关代码放到一个单独包workflow.llm下里面再分delegate委托类、service模型调用服务两块。委托类只做流程变量编排不写 HTTP 调用模型调用细节全部收敛到LLMService。这样谁能改什么边界非常清楚后续接新的模型供应商也不需要动流程相关代码。4.2 在 BPMN 文件里定义 LLM 节点用 Flowable Modeler 画或者直接手写 XML 都可以。我先给一段能跑的 XML流程是提交工单内容 → LLM 分类 → 排他网关 → 分成网络 / 硬件 / 软件 / 其他。核心部分如下process idticketClassifyProcess name工单智能分类流程 isExecutabletrue startEvent idstartEvent name开始 / serviceTask idllmClassifyTask nameLLM 智能分类 flowable:delegateExpression${llmClassifyDelegate} flowable:asyncfalse extensionElements flowable:field namemodelName flowable:stringqwen-plus/flowable:string /flowable:field flowable:field namepromptTemplate flowable:string![CDATA[ 你是一名 IT 工单分类专家请根据用户描述将工单分类为网络、硬件、软件、其他。 只输出一个类别词。 用户描述 ${ticketContent} ]]/flowable:string /flowable:field /extensionElements /serviceTask exclusiveGateway idclassifyGateway name分类网关 / sequenceFlow idsf1 sourceRefstartEvent targetRefllmClassifyTask / sequenceFlow idsf2 sourceRefllmClassifyTask targetRefclassifyGateway / sequenceFlow idsfNetwork name网络 sourceRefclassifyGateway targetRefnetworkTask conditionExpression xsi:typetFormalExpression![CDATA[${网络.equals(llmCategory)}]]/conditionExpression /sequenceFlow sequenceFlow idsfHardware name硬件 sourceRefclassifyGateway targetRefhardwareTask conditionExpression xsi:typetFormalExpression![CDATA[${硬件.equals(llmCategory)}]]/conditionExpression /sequenceFlow sequenceFlow idsfDefault name默认 sourceRefclassifyGateway targetRefotherTask conditionExpression xsi:typetFormalExpression![CDATA[${true}]]/conditionExpression /sequenceFlow /process上面这段 XML 我故意省略了 process 根标签上的命名空间声明实际部署时记得带上 BPMN 2.0 标准命名空间和xmlns:xsi声明。注意![CDATA[]]里的内容会原样交给委托类所以 Prompt 里的换行和中文字符都能正常保留。网关条件我用了字符串equals判断因为llmCategory是委托类写回的流程变量Flowable 对返回的字符串不做隐式布尔转换必须显式比较。4.3 JavaDelegate 的实现细节XML 里指向的是${llmClassifyDelegate}所以在 Spring 容器里注册一个 bean名字就叫llmClassifyDelegate。我通常用Component(llmClassifyDelegate)固定名字避免类重命名导致表达式失联。完整代码如下Component(llmClassifyDelegate) public class LLMClassifyDelegate implements JavaDelegate { private static final Logger log LoggerFactory.getLogger(LLMClassifyDelegate.class); Autowired private LLMService llmService; Override public void execute(DelegateExecution execution) { // 1. 读取流程变量 String ticketContent String.valueOf(execution.getVariable(ticketContent)); if (StringUtils.isBlank(ticketContent)) { throw new BpmnError(TICKET_CONTENT_EMPTY, 工单内容为空无法进行分类); } // 2. 解析节点配置 String modelName qwen-plus; String promptTemplate 你是一名 IT 工单分类专家请将工单分类为网络、硬件、软件、其他。只输出一个类别词。; FlowElement currentElement execution.getCurrentFlowElement(); if (currentElement instanceof ServiceTask) { ServiceTask serviceTask (ServiceTask) currentElement; for (FieldExtension field : serviceTask.getFieldExtensions()) { if (modelName.equals(field.getFieldName()) StringUtils.isNotBlank(field.getStringValue())) { modelName field.getStringValue(); } if (promptTemplate.equals(field.getFieldName()) StringUtils.isNotBlank(field.getStringValue())) { promptTemplate field.getStringValue(); } } } // 3. 渲染 Prompt String prompt promptTemplate.replace(${ticketContent}, ticketContent); // 4. 调用模型 String category llmService.chat(prompt, modelName, 0.2); // 5. 结果清洗 白名单校验 category category null ? : category.trim(); SetString allowedCategories new HashSet(Arrays.asList(网络, 硬件, 软件, 其他)); if (!allowedCategories.contains(category)) { log.warn(模型返回类别[{}]不在白名单按其他处理, category); category 其他; } // 6. 写回流程变量网关据此路由 execution.setVariable(llmCategory, category); execution.setVariable(llmRawOutput, category); execution.setVariable(llmModel, modelName); log.info(流程[{}]工单[{}]分类结果: {}, execution.getProcessInstanceId(), ticketContent, category); } }几个容易忽略的点第 1 步用String.valueOf做空安全是因为流程变量可能没传或传了非字符串类型第 2 步解析FieldExtension时一定要判空BPMN 里没配的 field 取出来是 null直接getStringValue()会闪出空指针第 4 步把 temperature 固定传 0.2分类场景要的是确定性结果温度越低输出越稳第 5 步的白名单校验是我踩过坑后加上的——模型偶尔会输出“网络问题”而不是“网络”如果不做归一化网关会直接走默认分支。这种问题模型侧很难彻底规避流程侧兜底最稳妥。4.4 LLMService 到底怎么写前面委托类用到了llmService.chat(...)这里给出一个最简可用的实现。以兼容 OpenAI 协议的接口为例HTTP 客户端用 JDK 自带的没有额外依赖的心智负担Service public class LLMService { Value(${llm.api-key:}) private String apiKey; Value(${llm.base-url:https://api.example.com/v1/chat/completions}) private String baseUrl; private final HttpClient httpClient HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); public String chat(String prompt, String model, double temperature) { // 构建 OpenAI 风格请求体 MapString, Object body new LinkedHashMap(); body.put(model, model); body.put(temperature, temperature); ListMapString, String messages new ArrayList(); MapString, String userMsg new LinkedHashMap(); userMsg.put(role, user); userMsg.put(content, prompt); messages.add(userMsg); body.put(messages, messages); String json JSON.toJSONString(body); HttpRequest request HttpRequest.newBuilder() .uri(URI.create(baseUrl)) .header(Content-Type, application/json; charsetUTF-8) .header(Authorization, Bearer apiKey) .timeout(Duration.ofSeconds(60)) .POST(BodyPublishers.ofString(json, StandardCharsets.UTF_8)) .build(); try { HttpResponseString response httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); if (response.statusCode() ! 200) { throw new RuntimeException(LLM 调用失败, status response.statusCode() , body response.body()); } // 解析 choices[0].message.content JSONObject obj JSON.parseObject(response.body()); JSONArray choices obj.getJSONArray(choices); if (choices null || choices.isEmpty()) { throw new RuntimeException(LLM 返回结果缺少 choices); } return choices.getJSONObject(0).getJSONObject(message).getString(content); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(LLM 调用被中断, e); } catch (IOException e) { throw new RuntimeException(LLM 调用网络异常, e); } } }这里用 FastJSON 写解析只是示例团队里有 Jackson 或 Gson 就直接换掉。值得说明的是JSON.toJSONString在序列化messages时会把那一层嵌套带上OpenAI 协议对这种结构要求很死板一定要保证messages是数组、数组里每个元素带role和content两个字符串字段。如果你用 Spring 的 RestTemplate 或者 WebClient同样要注意设置 UTF-8 编码否则 API 端可能解析失败或者中文字符直接乱码。4.5 本地自测与启动验证项目跑起来后先注册一个流程实例再推进一步。写个临时接口或者干脆用 Flowable 自带的RuntimeServiceruntimeService.startProcessInstanceByKey(ticketClassifyProcess, Map.of(ticketContent, 办公室打印机一直提示卡纸换了纸也没用是什么问题));启动后到数据库查act_ru_variable应该能看到llmCategory等变量已被写入。如果是同步节点这个调用返回时变量就已经写好了如果是异步节点需要等一下 JobExecutor 执行完再查时间一般不超过几十毫秒到几秒取决于模型 API 的响应速度。本地验证时我最喜欢用这种“零界面”方式因为完全不依赖前端表单和部署环境。先调startProcessInstanceByKey紧接着查act_ru_execution看流程实例当前停留在哪个节点再查act_ru_variable验证模型结果变量是否写入整个过程一条命令链搞定。如果是在 Modeler 里画的流程图记得先部署最新 BPMN 再启动流程否则改的节点不会生效。逻辑链路通了之后再去套表单、配任务监听器效率会高很多。5. 进阶优化异步化、重试、降级与多模型路由5.1 同步还是异步判断标准是什么上面 XML 里我把flowable:async设成了false也就是同步调用。同步的好处是写代码、排查都直接模型返回后流程变量立刻落库。坏处是startProcessInstanceByKey这个请求会一路阻塞在 HTTP 调用上如果模型接口要 10 秒一个流程实例的请求就要挂 10 秒。判断标准其实很简单你的流程是给前端按钮调的还是给内部 Job 批量推的如果是用户点一下按钮就走完的流程一定要异步否则用户体验就是点击没反应、转圈半分钟如果是定时 Job 触发的批处理同步异步都行但并发高了还是建议异步。异步的配置也简单把 XML 里的flowable:asynctrue就好Flowable 会在到达节点时创建 Job由 JobExecutor 线程池执行不再占用发起请求的线程。异步之后有一个新问题如果模型服务持续超时Job 会按重试策略反复执行act_ru_job里会堆积大量 RETRIES 状态的 Job。解决办法是把重试次数调小或者给 Job 加一个失败重试时间间隔属性同时做一个监控扫act_ru_job表把异常 Job 捞出来报警。这个监控一定要在异步化之前就安排好否则上线初期很容易被一堆僵尸 Job 打懵。5.2 失败重试、降级和异常隔离LLM API 不像数据库查询那么稳偶尔 5xx、429、超时都很正常。委托类里不能“裸奔”第一层可以在LLMService里做重试手写循环就可以重试次数控制在 2 到 3 次第二层在execute方法里捕获已知异常把流程引到人工兜底节点而不是直接抛异常终止流程。降级策略上我常用三档模型 A高精度失败 → 模型 B低成本顶上 → 都失败 → 设置llmFailedtrue并让网关走到人工处理分支。这样核心流程不会被模型故障打断最多是转人工。注意降级的判断条件和结果一定要作为流程变量记录否则运维根本看不出来这是降级了后面复盘只能靠猜。5.3 结果缓存与成本控制同一条工单如果因为人工驳回又重跑了一次流程模型调用就会重复产生一笔费用。我的经验是做一层简单的“结果缓存”以流程实例 ID 节点 ID 输入的文本哈希作为 key在 Redis 里存一份模型输出有效期按业务场景设定一般几分钟到几小时。下次同一个流程实例重新经过这个节点时先取缓存命中就直接返回不调模型。这不仅能节省成本还能让重试时结果保持一致避免人工看到两次判断不一致。对成本更敏感的场景还可以用模型分级。分类、抽取这类简单任务用qwen-turbo这类低价模型总结、写作判断这类对质量要求更高的任务再用qwen-plus或qwen-max。这个方法不需要改流程结构只需在LLMService里加一个“任务类型 → 模型名”的路由配置。5.4 多模型供应商接入的扩展思路如果公司后来签了新的模型供应商不想改所有 Delegate核心是抽象。把模型调用收敛成一个ChatClient接口接口方法就一个ChatResult chat(ChatRequest request)用策略模式按配置分发到不同实现。这样 Flowable 流程侧完全不感知模型品牌修改只发生在配置中心。我现在项目里就是这个结构切模型只需要改一行配置Delegate 代码从头到尾没动过。public interface ChatClient { ChatResult chat(ChatRequest request); }再往上还可以加一层模型路由按流程变量llmModelPreference决定走哪个 provider。这个路由信息本身也可以作为流程启动参数传入让不同业务线用不同的模型档次管理上更灵活。6. 常见问题与排查技巧实录6.1 节点执行报错先看日志再看变量JavaDelegate抛异常时Flowable 不会帮你把变量打印出来日志里只会报哪个类哪个行号。我排查第一件事是把execution.getVariables()打个快照确认输入变量是否齐全、类型是否符合预期。尤其注意从getVariable拿到的对象可能是LinkedHashMap、整数或者 BigDecimalString.valueOf是最稳妥的转字符串办法。另一个高发问题是getCurrentFlowElement()在某个版本返回的不是ServiceTask而是别的类型加一个 instanceof 判断不要强行强转。6.2 模型结果看着对网关却没走对分支遇到这种问题先到act_ru_variable查llmCategory的实际值十有八九是多了空格、换行或者模型输出带了标点。我在代码里已经做了trim()但对别的模型供应商不一定够正则清理更保险category.replaceAll([\\s。、], )。另一个隐蔽原因是网关条件写成了${llmCategory 网络}这种写法在 Flowable 的 JUEL 表达式里很可能不是字符串相等要改成${网络.equals(llmCategory)}才稳。6.3 同步调用拖死请求线程怎么定位如果你确认 XML 里flowable:async是 false但流程启动接口还是快要超时十有八九是模型 API 响应慢。可以在LLMService里加耗时日志每次调用记录请求开始时间和结束时间。我看到过一次典型案例模型侧流式输出没关服务端一直等完整输出一个请求干了 40 秒直接把接口拖垮。解决办法除了加超时还可以让模型服务开启流式、前端配合 SSE如果在 Flowable 里只是要最终文本对超时更敏感直接限制max_tokens并缩短 Prompt能有效控制响应时间。6.4 中文和特殊字符问题BPMN XML 文件里的 Prompt 包含中文和换行保存文件务必用 UTF-8 编码。流程部署时如果从外部系统读取 XML 字符串要注意编码统一否则 Prompt 到了模型那边就乱码了。HTTP 请求体在LLMService里我已经显式指定了charsetUTF-8如果你用 RestTemplate记得显式设置编码和媒体类型很多框架默认的 ISO-8859-1 会踩坑。还有一个容易忽略的地方模型返回的 JSON 里如果有\uXXXX转义序列要确保 JSON 解析库默认支持 Unicode 解码否则打印出来就是一堆转义字符调试时会很痛苦。6.5 高频问题排查速查表现象可能原因对应处理部署成功但执行节点抛 ClassNotFound / 表达式异常delegateExpression 的 Bean 未注册或类名拼错检查Component(llmClassifyDelegate)的名称和 XML 是否一致用 Spring 容器getBean验证模型接口返回空 contentPrompt 被截断、模型上下文过短或请求结构不对检查max_tokens、请求 JSON 结构、返回体日志流程走到默认分支模型输出不规范或网关条件写错打印原始输出做白名单归一化改用equals表达式流程卡住且act_ru_job有 RETRIES 状态异步节点模型调用持续失败调小重试次数检查模型 API Key 和网络配置降级中文 Prompt 乱码XML / HTTP 编码不一致统一 UTF-8检查 RestTemplate 与 HttpClient 编码设置节点执行慢但模型响应不慢可能是同步节点阻塞或模板渲染含大数据量变量开启flowable:async控制传入 Prompt 的变量大小同一个流程实例重跑产生重复费用缺少缓存增加基于文本哈希的模型结果缓存这张表基本覆盖了我从二三十个线上问题里提炼出来的高频场景。遇到新问题排查顺序永远是确认变量 → 确认日志 → 确认 model 请求体 → 确认网关表达式按这个顺序走大多数问题十分钟内能定位。结合我自己几个月的实践再啰嗦三点。第一别一上来就把所有判断都丢给大模型。能用规则引擎的先规则引擎LLM 只做规则引擎的死角这样成本可控、结果可控、审批人也更容易接受。第二Prompt 模板要当代码管理BPMN 文件在 Git 里要有 review改一个分类词的粒度也值得走一次评审因为模型输出是概率性的Prompt 一发变化线上行为就会漂移。第三接入 LLM 节点之后一定要把原始输入、模型输出、耗时、token 数、模型版本这些数据留痕迹这样才能在业务方质疑“为什么分类变了”的时候有一份能查、能回溯的依据。说白了Flowable 接 LLM 不是把工作流从确定性系统变成黑盒系统而是把“枚举式判断”升级成“理解式判断”再用 BPMN 的网关和人工任务兜底。流程引擎的严谨性照旧模型的灵活性做增量。这套方案我现在用得很顺手你们照着把第一个 Service Task 跑通后面就水到渠成了。