有了 Flowable,为什么还写了个编排引擎

SailTrack
2026-09-28
点 赞
0
热 度
7
评 论
0
  1. 首页
  2. 后端开发
  3. 有了 Flowable,为什么还写了个编排引擎

写在前面

项目里已经接了 Flowable 做审批流,agent 模块又实现了一个自己的工作流引擎(一千多行)——评审时这是被问得最多的问题:“工作流为什么要有两套?”

答案藏在两个"工作流"的本质差异里:Flowable 编排的是"人的流程"——审批节点等一个人来点,一个流程跑几天,引擎的耐心以天为单位;Agent 工作流编排的是"模型的调用链"——意图识别、工具调用、知识检索、条件分支,一个链路跑几秒钟,节点输出的不是"待办"而是变量。

这篇记录这个自研引擎的设计:DSL 长什么样、校验怎么做、执行怎么走。它不替代 Flowable,它们住在系统的不同楼层。

一、DSL:JSON 里的图

工作流的定义就是一段 JSON,两个核心集合:

{
  "nodes": [
    { "id": "start",     "type": "START", ... },
    { "id": "classify",  "type": "INTENT",   "outputKey": "intent", "config": {...} },
    { "id": "query",     "type": "TOOL",     "outputKey": "query_result", "config": {...} },
    { "id": "branch",    "type": "CONDITION", "config": {...} },
    { "id": "answer",    "type": "SUMMARY",  "outputKey": "answer", "config": {...} }
  ],
  "edges": [
    { "from": "start", "to": "classify" },
    { "from": "classify", "to": "query", "fromPort": 0 },
    { "from": "classify", "to": "answer", "fromPort": 1 },
    { "from": "query", "to": "branch" }
  ]
}

数据模型在 Java 侧是三个 record,窄到不能再窄:

public record WorkflowNode(String id, WorkflowNodeType type,
                           JsonNode config, String outputKey, JsonNode outputFormat) {
}

public record WorkflowEdge(String from, String to, Integer fromPort) {
}

节点类型枚举了十一种:START、END、LLM、TOOL、CONDITION、INTENT、APPROVAL、KNOWLEDGE、FORM_EXTRACT、SUMMARY、TRANSFORM。每种类型的执行语义固定,可配置的部分全部收进 config 这个 JSON 节点里——类型是封闭集合,配置是开放空间,新增一种节点能力才需要动代码,调参、改提示词都是纯配置。

outputKey 是数据流的关键:每个节点把自己的产出写进上下文的这个键位,下游节点按名字取用。图结构(edges)控制执行顺序,outputKey 控制数据流向——控制流和数据流分开建模,这是所有编排引擎的通用功课。

二、校验在前:不让用户在运行时撞墙

工作流配置是管理员在界面上拼的,错误不可避免。引擎的原则是把所有错误拦在保存和执行之前,校验器分四步:

// 工作流校验:解析 JSON → 校验节点 → 校验边 → 可达性 → 环路检测

前两步查结构:JSON 能不能解析、节点类型合不合法、边指向的节点存不存在。后两步查拓扑,才是真正的价值所在:

可达性(BFS):从 START 节点做广度优先遍历,凡是遍历不到的节点都是"孤立节点"——界面上看它是链路的一部分,运行时永远不会执行。这种"看起来接上了、实际没接上"的错误,是可视化编辑器最常见的事故。

环路检测(DFS):用递归栈(visiting 集合)做深度优先,走到栈中已有的节点即有环:

// DFS 检测环路:visiting 集合记录当前递归栈,遇到栈中节点即存在环
messages.add("工作流存在环路");

没有环路检测,一个配置失误的 CONDITION 回边会让执行器在两个节点之间无限循环——LLM 每转一圈都是真金白银的 token 费用。在保存按钮上花二十毫秒做一次 DFS,省下的是运行时的无限循环,这笔账没有第二个算法。

三、执行:上下文是一袋变量

执行器从 START 出发逐步走图,每一步把节点产出写进 WorkflowContext:

context.putJson("input", input);                 // 入口输入
context.put("currentUserId", currentUserId);     // 触发人
context.putJson("currentUser", objectMapper.valueToTree(orgDirectoryService.getUserDetail(currentUserId)));
...
case LLM -> { ... context.putJson(outputKey(node), response); }
case TOOL -> {
    ...
    context.putJson(outputKey, output.path("response"));   // 或按 toolCall 的 outputKey 逐个写入
}

执行开始时注入的全局变量(input、currentUser、taskId)是链路的"环境",各节点按 outputKey 留下的产物是链路的"工作内存"——SUMMARY 节点汇总时读 query_result,CONDITION 判断时读 intent,全部通过键名接线。

CONDITION 节点的分支选择读上下文里的字段值,按配置的分支规则选中 fromPort 对应的出边——一条边一个端口,同一节点可以有多条出路。分支的判定依据来自上下文,而不是图结构,这让"意图是 A 走工具链、是 B 直接回答"这类路由变得极其直白。

四、APPROVAL:唯一会"停下来"的节点

十一种节点里,九种是同步执行、毫秒级返回。例外是 APPROVAL——它执行到时,整个任务暂停,状态置为 WAITING_HUMAN_CONFIRM,记录截止时间,然后执行器返回。

人确认(或超时收口扫描)之后,任务从断点恢复执行。这和 Flowable 的"审批等几天"貌合神离:Agent 工作流的 APPROVAL 预期在几分钟到几十分钟内被处理,超时有专门的收口扫描兜底(按 waitingDeadlineAt 批量取消)。同一个"等待"概念,在两个引擎里有完全不同的时间尺度和善后机制——这正是它们必须分开的微观例证。

五、配置里的模板:让链路能引用上下文

节点配置里有一个小而重要的能力:模板解析(TemplateResolver)。配置写 "prompt": "用户问的是 ${query_result.text},请给出回答",执行时占位符被上下文的实际值替换。

这让"取数 → 汇总"的链路不需要任何代码胶水:TOOL 节点产出的键名,直接以 ${...} 出现在 LLM 节点的提示词模板里。编排平台的价值不在于图拖得多顺滑,在于节点之间传递数据的阻力有多小——模板就是那个把阻力降为零的小齿轮。

六、小结

三点体会:

  1. 两个"工作流"是两个物种。 Flowable 编排人的流程(天级、合规、待办),Agent 引擎编排模型链路(秒级、token、变量)——硬要一套承载两种语义,得到的是两套别扭。
  2. 校验的价值集中在拓扑上。 结构错误人眼能看,孤立节点和环路必须靠 BFS/DFS——尤其环路,运行时的代价是烧钱而不是报错。
  3. 控制流和数据流分开建模。 edges 管顺序,outputKey 管数据,模板管注入——三者解耦后,拼一条新链路是纯配置劳动。

引擎上线后,"知识库问答"和"报销政策咨询"两条 AI 链路都是管理员在工作台上拼出来的,没动一行 Java。那是我第一次直观感受到这层抽象的回报:引擎写一次,链路无限次。


让我们忠于理想,让我们面对显示

SailTrack

entp 辩论家

站长

不具版权性
不具时效性

文章内容不具时效性。若文章内容有错误之处,请您批评指正。

目录

欢迎来到SailTrack的站点,为您导航全站动态

65 文章数
11 分类数
5 评论数
60标签数