从单体Agent到图工作流:Spring AI Alibaba订单助手改造中的5个关键决策点

去年我们团队接手了一个基于Spring AI的订单助手项目,最初的版本是典型的“单体Agent”架构:一个庞大的Orchestrator负责所有意图识别和路由,后面跟着几个功能Agent。上线初期运行还算顺畅,但随着业务规则越来越复杂,比如增加了预售商品咨询、跨境订单查询、部分退款等场景后,代码里的if-else嵌套已经深到让人头晕目眩的程度。更头疼的是,每次新增一个业务节点,都要小心翼翼地修改中心调度逻辑,生怕影响到其他看似无关的流程。测试回归的成本呈指数级上升,团队开始对“智能体”这个词产生了怀疑——这智能吗?这明明是一团乱麻。

正是在这种背景下,我们开始评估引入图工作流(Graph Workflow)来重构整个系统。这不是一个简单的技术选型,而是一系列关乎架构演进方向、团队协作模式和未来可维护性的关键决策。今天,我想抛开那些华丽的PPT和概念,聚焦于我们实际改造Spring AI Alibaba订单助手时,所面临的五个最核心、也最纠结的决策点。这些决策没有绝对的对错,只有是否适合你当前的团队、业务阶段和技术债务。

1. 决策一:何时告别“单体Agent”?识别引入图工作流的临界信号

很多团队看到“图”、“工作流”这些时髦词就跃跃欲试,但过早引入复杂度反而会拖慢项目。我们的经验是,需要等待一些明确的“临界信号”出现。如果你的系统还处于探索期,业务路径非常固定,那么一个精心设计的单体Agent或许更高效。图工作流带来的抽象和管理成本是实实在在的。

那么,哪些信号告诉我们“是时候了”?

首先,最直观的信号是路由逻辑的“熵增”。我们的Orchestrator最初只有三个简单的if语句,分别对应商品、订单、退款。后来,我们增加了“根据用户历史行为推荐路由”、“在特定促销期间走特殊咨询通道”、“对高风险用户强制进入人工审核”等规则。代码变成了这样:

// 改造前令人头疼的Orchestrator核心逻辑片段
public String route(String userInput, UserProfile profile) {
    if (isDuringPromotion() && containsPromotionKeyword(userInput)) {
        return "PROMOTION_AGENT";
    }
    if (profile.getRiskLevel() > RISK_THRESHOLD && involvesRefund(userInput)) {
        return "MANUAL_REVIEW";
    }
    if (hasRecentOrder(profile) && isQueryAboutThatOrder(userInput)) {
        return "ORDER_AGENT_WITH_CONTEXT";
    }
    // ... 更多嵌套的if和else if
    // 原始的意图识别反而被埋在了后面
    String intent = llmRecognizeIntent(userInput);
    if ("PRODUCT".equals(intent)) {
        return "PRODUCT_AGENT";
    }
    // ... 更多
}

注意:当你的路由逻辑开始需要读取外部配置、用户画像、业务上下文等多个维度的信息,并且这些判断之间存在优先级和覆盖关系时,用代码硬编码的方式很快就会失控。这时,图工作流将路由逻辑“边”化,用条件边(Conditional Edge)来显式声明这些规则,可读性和可维护性会好得多。

其次,是Agent间协作模式变得复杂。最初,我们的Agent是顺序执行的:识别意图 -> 调用对应Agent -> 返回结果。后来,业务要求“在商品咨询后,如果用户表现出购买意向,自动查询其优惠券并推荐最划算的购买组合”。这就变成了一个带有分支和合并的流程:商品Agent和优惠券Agent需要并行或按条件先后执行,结果还需要聚合。用代码手动编排这种协作,不仅容易出错,而且难以可视化调试。

最后,一个容易被忽略但至关重要的信号是对“可观测性”和“可控性”的需求激增。业务方和风控团队不断追问:“用户走到哪一步了?”“为什么在这个节点卡住了?”“能否在退款前强制插入一个人工审批?”在单体Agent架构下,要实现细粒度的步骤追踪、状态快照和人工干预点(Human-in-the-loop),需要对原有代码进行伤筋动骨式的改造。而图工作流天生就是以“节点”和“状态”为核心的,每个节点的输入、输出、耗时、异常都更容易被监控和拦截。

我们当时画了一张简单的决策矩阵,帮助团队判断:

评估维度 单体Agent架构仍适用 应考虑引入图工作流
业务路径复杂度 线性、固定,少于5个主要分支 网状、动态,有并行、循环或条件合并
变更频率 低,核心逻辑稳定 高,需要频繁增删或调整业务步骤
团队协作 单人或小团队维护全部逻辑 需要多个团队/角色(如业务、风控、AI)独立维护不同节点
运维需求 只需关注最终输入输出 需要监控每个步骤的状态、性能,并支持人工介入

