Ohhnews

分类导航

$ cd ..
Baeldung原文

使用Spring AI Alibaba构建基于图的工作流

#spring ai alibaba#工作流编排#ai智能体#图结构#java

[LOADING...]

1. 概述

在构建 AI 智能体时,通常只向 LLM 发出一个提示是不够的。解决复杂任务需要多个步骤,其中一步的输出决定下一步会发生什么。然而,手动实现并维护这种编排逻辑很困难。

在本教程中,我们将使用 Spring AI Alibaba 来解决这个问题。我们将用它把一个智能体工作流建模为步骤图,并在运行时自行决定路径。

2. 基于图的工作流入门

基于图的工作流将复杂任务拆分为更小的工作单元,并把它们连接成图。无须硬编码执行顺序,我们只需描述可能的路径。工作流会根据自身持有的数据沿着合适的路径执行。

这类工作流包含三个主要概念:

  • 状态:在工作流中流动的共享数据。
  • 节点:图中的单个工作单元。它们通常会读取状态、执行业务逻辑,并在最后更新状态。
  • 边:节点之间的连接。它们通过决定下一个要移动到哪个节点来控制执行顺序。

为了实际了解这些概念,我们将构建一个借口升级工作流。首先,一个员工节点会为工作中给定的情况编造借口。然后,一个经理节点会评价该借口的可信程度。

接着,一条边会检查经理的评分。如果经理相信这个借口,工作流就结束。否则,流程会循环回员工节点,由员工对原借口进行完善。

3. 配置 LLM

现在,让我们设置项目。首先向 pom.xml 文件中添加必要的依赖:

$ xml
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-openai</artifactId>
    <version>2.0.1</version>
</dependency>
<dependency>
    <groupId>com.alibaba.cloud.ai</groupId>
    <artifactId>spring-ai-alibaba-graph-core</artifactId>
    <version>1.1.2.3</version>
</dependency>

这里,我们首先添加 Spring AI 的 OpenAI starter 依赖,用于与 LLM 交互。接着添加 Spring AI Alibaba graph core 依赖。它提供了定义基于图的工作流所需的类。

为了避免这些依赖之间的版本冲突和兼容性问题,我们还要引入 Spring AI 物料清单(BOM):

$ xml
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>2.0.1</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

接下来,在 application.yaml 文件中配置 OpenAI API 密钥 和 聊天模型:

$ config
spring:
  ai:
    openai:
      api-key: ${OPENAI_API_KEY}
      chat:
        model: gpt-5.6-luna

这里,我们使用 gpt-5.6-luna 模型 ID 指定 OpenAI 的 GPT-5.6 Luna 模型。或者,我们也可以使用其他聊天模型,因为具体使用哪个 AI 模型或提供商对本演示并不重要。

设置这两个属性后,Spring AI 会自动创建一个 ChatClient.Builder bean。我们将在节点中使用它与指定的模型交互。

4. 定义状态键

工作流的状态是一组键值对。在节点读取或写入它们之前,我们需要声明这些键以及相应策略。这些策略控制图如何更新某个值。

让我们在 KeyStrategyFactory bean 中定义这些键:

$ java
@Bean
KeyStrategyFactory excuseKeyStrategyFactory() {
    return () -> {
        Map<String, KeyStrategy> strategies = new HashMap<>();
        strategies.put("situation", new ReplaceStrategy());
        strategies.put("excuse", new ReplaceStrategy());
        strategies.put("managerReplies", new AppendStrategy());
        strategies.put("believability", new ReplaceStrategy());
        strategies.put("attempts", new ReplaceStrategy());
        return strategies;
    };
}

这里,situation 键保存工作流的初始输入。其余键表示节点将产生的值。

对其中大多数键,我们使用 ReplaceStrategy,它会用新值覆盖现有值。不过,对于 managerReplies 键,我们使用 AppendStrategy,它会将每个新值添加到一个列表中。

5. 实现节点

定义好状态键后,让我们构建图的节点。要创建一个节点,我们实现 NodeAction 接口及其 apply() 方法。该方法会以 OverAllState 实例的形式接收当前状态。然后,在执行完业务逻辑后,我们返回一个 Map,其中包含我们想对状态进行的更新。

5.1. 编造借口

首先,创建扮演员工角色的节点:

$ java
@Component
class InventExcuseNode implements NodeAction {
    private static final String PROMPT_TEMPLATE = """
      You're an employee explaining why you missed something at work.
      You're a bad liar, but a creative one.
      You never take accountability or admit to lying.
      Situation: {situation}
      The excuse you already gave: {previousExcuse}
      How your manager responded: {managerReplies}
      Give a new excuse in at most two sentences. If you already gave one, do not
      abandon it. Keep the original story and add a further complication.
      Respond with only the excuse.
      """;
    private final ChatClient chatClient;
    InventExcuseNode(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }
}

