前阵子手上有个内部的费用报销审批流程要做智能化改造,业务方提了个需求:系统要在审批阶段自动判断一笔报销单的风险等级,并顺手生成一段处理建议。难点在于,判断依据不只是金额和类别这些结构化字段,还包括报销说明这种没法用硬规则处理的非结构化文本。
当时第一反应是写一堆 if/else 规则,或者搞一张决策表放到 Flowable 的条件表达式里。但很快就发现这条路行不通——“外出参加行业会议,对方开具的发票金额与会议日程不符”这种描述,规则引擎根本没法给出合理结论。真正能处理这种内容的东西只有大模型。
于是问题变成了一个非常具体的工程问题:如何在 Flowable 工作流里接入一个能自动执行的大模型节点。两个多星期折腾下来,从 BPMN 建模、JavaDelegate 编写,到异步执行、异常处理,踩了不少坑,也把一套可复用的接入模式跑通了。这篇就把完整思路、代码和排错过程整理出来,给同样在 Flowable 项目里做 AI 改造的同学做个参考。
1. 为什么要在 Flowable 里塞一个“AI 智能审核”节点:场景与路线选型
先明确一点:Flowable 本身是一个状态机 + 任务调度的引擎,它没有任何“智能”成分。我们说的“LLM 节点”,本质上是在流程定义的某个位置,挂一个由代码实现的自动步骤,这个步骤负责调用大模型接口,再把模型输出转化成后续路由需要的数据。
1.1 这类节点在真实项目里能干什么
从我接触过的项目来看,LLM 节点最常见的落地场景有这么几类:
- 审批建议生成:读取单据内容,让模型输出风险等级、通过建议、需补充的材料清单。这是最常用的场景,改造成本低、效果最直观。
- 智能分单:根据工单描述自动确定负责人团队,替代原来靠人肉选择或者简单关键词匹配的逻辑。
- 简历初筛:把候选人简历文本和岗位要求一起喂给模型,让模型输出匹配度评分和不匹配点。
- 条款风险提取:读取合同段落,提取可能的风险条款,给法务人员附上原文定位。
- 动态条件判断:表单里没有现成字段支撑网关分支条件时,用模型输出一个枚举值,作为排他网关的路由依据。
这些场景有一个共同特点:输入是文本,输出是结构化或半结构化结果,而且结果直接参与流程走向。也就是说,这不是锦上添花的“智能问答”,而是流程内部的一个真实环节,它的稳定性会直接影响流程能否正确跑完。
1.2 三条可选技术路线,为什么我选了 Service Task
聊方案的时候,团队里出现过三种声音,这里把对比列出来:
| 路线 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 外部回调 | AI 服务在 Flowable 外部运行,把结果通过 REST API 回写流程变量 | 流程引擎改动最小 | 需要额外开发回调接口,AI 执行节点在流程图上不可见,业务没法直观看到“这里有个智能判断” |
| 事件监听器 | 用 ExecutionListener 或 TaskListener 在节点触发时调用 LLM | 可以复用 Flowable 生命周期事件 | 监听器逻辑分散在多个节点事件的回调里,不方便做成“一个粒度的 AI 动作”,测试和排查都比较绕 |
| Service Task + JavaDelegate | 把 LLM 调用封装成 Flowable 的服务任务,在 BPMN 里作为一个独立节点 | 节点可视化、可复用、可实现异步、可用流程变量天然传递数据 | 需要写 Java 代码,对建模工具有一点要求 |
我最后选的是第三条路线。原因很实际:Service Task 是 BPMN 规范里标准的自动执行节点,Flowable 对它支持得最好,既能同步执行也能启用异步,出错后引擎会负责重试;而且节点画在流程图上是可见的,业务方评审流程图时能直观看到“这里有一个 AI 审核动作”,而不是去代码里猜哪条监听器做了什么事。
后来项目里又加了其他流程,这个 LLM Service Task 被直接复用,类似功能在多个流程里只要拖一个节点进来就行。如果当时用了监听器,每个流程都得单独维护一套监听逻辑,想想就头疼。
2. 扩展点梳理:Service Task 为什么是接入 LLM 的正解
要把 LLM 节点做对,先得理解 Flowable 给自定义自动节点提供的几种实现方式,以及它们各自的适用边界。这块值得单独说清楚,因为选错实现方式,后面维护会很难受。
2.1 BPMN 里的 Task 类型乱花眼,自动执行其实只有一个标准入口
BPMN 2.0 里和“任务”相关的东西很多:User Task(人工任务)、Service Task(服务任务)、Script Task(脚本任务)、Send Task(发送任务)、Receive Task(接收任务)等。
对一个 LLM 节点来说,它要做的是:读取数据、调用外部 API、写回结果,这个模式就是锁定 Service Task。Script Task 虽然也能做这件事,但要在脚本里拼 HTTP 请求、处理 JSON、管理密钥,既不安全也不好调试;Send Task 更多用于消息发送模式,不适合承载业务逻辑。
所以结论很简单:Service Task 是接入 LLM 的正解,没有之一。
2.2 Service Task 的三种实现方式,要分清“绑定类”和“绑定表达式”
Flowable 的 Service Task 有几种绑定方式,官方文档里都列了,但实际项目里最容易混淆的是这三种:
- flowable:class:直接指定一个实现
JavaDelegate的类全限定名。引擎会自行实例化这个类,不经过 Spring 容器。 - flowable:expression:写一个表达式,比如
${myService.doWork()},引擎直接调用这个表达式返回结果。 - flowable:delegateExpression:也是表达式,但表达式的值的需要是一个实现了
JavaDelegate的对象。最常见写法是${myDelegateBean},其中myDelegateBean是 Spring 容器里的一个 Bean。
先看一个容易踩的坑:如果用flowable:class,引擎是通过反射创建类的,这个类不会注入 Spring 依赖,里面的@Autowired全是 null。很多刚接触 Flowable 的人在这里碰壁,包括我。
如果有 Spring Boot 环境,强烈建议用delegateExpression指向一个注册成 Bean 的 Delegate 实现类,这样可以正常使用依赖注入、读取配置、走 AOP 日志。
2.3 为什么我选 Delegate Expression 而不是直接写死在类上
除了依赖注入的问题,delegateExpression还有一个好处:可以在部署不同流程版本时,通过配置切换不同的节点实现。
举个实际例子:我们在做 LLM 节点时,先接了一个通用对话模型做验证,后来切换成更便宜的专用分类模型。如果类名硬编码在 BPMN 里,就得改流程图重新部署;但用delegateExpression的话,可以在 Spring 配置里控制哪个 Bean 生效,流程图不用动,切模型就像改了个配置项。
另外,配合 Spring 的@Primary、@Qualifier或者 Profile 机制,还可以实现测试环境用 Mock 实现、生产环境用真实模型,这对工作流自动化测试特别有用。后面讲测试的时候我会再展开。
3. 把 LLM 节点写成可用代码:从依赖到 BPMN 配置
下面进入正题,直接上一套可以在 Spring Boot + Flowable 环境里跑起来的代码。这套代码我按生产标准写过,不是 demo。
3.1 工程准备:Flowable 版本和依赖选择
项目用的是 Spring Boot 2.7,Flowable 7.1.0。这里有个经验:Flowable 7.x 对 Spring Boot 版本有对应关系,不要随意混搭。可以直接看官方文档的版本兼容矩阵,或者干脆用 Flowable 提供的 BOM 来管理版本,省心不少。
Maven 依赖核心就两个:
<dependency> <groupId>org.flowable</groupId> <artifactId>flowable-spring-boot-starter</artifactId> <version>7.1.0</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>HTTP 客户端我直接用 Spring 的RestTemplate,设置好超时时间即可。如果项目里已有 OkHttp 或者 WebClient,也没问题,只要封装在 LLM Client 里就行。这里不推荐 service task 里直接写裸的HttpClient代码,后面要加超时、重试、日志都会很零散。
3.2 核心实现:LlmServiceDelegate
Flowable 的自定义节点逻辑,核心是实现org.flowable.engine.delegate.JavaDelegate接口。我在一个报销审批流程里定义了一个 LLM 节点,它的做法是这样的:从流程变量中读取报销类别、金额、报销说明,组装提示词,调用大模型,解析返回的 JSON,最后把风险等级和处理建议写回流程变量。
@Component("llmReviewDelegate") public class LlmReviewDelegate implements JavaDelegate { private final LlmClient llmClient; public LlmReviewDelegate(LlmClient llmClient) { this.llmClient = llmClient; } @Override public void execute(DelegateExecution execution) { String category = execution.getVariable("category", String.class); BigDecimal amount = execution.getVariable("amount", BigDecimal.class); String description = execution.getVariable("description", String.class); String prompt = """ 你是财务报销审核助手。根据以下信息判断该报销单的风险等级。 类别:%s 金额:%s 报销说明:%s 只输出如下 JSON 格式,不要输出多余文字: {"riskLevel":"LOW|MEDIUM|HIGH","suggestion":"处理建议","reason":"判断理由"} """.formatted(category, amount, description); LlmResponse response = llmClient.chat(prompt); // 解析模型输出,注意要做容错处理 RiskResult riskResult = RiskResultParser.parse(response.getContent()); // 把结果写回流程变量,后续网关和人工任务都能读取 execution.setVariable("riskLevel", riskResult.getRiskLevel()); execution.setVariable("riskSuggestion", riskResult.getSuggestion()); execution.setVariable("riskReason", riskResult.getReason()); } }这段代码有几个容易忽略的点,提一下:
- 变量命名规范:写回的变量如果后续要在网关条件里使用,建议保持纯小写驼峰,避免在表达式里和 JavaBean 属性解析混淆。
- 模型输出解析不能假设一定成功:大模型偶尔会返回多出前缀的文本,建议在解析前做一次清理,后面我会单独讲这个坑。
- DelegateExecution 是线程安全的吗?每个流程实例有独立的 execution 实例,但 Delegate Bean 是单例的,里面不要存跟具体请求相关的状态,所有数据都放 execution 变量。
3.3 LLM Client:多模型适配的关键封装
代码里的LlmClient是一个接口,我没有直接把某个厂商的 SDK 硬编码到 Delegate 里。理由很简单:大模型领域变化太快,今天用这个模型,明天可能要换成别的,接口隔离一下,Delegate 代码就不用动了。
public interface LlmClient { LlmResponse chat(String prompt); }对于大模型服务,我建议优先找支持 OpenAI 兼容接口的服务。因为现在私有化部署方案、国内外主流云厂商,绝大部分都提供 OpenAI 兼容的/chat/completions接口。这样我们只需要写一个实现:
@Component public class OpenAiCompatibleClient implements LlmClient { @Value("${llm.api-url}") private String apiUrl; @Value("${llm.api-key}") private String apiKey; @Value("${llm.model}") private String model; private final RestTemplate restTemplate; public OpenAiCompatibleClient(RestTemplate restTemplate) { this.restTemplate = restTemplate; } @Override public LlmResponse chat(String prompt) { Map<String, Object> body = new HashMap<>(); body.put("model", model); body.put("messages", List.of( Map.of("role", "system", "content", "你是企业流程自动化助手,总是输出严格要求的格式。"), Map.of("role", "user", "content", prompt) )); body.put("temperature", 0.1); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.setBearerAuth(apiKey); HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers); ResponseEntity<JsonNode> resp = restTemplate.exchange(apiUrl, HttpMethod.POST, entity, JsonNode.class); String content = resp.getBody().path("choices").path(0).path("message").path("content").asText(); return new LlmResponse(content); } }这里有两个细节值得注意:
- temperature 要调低:工作流里的模型输出直接影响流程走向,我不希望它“太有创造力”,通常设 0.1 或者 0,尽量稳定输出。
- 超时设置必须在 RestTemplate 层面做,否则默认是不超时的,一旦模型接口卡住,整个服务可能被拖垮。超时配置后面单独说。
3.4 BPMN 里怎么配这个节点
代码写好了,接下来要把节点画到流程图上。这里有个经验:Flowable 官方自带的 Modeler 也能画,但很多生产项目其实是拿 Camunda Modeler 画图,再导出 BPMN 文件给 Flowable 部署,因为 Camunda Modeler 是独立桌面端,画起来顺手,版本兼容性也好。两个工具生成的 BPMN XML 都是标准格式,Flowable 能直接识别。
在 Camunda Modeler 里拖一个 Service Task,然后在右侧属性面板找到Implementation相关设置,填入flowable:delegateExpression="${llmReviewDelegate}"。对应生成的 XML 片段长这样:
<serviceTask id="llmReviewTask" name="AI 智能审核" flowable:delegateExpression="${llmReviewDelegate}" flowable:async="true"> </serviceTask>注意flowable:async="true"这个属性,这是异步执行开关,我后面专门讲为什么建议打开。
部署流程还是在代码里最直观:
@SpringBootTest class ProcessDeployTest { @Autowired private RepositoryService repositoryService; @Test void deployProcess() { repositoryService.createDeployment() .name("报销审批流程") .addClasspathResource("processes/expense-approval.bpmn20.xml") .deploy(); } }部署完成后,去 Flowable 的 ACT_RE_PROCDEF 表里查一下流程定义是否正常注册,只要状态正常,节点就挂上去了。
4. 数据流转和异步保护:别让一次 API 调用拖垮整个流程实例
LLM 节点和其他 Service Task 最大的不同,在于它的执行时间可能很长。普通服务任务几十毫秒就结束了,LLM 调用动辄三五秒,高峰期甚至二三十秒。如果不做异步和超时保护,整个流程引擎都会被拖垮。这一章把变量传递和异步执行的实操讲透。
4.1 流程变量作用域:getVariable 和 setVariable 到底写在哪儿
先解释一个容易懵的点:DelegateExecution.getVariable()和setVariable()操作的是当前执行实例可见的变量,但 Flowable 的变量不是全平铺在一个大 Map 里。
一个流程实例启动时,会创建一个根执行实例(Process Instance Execution)。排他网关、并行网关会从它分支形成子执行实例。在子执行实例里调用getVariable(),会沿着执行树的父级向上查找;而setVariable()默认会写到当前执行实例的父级,也就是流程实例级别,除非你在多实例节点里。
这个机制绝大多数时候是够用的,但有一个场景必须注意:并行网关。
如果流程里有并行分支,两条分支同时跑,其中一条分支的 LLM 节点setVariable("riskLevel", ...),另一条分支如果也操作同名变量,就会出现互相覆盖的竞态问题。解决办法是:尽量让写变量发生在合并后的节点上,或者在分支里用带前缀的变量名区分,比如leftRiskLevel和rightRiskLevel。
多实例节点(会签、或签)里更要注意:每个实例有独立的 execution,getVariable能取到共享变量,但如果你setVariable想改共享变量,默认行为可能会造成局部变量和全局变量不一致。建议多实例场景下用execution.getParent().setVariable()显式指定作用域,或者用execution.setVariable()后针对多实例结果做getVariable("nrOfCompletedInstances")汇总判断。
4.2 同步执行会让流程引擎线程池寸步难行
Flowable 默认情况下,Engine 会在发起流程的线程里直接执行 Service Task。如果你在接口线程里启动流程实例,而这个流程里有一个同步的 LLM 节点,这个线程就会在restTemplate.exchange()上阻塞好几秒。
我实测过一个场景:压测并发 30 个报销单启动流程,每个 LLM 调用平均 4 秒,后面的流程全部排队,看起来就像线程池被“卡死”了。试想生产环境高峰期几十个工单同时在跑,系统响应时间直接上天。
解决办法就是给 Service Task 开异步执行:
<serviceTask id="llmReviewTask" ... flowable:async="true">开了这个属性,Flowable 会把该节点的执行封装成一个 Job,放入异步执行器的队列中,由 AsyncExecutor 的后台线程去消费。这样启动流程的接口线程立刻返回,流程实例在那个节点处“暂停”,等异步线程把 LLM 调用完成后,流程再从该节点继续往下走。
Flowable 在 Spring Boot 环境下默认会启动 AsyncExecutor,可以在配置里调整线程池大小和队列长度:
flowable: async-executor-activate: true async: async-executor-core-pool-size: 8 async-executor-max-pool-size: 16 async-executor-queue-capacity: 100这里注意一点:并发量特别大、模型接口又慢的时候,队列容量填太大会导致流程实例长期停留在“未完成”状态,业务上要注意超时提醒。容量太小又会触发RejectedExecutionException,Job 会按重试策略反复补偿。
4.3 超时和重试:一张表说清楚配置思路
LLM 调用失败是常态,网络抖动、模型服务过载、返回格式不对,都可能导致节点执行失败。Flowable 对异步 Job 有默认重试机制,但重试次数和间隔要按场景调。
RestTemplate 超时配置:
@Bean public RestTemplate llmRestTemplate() { HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(); factory.setConnectTimeout(5_000); factory.setReadTimeout(30_000); return new RestTemplate(factory); }| 场景 | 建议配置 | 原因 |
|---|---|---|
| 连接超时 | 5 秒 | 模型服务地址不可达时要快速失败 |
| 读取超时 | 30 秒 | 大模型生成可能较慢,但超过 30 秒基本就是出问题了 |
| Flowable Job 重试次数 | 3 次 | 次数太少抖动时误失败,太多会积压消息队列 |
| 重试间隔 | 指数递增,从 2 秒开始 | 给模型服务恢复时间 |
Flowable 异步 Job 重试配置在process-engine-config:
flowable: async: async-executor-activate: true default-timer-job-acquire-wait-time: 10000 async-job-retry-wait-time: 2000 async-job-retry-max-retries: 3重试有一个隐藏问题:LLM 调用如果已经成功写入了流程变量,只是因为解析或网络异常抛了错,重试会把同一个请求再发一次。所以要在节点里做幂等处理。我在代码里加了一个简单判断:
if (execution.getVariable("riskLevel") != null) { return; }这是在流程变量中写入标记,重试时发现已有结果就直接跳过,避免重复调模型浪费成本。
5. 真实踩坑记录:变量丢失、线程阻塞、返回解析的排查全过程
这章把我在接入过程中遇到的最典型的四个问题按排查链路写出来。看答案没意思,看排查思路才有价值,以后换个坑也能自己定位。
5.1 坑一:Service Task 里 getVariable 拿到的全是 null
现象:流程流转到 LLM 节点,日志打印出来 category、description 全是 null。
排查过程:
- 先怀疑流程变量没传对,去 ACT_RU_VARIABLE 表查,发现变量明明存在。
- 再怀疑节点 ID 写错,BPMN 图看了一遍,没错。
- 最后进调试模式,把
execution.getVariables()全部打印,发现能拿到变量,但getVariable("category")返回 null,因为变量名在数据库里存的是category和Category之类的差异,大小写不一致。
定位到原因:流程启动时用Map.of("Category", ...)传入,而 Delegate 里读取的是"category"。Flowable 变量名是大小写敏感的,而且我踩过的是Map.of构造出来的 key 是Category,我以为是category。
解决办法:统一变量名。我在项目里定了一条规范,所有流程变量统一小驼峰命名,启动流程和 Delegate 读取必须用同一个常量类引用。后来再没出过这类问题。
5.2 坑二:同步调用把流程服务线程池整个占满
现象:压测时从第 20 个请求开始接口响应从 200ms 涨到 15 秒,日志里出现Acquire async job和大量阻塞线程。
排查过程:
- 先看数据库 ACT_RU_EXECUTION,发现大量流程实例停在 LLM 节点。
- 再用
jstack抓线程,发现线程都停留在RestTemplate的 socketRead0。 - 确认是同步 Service Task 耗死线程。
这个坑我之前已经讲了,解决办法就是开flowable:async="true"。还有一个隐蔽点:即使开了异步,如果 AsyncExecutor 线程池太小或者队列容量太高,同样会出现隐性问题。我最后把核心线程数设为 8,最大 16,队列 100,压测下来吞吐没问题。
5.3 坑三:模型返回的 JSON 里带了 markdown 代码块标记,直接解析失败
现象:生产环境偶发报JsonParseException,有时候同一个输入这次成功下次失败。
排查过程:
- 打点日志记录模型原始返回。果然,模型偶尔会在 JSON 外面包上 ```json 代码块标记。
- 这确实是很多模型的“种族天赋”:你让它输出 JSON,它非要给你加装饰。
解决办法是写一个健壮的解析函数:
public class RiskResultParser { public static RiskResult parse(String raw) { String content = raw.trim(); // 去掉可能的 markdown 代码块标记 content = content.replaceAll("^```json\\s*", "").replaceAll("\\s*```$", ""); JsonNode node = new ObjectMapper().readTree(content); String level = node.path("riskLevel").asText(); if (!Set.of("LOW", "MEDIUM", "HIGH").contains(level)) { throw new IllegalStateException("riskLevel 不在合法枚举内: " + level); } return new RiskResult(level, node.path("suggestion").asText(), node.path("reason").asText()); } }5.4 坑四:Flowable 和项目里 Jackson 版本冲突,启动直接报错
现象:接入 Flowable 后,Spring Boot 启动时报JsonMappingException,或者NoSuchMethodError,指向 Jackson 的某个类。
排查过程:
mvn dependency:tree查 Jackson 依赖,发现 Flowable 7.x 传递依赖 Jackon 2.15,而项目里被一个旧依赖压成了 2.13。- 典型错误是
InvalidDefinitionException,Jackson 2.13 和 2.15 在时间日期序列化上行为不同。
解决办法:在 pom 里统一 Jackson 版本到 Flowable 认可的版本。如果不想动全局版本,也可以在 flowable-spring-boot-starter 里把 jackson 排除,显式声明和项目一致的版本。这个问题的通用排查思路就一句话:报NoSuchMethodError先查依赖树,别急着定位业务代码。
6. 可以从这三点继续扩展:动态路由、结果校验、成本缓存
LLM 节点跑通只是第一步,实际生产要考虑的还有不少。但把这三个方向做好,体系就比较完整了。
6.1 动态路由:用模型输出当排他网关的路由条件
LLM 节点输出的riskLevel可以直接作为排他网关的判断依据,在 BPMN XML 里就是这样:
<exclusiveGateway id="riskGateway" name="风险等级网关" /> <sequenceFlow id="flowLow" sourceRef="riskGateway" targetRef="autoApproveTask"> <conditionExpression xsi:type="tFormalExpression"> <![CDATA[${riskLevel == 'LOW'}]]> </conditionExpression> </sequenceFlow> <sequenceFlow id="flowHigh" sourceRef="riskGateway" targetRef="manualAuditTask"> <conditionExpression xsi:type="tFormalExpression"> <![CDATA[${riskLevel != 'LOW'}]]> </conditionExpression> </sequenceFlow>这里有一个我在表达式中踩过的细节:Flowable 的 EL 表达式里,字符串比较要用==而不是equals,它用的是 Spring EL 的语法。如果你写成${riskLevel.equals('LOW')}也能跑,但格式风格不统一会容易出错。另外,条件表达式里不能直接依赖riskSuggestion这种中文字符串做精确匹配,常有换行和空格问题,建议以枚举值为准。
6.2 结果校验:给模型输出上保险
模型输出不像接口文档那么可靠。这里说的校验有两个层面:
- 格式校验:必须能被解析成合法 JSON,且字段完整。解析失败就走重试或降级。
- 业务语义校验:
riskLevel必须在合法枚举内,金额大但风险等级为 LOW 这种结果,建议校验通过后再放行。
我在自定义的RiskResultParser里做了第一道校验,在 Delegate 里还可以加入规则联动:
if ("LOW".equals(riskResult.getRiskLevel()) && riskResult.getReason() == null || riskResult.getReason().isBlank()) { riskResult = RiskResult.fallbackHigh("模型输出缺少理由,按高风险人工处理"); }这个降级策略很重要:宁可让模型保守一点走人工,也不要因为一个失误把不该通过的单子直接放行了。LLM 节点先做好异常兜底,才敢真正驱动业务决策。
6.3 成本缓存:同样的输入别反复调模型
LLM API 按 token 计费,工作流里的节点调用频率还蛮高的,同一个工单因为流程回退、重试可能被反复触达。我给 LLM 节点加了简单缓存,以“提示词内容的 SHA-256 Hash”为 key,带上流程的业务 ID 一起存:
@Component public class LlmCacheService { private final Cache<String, LlmResponse> cache = Caffeine.newBuilder() .maximumSize(500) .expireAfterWrite(30, TimeUnit.MINUTES) .build(); public LlmResponse get(String prompt) { String key = DigestUtils.sha256Hex(prompt); return cache.getIfPresent(key); } public void put(String prompt, LlmResponse response) { String key = DigestUtils.sha256Hex(prompt); cache.put(key, response); } }注意缓存 key 只包含提示词,不包含流程实例 ID,这样两个审批单如果描述完全一样就能命中。但如果你的业务场景对时效性要求高,缓存时间要调短;如果每次都希望模型重新判断,就不要加缓存。这个看业务取舍。
6.4 日志与观测:记录 token 消耗和模型耗时
LLM 节点进生产以后,运维上最实用的就是记录每次调用的模型、耗时、token 消耗、原始输出、流程实例 ID。建议在LlmServiceDelegate里用 MDC 塞上流程实例 ID:
MDC.put("processInstanceId", execution.getProcessInstanceId()); try { LlmResponse response = llmClient.chat(prompt); log.info("LLM 调用成功, 耗时={}ms, token={}, 输出={}", costMs, response.getUsage(), response.getContent()); } finally { MDC.remove("processInstanceId"); }这套日志做好了,以后出问题可以按流程实例 ID 直接拉出这个节点的完整调用链路,排查效率提升不是一点半点。
最后分享个我个人的习惯:在做 Flowable 和 LLM 集成这类偏探索性的工作时,先把主链路用通用模型跑通,再慢慢加缓存、校验、降级这些“防倒”机制。这里面的顺序很重要。另外,真正让这个方案稳定的是把“模型输出”当成一个不信任的输入源来对待,所有下游逻辑都要默认它可能是错的、慢的、格式不对的。把这一点想明白了,踩坑的速度会慢一半。