当你的项目在右侧列出现多个“是”时,就是认真考虑图工作流的时候了。

2. 决策二:节点粒度设计——如何在“原子性”与“复用性”之间寻找平衡

确定了要引入图,第一个具体的设计难题就是:一个节点应该多大? 是把原来的每个Agent直接变成一个节点,还是应该拆得更细?比如,原来的ProductAgent负责商品知识问答、推荐和比价。我们是把它作为一个大节点,还是拆成QueryProductNodeRecommendProductNodeComparePriceNode三个小节点?

这是一个典型的架构权衡。节点粒度太粗,就失去了图工作流灵活编排的优势,变成了“换汤不换药”;节点粒度太细,又会带来巨大的编排复杂度和节点间状态传递的 overhead。

我们的设计原则是:“一个节点,一个明确的责任,一次清晰的状态转换”

  • “一个明确的责任”:避免一个节点做多件语义上独立的事情。例如,如果“查询商品详情”和“根据详情生成推荐话术”在业务上经常独立变化或被复用,那就应该拆开。
  • “一次清晰的状态转换”:节点执行后,全局状态(OverAllState)应该发生一次易于理解的变化。例如,IntentRecognitionNode的责任是将userInput转换为intent字段;QueryOrderNode的责任是根据userIdintent填充orderList字段。

基于这个原则,我们对原有Agent进行了如下拆分和重构:

原Agent 图工作流中的节点设计 设计考量
OrchestratorAgent EntryNode (入口) -> IntentRecognitionNode (意图识别) -> IntentRouterDispatcher (条件边) 将“识别”和“路由”分离。识别是纯计算节点,路由是规则边,便于独立修改路由策略(如A/B测试)。
ProductAgent QueryProductInfoNode -> CheckInventoryNode -> GenerateRecommendationNode 拆分为查询、检查、推荐三个节点。因为库存检查可能依赖外部API,耗时较长且可能失败,独立出来便于重试和降级处理。推荐节点则可被其他工作流(如营销活动流)复用。
OrderAgent ValidateUserNode -> QueryOrderNode -> FormatResponseNode 用户验证是许多流程的公共前置步骤,独立成节点利于复用。格式化响应也独立,便于统一调整响应风格。
RefundAgent CheckRefundPolicyNode -> CalculateRefundAmountNode -> CreateRefundTicketNode 将政策检查、金额计算、工单创建分离。政策检查节点逻辑复杂且常变,独立后可以单独进行版本管理和灰度。

在Spring AI Alibaba Graph中,一个节点的实现非常清晰。以下是我们CheckInventoryNode的简化版代码,它只做一件事:检查库存并更新状态。

@Component
public class CheckInventoryNode implements Node<OrderAssistantState> {

    private final InventoryService inventoryService;

    public CheckInventoryNode(InventoryService inventoryService) {
        this.inventoryService = inventoryService;
    }

    @Override
    public OrderAssistantState apply(OrderAssistantState state) {
        // 1. 从状态中获取上游节点已查询到的商品ID
        String productId = state.getCurrentProductId();
        if (productId == null) {
            state.addError("商品ID缺失,无法检查库存");
            return state;
        }

        // 2. 执行单一职责:调用库存服务
        InventoryInfo inventory = inventoryService.getRealTimeInventory(productId);

        // 3. 清晰的状态转换:将库存信息写入状态,供下游节点使用
        state.setInventoryInfo(inventory);
        state.setInStock(inventory.getStock() > 0);

        // 4. 可选:记录审计日志或指标
        log.info("库存检查完成,商品: {}, 库存: {}", productId, inventory.getStock());

        return state;
    }
}

提示:在定义节点时,务必思考“这个节点失败后,整个流程应该如何应对?”图工作流允许你为节点配置重试策略、超时以及失败后的备用边(Fallback Edge),细粒度节点让这种弹性设计更加精准。

3. 决策三:状态共享策略——设计一个健壮且可扩展的OverAllState

图工作流的核心是“状态驱动”。所有节点都读写一个共享的OverAllState对象。这个状态对象的设计好坏,直接决定了系统的耦合度和未来扩展的难易度。最初我们犯了一个错误:把几乎所有可能用到的字段都塞进了一个巨大的OrderAssistantState里。

