1. 项目概述:为什么动手前必须“谋定而后动”
最近在社区里看到不少朋友开始尝试 Spring AI Graph,尤其是对那个听起来很酷的“Supervisor”模式跃跃欲试。大家拿到一个新框架,特别是像 Spring AI Graph 这样集成了智能体(Agent)工作流概念的组件,第一反应往往是“赶紧跑个 Demo 看看效果”。这种热情很棒,但根据我过去在多个项目中集成类似工作流引擎的经验,直接开干很容易踩坑,后期重构的成本会非常高。这个项目标题“动手之前,先定好3个架构决策”可以说是一针见血,它点出了一个关键但常被忽略的环节:在写第一行代码之前,我们需要像架构师一样思考,为整个智能工作流奠定一个坚实、可扩展的基础。
Spring AI Graph 的核心是构建一个由多个 AI 节点(或工具节点)组成的、有状态的工作流(StateGraph)。你可以把它想象成一个智能化的流水线,数据(状态)在不同的处理单元(节点)间流转,每个节点根据当前状态决定下一步做什么,甚至可能调用外部工具或大模型。而“Supervisor”模式,则是这个流水线上一个特殊的“调度员”或“协调者”节点,它负责监控流程的进展,在特定条件下(比如某个节点执行失败、结果不明确时)介入,决定重试、转向备用路径还是终止流程。这个模式能极大地增强工作流的鲁棒性和灵活性。
但是,实现一个 Supervisor 远不止是加一个@Bean那么简单。它涉及到状态如何管理、决策逻辑放在哪里、异常如何定义和传递等一系列架构问题。如果在项目初期随意实现,后期当业务逻辑变得复杂,需要增加新的节点类型、新的决策分支时,整个代码结构可能会变得难以维护,各种if-else和状态判断散落在各处。因此,在动手编码之前,我们必须先回答三个核心的架构决策,这决定了你的 Spring AI Graph 项目是成为一个优雅的、可维护的解决方案,还是一团纠缠不清的“面条代码”。
2. 核心架构决策一:状态(State)的设计与存储策略
这是整个 Spring AI Graph 工作流的基石。State 是在节点间传递的上下文对象,它携带了流程的输入、中间结果、控制标志等信息。如何设计这个 State 对象,直接影响了工作流的表达能力、可调试性和性能。
2.1 State 对象的结构设计:扁平化 vs. 领域化
第一个决策点是 State 的结构。常见的有两种思路:
1. 通用键值对(Map<String, Object>)风格:这是最直接的方式,Spring AI Graph 默认也倾向于使用Map或Conversation作为状态的载体。它的优点是极其灵活,任何节点都可以向 State 中放入或取出任意类型的数据。对于快速原型或简单流程,这很方便。
// 示例:一个简单的状态Map Map<String, Object> state = new HashMap<>(); state.put("userQuery", "帮我总结一下Spring AI的文档"); state.put("retrievedDocuments", listOfDocs); state.put("summary", null);但是,它的缺点在项目规模扩大后会非常明显:类型不安全。你从state.get("summary")拿到的可能是一个String,也可能是null,甚至是其他节点误存进去的另一个对象,这只能在运行时发现。同时,缺乏契约,新加入的开发者需要翻阅大量代码或文档才能知道 State 里到底有哪些键、它们的含义和类型是什么。
2. 强类型领域对象(Custom State Class)风格:我强烈推荐在正式项目中采用这种方式。即为你的工作流定义一个专用的、强类型的 State 类。
// 示例:一个自定义的强类型状态类 public class DocumentQaState { private String userQuery; private List<Document> retrievedDocuments; private String summary; private QaStatus status; // 枚举,如:RETRIEVING, SUMMARIZING, FINISHED, ERROR private String errorMessage; // 标准的 getter, setter, builder 等 }然后,在定义 Graph 时,指定这个类型:
StateGraph<DocumentQaState> graph = new StateGraph<>(DocumentQaState.class);为什么选择强类型 State?
- 类型安全与编译时检查:IDE 会帮你自动补全,编译器能提前发现类型错误。
- 清晰的领域模型:这个类本身就是一份最好的文档,清晰地定义了工作流中流动的数据结构。
- 易于扩展和维护:当需要新增字段时,只需修改这个类,所有相关节点的代码都会在编译时提示需要适配,避免了运行时错误。
- 与序列化/持久化友好:如果你需要将工作流状态暂存到数据库或 Redis(后面会讨论),一个结构清晰的 POJO 比一个充满未知类型的 Map 要容易处理得多。
实操心得:即使 Spring AI Graph 的某些内置组件(如一些
PromptTemplate)可能默认处理Map,你也可以通过适配器模式轻松转换。定义一个StateConverter工具类,将你的DocumentQaState转换为节点所需的Map,或者更好的是,直接编写或扩展节点组件,让其接受你的自定义 State 类型。这初期多花的一点功夫,会在后期维护时节省大量时间。
2.2 状态的存储(Persistence)决策:内存、外部存储与恢复
工作流可能运行很长时间(例如,处理一个需要多轮人工审核的任务)。如果服务重启,正在运行的工作流状态不能丢失。这就引出了状态存储的决策。
1. 纯内存存储(默认,仅适用于短时任务):状态保存在内存中。服务重启,状态全丢。仅适用于那些秒级完成、且允许失败重试的简单场景。
2. 外部持久化存储(生产环境必备):你需要实现 Spring AI 的StateStore接口,将状态保存到外部存储,如 Redis、MongoDB 或关系型数据库。
- Redis:如果你的状态对象可以高效地序列化/反序列化(例如使用 Jackson),Redis 作为内存数据库,读写速度快,适合高并发、状态结构相对固定的场景。注意设置合理的 TTL。
- 关系型数据库(如 PostgreSQL):如果你的状态结构复杂,且需要利用 SQL 进行复杂的查询分析(例如,查询所有失败的任务),关系型数据库更合适。可以将整个 State 对象序列化为 JSON 存储在一个
TEXT字段,或者更规范地,将关键字段拆分成列。
关键决策点:存储粒度与恢复成本
- 全量存储:每次状态变更后,都序列化整个 State 对象并保存。实现简单,但如果 State 很大(例如包含了完整的文档内容),IO 开销会很大。
- 增量存储/快照:只存储变化的部分,或者定期存储全量快照。这更高效,但实现复杂,需要维护状态版本。
注意事项:实现
StateStore时,序列化/反序列化是关键。确保你的自定义 State 类是无状态的(不包含Transient字段以外的业务服务引用),并且能够被选定的序列化库(如 Jackson)正确处理。对于复杂对象,可能需要自定义序列化器。
3. 核心架构决策二:Supervisor 的职责边界与实现模式
Supervisor 是整个工作流的“大脑”,它的设计好坏直接决定了工作流的智能程度和可维护性。这里最大的陷阱是把它变成一个“上帝类”,什么都管。
3.1 明确 Supervisor 的单一职责
Supervisor 的核心职责应该是“路由决策”和“异常处理策略执行”,而不是包含具体的业务逻辑。它应该根据当前 State 中的信息,决定下一步该跳转到哪个节点(Node A还是Node B),或者决定在出错时是重试、转人工还是终止。
反模式:业务逻辑渗入 Supervisor
// 不推荐:Supervisor 里包含了具体的摘要生成逻辑 public Action supervise(State state) { if (state.get("summary") == null) { // 错误!这里直接调用了生成摘要的复杂逻辑 String summary = callLLM(state.get("documents")); state.put("summary", summary); return Action.RETRY; // 职责混乱 } return Action.NEXT; }正确模式:Supervisor 只做路由决策
// 推荐:Supervisor 只检查状态并返回路由指令 public Action supervise(DocumentQaState state) { if (state.getStatus() == QaStatus.ERROR) { // 根据错误类型决定路由 if (state.getErrorMessage().contains("timeout")) { // 告诉框架:重试当前节点 return Action.RETRY; } else { // 告诉框架:跳转到专门的“人工处理”节点 return Action.GOTO("humanReviewNode"); } } if (state.getSummary() == null && state.getRetrievedDocuments().isEmpty()) { // 文档检索为空,跳转到“澄清问题”节点 return Action.GOTO("clarifyQueryNode"); } // 一切正常,继续默认流程 return Action.NEXT; }3.2 Supervisor 的实现模式:集中式 vs. 分布式
这是架构上的一个重要选择。
1. 集中式 Supervisor(一个总控节点):整个 Graph 只有一个 Supervisor 节点。它需要处理所有可能的异常和分支决策。这在小规模或逻辑简单的工作流中没问题。但当节点增多、分支复杂时,这个 Supervisor 的supervise方法会变得极其庞大和复杂,难以维护。
2. 分布式/层次化 Supervisor(多个监督者):这是更优雅、更 scalable 的模式。你可以为 Graph 中的每一个子图(Subgraph)或每一组功能相关的节点集群,配置一个专门的 Supervisor。
- 例如,一个“文档检索”子图有自己的
RetrievalSupervisor,负责处理网络超时、无结果等异常。 - 一个“内容生成”子图有自己的
GenerationSupervisor,负责处理模型调用失败、内容过滤等异常。 - 最外层还可以有一个
GlobalSupervisor,处理最顶层的流程异常(如整个任务超时)。
Spring AI Graph 的StateGraph支持构建复杂的、嵌套的图结构。你可以利用这一点,将大图分解为多个职责清晰的小图,每个小图配备一个专注的 Supervisor。这样,每个 Supervisor 的逻辑都保持简单和内聚。
实操心得:在设计之初,就用白板或绘图工具画出你设想的工作流图。识别出图中那些容易出错、或需要条件分支的“关键区域”。这些区域就是候选的“子图”边界,也是放置专属 Supervisor 的最佳位置。这种“分而治之”的思想,能让你的系统在复杂性增长时依然保持清晰。
4. 核心架构决策三:节点(Node)间的通信与异常处理契约
节点是工作的执行单元。它们之间如何通信、如何告知对方失败,需要一套清晰的契约。
4.1 状态(State)作为唯一的通信渠道
在 Spring AI Graph 中,节点间不应通过直接的方法调用或消息队列通信(除非是调用外部服务)。State 对象是节点间共享信息的唯一正式渠道。一个节点完成任务后,将结果写入 State,下一个节点从 State 中读取所需数据。
这要求我们:
- 在 State 类中定义清晰的字段:如前所述,强类型 State 类本身就是一份通信协议。
- 约定字段的读写权限:哪些字段是某个节点独占写入的?哪些是只读的?虽然语言层面无法强制,但可以通过命名规范或文档约定(例如,
节点A_output这样的字段名)。
4.2 建立统一的异常处理与状态标记机制
当某个节点执行失败时,如何通知后续节点和 Supervisor?有两种主流模式:
1. 异常抛出模式:节点在执行过程中遇到错误,直接抛出RuntimeException。Spring AI Graph 框架会捕获这个异常,将当前状态标记为ERROR(或类似状态),然后触发 Supervisor 的介入。
- 优点:符合 Java 编程习惯,错误传播直接。
- 缺点:抛出的异常类型需要精心设计,以便 Supervisor 能区分不同的错误原因(网络超时、业务校验失败、资源不足等)。而且,一些非致命的“异常情况”(如“检索结果为空”)用异常来表示可能不够优雅。
2. 状态标记模式(推荐):节点不抛出异常,而是将错误信息、错误类型作为结果的一部分,写入 State 对象中特定的字段(如errorCode,errorMessage,status)。然后,节点正常结束,由后续的 Router 或 Supervisor 来检查这些状态字段并决定下一步流向。
- 优点:将“错误”视为一种正常的业务状态,流程控制更灵活。可以更容易地实现“重试三次后转人工”这类复杂策略。
- 缺点:每个节点都需要在最后检查自己的执行结果,并规范地更新状态字段。
我的建议是结合两者:
- 对于不可恢复的系统级错误(如数据库连接断开、第三方服务完全不可用),直接抛出异常,让框架层面处理,可能直接导致整个工作流失败并告警。
- 对于可预期的业务级“异常”(如“查询无结果”、“内容违规”),采用状态标记模式。在 State 类中定义如
ProcessStatus枚举(SUCCESS, NO_RESULT, CONTENT_BLOCKED, NEED_CLARIFICATION)和对应的消息字段。
public class MyState { private ProcessStatus status; private String statusDetail; // ... other fields } // 在节点中的使用 public void someNode(MyState state) { try { Result result = someService.call(); if (result.isEmpty()) { state.setStatus(ProcessStatus.NO_RESULT); state.setStatusDetail("未找到相关数据"); return; // 正常结束,让Supervisor处理 } // 正常处理... state.setData(result.getData()); state.setStatus(ProcessStatus.SUCCESS); } catch (RemoteServiceTimeoutException e) { // 可重试的系统错误,可以抛出,也可以标记状态 state.setStatus(ProcessStatus.SYSTEM_ERROR); state.setStatusDetail("服务调用超时"); // 或者直接抛出,让框架的重试机制处理 throw new RetryableException("Remote service timeout", e); } }4.3 节点的幂等性与重试支持
由于 Supervisor 可能决定重试某个节点,或者工作流可能从中断状态恢复后重新执行,节点逻辑应尽可能设计为幂等的。即,使用相同的 State 输入,多次执行节点应产生相同的效果,且没有副作用。
实现幂等性的一些技巧:
- 使用唯一标识(ID):在 State 中携带一个本次工作流执行的唯一
flowId或taskId。节点在调用外部服务或写数据库时,可以以此 ID 作为条件,避免重复操作。 - 检查点(Checkpoint):在 State 中记录某个节点是否已经成功执行过。节点开始执行时,先检查这个标志。
- 外部服务的幂等调用:如果节点调用外部 API,尽量使用支持幂等性的 API(如传入请求 ID)。
常见问题与排查技巧实录:
- 问题:工作流总是意外终止,日志没有明显错误。
- 排查:首先检查 State 的序列化/反序列化是否正常。一个常见的坑是 State 中包含了无法序列化的对象(如
HttpServletRequest),导致状态保存或恢复时失败,框架静默处理了异常。确保 State 中的所有字段都是可序列化的基本类型、POJO 或transient的。- 问题:Supervisor 的逻辑没有被触发。
- 排查:确认你的 Graph 定义中是否正确配置了
withSupervisor(supervisorBean)。检查 Supervisor 的supervise方法返回值是否有效(Action.NEXT,Action.RETRY,Action.GOTO(“nodeName”))。最重要的是,检查节点是正常结束还是抛出异常。只有节点抛出异常,或者通过State明确传递了错误信号,并触发了框架的异常处理路径,Supervisor 才会被调用。对于“状态标记”模式,你需要配置一个Router或者条件流转,在节点正常结束后,根据State中的状态字段,将流程导向 Supervisor 节点或错误处理分支。- 问题:重试机制导致无限循环。
- 排查:为
Action.RETRY设置最大重试次数。可以在 State 中维护一个retryCountMap,记录每个节点的重试次数。在 Supervisor 的决策逻辑里,检查重试次数是否超过阈值,如果超过,则转向失败处理节点,而不是继续重试。
5. 从决策到实践:一个简化的架构蓝图
基于以上三个决策,我们可以勾勒出一个适合中等复杂度项目的 Spring AI Graph with Supervisor 的架构蓝图:
- 定义强类型 State:创建一个如
BusinessFlowState的类,包含输入、分阶段输出、状态枚举、错误码、重试计数等字段。使用 Lombok 简化代码。 - 设计层次化图结构:将总工作流拆分为“输入校验”、“核心处理”、“结果包装”等子图。为每个子图定义清晰的输入/输出 State 字段。
- 实现专用 Supervisor:为每个子图创建一个
XxxPhaseSupervisor,只关注该阶段特有的错误(如校验失败、处理超时、结果为空)。顶层的GlobalSupervisor处理任务超时等全局异常。 - 实现幂等节点:每个节点逻辑独立,从 State 读输入,向 State 写输出或更新状态。调用外部服务时使用幂等键。对于可能失败的操作,优先采用“状态标记”而非直接抛异常(系统级错误除外)。
- 配置外部状态存储:实现一个基于 Redis 的
StateStore,使用 Jackson 序列化你的BusinessFlowState。为不同的工作流类型设置不同的 Redis key 前缀和 TTL。 - 建立监控与调试:在 State 中增加
traceId和stepLogs字段。每个节点执行时,将关键步骤和结果摘要追加到stepLogs(List )中。这个日志会随 State 持久化,便于事后追踪任何一次工作流执行的详细路径和状态,这是线上排查问题的利器。
这个蓝图不是一成不变的,但它为你提供了一个基于深思熟虑的架构起点。记住,在 Spring AI Graph 这类灵活框架中,前期在状态设计、职责分离和通信契约上多花一小时,可能会在后期节省几十小时的调试和重构时间。动手之前,花时间画图、讨论并敲定这些架构决策,你的“Supervisor”才能真正地“监督”起一个健壮、可控的智能工作流,而不是成为另一个需要被“监督”的混乱源头。