从单体Agent到图工作流:Spring AI Alibaba订单助手改造中的5个关键决策点
从单体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负责商品知识问答、推荐和比价。我们是把它作为一个大节点,还是拆成QueryProductNode、RecommendProductNode、ComparePriceNode三个小节点?
这是一个典型的架构权衡。节点粒度太粗,就失去了图工作流灵活编排的优势,变成了“换汤不换药”;节点粒度太细,又会带来巨大的编排复杂度和节点间状态传递的 overhead。
我们的设计原则是:“一个节点,一个明确的责任,一次清晰的状态转换”。
- “一个明确的责任”:避免一个节点做多件语义上独立的事情。例如,如果“查询商品详情”和“根据详情生成推荐话术”在业务上经常独立变化或被复用,那就应该拆开。
- “一次清晰的状态转换”:节点执行后,全局状态(
OverAllState)应该发生一次易于理解的变化。例如,IntentRecognitionNode的责任是将userInput转换为intent字段;QueryOrderNode的责任是根据userId和intent填充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;
}
}
但异步化带来了新的挑战:状态一致性和错误处理。 同步节点中,异常可以直接抛出,由框架或上层统一处理。而在异步节点中,我们需要更精细地管理。
我们为异步节点设计了统一的错误处理模式:
- 节点内部捕获所有检查异常:不将
IOException、TimeoutException等直接抛出,以免导致整个图执行崩溃。 - 将错误信息写入状态:在
OverAllState的SystemMetadata中设置错误码和错误信息。 - 利用条件边实现错误路由:在图定义中,配置从异步节点出发的“错误边”(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周) 在稳定运行的基础上,开始发挥图的优势。我们做了三件事:
- 拆分粗粒度节点:将原来一些“大而全”的节点按决策二的原则进行拆分,提高复用性。
- 引入并行执行:对于彼此独立的节点(如“查询用户基本信息”和“查询用户偏好”),使用
ParallelNode让它们同时执行,降低整体延迟。 - 增加关键节点的弹性:为调用外部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时代日益复杂的业务逻辑,提供了一套极具韧性的工程范式。
更多推荐
所有评论(0)