// 反面教材:臃肿的状态类
@Data
public class OrderAssistantState {
    private String userId;
    private String sessionId;
    private String userInput;
    private String finalReply;
    private String intent;
    private Product product;
    private List<Order> orders;
    private RefundPolicy refundPolicy;
    private BigDecimal calculatedAmount;
    private String customerServiceNotes;
    private Map<String, Object> context; // 万能兜底Map
    // ... 更多字段
}

这导致了几个问题:1) 节点职责不清,任何节点都可以修改任意字段;2) 序列化/反序列化成本高(尤其在需要持久化状态时);3) 字段含义随时间演变,出现歧义。

我们最终采纳了 “分区状态” + “强类型访问” 的策略。

1. 按领域分区状态:我们将状态划分为几个内部类,每个类代表一个逻辑子域。

@Data
public class OrderAssistantState {
    // 核心会话上下文
    private SessionContext sessionContext;
    // 意图与路由信息
    private IntentContext intentContext;
    // 商品咨询相关中间结果
    private ProductContext productContext;
    // 订单查询相关中间结果
    private OrderContext orderContext;
    // 退款流程相关中间结果
    private RefundContext refundContext;
    // 系统执行元数据(如错误、跟踪ID)
    private SystemMetadata metadata;

    // 内部静态类定义
    @Data
    public static class SessionContext {
        private String userId;
        private String sessionId;
        private String userInput;
        private String finalReply;
    }

    @Data
    public static class IntentContext {
        private String recognizedIntent; // PRODUCT, ORDER, REFUND
        private Double intentConfidence;
        private Map<String, String> extractedSlots; // 从用户输入中提取的实体,如产品ID、订单号
    }

    @Data
    public static class ProductContext {
        private String productId;
        private ProductDetail detail;
        private InventoryInfo inventory;
        private List<Recommendation> recommendations;
    }
    // ... 其他Context类
}

2. 通过工具方法提供强类型访问:我们不为节点提供状态对象的直接引用,而是通过一个StateAccessor工具类,让节点只能访问和修改其被授权的那部分状态。这虽然增加了一点代码量,但极大地提高了代码的健壮性和可读性。

// 在节点中这样使用
public class QueryProductInfoNode implements Node<OrderAssistantState> {
    @Override
    public OrderAssistantState apply(OrderAssistantState state) {
        // 1. 读取:从IntentContext中获取产品ID
        String productId = StateAccessor.getProductIdFromIntent(state);
        // 2. 业务逻辑...
        ProductDetail detail = productService.query(productId);
        // 3. 写入:将结果写入ProductContext
        StateAccessor.setProductDetail(state, detail);
        return state;
    }
}

这个决策带来的最大好处是隔离性。当我们需要修改退款流程的状态结构时,只需要改动RefundContext和相关的StateAccessor方法,完全不用担心会影响到商品咨询或订单查询的节点。这也为未来可能的“子图”拆分奠定了基础。

4. 决策四:同步 vs 异步节点——优化性能与资源利用的关键选择

在单体Agent时代,由于是顺序执行,我们很少深入思考每个步骤的阻塞问题。但在图工作流中,节点的执行模型(同步 vs 异步)直接影响到系统的吞吐量、响应时间和资源利用率。Spring AI Alibaba Graph 原生支持异步节点(node_async),但这并不意味着所有节点都应该异步。

我们的决策框架基于以下两个维度:节点执行时间节点对外部资源的依赖类型

  • CPU密集型或快速内存操作:例如意图识别(调用本地小模型或规则引擎)、状态字段格式转换。这类操作通常在毫秒级完成,使用同步节点更简单,开销更小。
  • I/O密集型或长时操作:例如调用远程LLM API、查询数据库、访问外部商品/订单服务、调用支付网关。这类操作耗时从几百毫秒到数秒不等,且大部分时间在等待网络响应,必须使用异步节点,以避免阻塞工作流引擎线程,提升系统整体并发能力。

在Spring AI Alibaba Graph的配置中,区别对待这两种节点:

@Configuration
public class OrderAssistantGraphConfig {
    @Bean
    public StateGraph<OrderAssistantState> orderAssistantGraph(
            IntentRecognitionNode intentNode, // 同步节点
            QueryProductInfoNode productNode, // 异步节点(调用外部服务)
            ChatWithLLMNode llmNode) { // 异步节点(调用LLM)

        StateGraph<OrderAssistantState> graph = new StateGraph<>("order-assistant", stateSerializer);

        // 同步节点注册
        graph.addNode("intent_recognition", node(intentNode));

        // 异步节点注册,使用 node_async
        graph.addNode("query_product", node_async(productNode));
        graph.addNode("chat_with_llm", node_async(llmNode));

        // ... 设置边关系
        return graph;
    }
}

