工作流执行引擎怎么按连线执行?条件分支和死循环怎么处理?
简化版
执行引擎是一个「执行当前节点 → 选下一条边 → 换到目标节点」的循环:先建一条「运行中」的运行记录,加载启用的节点和全部连线,从开始节点起步;每执行完一个节点,就在它的出边里按排序依次求值条件,取第一条条件为真的边走下去;走到结束节点、或者没有任何一条边满足条件时停止,最后回写成功或失败。条件分支靠「连线上的条件表达式」实现,条件节点只负责算出一个布尔值写进上下文。连线允许形成环,所以必须设最大步数,超过就判为死循环、中断运行。条件表达式最好手写一个只支持少数写法的解析器,而不是直接交给脚本引擎执行,避免把任意代码执行的能力交给流程配置。
详细版
run = 插入运行记录(状态=运行中)
nodes = 启用的节点;edges = 全部连线
current = 开始节点(没有就取第一个)
step = 1
while current != null:
if step > 最大步数: 抛出「可能形成死循环」
执行 current,写一条步骤日志,输出按输出变量写回上下文
if current 是结束节点: break
edge = current 的出边按排序依次求值,取第一条为真的
if edge == null: break
current = edge 的目标节点;step++
回写运行记录(成功 / 失败、最终上下文、耗时)
| 问题 | 常见做法 |
|---|---|
| 从哪开始 | 找类型为「开始」的节点,没有就按排序取第一个 |
| 多条出边走哪条 | 按排序依次判断,取第一条条件为真的(单路径);并行引擎则是所有为真的都走 |
| 什么时候停 | 执行完结束节点;或没有满足条件的出边;或超过最大步数 |
| 分支谁决定 | 连线上的条件表达式;条件节点只产出布尔值 |
| 死循环 | 最大步数兜底,超过即中断并记录原因 |
| 条件怎么求值 | 手写解析少数几种写法,不执行任意脚本 |
完整版教学
一、引擎本质上是一个图遍历循环
流程在库里是节点和连线,执行引擎要做的就是按图走一遍。它和普通的图遍历有两点不同:一是每走一步都有副作用(调模型、调接口、写上下文),不能随便回溯重来;二是下一步走哪取决于刚算出来的数据,而不是事先确定的顺序。
执行节点 ──▶ 输出写进上下文 ──▶ 用上下文求值出边条件 ──▶ 选中一条 ──▶ 目标节点
▲ │
└──────────────────────────────────────────────────────────────────┘
因为有副作用,引擎开跑前就要先落一条「运行中」的运行记录,再逐步写步骤日志;无论中间哪一步失败,都要把运行记录回写成失败,而不是让异常直接抛给调用方,否则库里会留下一条永远「运行中」的记录。节点失败时整条流程停还是继续,见「工作流里某个节点失败了,整条流程该停还是继续?」。
二、起点和终点
起点:找类型为「开始」的节点。如果流程没有配开始节点,要么报错,要么按排序取第一个节点兜底,两种都可以,但要在文档里说清楚。只加载「启用」的节点也在这一步,停用的节点相当于从图里摘掉。
终点有三种情况,处理方式要想清楚:
| 情况 | 含义 | 建议处理 |
|---|---|---|
| 执行完结束节点 | 正常走完 | 成功 |
| 没有任何出边满足条件 | 流程配置里没覆盖到这种情况 | 按设计记为结束;如果业务上「必须走到结束节点」,就应该记为异常 |
| 目标节点不存在或已停用 | 连线指向了被摘掉的节点 | 应明确报错,而不是悄悄停下 |
第二种最容易被忽略:一条连线的条件是 replyDraft != null,上游节点这次没有产出 replyDraft 这个变量,就没有边可走,流程在中途停下。如果引擎把它当成正常结束,用户看到的是「成功」但没有结果。所以要么在配置时保证每个分支点都有兜底的边,要么让引擎在「没走到结束节点就停了」时给出提示。
三、多条出边:第一条为真,还是全部为真
同一个节点有多条出边时,有两种语义:
| 语义 | 规则 | 适合 | 代价 |
|---|---|---|---|
| 排他分支 | 按排序依次判断,取第一条为真的 | if / else if / else 式的分支 | 一次只走一条路径 |
| 并行分支 | 所有为真的边都走 | 要同时做几件事再汇合 | 需要汇合节点、并发控制、部分失败处理 |
大多数业务编排从排他分支做起:实现简单、执行轨迹是一条直线,日志和回放都容易理解。用排他分支时,排序字段就是优先级:两条边的条件有交集时,排序靠前的赢。比如「金额 > 1000 走人工审核」排序 1,「金额 > 0 走自动审批」排序 2,金额 5000 两条都为真,走的是排序 1。排序配反了,大额单子就会走自动审批。
四、条件节点和连线条件的分工
条件判断有两个地方可以放:条件节点和连线。一种清晰的分工是:
条件节点: 表达式 cleanReplyDraft != null && cleanReplyDraft.length > 0
→ 求值得到 true,按输出变量写进上下文:needCreateTicket = true
连线 A: needCreateTicket == true → 去 HTTP 节点记录工单
连线 B: needCreateTicket == false → 直接去结束节点
条件节点把「复杂判断」算成一个有名字的布尔变量,写进上下文,步骤日志里能看到它算出来的值;连线只做简单的相等判断。这样复杂逻辑只写一处,排查时一眼能看出「这次为什么走了这条边」。
五、条件表达式:自己解析还是交给脚本引擎
条件表达式由流程配置者填写,如果直接交给 JavaScript、Groovy 这类脚本引擎执行,配置者就拥有了在服务器上执行任意代码的能力。常见的替代是手写一个小解析器,只支持有限几种写法:
| 写法 | 含义 |
|---|---|
a != null / a == null | 变量是否存在 |
a.length > 0 | 字符串非空 |
a == 'x' / a != 'x' | 等于 / 不等于常量 |
a && b / a || b | 与 / 或 |
a | 布尔值或「非空且不为 false」 |
手写解析最容易出错的是运算符优先级。按惯例 && 比 || 优先,a || b && c 应理解为 a || (b && c);如果实现时先按 && 切分、再在每段里按 || 切分,得到的会是 (a || b) && c。正确的切分顺序是先按优先级低的 || 切,再在每段里按 && 切,或者干脆在文档里规定不允许混写。
易错点:递归下降解析时,优先级越低的运算符越先切分。先切
&&等于让||绑得更紧,和人的直觉正好相反。
六、环与死循环:最大步数怎么定
连线允许指回前面的节点,这是实现「不合格就重做」的基础,但也可能因为条件写错而永远转下去。引擎必须有最大步数:
一条 7 个节点的流程,正常一次最多走 7 步
允许某个环最多重做 3 次,环里有 3 个节点:7 + 3 × 3 = 16 步
最大步数设 100:正常流程远远碰不到,死循环时第 101 步就会被拦下
步数上限要远大于正常流程的步数,又不能大到死循环时烧掉大量调用。触发上限时的错误信息要写清楚原因,比如「执行步数超过上限,请检查连线是否形成死循环」,配置者看到就知道该去查连线,而不是去查模型。
七、同步跑完再回放,还是边跑边推
引擎可以同步执行,一个请求跑完整条流程再返回运行记录;也可以放到后台任务里,边跑边推进度。同步的实现最简单,但流程里有几次模型调用时,请求要等十几秒,某个节点慢就阻塞整个请求。做调试台时,同步执行也能实现「逐步点亮」的效果:后端跑完后返回步骤日志列表,前端用定时器一格一格回放。它看起来像在实时执行,其实是回放。真正需要实时进度时,就得改成后台执行加推送,做法见「耗时的 AI 流程怎么异步执行?进度怎么推给前端?」。
八、常见误区与追问
- 误区:多条出边满足条件时都要走。 这是并行语义,排他分支引擎只取第一条为真的边,排序就是优先级。
- 误区:没有边可走就是正常结束。 可能是配置漏了某种情况,要么配兜底边,要么让引擎提示「没走到结束节点」。
- 误区:条件表达式直接用脚本引擎执行最灵活。 这等于把任意代码执行权交给流程配置者,手写有限语法更安全。
- 误区:有了最大步数就不用检查环。 最大步数只是兜底,合理的环应该有自己的计数和退出条件。
- 误区:条件节点决定了下一步去哪。 条件节点只产出布尔值,真正决定走向的是连线上的条件。
- 追问:
a || b && c应该怎么解析? 按惯例&&优先,是a || (b && c);手写解析要先按||切分。 - 追问:引擎为什么要先插一条「运行中」的记录再执行? 执行过程中每写一条步骤日志都要引用运行 ID;而且任何一步失败时,都要有一条记录可以回写成失败。
九、加强记忆
执行引擎记「一个循环、三种终点、两个上限」:循环是执行节点 → 输出写回上下文 → 出边按排序求值取第一条为真的 → 换到目标节点,开跑前先插「运行中」记录,失败也回写。三种终点是走完结束节点、没有边可走、目标节点不存在,后两种不能悄悄当成功。分支由连线条件决定,条件节点只把复杂判断算成有名字的布尔变量;排序就是优先级,条件有交集时排序靠前的赢。条件表达式手写有限语法,先切 || 再切 &&,不交给脚本引擎。两个上限:环要有自己的退出条件,引擎再用最大步数兜底并写清原因。
项目实战落地
项目里怎么做的
《AI企业流程编排系统》的执行引擎在 WorkflowRunService.execute,工作台和画布都调同一个接口 POST /workflowRun/execute,请求里只有流程 ID 和初始上下文:
- 先落运行记录:插入一条状态为「运行中」的
workflow_run,再加载启用的节点和全部连线,节点按 ID 放进nodeMap; - 起点:优先找类型为「开始节点」的,没有就取节点列表第一条;
- 主循环:
executeStep执行当前节点并写一条步骤日志,结束节点执行完就跳出;否则chooseNextEdge取当前节点的出边,按sort升序,条件为空直接通过,取第一条条件为真的边,一条都没有就结束; - 死循环兜底:
MAX_STEP_COUNT = 100,超过就抛出「流程执行步数超过上限,请检查连线是否形成死循环」; - 条件求值:连线条件和条件节点共用
evaluateExpression,手写解析&&、||、!= null、== null、.length > 0、==、!=和单个变量名,不是脚本引擎;条件节点把结果写进输出变量(如needCreateTicket),走向由连线条件needCreateTicket == true / false决定; - 收尾:全程无异常回写成功;任一节点抛异常就回写失败并记下原因,不再向外抛,最后返回这条运行记录。
为什么这样取舍
- 自研引擎:流程编排的核心是「按节点和连线执行 + 变量传递」,自研能讲清原理,不引入 Activiti、Flowable 这类偏审批流的重型工作流框架。
- 失败不向外抛:运行记录本身要落库,页面拿到的是一条状态为失败、带着错误原因的运行记录,而不是一个笼统的报错。
面试官还会追问
- 画布怎么判断一条连线在这次运行里「走过了」?条件分支没走的那条为什么不会亮?
- 开始节点的输入变量没传时,引擎为什么直接报错?这个提示最后出现在页面的哪里?
学完《AI企业流程编排系统》,上面这些追问你都会迎刃而解。