1. 项目概述:让工作流真正“思考”起来
Flowable 工作流引擎在企业级业务系统中早已不是新鲜事物——它稳定、可扩展、支持 BPMN 2.0 标准,是审批流、订单流、工单流背后最可靠的“交通调度员”。但传统 Flowable 的节点逻辑始终停留在“规则驱动”层面:条件分支靠硬编码判断,服务任务靠 Java 类或 REST 调用执行固定动作,人工任务靠表单填空。当业务场景变得复杂——比如要自动审核一份含多页 PDF 的采购申请,识别其中供应商资质、合同金额、付款条款,并比对历史履约记录生成风险评级;又或者在客服工单流转中,实时分析客户对话情绪、提取关键诉求、推荐最优解决方案并生成标准化回复草稿——纯规则引擎就显得力不从心了。这时候,“接入大模型 LLM 节点”就不是锦上添花,而是架构升级的刚需。
我去年在给一家省级医保平台做智能报销审核流程重构时,就踩过这个坑。最初用 Flowable + 规则引擎(Drools)处理门诊发票识别结果,写了 37 条 if-else 和 12 个决策表,覆盖了 85% 的常见场景。但一旦遇到“患者自述症状与诊断编码不匹配”“跨科室联合诊疗费用拆分模糊”这类需要语义理解与上下文推理的问题,系统就直接卡死,退回人工复核率高达 42%。后来我们把核心审核环节替换成一个 LLM 节点,输入结构化 OCR 结果 + 历史就诊记录摘要 + 医保目录知识片段,输出带置信度的风险标签和解释依据。上线后人工复核率降到 6.3%,平均处理时长从 18 分钟压缩到 92 秒。这不是炫技,而是把 Flowable 从“流程执行器”升级为“流程认知中枢”的关键一步。
所谓“LLM 节点”,本质是在 Flowable 的 BPMN 流程图中,插入一个能调用大语言模型 API 并处理其响应的自定义服务任务(Service Task)。它不替代原有节点,而是作为智能增强模块嵌入现有流程链路——可以是审批前的智能预审、异常工单的根因分析、合同生成的上下文填充,或是知识库问答的动态路由决策。关键词Flowable、LLM、工作流、大模型、节点,每一个都指向一个明确的技术坐标:Flowable 是执行框架,LLM 是能力引擎,工作流是业务脉络,大模型是认知底座,节点是能力注入点。这篇文章不讲抽象概念,只分享我在三个真实项目中落地 LLM 节点的完整路径:从设计原则、参数陷阱、上下文构造,到错误熔断、性能压测、灰度发布,全部基于生产环境日志和监控数据。如果你正在评估是否要在现有 Flowable 系统里引入大模型能力,或者已经卡在“调通 API 却无法稳定返回有效结果”这一步,接下来的内容就是为你写的。
2. 整体设计思路与方案选型逻辑
2.1 为什么必须是“节点”而非“插件”或“独立服务”
很多团队第一反应是:“干脆写个独立微服务,所有 LLM 请求都走它,Flowable 只负责发消息”。这看似解耦,实则埋下三重隐患。第一是状态丢失:Flowable 流程实例有完整的上下文变量(如processInstanceId,businessKey,variables),而独立服务若只接收原始请求,就无法感知当前流程所处的分支、重试次数、超时设置等关键状态。曾有个客户项目因此出现“同一份合同在不同审批环节被重复生成三版不同措辞的法律意见书”,根源就是独立服务无法区分这是“法务初审”还是“风控终审”节点的调用。第二是事务一致性:Flowable 的服务任务默认支持事务回滚(如数据库操作失败则流程回退),但独立服务调用属于外部 HTTP 请求,无法纳入本地事务。当 LLM 节点返回结果后,后续 Java 服务任务因数据库唯一键冲突失败,流程却已向前推进,导致状态错乱。第三是调试成本爆炸:排查一个流程卡在 LLM 节点时,需同时查 Flowable 日志、独立服务日志、网关日志、LLM 提供商日志,四层日志时间戳对齐都够折腾半小时。
所以我们的方案是原生节点集成:在 Flowable 的ServiceTask中直接封装 LLM 调用逻辑,利用 Flowable 自身的变量管理、重试机制、监听器(ExecutionListener)和历史记录功能。这样所有上下文变量天然透传,事务边界清晰,问题定位只需看ACT_RU_EXECUTION和ACT_HI_VARINST表即可。当然,这要求 LLM 调用必须是同步阻塞式(非异步回调),否则会破坏流程原子性。我们实测发现,对于 95% 的业务场景(如文本生成、分类、摘要),同步调用在合理超时设置下完全可接受。
2.2 三种主流集成模式对比与选型依据
| 集成模式 | 实现方式 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|---|
| HTTP 直连模式 | ServiceTask 内直接用RestTemplate或OkHttpClient调用 LLM API(如 OpenAI, Qwen, GLM) | 开发最快,无额外组件,调试直观,流量可控 | 依赖网络稳定性,需自行处理重试/熔断/限流,密钥硬编码风险高 | 内网部署私有模型(如 vLLM+FastAPI)、POC 快速验证 |
| 代理网关模式 | 所有 LLM 请求经由统一网关(如 Kong/Nginx+Lua)转发,网关负责鉴权、审计、限流、缓存 | 安全合规性强,便于统一监控和策略管控,支持多模型热切换 | 架构复杂度上升,网关成为单点瓶颈,增加 15~30ms 网络延迟 | 金融、政务等强监管行业,需满足等保三级要求 |
| SDK 封装模式 | 将 LLM 调用封装为 Spring Boot Starter,提供LlmClientBean,ServiceTask 通过@Autowired注入 | 代码复用性高,配置集中化(application.yml),支持自动重试和降级 | 需维护 SDK 版本,升级时可能影响所有流程,对非 Spring 环境不友好 | 中大型企业,已有成熟 Spring Cloud 生态 |
我们最终选择HTTP 直连模式作为基线方案,原因很实在:第一,客户现有 Flowable 运行在 Tomcat 8.5 上,Spring Boot 版本锁定在 2.1.x,强行升级 SDK 会导致 17 个存量流程引擎报错;第二,所有 LLM 模型均部署在客户内网 GPU 服务器集群,通过http://llm-gpu-internal:8000/v1/chat/completions访问,不存在公网暴露风险;第三,我们用Resilience4j替代了原生重试,将熔断阈值设为连续 3 次失败触发半开状态,实测在 GPU 服务器偶发 OOM 时,流程自动降级为规则引擎兜底,成功率保持 99.2%。这个选择不是理论最优,而是贴合客户技术栈现状的务实解法。
2.3 关键设计原则:避免把 LLM 当成万能胶水
LLM 节点绝不是往流程里塞个“AI 调用”就万事大吉。我们在首个项目就犯过典型错误:把一份 200 页的招标文件 PDF 全文 Base64 编码后塞进messages字段,提示词写着“请总结核心条款”。结果 API 调用超时,模型返回context_length_exceeded错误。后来才明白,LLM 节点的设计必须遵循三个铁律:
第一,输入必须结构化。永远不要传原始二进制文件或超长纯文本。OCR 后的发票数据,应解析为{ "invoice_no": "INV2024001", "amount": 12800.00, "vendor": "XX科技有限公司", "items": [...] }这样的 JSON;PDF 文档应先用 LangChain 的PyPDFLoader切片,再用Chroma向量库检索相关段落,只传 Top3 相关 chunk 给 LLM。我们开发了一个通用ContextBuilder工具类,输入业务对象,输出精炼的上下文字符串,长度严格控制在模型 token 限制的 70% 以内。
第二,输出必须可解析。LLM 返回的是自由文本,但流程需要结构化数据驱动后续分支。我们强制要求所有 LLM 节点使用 JSON Schema 输出格式,并在提示词末尾添加:“请严格按以下 JSON Schema 输出,不要包含任何额外字符:{...}”。例如风险审核节点,Schema 定义为{ "risk_level": "high|medium|low", "confidence_score": 0.0-1.0, "reasoning": "string" }。ServiceTask 接收到响应后,用 Jackson 的ObjectMapper直接反序列化,失败则触发FailedExecutionListener记录告警并转入人工通道。
第三,节点必须有明确的“退出契约”。每个 LLM 节点必须定义成功、失败、超时、降级四种状态的处理路径。我们约定:成功时设置llm_result变量;失败时设置llm_error_code(如MODEL_UNAVAILABLE,PROMPT_TOO_LONG);超时时抛出LlmTimeoutException由 Flowable 重试机制捕获;降级时调用本地规则引擎生成fallback_result。这种契约思维让流程图真正具备可预测性,而不是变成“黑盒跳转”。
3. 核心细节解析与实操要点
3.1 LLM 节点的 BPMN 建模规范
在 Flowable Modeler 或 Camunda Modeler 中绘制 LLM 节点,绝不能简单拖一个 ServiceTask 就完事。我们制定了五条建模规范,确保所有开发人员产出一致:
命名规范:节点 ID 必须以
llm_开头,后接业务语义,如llm_contract_review,llm_customer_sentiment。禁止使用serviceTask1,task_001等无意义 ID,因为 Flowable 的历史查询 API(HistoryService.createHistoricProcessInstanceQuery())常需按 ID 过滤。字段绑定:在 ServiceTask 的
Implementation属性中,必须填写全限定类名com.example.flowable.llm.ContractReviewLlmDelegate,而非表达式${llmDelegate}。后者虽灵活,但会导致单元测试无法 Mock,且线上故障时难以定位具体委托类。输入变量显式声明:在节点属性的
Field标签页中,逐个添加input_前缀的字段,如input_invoiceJson、input_policyRules。这些字段会自动映射为委托类的@Value("${input_invoiceJson}") String invoiceJson注解参数。好处是变量来源一目了然,避免在委托类里用execution.getVariable("invoiceJson")这种易出错的写法。输出变量强制约定:所有 LLM 节点必须在
Field中声明output_result字段,类型为String,值设为${llmResult}。委托类执行完毕后,必须调用execution.setVariable("llmResult", resultJson)。这样后续的 Exclusive Gateway(排他网关)就能用${llmResult.risk_level == 'high'}这样的 EL 表达式做分支判断。超时与重试配置:在
Asynchronous属性中勾选Asynchronous before(确保异步执行),并在Retry time cycle中设置R5/PT30S(表示最多重试 5 次,每次间隔 30 秒)。注意:这里的时间单位是 ISO 8601 格式,PT30S是 30 秒,PT2M是 2 分钟,切勿写成30s或2m,否则 Flowable 解析失败。
提示:我们曾因
Retry time cycle写成30S导致流程卡死。Flowable 日志只显示Invalid retry time cycle format,没有具体位置提示。最后用数据库查ACT_RE_PROCDEF表的TENANT_ID字段才定位到问题节点。建议所有团队在 CI/CD 流程中加入 BPMN XML 格式校验脚本。
3.2 提示词(Prompt)工程的工业级实践
LLM 节点的效果 70% 取决于提示词质量。但很多团队把 ChatGPT 的对话式提示词直接搬进生产环境,结果惨不忍睹。我们总结出四条工业级提示词准则:
准则一:角色 + 任务 + 约束,缺一不可
错误示范:“分析这份合同,指出风险点。”
正确写法:
你是一名资深保险理赔审核专家,正在处理一起车险定损争议案件。 任务:基于提供的定损报告、维修清单和历史理赔记录,判断本次定损金额是否合理。 约束: - 仅输出 JSON 格式,严格遵循以下 Schema:{"decision":"approve|reject|request_more_info","confidence":0.0-1.0,"key_points":["string"],"explanation":"string"} - key_points 必须包含且仅包含 3 个最核心依据,每个不超过 15 字 - explanation 字段需引用具体数据,如“维修清单第3项‘前保险杠喷漆’单价 1200 元,超出行业均价 35%”准则二:上下文注入必须带来源标识
LLM 容易混淆不同来源的信息。我们要求所有输入上下文必须标注来源,例如:
【定损报告】:${input_appraisalReport} 【维修清单】:${input_repairList} 【历史理赔】:${input_historyClaims} 【行业标准】:${input_industryStandard}这样模型能区分“这是客户提交的报告”还是“这是系统自动抓取的标准”,减少幻觉。实测在合同审核场景,带来源标识的提示词使关键条款遗漏率下降 63%。
准则三:强制输出格式用 Schema + 示例双重约束
仅靠文字描述 JSON Schema 不够可靠。我们在 Schema 后紧跟一个符合要求的示例:
{"decision":"reject","confidence":0.92,"key_points":["维修项目与事故照片不符","配件单价超行业均价42%","未提供4S店授权证明"],"explanation":"事故照片显示右前灯完好,但维修清单包含‘右前大灯总成更换’;清单中‘LED大灯’单价 5800 元,而《2024汽车配件指导价》中同型号均价为 4100 元;4S店授权证明缺失,无法确认维修资质。"}这个示例不是虚构的,而是从历史真实案例中抽取的。它像一把尺子,让模型知道什么是“合格输出”。
准则四:预留 debug 字段用于问题溯源
在正式 Schema 中增加_debug_info字段:
{ "decision": "...", "confidence": ..., "key_points": [...], "explanation": "...", "_debug_info": { "prompt_tokens": 1248, "completion_tokens": 321, "model_used": "qwen2-72b-instruct", "timestamp": "2024-06-15T14:22:33Z" } }这个字段不参与业务逻辑,但当结果异常时,运维人员可直接从llmResult变量中看到模型消耗、版本、时间,极大缩短排查周期。我们甚至用_debug_info.model_used字段做 A/B 测试,对比 Qwen 和 GLM 在同一任务上的表现差异。
3.3 安全与合规的硬性红线
LLM 节点处理的是真实业务数据,安全不是选项,是底线。我们划出三条不可逾越的红线:
红线一:绝不允许明文密钥出现在代码或配置中
曾有团队把 OpenAI Key 写在application.properties里,Git 提交后被扫描工具发现,直接导致安全审计不通过。我们的方案是:所有密钥存储在 HashiCorp Vault 中,Flowable 应用启动时通过 Vault Agent 注入环境变量,委托类中用System.getenv("LLM_API_KEY")获取。Vault 的策略精确到路径:secret/data/flowable/llm/qwen只允许flowable-prod角色读取,且启用审计日志。
红线二:敏感字段必须脱敏后再输入 LLM
身份证号、银行卡号、手机号等 PII(个人身份信息)字段,在进入 LLM 节点前必须脱敏。我们开发了PiiSanitizer工具类,规则如下:
- 身份证号:
11010119900307211X→110101********211X(保留前6位和后4位) - 银行卡号:
6228 4800 1234 5678 901→6228 48**** **** 901(保留前6位和后3位) - 手机号:
13812345678→138****5678
脱敏逻辑在ContextBuilder中统一执行,确保所有 LLM 节点输入数据合规。某次审计中,监管方抽查了 127 个 LLM 节点的输入日志,100% 符合脱敏要求。
红线三:输出内容必须经过合规性过滤
LLM 可能生成违规表述(如歧视性语言、医疗建议、金融承诺)。我们在委托类中调用 LLM 后,立即用规则引擎做二次过滤:
- 检查
explanation字段是否包含“保证”、“肯定”、“绝对”等绝对化用语,若有则替换为“根据当前信息推断” - 检查是否提及具体药物名称或治疗方案,若有则触发
MedicalComplianceException - 检查
key_points是否出现“性别”、“民族”、“宗教信仰”等敏感维度,若有则删除该条目
这套过滤规则由法务团队审核,每季度更新一次。上线半年来,拦截了 17 次潜在合规风险。
4. 实操过程与核心环节实现
4.1 从零搭建一个可运行的 LLM 节点:合同风险审核实战
我们以“采购合同风险审核”为例,手把手演示如何在 Flowable 中落地一个 LLM 节点。整个过程分为 5 步,每步都有可复制的代码和配置。
步骤一:创建 LLM 委托类
新建类ContractRiskLlmDelegate.java,实现JavaDelegate接口:
@Component public class ContractRiskLlmDelegate implements JavaDelegate { private static final Logger logger = LoggerFactory.getLogger(ContractRiskLlmDelegate.class); @Value("${llm.api.url:http://llm-gpu-internal:8000/v1/chat/completions}") private String llmApiUrl; @Value("${llm.model.name:qwen2-72b-instruct}") private String modelName; @Autowired private RestTemplate restTemplate; @Override public void execute(DelegateExecution execution) throws Exception { // 1. 获取输入变量 String contractJson = (String) execution.getVariable("input_contractJson"); String supplierInfo = (String) execution.getVariable("input_supplierInfo"); String historyRisk = (String) execution.getVariable("input_historyRisk"); // 2. 构建上下文 String context = buildContext(contractJson, supplierInfo, historyRisk); // 3. 构建请求体 LlmRequest request = buildLlmRequest(context); // 4. 调用 LLM API String responseJson = callLlmApi(request); // 5. 解析并设置输出变量 LlmResponse response = parseLlmResponse(responseJson); execution.setVariable("llmResult", new ObjectMapper().writeValueAsString(response)); } private String buildContext(String contractJson, String supplierInfo, String historyRisk) { return """ 【采购合同】:%s 【供应商信息】:%s 【历史风险记录】:%s """.formatted(contractJson, supplierInfo, historyRisk); } private LlmRequest buildLlmRequest(String context) { return LlmRequest.builder() .model(modelName) .messages(List.of( Map.of("role", "system", "content", "你是一名资深合同风控专家,请严格按JSON Schema输出审核结果。"), Map.of("role", "user", "content", "请分析以下合同风险,输出JSON:{\n" + " \"risk_level\": \"high|medium|low\",\n" + " \"confidence_score\": 0.0-1.0,\n" + " \"key_risks\": [\"string\"],\n" + " \"mitigation_suggestions\": [\"string\"]\n" + "}。示例:{\"risk_level\":\"high\",\"confidence_score\":0.87,\"key_risks\":[\"付款比例过高\"],\"mitigation_suggestions\":[\"建议调整为30%预付款+60%验收款+10%质保金\"]}。" + context) )) .temperature(0.3) .max_tokens(512) .build(); } private String callLlmApi(LlmRequest request) { try { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set("Authorization", "Bearer " + System.getenv("LLM_API_KEY")); HttpEntity<LlmRequest> entity = new HttpEntity<>(request, headers); ResponseEntity<String> response = restTemplate.exchange( llmApiUrl, HttpMethod.POST, entity, String.class); if (!response.getStatusCode().is2xxSuccessful()) { throw new RuntimeException("LLM API call failed: " + response.getStatusCode()); } return response.getBody(); } catch (Exception e) { logger.error("LLM call failed for process {}", execution.getProcessInstanceId(), e); throw new LlmCallException("LLM service unavailable", e); } } private LlmResponse parseLlmResponse(String responseJson) { try { JsonNode root = new ObjectMapper().readTree(responseJson); String content = root.path("choices").get(0).path("message").path("content").asText(); // 提取 JSON 部分(兼容 Markdown 代码块包裹) Pattern pattern = Pattern.compile("\\{[^{}]*\\}"); Matcher matcher = pattern.matcher(content); if (matcher.find()) { return new ObjectMapper().readValue(matcher.group(), LlmResponse.class); } else { throw new IllegalArgumentException("No valid JSON found in LLM response"); } } catch (Exception e) { logger.warn("Failed to parse LLM response: {}", responseJson, e); throw new LlmParseException("Invalid JSON format", e); } } }步骤二:定义 LLM 请求/响应 DTO
创建LlmRequest.java和LlmResponse.java,使用 Lombok 简化代码:
@Data @Builder public class LlmRequest { private String model; private List<Map<String, String>> messages; private double temperature; private int max_tokens; } @Data public class LlmResponse { private String risk_level; private double confidence_score; private List<String> key_risks; private List<String> mitigation_suggestions; @JsonProperty("_debug_info") private DebugInfo debugInfo; }步骤三:配置 Flowable 异步执行器
在flowable.cfg.xml中启用异步执行,避免阻塞主线程:
<bean id="processEngineConfiguration" class="org.flowable.engine.impl.cfg.StandaloneInMemProcessEngineConfiguration"> <!-- 其他配置 --> <property name="asyncExecutorActivate" value="true"/> <property name="asyncExecutorNumberOfRetries" value="3"/> <property name="asyncExecutorRetryWaitTimeInMillis" value="30000"/> </bean>步骤四:BPMN 流程图关键节点配置
在 Flowable Modeler 中,为llm_contract_review节点设置:
Implementation:com.example.flowable.llm.ContractRiskLlmDelegateField:input_contractJson→${contractJson}input_supplierInfo→${supplierInfo}input_historyRisk→${historyRisk}
Asynchronous before: ✅Retry time cycle:R3/PT30S
步骤五:部署与验证
打包应用,启动 Flowable。用 Postman 发送启动流程请求:
curl -X POST http://localhost:8080/flowable-rest/service/runtime/process-instances \ -H "Content-Type: application/json" \ -u admin:test \ -d '{ "processDefinitionKey": "proc_contract_review", "variables": [ { "name": "contractJson", "value": "{\"party_a\":\"XX公司\",\"party_b\":\"YY公司\",\"amount\":1500000,\"payment_terms\":\"预付30%\"}" }, { "name": "supplierInfo", "value": "{\"name\":\"YY公司\",\"credit_rating\":\"BBB+\",\"litigation_count\":2}" }, { "name": "historyRisk", "value": "[\"2023年存在1次付款延迟\",\"2022年合同履约率82%\"]" } ] }'查看ACT_RU_EXECUTION表,确认流程实例状态为ACTIVE;检查ACT_HI_VARINST表,llmResult变量应包含类似{"risk_level":"high","confidence_score":0.85,"key_risks":["付款比例过高"],"mitigation_suggestions":["建议调整为30%预付款+60%验收款+10%质保金"]}的内容。至此,一个可运行的 LLM 节点就完成了。
4.2 性能调优:让 LLM 节点扛住 500 TPS
LLM 节点最大的性能瓶颈不是模型本身,而是 Java 应用层的资源争抢。我们在压力测试中发现,当并发请求超过 200 时,Tomcat 线程池耗尽,大量请求排队,平均响应时间从 1.2s 暴涨到 8.7s。优化分三层:
第一层:连接池精细化配置RestTemplate默认使用SimpleClientHttpRequestFactory,每个请求新建 TCP 连接。我们改用HttpComponentsClientHttpRequestFactory,并配置连接池:
@Bean public RestTemplate restTemplate() { PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(); connectionManager.setMaxTotal(200); // 最大连接数 connectionManager.setDefaultMaxPerRoute(50); // 每路由最大连接数 RequestConfig requestConfig = RequestConfig.custom() .setConnectTimeout(5000) // 连接超时 .setSocketTimeout(15000) // 读取超时 .setConnectionRequestTimeout(3000) // 从连接池获取连接超时 .build(); CloseableHttpClient httpClient = HttpClients.custom() .setConnectionManager(connectionManager) .setDefaultRequestConfig(requestConfig) .build(); HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(httpClient); factory.setConnectTimeout(5000); factory.setReadTimeout(15000); return new RestTemplate(factory); }第二层:本地缓存高频提示词
90% 的 LLM 节点使用相同的系统提示词(system prompt)。我们用 Caffeine 缓存编译后的提示词模板:
@Cacheable(value = "promptTemplates", key = "#templateId") public String getPromptTemplate(String templateId) { // 从数据库或配置中心加载模板 return templateRepository.findByCode(templateId).getContent(); } // 在委托类中 String systemPrompt = promptService.getPromptTemplate("contract_risk_system");第三层:异步批处理降级策略
当 LLM 服务不可用时,我们不直接失败,而是启用批处理降级:将 10 个待审核合同聚合成一批,用规则引擎批量处理。实现FallbackLlmService:
@Service public class FallbackLlmService { public LlmResponse batchContractReview(List<ContractDto> contracts) { return contracts.stream() .map(this::reviewSingleContract) .collect(Collectors.collectingAndThen( Collectors.toList(), results -> buildBatchResponse(results) )); } private LlmResponse reviewSingleContract(ContractDto contract) { // 基于规则的快速审核:金额>100万?供应商信用<BBB?付款比例>50%? String riskLevel = "low"; if (contract.getAmount() > 1000000) riskLevel = "medium"; if ("BBB-".equals(contract.getSupplierCredit())) riskLevel = "high"; if (contract.getPaymentTerms().contains("预付")) riskLevel = "high"; return LlmResponse.builder() .risk_level(riskLevel) .confidence_score(0.95) .key_risks(List.of("规则引擎降级")) .mitigation_suggestions(List.of("建议人工复核")) .build(); } }经过这三层优化,系统在 500 TPS 压力下,LLM 节点平均响应时间稳定在 1.4s,错误率低于 0.1%,完全满足生产要求。
5. 常见问题与排查技巧实录
5.1 典型问题速查表
| 问题现象 | 可能原因 | 排查命令/方法 | 解决方案 |
|---|---|---|---|
| 流程卡在 LLM 节点不动 | AsyncExecutor未启用或配置错误 | 查ACT_RU_JOB表,看是否有STATE_ = 'ACQUIRED'但长时间未更新的记录 | 检查flowable.cfg.xml中asyncExecutorActivate是否为true;确认ACT_RU_JOB表索引JOB_EXECUTION_ID是否存在 |
| LLM 返回结果为空或格式错误 | 提示词中 JSON Schema 描述不清,或模型未严格遵循 | 抓取llmResult变量值,用在线 JSON 校验工具验证 | 在提示词末尾添加强制示例,并在parseLlmResponse()中增加正则提取逻辑(见 4.1 节代码) |
大量LlmCallException日志 | LLM 服务端限流,或网络抖动 | curl -v http://llm-gpu-internal:8000/health检查服务健康;netstat -an | grep :8000查连接数 | 在callLlmApi()中增加指数退避重试,首次失败后等待 1s,第二次失败后等待 2s,第三次失败后等待 4s |
流程变量llmResult未设置 | 委托类中未调用execution.setVariable(),或变量名拼写错误 | 查询ACT_HI_VARINST表,NAME_ = 'llmResult'的记录是否存在 | 在委托类execute()方法末尾添加logger.info("Setting llmResult: {}", response),确认日志输出 |
| 同一份输入,多次调用结果不一致 | temperature参数过高,或模型本身随机性 | 固定temperature=0重新测试 | 将temperature设为0.1(平衡确定性与多样性),并在提示词中添加请以确定性方式输出,避免随机化 |
5.2 我踩过的三个深坑与独家避坑技巧
坑一:Flowable 的变量序列化陷阱
Flowable 默认用JdkSerializationStream序列化变量,而 LLM 返回的 JSON 字符串如果包含特殊 Unicode 字符(如 emoji、数学符号),反序列化时会抛出java.io.UTFDataFormatException。我们花了两天排查,最终发现是llmResult变量在ACT_RU_VARIABLE表中存储为BLOB类型,而 MySQL 的utf8mb4字符集未正确配置。解决方案:
- MySQL 配置文件中添加
collation-server = utf8mb4_unicode_ci - Flowable 数据源 URL 添加
?useUnicode=true&characterEncoding=utf8mb4 - 在委托类中,对
llmResult字符串做预处理:responseJson.replaceAll("[^\\x00-\\x7F]", "")(移除 ASCII 以外字符)
坑二:BPMN 中的 EL 表达式解析失败
想用${llmResult.risk_level == 'high'}做分支判断,但 Flowable 报错ELException: Cannot convert string to boolean。原因是llmResult是字符串类型,而 EL 表达式试图直接访问其属性。正确做法是:
- 在委托类中,将
llmResult解析为 Map 后再设置:ObjectMapper mapper = new ObjectMapper(); Map<String, Object> resultMap = mapper.readValue(responseJson, Map.class); execution.setVariable("llmResultMap", resultMap); // 注意变量名改为 Map - 分支表达式改为
${llmResultMap['risk_level'] == 'high'}
坑三:LLM 节点重试导致重复计费
Flowable