但异步化带来了新的挑战:状态一致性和错误处理。 同步节点中,异常可以直接抛出,由框架或上层统一处理。而在异步节点中,我们需要更精细地管理。

我们为异步节点设计了统一的错误处理模式:

  1. 节点内部捕获所有检查异常:不将IOExceptionTimeoutException等直接抛出,以免导致整个图执行崩溃。
  2. 将错误信息写入状态:在OverAllStateSystemMetadata中设置错误码和错误信息。
  3. 利用条件边实现错误路由:在图定义中,配置从异步节点出发的“错误边”(Error Edge),当节点执行器检测到状态中包含错误信息时,自动跳转到专门的“错误处理节点”或“降级节点”。
// 在Graph配置中定义错误边
graph.addConditionalEdges(
    "query_product",
    edge_async(new ProductNodeResultDispatcher()), // 正常结果分发器
    Map.of(
        "SUCCESS", "next_node",
        "INVENTORY_ERROR", "inventory_fallback_node", // 库存错误,走降级
        "NETWORK_ERROR", "retry_or_fail_node" // 网络错误,重试或失败
    )
);

这个决策让我们在享受异步带来的高吞吐量之余,依然保持了工作流执行的可靠性和可观测性。监控面板上可以清晰地看到每个异步节点的成功率、平均耗时和主要错误类型。

5. 决策五:演进路线图——从简单重构到平台化治理

将单体Agent重构为图工作流,绝不是一蹴而就的“大爆炸”式替换。我们制定了一个分阶段的演进路线图,确保每一步都带来可衡量的价值,并控制风险。

第一阶段:平迁与可视化(1-2周) 目标是将现有功能无损地迁移到图工作流上。此阶段不改变外部接口和业务逻辑,只改变内部实现。我们利用Spring AI Alibaba Graph的可视化工具(或自己简单绘制),将之前隐藏在代码里的业务流程画了出来。这张图立刻成为了团队和业务方沟通的“通用语言”,价值立现。

  • 关键产出:一个能正确运行、逻辑与旧系统一致的图工作流。
  • 技术重点:正确设计OverAllState,实现核心节点,完成端到端测试。

第二阶段:逻辑优化与弹性增强(2-4周) 在稳定运行的基础上,开始发挥图的优势。我们做了三件事:

  1. 拆分粗粒度节点:将原来一些“大而全”的节点按决策二的原则进行拆分,提高复用性。
  2. 引入并行执行:对于彼此独立的节点(如“查询用户基本信息”和“查询用户偏好”),使用ParallelNode让它们同时执行,降低整体延迟。
  3. 增加关键节点的弹性:为调用外部LLM或核心服务的节点配置重试、超时和熔断策略。
// 示例:配置一个带重试的异步节点(伪代码,实际依赖具体实现)
graph.addNodeWithRetry("call_llm_service",
        node_async(llmNode),
        RetryPolicy.fixedDelay(3, Duration.ofSeconds(2)) // 重试3次,间隔2秒
);

第三阶段:引入人工干预与审计(持续迭代) 对于退款审批、大额优惠发放等敏感环节,我们引入了HumanInTheLoopNode。当工作流执行到该节点时,会自动暂停,生成一个待办任务发送到客服或审核系统,等待人工处理完成后,再携带结果继续执行后续流程。同时,所有节点的关键操作和状态变更都被结构化的审计日志记录,满足合规要求。

第四阶段:平台化与治理(长期目标) 当团队内有多个图工作流(如订单助手、客服助手、营销助手)运行时,我们开始构建统一的“AI工作流平台”。

  • 图形化编排:提供低代码界面,让产品经理也能拖拽节点来设计或调整简单流程。
  • 版本管理与灰度发布:对Graph定义进行版本控制,支持灰度发布和快速回滚。
  • 统一可观测性:集成Metrics、Tracing、Logging,提供全局的流量、性能、成本仪表盘。
  • 节点市场:将通用的节点(如“用户验证”、“短信发送”、“风险检测”)沉淀为共享组件,供所有工作流复用。

回过头看,从单体Agent到图工作流的改造,最大的收获不是性能提升了多少,而是认知负担的降低和变更速度的加快。新的需求过来,我们不再去那个庞大的Orchestrator里小心翼翼地找位置插入代码,而是讨论:“这个新步骤,应该作为哪个节点的前置或后置节点?它的输入输出状态是什么?”这种思维模式的转变,让复杂系统的构建变得更加有序和可控。Graph工作流不是银弹,但它为我们管理AI时代日益复杂的业务逻辑,提供了一套极具韧性的工程范式。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