上个月接了一个简历筛选的 Agent 需求。一开始我图省事直接在业务类里用 if-else 把流程串了起来解析简历过滤关键词命中条件就调 LLM 生成摘要然后保存报告、通知 HR。前两三个节点的时候还挺清爽等流程加到十个节点、十几个分支判断之后代码彻底没法看了——每个方法里都是下一步该调谁的判断加一个新人来改需求得在线索链里翻半天才知道某个分支在哪儿接出去的。后来我用了三天时间用 Java 从 0 到 1 写了一个轻量级的 Agent 工作流引擎核心就两件事节点状态轮转和流式输出。状态机负责让节点按定义好的流转规则自动推进业务代码里不再出现如果 a 成功就调 b否则调 c这类硬编码路由流式输出则让前端能实时看到流程跑到了哪个节点、Agent 当前正在做什么。这篇文章就把整个设计思路、关键代码和踩坑记录完整地写出来适合已经会用 Spring Boot、但对 Agent 工作流底层实现还好奇的 Java 工程师。1. Agent工作流为什么需要一个真正的引擎if-else的失控与Flowable的过度设计1.1 靠if-else串流程问题出在状态和路由缝在一起先还原一下大多数人的第一版写法。假设流程是接收简历 - 解析简历 - 关键词过滤 - LLM 摘要 - 保存报告 - 通知 HR用 if-else 大概长这样void execute(String cvId) { Resume resume parseResume(cvId); if (resume null) { retryOrFail(cvId); return; } boolean matched keywordMatch(resume); if (matched) { String summary llmSummarize(resume); saveReport(cvId, summary); notifyHr(cvId, matched); } else { saveReport(cvId, no match); notifyHr(cvId, not matched); } }这段代码在需求不变的时候还能忍受。问题是 Agent 工作流几乎每周都在变。比如加一个解析失败先重试两次再走人工兜底的规则加一个LLM 超时走降级模板的逻辑加一个关键词匹配命中的同时并行生成多份不同视角的摘要。每加一个规则你就得在代码里再嵌套一层 if-else然后方法越来越长状态越来越多最后每个节点到底在什么情况下走到哪个分支只能靠人肉回忆。我用一段时间后发现if-else 串流程的核心弊病是把两件事焊死在一起了路由规则和业务逻辑。节点本身只应该做一件事比如解析简历过滤关键词但 if-else 写法里本节点必须在代码里知道下一个节点是谁、满足什么条件才去。这套东西一旦数量多了本质就是在地狱级地维护一张肉眼不可见的状态机。1.2 Flowable/Camunda为什么不合适既然 if-else 不行那直接上 Flowable、Activiti 这类成熟的 BPM 引擎行不行我的答案是行但不是为 Agent 工作流准备的。Flowable 的优点非常硬核完整的 BPMN 2.0 规范支持、数据库持久化、历史流程审计、人工审批节点、会签或签等企业级能力。如果你的流程很大概率会涉及人工审批、需要严格合规审计那直接选 Flowable 没毛病。但 Agent 工作流的真实画风不是这样的——它更像一条动态编排的图节点是调用大模型调工具做条件判断边是成功才能往下走某条件满足时走这条分支。你并不需要一个人工审批列表也不需要完整的历史流程实例库你真正想要的只是让十几个 Agent 节点按规则有条不紊地跑完并且把过程实时暴露给前端。用 Flowable 跑 Agent 工作流意味着你得把每个 Agent 节点包成 JavaDelegate在 BPMN XML 里定义各种 serviceTask、sequenceFlow再配一个流程引擎的 History 库。为了串起十个节点引入一套完整的事务和任务机制调度复杂度和运维成本都上去了。而且 Flowable 的默认输出模型是流程结束时拿结果和 Agent 场景里想要的一边跑一边把 token 和节点状态推给前端天然有张力。1.3 自研轻量级引擎的适用边界再往上一层的对比对象其实是很多人用过的 Coze、Dify 这类平台。它们帮你处理了流程编排、运行界面、多租户体验确实好。但这里的痛点是它是一个托管黑盒流程定义存在平台侧只能通过 API 调用流程中间你要是想改上下文管理策略、想把执行事件推到自己的业务系统里观察、想接自己公司的内部 RPC都得看平台能力脸色。所以我建议的判断标准是这样的如果你的流程规模可控几十个节点以内、需要深度嵌进现有 Spring Boot 业务系统、希望自定义流式输出事件、有精力维护一点基础设施代码那自研轻量级引擎非常值得反之如果你需要真正的 BPMN 标准、需要人工审批流转或者你的诉求是今天就要可视化编排上线直接上 Flowable 或者用平台别自己折腾。我的选择是前者下面开始讲怎么设计。2. 节点状态机的核心设计让状态流转与业务逻辑彻底解耦2.1 单节点状态枚举与状态转移表工作流引擎里的第一个核心概念是节点状态机。每个节点不会只有开始/结束两个状态它至少应该有这样一套状态状态含义触发条件PENDING等待执行已入队但还没轮到流程启动时初始状态RUNNING执行中可能正在调 LLM 或工具被执行器取走并开始运行SUCCESS执行成功已产出结果执行器返回成功结果FAILED执行失败执行器返回失败且不再重试SKIPPED被跳过上游条件不满足或降级策略生效TERMINATED被强制终止全局取消或流程终止配套的还有流程级状态READY、RUNNING、COMPLETED、FAILED、TERMINATED。节点状态和流程状态要分清楚前者是引擎里单个节点的生命周期后者是整个流程实例的生命周期。这套状态转移关系用一个表格给出来就当是状态机定义当前状态事件下一状态说明PENDINGSTARTRUNNING执行器开始干活RUNNINGCOMPLETEDSUCCESS节点正常结束RUNNINGRETRYRUNNING失败但允许重试重新执行RUNNINGFAILEDFAILED失败且不再重试PENDING 或 RUNNINGSKIPSKIPPED路由判定不满足跳过任意状态TERMINATETERMINATED流程被取消时全局终止实际代码里我建议把这套状态转移逻辑收敛到一个小的状态机工具类里里面维护一张当前状态 - 事件 - 目标状态的映射表而不是散落在各节点里自己 setState。2.2 用状态机代替散落各处的if-else判断这一节是整个引擎的灵魂。以前我们用 if-else 管流程是让每个节点自己决定下一步去哪。改成状态机之后节点执行器彻底变成哑节点——它只负责处理自己的业务逻辑返回一个 ExecutionResult里面包含 status 和数据绝不包含下一个节点是谁这个信息public interface NodeExecutor { String type(); ExecutionResult execute(NodeContext ctx); }ExecutionResult 大概长这样public class ExecutionResult { private NodeStatus status; // SUCCESS / FAILED / SKIPPED private MapString, Object data; // 节点输出写入共享上下文 private int retryCount; // 配合 maxRetries 用 }那么下一个节点是谁这个路由决策交给谁交给引擎由引擎根据流程定义里的 Transition 数组去判定。流程定义大概是{ id: keyword-filter, type: keywordFilter, transitions: [ { condition: #ctx.get(matched) true, target: llm-summarize }, { condition: #ctx.get(matched) false, target: save-report } ] }节点跑完引擎把 ExecutionResult 里的数据合并进共享上下文然后逐个评估 transitions 里的 condition命中哪条就走哪个 target。业务代码里那个如果匹配就走摘要否则保存报告的判断彻底从 Java 方法里消失变成了 JSON 里的一行路由规则。这就把状态轮转从业务代码里解耦出来了。以后要加一个新分支改 JSON 就好不用动 Java 代码。2.3 节点执行器的注册表设计状态机定了引擎还需要知道type 为 keywordFilter 时究竟调用哪个 Java 类。这里本来可以写一个 switch-case但那就又把类型和实现耦合死了。我用的是 Spring 环境下最常见的注册表思路定义注解启动时扫描注册。Target(ElementType.TYPE) Retention(RetentionPolicy.RUNTIME) public interface NodeExecutorType { String value(); }每个执行器加注解例如NodeExecutorType(keywordFilter) Component public class KeywordFilterExecutor implements NodeExecutor { Override public ExecutionResult execute(NodeContext ctx) { // 业务逻辑... } }引擎启动时通过 ApplicationContext 拿到所有带注解的 Bean构建成 MapString, NodeExecutorComponent public class NodeExecutorRegistry { private final MapString, NodeExecutor registry new HashMap(); public NodeExecutorRegistry(ApplicationContext applicationContext) { MapString, Object beans applicationContext.getBeansWithAnnotation(NodeExecutorType.class); for (Object bean : beans.values()) { NodeExecutorType annotation bean.getClass().getAnnotation(NodeExecutorType.class); registry.put(annotation.value(), (NodeExecutor) bean); } } public NodeExecutor get(String type) { NodeExecutor executor registry.get(type); if (executor null) { throw new IllegalStateException(unknown node executor type: type); } return executor; } }这样新增一种节点类型只需要新写一个执行器类并打上注解引擎的循环逻辑完全不用动。这个注册表模式本质上就是干掉了把所有执行器塞进一个巨型 if-else 或 switch-case 的做法。3. 流程定义层用JSON编排一张Agent执行图3.1 流程定义JSON的最小模型流程引擎能不能用得舒服流程定义的表达力占一大半。我设计的 JSON 模型保持最小化但足够支撑 Agent 工作流的常见需求{ flowId: resume-screening-flow, startNode: receive-resume, nodes: [ { id: receive-resume, type: inputReceiver, name: 接收简历, params: { requiredFields: [cvId, source] }, next: parse-resume }, { id: keyword-filter, type: keywordFilter, params: { keywords: [Java, Agent, 工作流] }, transitions: [ { condition: #ctx.get(matched) true, target: llm-summarize }, { condition: #ctx.get(matched) false, target: save-report } ] } ] }这个模型里我故意把next和transitions都保留无条件的直连边用next表达起来更简洁有条件的边用transitions里面每一项都是条件 目标节点。一个节点上多个 transitions 会被按顺序评估命中第一个就停止这样天然支持优先级路由。params是节点执行器自己消费的参数比如关键词列表、超时时间、模型 ID 等从 JSON 里解析出来装入 NodeContext。这套模型的核心思想是流程定义是图数据不是代码数据。每个节点只知道自己的出边不需要知道上游是谁整张图可以被解析、校验、可视化甚至前端可以直接拿这棵节点关系树渲染一个简版流程图。3.2 用SpEL作为条件路由的表达式引擎条件表达式我直接用 Spring 的 SpEL这是 Java 世界里现成的、表达能力足够、又不需要引入额外重依赖的表达式方案。引擎侧写一个很小的 ConditionEvaluatorComponent public class ConditionEvaluator { private final ExpressionParser parser new SpelExpressionParser(); public boolean evaluate(String expression, NodeContext ctx) { if (expression null || expression.isBlank()) { return true; } StandardEvaluationContext context new StandardEvaluationContext(); context.setVariable(ctx, ctx); try { return Boolean.TRUE.equals(parser.parseExpression(expression).getValue(context, Boolean.class)); } catch (Exception e) { // 表达式异常时按不满足条件处理走兜底 return false; } } }然后在 JSON 里写condition: #ctx.get(matched) true引擎解析时把整个 NodeContext 作为变量ctx暴露给表达式。这里有几个够用就行的小技巧表达式里统一走#ctx.get(key)不要在表达式里直接拿 Java 对象的复杂字段否则 JSON 定义很容易和苏式化。不满足条件就静默走 else不会因为表达式解析失败让整个流程崩掉。如果想调试可以在表达式前后打印一下变量快照后面我会讲一个调试套路。用 SpEL 的意义在于流程边界条件的调整不再需要发版普通开发也能读懂 JSON 里的条件路由反正就是匹配了吗这类简单判断。3.3 上下文对象Agent工作流的黑板Agent 工作流和传统表单审批流最大的不同是它有一个非常胖的共享上下文——各节点要传递的不只是一个个审批结果还有文档内容、解析结果、LLM 输出、工具调用痕迹。这里我采用底层叫法叫Blackboard黑板模式所有节点共享同一个黑板每个节点往黑板上写入自己的输出后续节点按 key 读取需要的内容。public class NodeContext { private final String flowId; private final String nodeId; private volatile MapString, Object variables new ConcurrentHashMap(); // getVariable / setVariable / removeVariable / snapshot }比较关键的一点是主动控制上下文体积。Dify 这类产品里经常遇到上下文超长的问题那是因为平台把所有中间结果都堆在一起传给大模型。自研最大的好处是可以在节点执行前做上下文摘要比如 LLM 节点只需要最近两条关键数据就用一个小的上下文组装器从黑板上只挑需要的 key 拼成 prompt而不是把整张黑板倒给模型。这块让我尝到甜头的地方后面在踩坑章节里会再展开。4. 引擎执行核心图遍历、分支汇聚与并发控制4.1 队列驱动的图遍历为什么不用递归拿到流程 JSON也拿到了注册表接下来是引擎主循环。不少新手会自然想到用递归去做深度优先遍历从 startNode 开始递归执行执行完 a 节点就递归调 b。但我不建议这么做原因是 Agent 工作流里一个节点可能要调 LLM 等几十秒递归版本的调用栈会挂起一长串中断、恢复、超时控制都很别扭而且节点多了还容易栈溢出。我采用的是队列驱动的遍历先用一个待执行队列把 startNode 放进去循环里取一个节点、执行、根据路由把后继节点放回队列直到队列为空或流程被终止。核心逻辑大概是public void start(WorkflowDefinition definition, MapString, Object initialData) { DequeString readyQueue new ArrayDeque(); readyQueue.push(definition.getStartNode()); while (!readyQueue.isEmpty()) { String nodeId readyQueue.poll(); WorkflowNode node definition.getNode(nodeId); if (!stateMachine.casState(nodeId, PENDING, RUNNING)) { continue; // 状态推进失败说明被其他线程抢跑了跳过 } ExecutionResult result executeNode(node); mergeResultToContext(nodeId, result); ListString nextNodes route(node, result); nextNodes.forEach(readyQueue::push); } }这个 while队列的版本好处非常实际执行顺序可预测容易做超时和终止控制线程模型也更贴近任务调度器而不是递归嵌套。而且流程做到一半如果想取消直接把流程状态置为 TERMINATED循环下一次取节点时发现终止标记就直接收尾不需要处理一串递归栈。4.2 ALL/ANY汇聚机制与并行分支Agent 工作流经常会遇到一个节点成功后两个分支并行执行的场景。最典型的就是解析完简历一边调 LLM 生成摘要一边生成简历结构化数据文件两个互不依赖。这时候单线程按顺序跑没问题但没有发挥效率所以我给流程节点加了两个汇聚策略ALL所有上游完成后本节点才允许执行ANY任意一个上游完成后本节点即可执行实现方式我用一个简单的输入计数来推进。每个节点在运行时会有 inputCount每当一条上游边完成就对该节点的计数做一次原子减一减到 0ALL 场景或减到 1ANY 场景时就把该节点投放入待执行队列。核心数据结构是ConcurrentHashMapString, AtomicInteger dependencyCounter new ConcurrentHashMap();并行部分的线程池我建议如果 JDK 版本允许直接用虚拟线程池老版本就用固定大小线程池。另外补充一个很多人会问的Agent 怎么扛并发的问题引擎本身的并发能力只是一部分更关键的是要在调用 LLM 的节点上做好信号量限流、给事件队列设置上限避免一个流程跑十条分支时把 LLM 供应商的配额打爆、把内存打爆。4.3 循环、重试与防死循环有向图只要不是纯 DAG就可能出现环。在 Agent 工作流里环通常是两种意图一是重试直到成功二是循环直到用户输入确认。我在节点上配置maxRetries和maxIterations两个参数来分别控制。重试逻辑放在状态机里RUNNING 状态收到 FAILED 事件时如果重试次数没到阈值状态回到 RUNNING节点被再次投放入队列如果到了直接 FAILED然后走降级路由。循环逻辑则用一个剩余迭代次数放进上下文每经过一次循环节点就减一减到 0 之后路由不再回跳到循环头而是跳到循环出口或降级节点。这个防呆设计非常重要。我见过同事直接用 while 循环写递归式工作流最后因为一次 LLM 输出的条件表达式没命中流程在确认 - 重试 - 确认这个环里卡了一整夜。有界循环 条件路由才能保证流程不会变成生产事故。5. 流式输出让Agent的每一步都可以实时推送到前端5.1 事件的类别设计流程引擎跑起来只是第一步Agent 工作流动辄几十秒甚至几分钟用户看不到中间过程就等于死页面。所以流式输出是这次重构里我最看重的部分。我设计了一套事件模型引擎在关键节点都会发事件出来public abstract class FlowEvent { private final String flowId; private final Instant timestamp; // 状态流转事件NODE_STARTED / NODE_COMPLETED / NODE_FAILED / FLOW_FINISHED // 内容增量事件TOKEN_PRODUCED }具体事件大概有这几种NodeStartedEvent某个节点开始执行前端可以渲染正在解析简历NodeCompletedEvent某个节点执行完成前端可以渲染简历解析完成耗时 1.2 秒TokenProducedEventLLM 节点产生的增量 token前端可以做打字机效果FlowTerminatedEvent整个流程结束或失败这里要提一下如果你在 Agent 节点里接了 MCP 工具工具运行的时候往往会产生持续的增量输出——比如一边调用工具一边把内容写到文件、或者一边流式生成一段报告。这些增量输出我也会统一包装成TokenProducedEvent发布出去这样前端的流式渲染逻辑不用分心去处理不同来源。5.2 事件总线与SSE对接事件生产出来了怎么推给前端我用的方案是引擎内部维护一个事件总线外部注册监听者。在 Spring Boot 里可以直接用一个简单的实现Component public class FlowEventBus { private final ConcurrentHashMapString, CopyOnWriteArrayListFlowEventListener listeners new ConcurrentHashMap(); public void subscribe(String flowId, FlowEventListener listener) { listeners.computeIfAbsent(flowId, k - new CopyOnWriteArrayList()).add(listener); } public void publish(FlowEvent event) { String flowId event.getFlowId(); CopyOnWriteArrayListFlowEventListener list listeners.get(flowId); if (list ! null) { for (FlowEventListener listener : list) { listener.onEvent(event); } } } }流式输出到前端的传输协议我直接选 SSE理由后面讲。Controller 里接 SseEmitterGetMapping(value /flows/{flowId}/events, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamEvents(PathVariable String flowId) { SseEmitter emitter new SseEmitter(0L); FlowEventBus.subscribe(flowId, event - { try { emitter.send(SseEmitter.event().name(event.getType()).data(event)); } catch (IOException e) { emitter.completeWithError(e); } }); return emitter; }前端一个 EventSource 就接住了。事件驱动和 SSE 结合之后整个引擎的运行过程对用户是完全可见的流程走到哪一步、哪个节点在跑、大模型生成了什么内容、最后结果是什么全都能在页面上实时滚出来。5.3 为什么选SSE而不是WebSocket在 Agent 工作流这个场景几乎是服务器单向推送为主。用户在页面发起一个请求然后整个流程的进展都是服务器主动往客户端推客户端基本不需要往服务器发什么业务消息。这种单向推送模型下SSE 是最合适的选择基于 HTTP实现简单有自动重连前端使用成本极低一个大大的EventSource对象就完事。WebSocket 更适合双方高频双向交互的场景比如聊天、白板协同。如果为了流式输出硬上 WebSocket你得处理连接管理、心跳、双通道消息协议复杂度高出不少收益在这个场景里却几乎为零。真要说需要客户端做点什么也就是取消流程那把取消单独做成一个 POST 接口就够了完全没有必要为这一个交互升级成 WebSocket。5.4 心跳、背压与事件降频的几个小细节这几个细节看着小实际影响体验很大。第一是心跳。SSE 连接长时间没数据部分网关和浏览器会自动断开。我在 SseEmitter 里加了个定时任务每 15 秒发一条注释型心跳保证连接不假死。第二是有界事件队列。如果某个流程瞬时生成大量 token 事件而客户端消费不及无界队列就会内存暴涨。我统一用有界队列ArrayBlockingQueue满了之后对TokenProducedEvent做合并降频把多个小片段合成一个大片段但NodeStartedEvent、NodeCompletedEvent这类核心事件只允许阻塞等待不能丢失。这样体验不至于变成卡帧底层也稳得住。6. 全链路演示简历筛选Agent从JSON到SSE跑通6.1 业务场景与节点设计用一个完整的简历筛选 Agent 来演示这套引擎。场景是候选人提交简历后系统自动完成解析、过滤、摘要、报告、通知的全流程前端的 HR 页面能看到流程实时进度。节点清单如下节点 ID执行器 type职责路由receive-resumeinputReceiver接收上传的简历文件next - parse-resumeparse-resumeresumeParser解析简历文本与结构化数据next - keyword-filterkeyword-filterkeywordFilter检查是否包含目标关键词条件路由到 llm-summarize 或 save-reportllm-summarizellmSummarizer调用 LLM 生成简历摘要next - save-reportsave-reportreportSaver保存最终报告next - notify-hrnotify-hrhrNotifier通知 HR 处理结束6.2 流程定义JSON完整示例完整 JSON 长这样。这里我为了演示方便把 LLM 节点也放进路由里方便你理解条件路由和并行分支的真实形态{ flowId: resume-screening-flow, startNode: receive-resume, nodes: [ { id: receive-resume, type: inputReceiver, name: 接收简历, params: { requiredFields: [cvId, source] }, next: parse-resume }, { id: parse-resume, type: resumeParser, params: { key: resumeRaw }, next: keyword-filter }, { id: keyword-filter, type: keywordFilter, params: { keywords: [Java, Agent, 工作流] }, transitions: [ { condition: #ctx.get(matched) true, target: llm-summarize }, { condition: #ctx.get(matched) false, target: save-report } ] }, { id: llm-summarize, type: llmSummarizer, params: { promptTemplate: 请总结这份简历…, maxTokens: 1024 }, next: save-report }, { id: save-report, type: reportSaver, params: { outputKey: reportPath }, next: notify-hr }, { id: notify-hr, type: hrNotifier, params: { channel: email } } ] }我开发时喜欢用JSON 里永远不写死下一个节点 ID 之外的业务判断这个原则所以上面的 keyword-filter 里你看到的是 transitions 条件路由而不是在 Java 里写 if。这样调整过滤条件或增加一个AI 初筛不通过进人工池的节点只需要改 JSON 的 transitions 和新增一个节点不动任何别人写好的执行器。6.3 引擎启动与SSE接入代码启动流程的入口对业务方来说应该简单到几个方法String flowId workflowEngine.start(resume-screening-flow, initialData); // 返回 flowId前端拿这个 flowId 去订阅 SSE引擎内部 start 方法的逻辑就是把流程定义做一次快照创建 NodeContext初始化状态机把 startNode 放入待执行队列然后异步开始跑。SSE 订阅代码我在 5.2 已经给出两个部分拼起来就是一个完整闭环POST /flows/{flowId}/start 开启流程GET /flows/{flowId}/events 接流等待 FlowTerminatedEvent 出现后前端关闭 EventSource任务完成。为了演示不依赖真实 LLM 也能跑通我在 llmSummarizer 执行器里做了一个 fake 版本生成一个固定模板摘要并按每 150 毫秒一个 chunk 抛 token 事件。这样本地启动项目之后打开页面就能看到正在解析简历 - 关键词命中 - LLM 逐字输出摘要 - 保存报告 - 通知 HR一条龙推进。6.4 本地调试的实操技巧最后一个环节讲讲调试。流程引擎类项目最怕的就是节点多了之后不知道卡在哪儿。我自己常用的几个手段在 NodeContext 里加一个snapshot()方法每个节点执行前后打印关键变量观察数据在每个环节怎么演化。设置慢放模式给每个节点执行前插一个Thread.sleep(200)配合 SSE 页面看事件顺序是否合理。这比断点调试更能看出路由问题。给 LLM 节点做一个 Mock 开关切到 mock 模式后不真实调用外部 API返回固定数据。调试和 CI 都稳。所有事件日志统一格式按 flowId 分组排查问题一条命令 grep 出来。这些手段不需要任何额外的监控系统纯粹靠引擎事件体系带来的透明性就足够跑通大部分场景。7. 实战踩坑与边界比if-else版本低了多少维护成本7.1 并发下重复执行状态CAS推进第一个坑是并行分支跑起来之后发现的两个线程同时从队列里取到了同一个 PENDING 节点在没有保护的情况下这个节点会被同时执行两次。如果节点是发通知那用户会收到两条一模一样的邮件如果是扣费节点那就是生产事故。修复思路是在状态机上做 CAS 推进。节点从 PENDING 推进到 RUNNING 是一个只允许一次的操作实现上是这样AtomicReferenceNodeStatus statusRef new AtomicReference(PENDING); boolean ok stateMachine.casState(nodeId, PENDING, RUNNING); if (!ok) { // 说明别的线程已经抢跑了当前线程直接忽略 }所以看到我前面主循环里的continue逻辑了吗那个不是随便写的就是为了配合 CAS 状态推进拦截掉重复执行的节点。状态推进的原子性是并发流程引擎的底线。7.2 流式输出的背压与内存控制第二个坑是事件队列。刚开始我把所有事件丢进一个无界队列结果是某个流程里 LLM 节点疯狂输出 token而前端消费速度跟不上JVM 堆一路涨上去最后 OOM。排查的时候看堆 dump里面全是待发送的 SseEmitter 消息对象。优化方案前面提过事件通道换 ArrayBlockingQueue并且区分重要事件和低频事件。NodeCompletedEvent这类必须保证送达采用阻塞投递TokenProducedEvent这类高频低价值事件采用合并批量投递。我还在中间做了一个小压缩策略同一节点产生的 token50 毫秒内的合并成一段再推送前端用打字机效果完全感知不到延迟但内存占用一下子降了一个数量级。7.3 我踩过的三个真实坑再列三个小但真实的坑你们遇到了能少走弯路第一个是 SpEL 表达式写错导致的静默路由错误。比如把#ctx.get(matched) true写成了#ctx.get(matched) trueJSON 里外层用了双引号SpEL 解析直接报错我的 ConditionEvaluator 又做了解析异常返回 false处理结果所有匹配到的简历全走了不匹配分支。排查了很久才反应过来。后面我在解析异常时增加了 error 日志表达式错误必须抛出来绝不静默吞掉。第二个是 LLM 节点超时导致整个流程挂起。Agent 工作流里最慢的就是模型调用如果不给节点设置超时一次上游模型接口的偶发卡顿会让整个流程几分钟没人管。我现在每个慢节点都配上超时时间超时后走 FAILED 路由宁可降级也不能挂死。第三个是日志里的敏感内容。简历里有手机号、邮箱、薪资期望流程日志和事件推送如果不做脱敏面试者隐私和公司数据合规都会有隐患。我在 NodeContext 的 snapshot 里增加了脱敏处理涉及个人信息的字段一律打码事件推送也只传摘要不进原文。这个细节你们用真实业务数据时一定要留意。7.4 千万别自研引擎的情况最后说点泼冷水的话。有些场景你要冷静判断别因为看了这篇文章就什么都自研你的流程需要严格遵循 BPMN 标准需要走人工审批、会签、或签直接上 Flowable别自己写。你需要一个可视化编排界面让非技术人员自己拖拽改流程而你的团队没有前端人力去搭这个配置台用 Coze/Dify 这类平台更合适。你的流程规模大上百个节点、需要完整的历史实例查询和审计自研引擎的成本会指数级上升。团队本身没有 Java 工程师长期维护基础设施代码只是为了不用 if-else就把引擎写出来那反而引入一个更复杂的长期维护负担。我自己写这套轻量级引擎核心动机是做一个可以被业务代码深嵌、可以自定义事件输出、运行模型完全可控的运行时。这套引擎我已经用在内部工具链里大半年了从维护成本来看比之前 if-else 版本低了不止一个量级——改流程变成改 JSON加节点变成新增一个执行器类排查问题变成看事件流整个工作流就是状态轮转 流式输出两个词。如果你也在做 Java 侧的 Agent 编排、还没想好怎么组织流程代码我建议你先别急着上重框架画清楚节点图、定好状态机写一个几百行的小引擎你对工作流的理解会完全不一样。最后一个小建议先设计好 NodeContext 的变量结构再谈引擎选型这是我吃亏之后最想提醒你的。