在这个新类中,我们在提示模板里定义 LLM 指令。此外,我们注入 Spring AI 自动配置的 ChatClient.Builder bean,并用它构建 ChatClient 实例。接下来,实现 apply() 方法:

$ java
@Override
public Map<String, Object> apply(OverAllState state) {
    String situation = state.value("situation", String.class)
      .orElseThrow(IllegalStateException::new);
    String previousExcuse = state.value("excuse", "none yet");
    List<String> managerReplies = state.value("managerReplies", List.of());
    Integer attempts = state.value("attempts", 0);
    String excuse = chatClient
      .prompt()
      .user(user -> user.text(PROMPT_TEMPLATE)
        .param("situation", situation)
        .param("previousExcuse", previousExcuse)
        .param("managerReplies", managerReplies.isEmpty()
          ? "none yet"
          : String.join("\n", managerReplies)))
      .call()
      .content();
    return Map.of(
      "excuse", excuse,
      "attempts", attempts + 1
    );
}

这里,我们使用 value() 方法读取当前状态。我们为这些键定义了默认值,situation 键除外。相反,我们把它视为启动工作流的必需输入。

然后,我们将这些值填入提示模板,调用 LLM,并通过 content() 方法获取 excuse。最后,用新借口以及递增后的尝试次数更新状态。

5.2. 以经理身份回应

接下来,我们需要一个经理节点来评价员工的借口。让我们创建新的 ManagerReactsNode 类并实现 apply() 方法:

$ java
private static final String PROMPT_TEMPLATE = """
  You are a tired engineering manager listening to an employee explain themselves.
  Situation: {situation}
  Their excuse: {excuse}
  Reply in one sentence and rate how believable the excuse is, from 0.0 to 1.0.
  """;
@Override
public Map<String, Object> apply(OverAllState state) {
    String situation = state.value("situation", String.class)
      .orElseThrow(IllegalStateException::new);
    String excuse = state.value("excuse", String.class)
      .orElseThrow(IllegalStateException::new);
    ManagerReaction managerReaction = chatClient
      .prompt()
      .user(user -> user.text(PROMPT_TEMPLATE)
        .param("situation", situation)
        .param("excuse", excuse))
      .call()
      .entity(ManagerReaction.class);
    return Map.of(
      "managerReplies", managerReaction.reply(),
      "believability", managerReaction.believability()
    );
}
record ManagerReaction(
  String reply,
  Double believability
) {}

与之前的节点类似,我们读取当前状态,填充提示模板,并调用 LLM。不过,这里不调用 content() 方法,而是调用 entity() 方法来获得 结构化输出。最后,我们将 reply 和 believability 分数作为状态更新返回。

6. 创建条件边

现在,我们需要一种方式来连接已实现的节点。此外,还需要编写逻辑来决定工作流应当结束,还是让员工再试一次。为此,我们将通过实现 EdgeAction 接口来创建一条条件边:

$ java
@Component
class ExcuseDispatcher implements EdgeAction {
    private static final double BELIEVABILITY_THRESHOLD = 0.8;
    private static final int MAX_ATTEMPTS = 3;
    @Override
    public String apply(OverAllState state) {
        Double believability = state.value("believability", 0.0);
        Integer attempts = state.value("attempts", 0);
        if (isBelieved(believability) || attempts >= MAX_ATTEMPTS) {
            return "stop";
        }
        return "escalate";
    }
    static boolean isBelieved(Double believability) {
        return believability >= BELIEVABILITY_THRESHOLD;
    }
}

这里,我们从状态中读取 believability 和 attempts 键。如果经理的评分达到我们配置的阈值,就返回 stop 标签。否则,返回 escalate 标签,给员工另一次机会。

在第一个条件中,我们还会检查员工是否已用完最大尝试次数。这对于避免工作流出现无限循环很重要。

7. 组装图

所有构建模块都已就绪,现在把它们连接起来,并暴露一个 CompiledGraph bean:

$ java
@Bean
CompiledGraph excuseGraph(
  KeyStrategyFactory keyStrategyFactory,
  InventExcuseNode inventExcuseNode,
  ManagerReactsNode managerReactsNode,
  ExcuseDispatcher excuseDispatcher
) throws GraphStateException {
    StateGraph excuseGraph = new StateGraph(keyStrategyFactory)
      .addNode("invent_excuse", AsyncNodeAction.node_async(inventExcuseNode))
      .addNode("manager_reacts", AsyncNodeAction.node_async(managerReactsNode))
      .addEdge(StateGraph.START, "invent_excuse")
      .addEdge("invent_excuse", "manager_reacts")
      .addConditionalEdges(
        "manager_reacts",
        AsyncEdgeAction.edge_async(excuseDispatcher),
        Map.of(
          "escalate", "invent_excuse",
          "stop", StateGraph.END
        )
      );
    return excuseGraph.compile();
}

