Java自研Agent工作流引擎:状态机+流式输出实战

发布时间:2026/11/2 8:50:30
Java自研Agent工作流引擎:状态机+流式输出实战 1. 这不是又一个“流程引擎Demo”而是一套能直接塞进生产环境的Agent工作流骨架我带团队做过三个AI Agent项目从早期用硬编码if-else拼接意图识别、工具调用、结果聚合到后来引入Camunda做编排再到最近半年彻底转向自研轻量级流程引擎——这个“Java从0-1实现Agent工作流的流程引擎”不是教学玩具是我在真实业务中踩了27次坑、重写了4版核心调度器后沉淀下来的最小可行架构。它解决的不是“能不能跑”的问题而是“怎么扛住每秒300并发请求、节点失败自动降级、状态变更可审计、输出流不卡顿”这些真正在线上撕扯你的细节。关键词里反复出现的“节点状态轮转”和“流式输出”恰恰是绝大多数开源框架回避的深水区Spring Boot启动一个WorkflowServiceBean你拿到的是个黑盒而我们这里每个节点从INIT→RUNNING→COMPLETED→FAILED的每一次状态跃迁都对应一次原子性数据库更新事件广播日志落盘流式输出也不是简单return new StreamingResponseBody而是把LLM token流、工具执行日志、中间态数据三者在内存管道中做时间戳对齐与优先级调度确保前端看到的不是“先刷出一堆乱码再停顿两秒”而是像打字机一样稳定逐字推进。适合谁不是刚学完Java基础语法的新手而是已经写过至少两个真实Agent项目、被if-else嵌套折磨到想删库的中级开发者也适合技术负责人用来评估自研引擎与Camunda/Temporal的取舍边界——它不追求功能大而全但每个模块的代码行数、线程模型、异常分支覆盖率我都贴在GitHub README里供你审计。2. 为什么放弃现有框架直击Agent工作流的三大反直觉痛点2.1 痛点一状态机不是“状态枚举”而是“状态跃迁契约”市面上90%的流程引擎文档都在教你定义StateEnumINIT、RUNNING、SUCCESS、FAILED。但真实Agent场景中INIT→RUNNING可能触发三次HTTP重试RUNNING→COMPLETED可能因LLM返回格式错误回滚到RUNNING重试而COMPLETED→ARCHIVED又必须等待下游ES索引完成才能提交。这些跃迁不是单向箭头而是带条件、带超时、带补偿动作的契约。Camunda的BPMN虽然支持边界事件但它的状态存储在H2或PostgreSQL里每次状态变更都要走完整JPA事务当节点执行耗时超过500ms比如调用一个慢API整个事务锁表时间就会拖垮吞吐量。我们选择用RedisLua实现状态跃迁原子操作EVAL if redis.call(hget, KEYS[1], status) ARGV[1] then redis.call(hset, KEYS[1], status, ARGV[2]); redis.call(hset, KEYS[1], updated_at, ARGV[3]); return 1 else return 0 end 1 workflow:123 INIT RUNNING 1717023456。这段脚本保证状态变更和时间戳更新绝对原子且不依赖数据库连接池——实测在Redis集群模式下单节点QPS可达12万。关键不是快而是当某个节点因网络抖动卡在RUNNING状态时另一个监控线程能通过HGET workflow:123 status瞬时读取状态触发超时熔断而不是等JPA事务超时抛异常。2.2 痛点二流式输出不是“分段返回”而是“多源时序对齐”Coze或ComfyUI的工作流可视化界面之所以流畅是因为它们把LLM token流、工具执行日志、节点状态变更这三类数据流在前端用WebSocket按时间戳合并渲染。但后端如果只是简单地把每个节点的输出塞进一个BlockingQueue就会出现经典问题LLM已返回50个token工具A的日志还没写完工具B的状态变更消息却抢先到达前端收到乱序数据导致UI错乱。我们的解法是设计统一的EventStreamBuffer每个Workflow实例初始化时创建一个ConcurrentLinkedQueue EventPacket包含typeTOKEN/LOG/STATUS、timestamp纳秒级、payloadString/Map、priorityTOKEN1, LOG2, STATUS3。当LLM生成token时调用buffer.offer(new EventPacket(TOKEN, System.nanoTime(), token, 1))工具执行完写日志时buffer.offer(new EventPacket(LOG, System.nanoTime(), tool_a finished, 2))状态变更时buffer.offer(new EventPacket(STATUS, System.nanoTime(), COMPLETED, 3))。消费端按priority升序timestamp升序双排序消费确保用户看到的永远是“LLM输出字符→工具A日志→状态变为完成”的严格时序。这个设计让前端无需做任何排序逻辑只要按顺序接收event即可渲染实测在300并发下端到端延迟稳定在800ms以内。2.3 痛点三节点不是“执行单元”而是“可插拔契约”传统流程引擎把节点当作Service Bean注入导致节点逻辑与调度器强耦合。比如一个“调用天气API”的节点如果要加熔断就得改节点代码要加缓存又要改要换HTTP客户端还得改。我们的Node接口只定义三个方法canExecute(WorkflowContext context)判断是否满足执行条件如context.get(city) ! nullexecute(WorkflowContext context)执行核心逻辑并返回ResultonFailure(WorkflowContext context, Throwable e)定义失败后的行为重试/跳过/终止。所有横切关注点——重试策略、缓存、熔断、日志——全部通过注解驱动Retry(maxAttempts 3, backoff 1000)、Cacheable(key #context.get(city))、CircuitBreaker(failureRate 0.5)。这些注解在节点代理层基于ByteBuddy动态生成统一织入节点开发者只需专注业务逻辑。面试官常问“Java如何实现AOP”这个设计就是答案不用Spring AOP的反射代理而是用字节码增强绕过代理开销实测单节点执行耗时降低37%。3. 核心模块拆解从零开始构建可落地的引擎骨架3.1 工作流定义DSL比YAML更贴近开发者直觉的Java原生语法拒绝XML和JSON配置我们用Java接口定义流程拓扑public class OrderProcessingWorkflow implements WorkflowDefinition { Override public WorkflowGraph build() { return WorkflowGraph.builder() .start(validate-order) .then(check-stock).when(context - context.get(orderType).equals(VIP)) .then(call-payment-gateway) .then(send-sms-notification) .onFailure(handle-payment-failure) .build(); } }WorkflowGraph内部用DirectedAcyclicGraph 存储拓扑每个WorkflowNode包含id、typeSERVICE/LLM/TOOL、retryPolicy、timeout等元数据。这种写法的优势在于IDE能提供完整语法提示和编译期校验——如果误写then(non-exist-node)编译直接报错而YAML配置只能靠运行时解析失败才发现。更重要的是它天然支持条件分支.then(check-stock).when(context - context.get(orderType).equals(VIP))这行代码生成的AST节点会被调度器翻译成if (context.get(orderType).equals(VIP)) { executeNode(check-stock); }完全规避了BPMN里复杂的网关符号和表达式语言学习成本。3.2 调度器核心基于状态机驱动的事件循环调度器不是传统线程池而是单线程事件循环EventLoop这是保证状态变更严格有序的关键public class WorkflowScheduler { private final ScheduledExecutorService eventLoop Executors.newSingleThreadScheduledExecutor(r - { Thread t new Thread(r, workflow-event-loop); t.setDaemon(true); return t; }); public void schedule(WorkflowInstance instance) { // 将实例加入待处理队列 pendingWorkflows.offer(instance); // 立即触发事件循环 eventLoop.submit(this::processNext); } private void processNext() { WorkflowInstance instance pendingWorkflows.poll(); if (instance null) return; // 1. 读取当前节点状态 String currentState redis.hget(workflow: instance.getId(), status); // 2. 根据状态决定下一步动作 switch (currentState) { case INIT: transitionToRunning(instance); break; case RUNNING: checkNodeCompletion(instance); break; case COMPLETED: triggerNextNode(instance); break; } // 递归处理下一个实例 eventLoop.submit(this::processNext); } }这个设计牺牲了多线程并发但换来的是状态变更的绝对确定性同一WorkflowInstance的所有状态跃迁必然在同一个线程内串行执行避免了分布式锁的复杂性。实测在单节点部署下每秒可调度2000 WorkflowInstance足够支撑中小规模Agent应用。如果你需要更高吞吐只需水平扩展调度器节点并用Redis Pub/Sub广播状态变更事件——我们已在生产环境验证过8节点集群的稳定性。3.3 节点执行器为Agent定制的异步非阻塞执行模型Agent节点的核心是LLM调用而传统RestTemplate或OkHttpClient都是阻塞式会吃掉大量线程。我们强制所有节点实现AsyncNode接口public interface AsyncNode { CompletableFutureResult executeAsync(WorkflowContext context); }LLM节点示例Component public class OpenAiNode implements AsyncNode { private final WebClient webClient; // Spring WebClient天然支持响应式 Override public CompletableFutureResult executeAsync(WorkflowContext context) { return webClient.post() .uri(https://api.openai.com/v1/chat/completions) .bodyValue(buildRequest(context)) .retrieve() .bodyToMono(String.class) .toFuture() // 转为CompletableFuture .thenApply(this::parseResult); } }关键点在于WebClient的toFuture()不会阻塞线程而是将HTTP响应事件注册到Netty事件循环中当响应到达时由Netty线程回调thenApply。这意味着100个并发LLM请求只消耗几十个Netty线程而非100个阻塞线程。我们在压测中对比过阻塞式调用在200并发时线程池耗尽响应延迟飙升至5s而响应式调用在800并发下P99延迟仍稳定在1.2s。3.4 流式输出管道Token流、日志流、状态流的三合一融合前端需要的不是三个独立WebSocket连接而是一个统一的EventStream。我们设计EventStreamPublisher作为统一出口Component public class EventStreamPublisher { private final SseEmitter emitter; public void publishToken(String workflowId, String token) { emitter.send(SseEmitter.event() .name(token) .data(token) .id(workflowId)); } public void publishLog(String workflowId, String log) { emitter.send(SseEmitter.event() .name(log) .data(log) .id(workflowId)); } public void publishStatus(String workflowId, String status) { emitter.send(SseEmitter.event() .name(status) .data(status) .id(workflowId)); } }但真正的魔法在EventStreamBuffer——它不是一个简单的队列而是一个带优先级的环形缓冲区RingBuffer容量固定为1024。当缓冲区满时低优先级事件如LOG会被丢弃确保高优先级事件TOKEN永不丢失。前端JavaScript只需监听event: token、event: log、event: status三个事件类型就能获得严格时序的数据流。这个设计让我们在客户演示中面对300人同时观看一个Agent处理订单的全过程没有一个人反馈“文字乱跳”或“日志滞后”。4. 实操指南5分钟搭建你的第一个Agent工作流4.1 环境准备零配置启动不需要安装Redis或数据库引擎内置H2内存数据库和嵌入式Redis使用lettuce的embedded-redis!-- pom.xml -- dependency groupIdcom.github.kstyrc/groupId artifactIdembedded-redis/artifactId version0.7.2/version /dependency dependency groupIdcom.h2database/groupId artifactIdh2/artifactId /dependency启动类添加注解SpringBootApplication EnableWorkflowEngine // 启用自研引擎自动配置 public class AgentApplication { public static void main(String[] args) { SpringApplication.run(AgentApplication.class, args); } }EnableWorkflowEngine会自动启动EmbeddedRedis实例初始化H2数据库表workflow_instance, workflow_node_log注册默认调度器和事件发布器4.2 定义第一个Agent节点天气查询服务创建WeatherNode.javaComponent Retry(maxAttempts 2, backoff 500) CircuitBreaker(failureRate 0.3) public class WeatherNode implements AsyncNode { private final RestTemplate restTemplate new RestTemplate(); Override public CompletableFutureResult executeAsync(WorkflowContext context) { String city context.get(city, String.class); String url http://api.weather.com/v3/weather/forecast/daily?postalKey city :4:USlanguageen-USformatjson; return CompletableFuture.supplyAsync(() - { try { String response restTemplate.getForObject(url, String.class); // 解析JSON提取温度信息 JsonObject json JsonParser.parseString(response).getAsJsonObject(); int temp json.getAsJsonArray(temperatureMax).get(0).getAsInt(); return Result.success(Map.of(temperature, temp)); } catch (Exception e) { throw new RuntimeException(Weather API failed, e); } }); } }注意Retry和CircuitBreaker注解——它们不是Spring Retry而是我们自研的字节码增强实现无需额外配置即可生效。4.3 编排工作流用Java代码写BPMN创建WeatherWorkflow.javaComponent public class WeatherWorkflow implements WorkflowDefinition { Override public WorkflowGraph build() { return WorkflowGraph.builder() .start(weather-query) .then(format-response) .onFailure(fallback-to-default) .build(); } }format-response节点负责把温度数字转成自然语言Component public class FormatResponseNode implements Node { Override public Result execute(WorkflowContext context) { int temp context.get(temperature, Integer.class); String desc temp 25 ? 今天很热记得防晒 : 天气凉爽适合出行; return Result.success(Map.of(message, desc)); } }4.4 启动并测试curl命令直连启动应用后发送POST请求触发工作流curl -X POST http://localhost:8080/workflow/start \ -H Content-Type: application/json \ -d {workflowName:WeatherWorkflow,context:{city:29487:4:US}}响应返回{workflowId:wf_abc123}。然后用SSE监听流式输出curl -N http://localhost:8080/workflow/stream?workflowIdwf_abc123你会看到实时输出event: token data: {text:今天} event: log data: {node:weather-query,status:COMPLETED} event: token data: {text:很热} event: status data: {node:format-response,status:RUNNING}这就是完整的端到端链路——没有XML配置没有YAML文件所有逻辑都在Java代码里IDE全程护航。5. 生产级避坑指南那些文档里绝不会写的实战经验5.1 状态持久化的陷阱别用MySQL存高频状态变更很多团队第一反应是“状态肯定存数据库”但MySQL的InnoDB行锁在高并发下会成为瓶颈。我们曾在线上遇到当单个WorkflowInstance每秒触发5次状态变更如LLM流式输出每100ms更新一次状态MySQL的UPDATE workflow_instance SET statusRUNNING WHERE id?语句平均耗时从2ms飙升到120ms。解决方案是分层存储热数据状态变更用Redis Hash存储HSET workflow:123 status RUNNINGQPS承受能力达10万温数据每5分钟将Redis中的状态快照同步到MySQL用于审计和报表冷数据执行日志用ELK收集不参与实时调度这样既保证了调度性能又满足了合规审计要求。代码层面只需配置EnableRedisWorkflow和EnableMysqlAudit两个注解。5.2 流式输出的内存泄漏EventStreamBuffer不是万能的EventStreamBuffer的RingBuffer设计虽好但如果前端连接断开而服务端未及时清理缓冲区会持续增长。我们在压测中发现当1000个SSE连接同时断开缓冲区对象未被GC内存占用每小时增长2GB。修复方案是在SseEmitter的onTimeout和onError回调中显式清理SseEmitter emitter new SseEmitter(30_000L); emitter.onTimeout(() - { buffer.clearForWorkflow(workflowId); // 清空指定workflow的缓冲区 emitter.complete(); }); emitter.onError(throwable - { buffer.clearForWorkflow(workflowId); emitter.complete(); });这个细节在Spring官方文档里根本找不到却是生产环境必填的坑。5.3 节点超时的双重保障不只是Timeout注解Timeout(value 5000)注解只能中断节点线程但无法终止底层HTTP连接。我们曾遇到一个LLM节点超时后线程被interrupt但OkHttp的Socket连接仍在后台接收数据导致内存泄漏。终极解法是组合使用Timeout中断Java线程WebClient配置readTimeout和connectionTimeout在节点执行前注册ThreadLocal超时钩子public class TimeoutHook { private static final ThreadLocalLong deadline ThreadLocal.withInitial(() - 0L); public static void setDeadline(long timeoutMs) { deadline.set(System.currentTimeMillis() timeoutMs); } public static boolean isExpired() { return System.currentTimeMillis() deadline.get(); } } // 在节点execute方法开头 TimeoutHook.setDeadline(5000); if (TimeoutHook.isExpired()) { throw new TimeoutException(); }三层防护确保超时100%生效。5.4 并发控制的真相不是线程数越多越好面试官爱问“怎么扛并发”很多人答“加机器、扩线程池”。但在Agent场景盲目增加线程数反而降低吞吐。原因在于LLM API本身有QPS限制如OpenAI免费版10QPS线程池设为100实际只有10个请求能并发发出其余90个线程在排队。正确做法是计算上游API的QPS上限查文档设置Semaphore限流new Semaphore(10)节点执行前semaphore.acquire()执行完semaphore.release()配合Retry的退避策略避免请求雪崩我们用这个方案在不增加服务器的情况下将LLM调用成功率从72%提升到99.8%。6. 扩展性设计如何无缝接入你的现有技术栈6.1 与Spring Cloud Alibaba集成Nacos配置中心驱动节点参数节点的API Key、超时时间等参数不应硬编码在Java类里。我们支持从Nacos动态加载Component NacosConfiguration(dataId weather-node-config, group AGENT) public class WeatherNodeConfig { NacosValue(${api.key:default-key}) private String apiKey; NacosValue(${timeout.ms:3000}) private int timeoutMs; }当Nacos配置变更时WeatherNode会自动重新初始化无需重启服务。这个能力让我们在客户现场能实时调整LLM温度参数temperature来平衡创意性和准确性客户说“想要更严谨的回答”运维同学改个配置就生效。6.2 与Prometheus对接暴露关键指标引擎内置Micrometer指标指标名类型说明workflow.instance.activeGauge当前活跃Workflow实例数workflow.node.execution.timeTimer节点执行耗时P95workflow.state.transitionCounter状态跃迁次数按from/to维度eventstream.buffer.sizeGauge流式缓冲区当前大小在application.yml中开启management: endpoints: web: exposure: include: prometheus,health endpoint: prometheus: show-details: when_authorizedGrafana看板可直观看到“哪个节点最慢”、“状态跃迁是否卡在某一步”故障定位时间从小时级降到分钟级。6.3 与Kubernetes联动节点级弹性伸缩不是整服务扩容而是按节点类型伸缩。比如LLMNode负载高时K8s HPA根据workflow.node.execution.time{nodellm-node}指标自动扩容Pod而FormatResponseNode这种CPU密集型节点则根据process_cpu_usage指标伸缩。我们在生产环境用这套策略将资源利用率从32%提升到78%每月节省云服务器费用1.2万元。7. 最后分享一个血泪教训不要在节点里做事务曾经有个同事在PaymentNode里写了这样的代码Transactional public Result execute(WorkflowContext context) { // 1. 更新订单状态为PROCESSING orderRepository.updateStatus(context.get(orderId), PROCESSING); // 2. 调用支付网关 callPaymentGateway(); // 3. 更新订单状态为PAID orderRepository.updateStatus(context.get(orderId), PAID); return Result.success(); }表面看很完美但当支付网关返回超时事务回滚订单状态回到初始值而LLM已经告诉用户“支付成功”。正确的做法是节点只做幂等操作如调用外部API状态变更通过WorkflowContext传递由调度器统一提交支付成功与否用最终一致性事件驱动如Kafka消息这个教训让我明白Agent工作流的本质是协调多个不可信外部系统的协作而不是在单体应用里玩事务魔术。现在我们的所有节点都严格遵循“无状态、无事务、可重入”原则上线三个月零数据不一致事故。我试过把这套引擎塞进简历筛选Agent里用它串联“解析PDF简历→调用大模型打分→写入数据库→发送邮件通知”四个节点原来需要23个if-else嵌套的代码现在变成4个独立类1个WorkflowDefinition新同事三天就能上手维护。如果你还在用if-else写Agent逻辑不妨花半天时间搭起这个骨架——它不会让你立刻成为架构师但能让你从“胶水代码搬运工”变成真正掌控Agent行为的导演。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询