首先,我们创建一个 StateGraph 实例,并将 KeyStrategyFactory bean 传给它。这有助于图处理每个状态键的更新。接着,我们使用 addNode() 方法注册两个节点,并为它们分别指定唯一名称。

然后,我们使用 addEdge() 方法定义流程。我们将 START 常量连接到 invent_excuse 节点,把它标记为入口点。从那里,再将 invent_excuse 连接到 manager_reacts 节点。

之后,我们使用 addConditionalEdges() 方法将 excuseDispatcher 附加到 manager_reacts 节点。这里传入的 map 将 dispatcher 返回的标签与其目标节点关联起来。escalate 标签会循环回 invent_excuse 节点。与此同时,stop 标签会转到 END 常量,从而结束工作流。

8. 测试工作流

现在我们已经组装好图,来看看工作流在不同情况下的表现。

8.1. 暴露 REST API

首先,暴露一个 API 端点,让我们可以启动工作流:

$ java
@PostMapping("/excuse")
ResponseEntity<ExcuseResponse> generateExcuse(@RequestBody ExcuseRequest request) {
    OverAllState finalState = excuseGraph
      .invoke(
        Map.of("situation", request.situation()),
        RunnableConfig.builder()
          .threadId(UUID.randomUUID().toString())
          .build())
      .orElseThrow();
    ExcuseResponse response = new ExcuseResponse(
      finalState.value("excuse", ""),
      finalState.value("managerReplies", List.of()),
      finalState.value("attempts", 0),
      ExcuseDispatcher.isBelieved(finalState.value("believability", 0.0)));
    return ResponseEntity.ok(response);
}
record ExcuseRequest(String situation) {}
record ExcuseResponse(
  String finalExcuse,
  List<String> managerReplies,
  Integer attempts,
  boolean believed
) {}

这里,我们在注入的 CompiledGraph bean 上调用 invoke() 方法。我们将请求中的 situation 作为初始状态传入。此外,还传入一个带有随机 threadId 的 RunnableConfig 实例。这有助于将并发请求的执行彼此隔离。

invoke() 方法从开始到结束运行整个图,并返回最终状态。然后,我们只需从这个状态中读取值,并用它们准备 API 响应。

8.2. 调用 API 端点

现在,让我们使用 HTTPie 命令行工具调用 API 端点:

$ bash
http POST :8080/excuse situation="Employee was 30 seconds late to the daily standup"

这里,我们输入一个平淡无奇的情况。看看会得到什么响应:

$ cat
{
    "attempts": 1,
    "believed": true,
    "finalExcuse": "Sorry, I was only 30 seconds late because my calendar reminder fired late while my laptop was still reconnecting to the meeting room.",
    "managerReplies": [
        "Understood---30 seconds is minor, but please make sure you join promptly next time."
    ]
}

如我们所见,经理相信了第一个借口,因此工作流在一次尝试后就结束了。因此,managerReplies 列表只包含一条回复,因为循环没有再运行。

接下来,让我们尝试一个更难解释的情况,并看看 API 响应:

$ bash
http POST :8080/excuse situation="The employee didn't show up to work for 3 days without any notice. Yet, the employee uploaded pictures of them partying on their public instagram account."
$ cat
{
    "attempts": 3,
    "believed": false,
    "finalExcuse": "The retreat's emergency coordinator told us all communication had to go through their satellite system, but a solar flare knocked it offline and corrupted the evacuation logs. That also delayed the proof they promised to send confirming the phone confiscation, shuttle failure, and automated Instagram reposts.",
    "managerReplies": [
        "Frankly, this is difficult to believe, and disappearing for three days without finding any way to notify us is unacceptable.",
        "That explanation is extraordinarily unlikely, and we need to discuss your three-day absence and the conflicting Instagram activity immediately.",
        "That explanation strains credibility beyond reason; send the promised proof immediately, and we'll discuss your three-day unreported absence."
    ]
}

这里,经理不相信任何一个借口。因此,工作流会一直循环,直到达到最大尝试次数。## 9. 结论

在本文中,我们使用 Spring AI Alibaba 实现了一个基于图的智能体工作流。

我们首先定义了状态键及其策略。然后,我们实现了两个由 LLM 驱动的节点,以及一个条件边,用于决定工作流是继续循环还是停止。最后,我们将所有内容组装成一个图,并观察了工作流在运行时如何调整其路径。

一如既往,本文中使用的所有代码示例都可以在 GitHub 上获取。