教你自創工作流,賦予AI助理個性化推薦超能力

努力的小雨發表於2024-12-11

之前,我們已經完成了工作流的基本流程和整體框架設計,接下來的任務就是進入實際操作和實現階段。如果有同學對工作流的整體結構還不夠熟悉,可以先參考一下這篇文章,幫助你更好地理解和掌握工作流的各個部分:

本篇文章是我關於Spring AI搭建Agent系列的第三篇實戰教程,雖然Spring AI目前仍處於快照版本,還未釋出正式版本,但這並不妨礙我們瞭解其最新的功能和發展動態。畢竟,人工智慧是未來發展的核心方向之一。接下來,我將直接進入主題,廢話不多說,我們開始吧!

今天我們將主要使用Spring AI Alibaba進行開發演示,展示如何透過這個框架構建AI助理。在最後,我還會附上整個AI助理的演示影片,供大家參考。透過這個演示,大家可以看到,當前的Java開發者不再需要轉向Python,依然可以參與到AI Agent開發的潮流中,抓住這一全新的技術趨勢!

簡單回顧

好的,回顧一下之前的工作,我們已經成功搭建了一個個人 AI 助理 Agent 示例,該示例已經具備了多項實用功能,接下來我將簡單回顧一下這些功能的實現及其特點:

  1. 旅遊攻略:透過直接呼叫第三方工作流工具 Coze 來完成旅遊攻略的生成,整個過程既快速又高效,無需複雜的配置,能夠快速響應使用者的需求,提供精準的旅行推薦。
  2. 天氣查詢:該功能透過從資料庫中查詢當前地址的編碼,然後呼叫天氣API介面進行實時天氣查詢,能夠準確地提供使用者所在地的天氣預報,確保使用者獲取到最新的天氣資訊。
  3. 個人待辦:系統可以根據使用者的指令,自動生成SQL查詢並執行相關操作,直接訪問資料庫,方便使用者新增、刪除或更新個人待辦事項,極大地提高了任務管理的效率。

這些功能在整個AI助理Agent中已經得到了有效的整合和應用,極大提升了使用者體驗和操作便捷性。接下來,整個系統的架構設計如下所示:

image

之前,我們一直使用的是hunyun相容OpenAI介面進行測試和聯調,今天我們繼續進行Spring AI的應用開發,不過這次使用的是國內的Spring AI版本——Spring AI Alibaba。與之前的OpenAI介面不同,Spring AI Alibaba更貼合國內的技術環境和需求。如果你希望使用原生的相容介面,目前可以考慮阿里巴巴的通義千問模型,它提供了較為成熟和穩定的API支援。

需要特別說明的是,關於Spring AI中第三方聊天模型介面的情況,在我目前瞭解的基礎上,只有Alibaba是官方開發並支援的,穩定性方面也有了更高的保證。而其他一些例如智普的介面,雖然功能上能夠滿足基本需求,但它們更多的是由個人開發者和愛好者維護,穩定性和技術支援相對有所差異,使用時需要特別留意其可用性。

個性化推薦

在這個功能模組中,我們主要依託使用者的歷史畫像,透過分析其過往的行為資料、興趣偏好等資訊,利用AI模型總結出相關的搜尋關鍵詞,進而推薦一些使用者可能會感興趣的電影、新聞等內容。

我已經大致畫出了一個簡單的設計圖,展示了這一推薦流程的架構。大家可以看一下這個設計圖:

image

為了更好地展示我工作流的並行處理能力,我特意選擇了百度和Bing兩個搜尋外掛進行演示。

此外,Spring AI Alibaba框架內部已經開始著手開發外掛商店,目前雖僅有4個外掛,但這只是一個起點,未來框架將會不斷擴充套件和豐富外掛的種類和功能,支援更多第三方外掛的整合和應用。如圖所示,當前框架的外掛商店介面已經初具雛形:

image

好的,接下來我們繼續深入討論。在這一部分,百度搜尋是實時進行的,即系統會直接呼叫百度的搜尋介面。相比之下,Bing搜尋則需要使用個人API金鑰進行呼叫。由於目前尚未配置好API金鑰,為了便於展示和測試,我暫時將Bing搜尋的返回值寫死為固定的結果,而沒有進行實際的API呼叫。這樣做的目的是為了簡化演示流程,確保其他部分能夠正常工作。

需要特別注意的是,目前Spring AI Alibaba框架本身並不支援完整的工作流功能。雖然框架在不斷迭代和最佳化,但目前如果想要實現工作流編排的功能,開發者需要自行開發相關的功能模組,或者選擇等待框架官方在未來版本中加入工作流支援。因此,在當前的工作流編排中,我們將依賴手動編碼來實現任務的順序執行和邏輯控制。

工作流實現

我們之前提供的僅僅是一些大致的框架和思路,但具體的內部實現並沒有詳細展開。在此,我將分享一些關鍵的程式碼實現。每個人在實現時可能會採用不同的方式,如果你有更為高效或更合適的解決方案,當然可以根據自己的理解進行調整和最佳化。

接下來,我將簡要展示整體框架的示意圖,以幫助大家更清晰地理解整體結構,避免後續討論時產生混淆。如圖所示:

image

好的,接下來我們將深入探討主要的核心部分。首先,我們需要將掃描步驟中的step方法進行封裝,轉化為任務(task),並將其交由工作流進行管理和儲存。

initialContext

我在這部分的設計上參考了LlamaIndex的工作流,將掃描類中的step方法整合到初始化上下文中,以實現更加高效和靈活的任務管理。透過這種方式,我們能夠確保每個步驟都能被系統有效地捕獲並按照預期的流程執行。接下來,我們來看一下核心程式碼的實現,具體如下所示:

private WorkflowContext initialContext() {
        WorkflowContext context = new WorkflowContext(false);
        // 獲取當前子類的類物件
        Class<?> clazz = this.getClass();
        // 獲取子類的所有方法
        Method[] methods = clazz.getDeclaredMethods();
        Graph graph = context.getGraph();
        // 遍歷所有方法,檢查是否有 StepConfig 註解
        for (Method method : methods) {
            if (method.isAnnotationPresent(Step.class)) {
                Step annotation = method.getAnnotation(Step.class);
                String name = method.getName();
                log.info("Method: {}", name);
                if (!context.getEventQueue().containsKey(name)){
                     context.getEventQueue().put(name, new ArrayBlockingQueue<>(10));
                }
                // 獲取方法的引數型別
                Class<?>[] parameterTypes = method.getParameterTypes();
                List<Class<? extends ToolEvent>> acceptedEventList = List.of(annotation.acceptedEvents());
                String eventName = name;
                StepConfig stepConfig = StepConfig.builder().acceptedEvents(acceptedEventList).eventName(eventName)
                                .returnTypes(method.getReturnType()).build();
                log.info("Adding node: {}", name);
                // 新增節點並設定節點標籤和樣式
                Node nodeA = graph.addNode(name);

                nodeA.setAttribute("ui.style", "text-size: 20;size-mode:fit;fill-color:yellow;size:25px;");
                // 建立執行緒物件但不啟動
                Thread thread = new Thread(() -> {
                    log.info("Thread started for method: {}", name);
                    //可能有多個事件,需要處理
                    ArrayList<Class<? extends ToolEvent>> events = new ArrayList<>(acceptedEventList);
                    // 獲取佇列物件
                    ArrayBlockingQueue<ToolEvent> queue = context.getEventQueue().get(name);
                    while (true) {
                        try {
                            ToolEvent event = queue.take(); // 從佇列中取出事件
                            if(!StringUtils.isEmpty(context.getResult())){
                                break;
                            }
                            if (isAcceptedEvent(event, acceptedEventList,events,context,name)) {
                                //開始執行時間
                                long startTime = System.currentTimeMillis();
                                context.setStepEventHolding(event);
                                Object returnValue = method.invoke(this, context); // 執行方法
                                //繼續釋出事件
                                continueSendEvent(context,returnValue,name);
                                // 執行時間
                                long endTime = System.currentTimeMillis();
                                graph.getNode(name).setAttribute("ui.label", name + "耗時:" + (endTime - startTime) + "ms");
                            } else {
                                continue;
                            }
                        } catch (InterruptedException e) {
                            log.error("Thread interrupted for method: {}", name, e);
                            Thread.currentThread().interrupt();
                            break;
                        } catch (Exception e) {
                            log.error("Error executing method: {}", name, e);
                            //繼續釋出事件
                            continueSendEvent(context,new StopEvent(e.getMessage()),name);
                        }
                    }
                });
                context.addThread(thread);
            }
        }
        return context;
    }

註釋寫的基本很清楚了,我再來簡單解釋一下這段程式碼的含義,幫助你理解。

  • 建立一個 WorkflowContext 物件,傳入 false 引數,表示並行處理。
  • 獲取當前類的資訊:獲取當前子類的類物件以及其宣告的方法列表。
  • 遍歷方法並尋找 Step 註解:遍歷子類中的所有方法,檢查每個方法是否被 @Step 註解標記。
  • 設定事件佇列:對每個被 @Step 註解的方法,首先獲得其名稱,然後檢查上下文中的事件佇列。若不存在,則為該方法名建立一個 ArrayBlockingQueue。
  • 新增圖形節點(Node):在圖形 (Graph) 中為每個步驟新增一個節點,並設定其視覺樣式(如文字大小和填充顏色)。
  • 建立並配置執行緒:
    • 為每個步驟建立一個新的執行緒。這個執行緒負責從事件佇列中讀取事件並根據事件處理邏輯執行步驟方法。
    • 在該執行緒中,透過無限迴圈持續讀取佇列中的事件,處理符合條件的事件。處理時會記錄方法的執行時間並更新節點標籤資訊,處理異常情況並繼續釋出事件。
  • 處理事件和執行方法:
    • 在每次取出事件後,首先檢查當前狀態是否應繼續執行(例如檢查是否有結果返回)。
    • 如果取到的事件被該步驟接受,則呼叫相應的方法執行處理,並根據返回值繼續釋出後續事件。如果方法執行時丟擲異常,也會捕獲並處理,同時繼續釋出一個停止事件。

run方法

在完成了任務的封裝後,接下來要做的就是如何啟動並執行所有的任務。這一部分的邏輯主要集中在run方法中,負責協調和控制所有任務的執行流程。接下來,讓我們簡單檢視一下run方法中的核心程式碼實現:

public String run(String jsonString) throws IOException {
    WorkflowContext context = initialContext();
    if (!StringUtils.isEmpty(jsonString)) {
        //初始引數
        context.getGlobalContext().putAll(JSONObject.parseObject(jsonString));
    }
    WorkflowHandler handler = new WorkflowHandler(context);
    handler.handleTask(timeout);
    if (showUI) {
        //todo 本地測試時,會將springboot程式一起殺掉,後期最佳化
        context.getGraph().display();
        try {
            System.out.println("Press any key to exit...");
            System.in.read();
        } catch (IOException e) {
            e.printStackTrace();
        }
    } else {
        // 匯出圖形為檔案
        FileSinkImages pic = new FileSinkImages(FileSinkImages.OutputType.PNG, FileSinkImages.Resolutions.HD1080);
        pic.setLayoutPolicy(FileSinkImages.LayoutPolicy.COMPUTED_FULLY_AT_NEW_IMAGE);
        pic.writeAll(context.getGraph(), "sample.png");
    }
    return context.getResult();
}

我來簡單的說下這部分程式碼的含義。

  • 初始化工作流上下文,就是上面剛說的部分程式碼。
  • 處理輸入的JSON字串:這裡主要考慮的是工作流是可以有輸入引數的,而我將輸入引數全都當做json處理,以後也好做物件封裝。
  • 執行工作流任務:這裡就是簡單的啟動一下所有task執行緒任務。
  • UI顯示或檔案匯出:因為我不是前端,技術有限,並沒有使用前端生成HTML程式碼的輸出,而是使用的graphstream類快速生成的圖片或彈窗UI。並簡單記錄了一下每個事件的執行時間。
  • 返回結果:最後我會將工作流的結果全都放到result中返回給呼叫方。整個工作流算是完成了。

剩下的部分基本上沒有太多複雜的程式碼了。透過參考LlamaIndex的工作流核心程式碼,我自定義了這一工作流封裝,並將其進行了適當的最佳化。接下來,我們進入真正的測試階段,重點檢驗它的穩定性和完整性。

雖然目前實現的版本仍然有許多最佳化空間和改進之處,但至少它已經能夠順利執行,具備了初步的可用性。現在,讓我們開始實際的執行測試,看看效果如何。

準備工作

申請api-key

連線地址如下:https://bailian.console.aliyun.com/?spm=a2c4g.11186623.0.0.140f3048QPbIUu#/model-market

進入後,請根據個人需求選擇任意一個千問模型。然後,檢視並儲存您的個人API金鑰(key)。如果沒有金鑰,您可以按照指引自行建立一個新的金鑰,具體操作步驟如圖所示。

image

儲存好後,我們需要用到這個api-key。

建立專案

我們可以直接複製一個官方提供的 demo,保留所有依賴項不變,其他部分可以根據我們的需求進行修改和調整。專案的整體結構如圖所示:

image

好的,接下來我們繼續進行。首先,由於我們需要整合百度搜尋和 Bing 搜尋外掛,因此我們可以直接將它們新增到 pom.xml 的依賴中。經過整合之後,最終的依賴配置如下所示:

<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.3</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.alibaba.cloud.ai</groupId>
    <artifactId>workflow-example</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>workflow-example</name>
    <description>Demo project for Spring AI Alibaba</description>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <maven-deploy-plugin.version>3.1.1</maven-deploy-plugin.version>
        <!-- Spring AI -->
        <spring-ai-alibaba.version>1.0.0-M3.2</spring-ai-alibaba.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>com.alibaba.cloud.ai</groupId>
            <artifactId>spring-ai-alibaba-starter</artifactId>
            <version>${spring-ai-alibaba.version}</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud.ai</groupId>
            <artifactId>spring-ai-alibaba-starter-plugin-baidusearch</artifactId>
            <version>1.0.0-M3.2</version>
            <scope>compile</scope>
        </dependency>
        <dependency>
            <groupId>com.alibaba.cloud.ai</groupId>
            <artifactId>spring-ai-alibaba-starter-plugin-bingsearch</artifactId>
            <version>1.0.0-M3.2</version>
            <scope>compile</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-deploy-plugin</artifactId>
                <version>${maven-deploy-plugin.version}</version>
                <configuration>
                    <skip>true</skip>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

接著,我們再去填寫一個配置檔案資訊,如下所示:

spring:
  application:
    name: workflow-example

  ai:
    dashscope:
      api-key: 自己的key,如:sk-
      chat:
        options:
          model: qwen-plus

在這裡,我使用的是 qwen-plus 模型,但你完全可以根據實際需求選擇其他可用的模型。根據目前的選項,你有以下幾種選擇:qwen-turbobailian-v1dolly-12b-v2qwen-plus 以及 qwen-max

Event封裝最佳化

接下來,我們將開始使用自己建立的 ToolEvent 類。在最近的最佳化中,我對該類進行了一些改進,主要加入了一個抽象邏輯,旨在更好地處理工作流事件的邏輯。不僅提升了程式碼的可擴充套件性和可維護性,還增加了一個 事件輸出引數名,該引數名的引入使得獲取其他節點的引數資訊變得更加簡便和高效。以下是更新後的程式碼示例:

public abstract class  ToolEvent {
//此處省略部分程式碼
public String getOutputName() {
    if (this.outputName == null) {
        this.outputName = this.getClass().getSimpleName();
    }
    return this.outputName;
}

public abstract void handleEvent(Map<String, Object> globalContext);
}

接下來,我們需要對時間處理相關的節點進行封裝。考慮到這個方法不應該暴露給使用者直接呼叫,而是應該在我們內部進行控制和執行,因此,我將其封裝到了釋出事件之前的時間節點。以下是相關程式碼的實現:

public class WorkflowContext {
  //此處省略部分程式碼
  public void sendEvent(ToolEvent value) {
      //這裡執行
      value.handleEvent(globalContext);
      eventQueue.entrySet().stream().forEach(entry -> {
          String key = entry.getKey();
          log.info("send event to {},event:{}", key, value.getEventName());
          entry.getValue().add(value);
      });
  }
}

剩下的部分已經沒有明顯需要進一步最佳化的地方了,我們可以直接開始使用抽象出來的事件類來建立所需的節點資訊,並將其整合到系統中以實現具體功能。

工作流節點建立

HistoryInfoEvent

為了方便演示,出於簡化的考慮,我們並沒有實際進行使用者歷史畫像的提取操作,而是簡單地寫了一句說明文字。在實際的應用場景中,完全可以將相關資訊從使用者資料中提取出來,進行進一步的分析和處理。以下是相關程式碼示例:

public class HistoryInfoEvent extends ToolEvent {

    private String HISTORY_INFO = "{\n" +
            "  \"name\": \"努力的小雨\",\n" +
            "  \"age\": \"28歲\",\n" +
            "  \"hobbies\": {\n" +
            "    \"sports\": [\"打籃球\"],\n" +
            "    \"movies\": [\"變形金剛\", \"鋼鐵俠\", \"復仇者聯盟\"],\n" +
            "    \"news\": [\"AI實時新聞\"]\n" +
            "  },\n" +
            "  \"occupation\": \"軟體工程師\",\n" +
            "  \"location\": \"北京\"\n" +
            "}";

    @Override
    public void handleEvent(Map<String, Object> globalContext) {
        //假設從使用者畫像中獲取資訊
        ThreadUtils.sleep(1342);
        //存入輸出值,給其他人使用
        globalContext.put(getOutputName()+":output", HISTORY_INFO);
    }
}

AIChatEvent

在這裡,我們需要為程式新增一個 chatClient 屬性物件,並確保在初始化時將其注入到相關的事件中,以便在後續的操作中能夠正常使用。為了更好地展示如何使用,我簡單地編寫了一些提示詞,用於規劃輸出格式。這些提示詞的作用是限定輸出內容的樣式和結構。通常情況下,Spring AI 是可以在呼叫時透過傳入相應的類,自動將預設的提示詞傳遞給類中的方法,從而對輸出結果進行有效的限制和格式化。

然而,為了方便本次演示,我採取了一種簡化的處理方式,以便更直觀地展示相關流程和功能。

public class AIChatEvent extends ToolEvent {

    private String systemprompt = """
                - Role: 個性化資訊檢索專家
                - Background: 使用者希望根據自己的興趣愛好等個人資訊,獲取定製化的推薦內容,包括電影、新聞。
                - Profile: 你是一位專業的個性化資訊檢索專家,擅長根據使用者的個人資訊和偏好,高效地檢索和推薦相關內容。
                - Skills: 你具備強大的資訊篩選能力、對各類資訊源的深入瞭解,以及快速響應使用者需求的能力。
                - Goals: 根據使用者的興趣愛好等資訊,提供兩個關鍵詞,幫助使用者在搜尋引擎中快速找到今日電影推薦、今日實時新聞。
                - Constrains: 關鍵詞需要簡潔、相關性強,並且能夠直接用於搜尋引擎查詢。
                - OutputFormat: 返回兩個陣列元素,每個元素包含一個關鍵詞。
                - Workflow:
                  1. 分析使用者的興趣愛好和個人資訊。
                  2. 根據分析結果,確定與電影、新聞相關的關鍵詞。
                  3. 將關鍵詞以陣列形式返回給使用者。
                - Examples:
                  - 例子1:使用者喜歡科幻電影、國際新聞
                    - ['今日科幻電影推薦', '今日國際新聞頭條']
                  - 例子2:使用者喜歡歷史紀錄片、財經新聞
                    - ['今日曆史紀錄片推薦', '今日財經新聞頭條']
                  - 例子3:使用者喜歡動作電影、體育新聞
                    - ['今日動作電影推薦', '今日體育新聞快訊']
                """;
    private ChatClient chatClient;

    public AIChatEvent(ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    @Override
    public void handleEvent(Map<String, Object> globalContext) {

        String prompt = """
                請根據提供的使用者畫像返回給我陣列,陣列中包含兩個關鍵詞,每個關鍵詞都需要簡潔、相關性強,並且能夠直接用於搜尋引擎查詢。
                ---
                """ + globalContext.get(HistoryInfoEvent.class.getSimpleName()+":output");
        String content = chatClient.prompt().system(systemprompt).user(prompt).advisors(new MyLoggerAdvisor()).call().content();
        log.info("content->{}",content);
        globalContext.put(getOutputName()+":output", content);
    }
}

SearchEvent

為了演示工作流的並行處理能力,我在“搜尋事件”部分建立了兩個獨立的節點。這些節點是為了展示在多個任務同時執行。由於Bing外掛需要使用API金鑰,而該金鑰在當前環境中沒有配置,因此我暫時將其寫死,導致無法獲取實際的搜尋結果。以下是相關的程式碼示例:

public class BingSearchEvnet extends ToolEvent {
//    private BingSearchService bingSearchService = new BingSearchService();
    private String searchWord;
    public BingSearchEvnet(String searchWord) {
        this.searchWord = searchWord;
    }
    @Override
    public void handleEvent(Map<String, Object> globalContext) {
        //仿製搜尋
        ThreadUtils.sleep(2000);
        globalContext.put(getOutputName()+":output", searchWord+",並無搜尋到");
    }
}


public class BaiDuSearchEvent extends ToolEvent {
    private BaiduSearchService baiduSearchService = new BaiduSearchService();
    private String searchWord;
    public BaiDuSearchEvent(String searchWord) {
        this.searchWord = searchWord;
    }
    @Override
    public void handleEvent(Map<String, Object> globalContext) {
        BaiduSearchService.Request request = new BaiduSearchService.Request(searchWord, 10);
        BaiduSearchService.Response response = baiduSearchService.apply(request);
        if (response == null || response.results().isEmpty()){
            return;
        }
        StringBuilder stringBuilder = new StringBuilder();
        for (BaiduSearchService.SearchResult result : response.results()) {
            stringBuilder.append("---------");
            stringBuilder.append("title:");
            stringBuilder.append(result.title());
            stringBuilder.append("text:");
            stringBuilder.append(result.abstractText());
            log.info("result.title:{}",result.title());
            log.info("result.abstractText:{}",result.abstractText());
        }
        globalContext.put(getOutputName()+":output", stringBuilder.toString());
    }
}

請注意,上面提供的程式碼包含了兩個類。為了減少程式碼的篇幅,我將它們合併在了一起。需要特別注意的是,官方外掛的百度搜尋結果物件並沒有提供 get 方法。因此,在處理結果時,我將其解析出來並儲存到一個字串變數中,而沒有直接使用 toJsonString 方法。因為如果直接呼叫該方法進行轉換,最終會得到一個空的結果。

工作流規劃

剩下的部分就是將工作流的各個節點進行規劃和連線。具體來說,這一步是確保所有節點之間的邏輯關係和執行順序能夠正確地銜接在一起,從而形成一個完整的工作流。接下來,我們可以透過以下程式碼來實現這一目標:

public class RecommendWorkflow extends Workflow {
    private ChatClient chatClient;
    public RecommendWorkflow(ChatClient chatClient) {
        this.chatClient = chatClient;
    }
    
    @Step(acceptedEvents = {StartEvent.class})
    public ToolEvent toHistoryInfoEvent(WorkflowContext context) {
        return new HistoryInfoEvent();
    }

    @Step(acceptedEvents = {HistoryInfoEvent.class})
    public ToolEvent toAIChatEvent(WorkflowContext context) {
        return new AIChatEvent(chatClient);
    }

    /**
     演示並行效果
     */
    @Step(acceptedEvents = {AIChatEvent.class})
    public ToolEvent toBaiduSearchEvent(WorkflowContext context) {
        Object array = context.getGlobalContext().get(AIChatEvent.class.getSimpleName() + ":output");
        //轉string陣列
        JSONArray jsonArray = JSONArray.parseArray((String) array);
        return new BaiDuSearchEvent(jsonArray.getString(0));
    }
    /**
     演示並行效果
     */
    @Step(acceptedEvents = {AIChatEvent.class})
    public ToolEvent toBingSearchEvent(WorkflowContext context) {
        Object array = context.getGlobalContext().get(AIChatEvent.class.getSimpleName() + ":output");
        //轉string陣列
        JSONArray jsonArray = JSONArray.parseArray((String) array);
        return new BingSearchEvnet(jsonArray.getString(1));
    }

    @Step(acceptedEvents = {BaiDuSearchEvent.class,BingSearchEvnet.class})
    public ToolEvent toStopEvent(WorkflowContext context) {
        //獲取搜尋結果
        JSONObject result = new JSONObject();
        result.put("百度搜尋結果:",context.getGlobalContext().get(BaiDuSearchEvent.class.getSimpleName() + ":output"));
        result.put("bing搜尋結果:",context.getGlobalContext().get(BingSearchEvnet.class.getSimpleName() + ":output"));
        return new StopEvent(result.toJSONString());
    }
}

在這部分程式碼中,我們可以清晰地看到,經過工作流最佳化後的處理邏輯變得非常簡潔。除了需要為事件封裝必要的引數外,幾乎沒有其他複雜的處理邏輯。接下來,我們需要進一步配置和注入所需的聊天大模型,以確保系統能夠順利地與其進行互動並完成相應的任務。

模型配置

在這段配置中,我對工作流的視覺化和超時時間進行了調整。特別需要注意的是,如果是在生產環境中,請避免啟用 showui 選項,這樣就會讓工作流的流程轉化成圖片儲存了。

除了這一點,我還對大模型的超時時間進行了配置,因為預設的超時時間較短,如果與模型的溝通時間過長,就容易觸發超時錯誤並導致報錯。

最後,我還新增了日誌列印的配置,這樣可以在測試過程中方便地檢視大模型的呼叫日誌,以便更好地除錯和排查問題。

@Configuration
class ChatConfig {

    @Bean
    RecommendWorkflow recommendWorkflow(ChatClient.Builder builder) {
        RecommendWorkflow recommendWorkflow = new RecommendWorkflow(builder.build());
        recommendWorkflow.setTimeout(20);
        recommendWorkflow.setShowUI(true);
        return recommendWorkflow;
    }

    @Bean
    RestClient.Builder restClientBuilder(RestClientBuilderConfigurer restClientBuilderConfigurer) {
        ClientHttpRequestFactorySettings defaultConfigurer =  ClientHttpRequestFactorySettings.DEFAULTS
                .withReadTimeout(Duration.ofMinutes(5))
                .withConnectTimeout(Duration.ofSeconds(30));
        RestClient.Builder builder = RestClient.builder()
                .requestFactory(ClientHttpRequestFactories.get(defaultConfigurer));
        return restClientBuilderConfigurer.configure(builder);
    }

    @Bean
    MyLoggerAdvisor myLoggerAdvisor() {
        return new MyLoggerAdvisor();
    }
}

日誌類

日誌的寫法其實非常簡單,大家可以參考一下。

@Slf4j
public class MyLoggerAdvisor implements CallAroundAdvisor, StreamAroundAdvisor {

    public static final Function<AdvisedRequest, String> DEFAULT_REQUEST_TO_STRING = (request) -> {
        return request.toString();
    };

    public static final Function<ChatResponse, String> DEFAULT_RESPONSE_TO_STRING = (response) -> {
        return ModelOptionsUtils.toJsonString(response);
    };

    private final Function<AdvisedRequest, String> requestToString;

    private final Function<ChatResponse, String> responseToString;

    public MyLoggerAdvisor() {
        this(DEFAULT_REQUEST_TO_STRING, DEFAULT_RESPONSE_TO_STRING);
    }

    public MyLoggerAdvisor(Function<AdvisedRequest, String> requestToString,
                           Function<ChatResponse, String> responseToString) {
        this.requestToString = requestToString;
        this.responseToString = responseToString;
    }

    @Override
    public String getName() {
        return this.getClass().getSimpleName();
    }

    @Override
    public int getOrder() {
        return 0;
    }

    private AdvisedRequest before(AdvisedRequest request) {
        log.info("request: {}", this.requestToString.apply(request));
        return request;
    }

    private void observeAfter(AdvisedResponse advisedResponse) {
        log.info("response: {}", this.responseToString.apply(advisedResponse.response()));
    }

    @Override
    public String toString() {
        return SimpleLoggerAdvisor.class.getSimpleName();
    }

    @Override
    public AdvisedResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) {

        advisedRequest = before(advisedRequest);

        AdvisedResponse advisedResponse = chain.nextAroundCall(advisedRequest);

        observeAfter(advisedResponse);

        return advisedResponse;
    }

    @Override
    public Flux<AdvisedResponse> aroundStream(AdvisedRequest advisedRequest, StreamAroundAdvisorChain chain) {

        advisedRequest = before(advisedRequest);

        Flux<AdvisedResponse> advisedResponses = chain.nextAroundStream(advisedRequest);

        return new MessageAggregator().aggregateAdvisedResponse(advisedResponses, this::observeAfter);
    }
}

這裡包含了我們需要實現的所有增強器方法,程式碼量較大,但實際上它們的核心邏輯並不複雜,主要就是在不同的地方列印當前物件的內容,並記錄相關的日誌資訊。目的是確保在系統執行過程中能夠及時獲取到足夠的除錯資訊,方便後續的除錯與最佳化。

訪問入口

最終,為了簡化對我們工作流的訪問過程,我直接編寫一個控制器(Controller)來接收和處理外部請求。以下是實現該控制器的程式碼示例:

@RestController
@RequestMapping("/hello")
public class HelloWordController {

    @Autowired
    private RecommendWorkflow recommendWorkflow;

    @RequestMapping("/word")
    public String hello() throws IOException {
        return recommendWorkflow.run("");
    }
}

為了便於演示和簡化示例的複雜度,我在這裡並沒有引入任何輸入引數。事實上,您完全可以根據實際需求,將一些必要的標識資訊(例如使用者ID、會話ID或其他工作流所需要的上下文資料)作為輸入引數傳遞給控制器。

工作流演示效果

接下來,我們將進行工作流功能的演示。在啟動專案之後,只需在瀏覽器中輸入以下地址:http://localhost:8080/hello/word,即可直接訪問該功能。具體內容將如下圖所示。

image

最終返回結果如圖所示:

image

在正常情況下,我們的專案會自動生成並輸出一張圖片,你可以在系統中看到該圖片的展示效果。如圖所示:

image

為了更好地展示效果,我已將UI開關功能開啟。以下是啟用開關後的介面效果圖:

image

嵌入AI助理

正如目前市面上許多智慧體平臺的設計模式,工作流通常是一個可以單獨呼叫的模組。因此,我們可以將工作流作為微服務的一部分進行部署,並透過暴露相應的介面,便於我們的AI Agent進行呼叫。這個介面通常以函式(function)的形式呈現。需要特別注意的是,AI聊天問答專案與工作流專案是兩個獨立的模組,儘管如此,你也完全可以將這兩個模組合併成一個整體,視專案需求而定。為了保持與其他智慧體平臺的一致性,在本示範中我們選擇將它們分別作為兩個獨立的專案進行演示。

為了幫助大家更清晰地理解整個架構,我簡單繪製了一張框架圖,大家可以參考一下,避免在後續的講解中感到困惑。

image

好的,接下來,我們需要對現有的系統進行一些調整,具體來說,就是將原本使用的OpenAI依賴替換成我們的千問模型。

大模型接入

在這裡,需要特別注意的是,目前千問模型並沒有直接整合在官方的Spring AI框架中,因此無法像其他常見模型那樣直接透過Spring AI的依賴引入進行使用。

image

因此,如果我們希望在專案中使用千問模型作為依賴,必須遵循Spring AI Alibaba的整合方式,按照其官方Demo的指導,將spring-ai-alibaba-starter依賴引入到我們的專案中。以下是該依賴的引入方式和配置示例,供大家參考:

<dependency>
    <groupId>com.alibaba.cloud.ai</groupId>
    <artifactId>spring-ai-alibaba-starter</artifactId>
    <version>1.0.0-M3.2</version>
</dependency>

緊接著,接下來的步驟是,務必將之前新增的OpenAI依賴在專案中進行註釋或者直接刪除掉,否則在執行時可能會導致依賴衝突和報錯問題。由於我的Spring AI問答專案是基於properties配置檔案來管理配置資訊的,因此我們需要在application.properties檔案中新增與千問模型整合所需的相關配置資訊。

spring.ai.dashscope.api-key= sk-63eb29c7f4dd4de489fa64382d94a797
spring.ai.dashscope.chat.options.model= qwen-plus

這樣我們就能夠順利啟動併成功執行系統。如圖所示,基本的問答流程已經正常工作,所有的功能都按預期進行。

image

專案已經成功啟動並正常執行,接下來我們可以繼續編寫一個函式呼叫。在這一階段,我們將直接透過 HTTP 呼叫進行通訊,而暫時不涉及服務發現和序號產生器制。這樣做的目的是為了簡化演示。

FunctionCall

需要注意的是,在進行這次變更後,我們發現出現了許多相容性問題,之前執行良好的流程也開始出現錯誤。因此,我對整個系統進行了全面最佳化。經過一番調整和修復後,最終保留下來的外掛呼叫配置就是當前的版本。

@Bean
public FunctionCallbackWrapper weatherFunctionInfo(AreaInfoPOMapper areaInfoPOMapper) {
  return FunctionCallbackWrapper.builder(new BaiDuWeatherService(areaInfoPOMapper))
      .withName("CurrentWeather") // (1) function name
      .withDescription("獲取指定地點的天氣情況") // (2) function description
      .build();
}
@Bean
@Description("旅遊規劃")
public FunctionCallbackWrapper travelPlanningFunctionInfo() {
    return FunctionCallbackWrapper.builder(new TravelPlanningService())
            .withName("TravelPlanning") // (1) function name
            .withDescription("根據使用者的旅遊目的地推薦景點、酒店以及給出實時機票、高鐵票資訊") // (2) function description
            .build();
}
@Bean
@Description("待辦管理")
public FunctionCallbackWrapper toDoListFunctionWithContext(ToDoListInfoPOMapper toDoListInfoPOMapper, JdbcTemplate jdbcTemplate) {
    return FunctionCallbackWrapper.builder(new ToDoListInfoService(toDoListInfoPOMapper,jdbcTemplate))
            .withName("toDoListFunctionWithContext") // (1) function name
            .withDescription("新增待辦,crud:c 代表增加;r:代表查詢,u:代表更新,d:代表刪除") // (2) function description
            .build();
}
@Bean
@Description("使用者查詢今日推薦內容")
public FunctionCallbackWrapper myWorkFlowServiceCall() {
    return FunctionCallbackWrapper.builder(new MyWorkFlowService())
            .withName("myWorkFlowServiceCall") // (1) function name
            .withDescription("使用者查詢今日推薦內容,引數:username為使用者名稱") // (2) function description
            .build();
}

接下來,我們將討論工作流中外掛的呼叫部分。這裡涉及到一個 HTTP 呼叫功能。下面是該部分的程式碼示例:

@Slf4j
@Description("今日推薦")
public class MyWorkFlowService implements Function<MyWorkFlowService.WorkFlowRequest, MyWorkFlowService.WorkFlowResponse> {
    @JsonClassDescription("username:使用者名稱字")
    public record WorkFlowRequest(String username) {}
    public record WorkFlowResponse(String result) {}

    public WorkFlowResponse apply(WorkFlowRequest request) {
        MyWorkFlowRun myWorkFlowRun = new MyWorkFlowRun();
        String result = myWorkFlowRun.getResult(request.username);
        return new WorkFlowResponse(result);
    }
}
@Slf4j
public class MyWorkFlowRun {
    RestTemplate restTemplate = new RestTemplate();

    /**
     * 這裡也可以最佳化成一個公用的外掛,比如傳入一個工作流id,然後工作流專案那邊根據id執行,這樣就可以複用
     * @param username 入參
     * @return 返回的結果
     */
    public String getResult(String username) {
        log.info("列印輸入引數-username:{}", username);
        //我們不使用入參作為搜尋詞,而是直接寫死,這裡只是演示下
        String result  = restTemplate.getForObject("http://localhost:8080/hello/word", String.class);
        return result;
    }
}


在這裡,我只是進行了一個簡化的操作,即簡單呼叫了本地的工作流介面,並列印了輸入引數。由於當前的實現中並不需要這些引數,因此我只是將其輸出做了展示。然而,如果在實際應用中你需要使用這些引數,完全可以將其傳遞給工作流介面進行相應的處理。

接下來,一切準備就緒,我們只需將這個外掛整合到我們的問答模型中。具體的程式碼實現如下:

@PostMapping("/ai-function")
ChatDataPO functionGenerationByText(@RequestParam("userInput")  String userInput) {
    //此處省略重複程式碼
    String content = this.chatClient
            .prompt(systemPrompt)
            .user(userInput)
            .advisors(messageChatMemoryAdvisor,myLoggerAdvisor)
            //用來區分不同會話的引數conversation_id
            .advisors(advisor -> advisor.param("chat_memory_conversation_id", conversation_id)
                    .param("chat_memory_response_size", 100))
            .functions("CurrentWeather","TravelPlanning","toDoListFunctionWithContext","myWorkFlowServiceCall")
    //此處省略重複程式碼

在這裡,我們只需簡單地將剛才使用的名稱直接新增到 functions 中,操作非常簡便。實際上,這一步驟無需過多複雜的配置,一旦新增完成,接下來的工作就交給大模型自動處理了。

助理效果

這裡簡要介紹一下前端UI部分的實現。我主要使用的是ChatSDK,並將其整合到專案中。整個過程相對簡單,配置項也比較基礎,開發人員只需參考官方提供的文件,按照指導步驟進行操作即可順利完成整合。這裡就不多說了。

image

接下來,我們將啟動兩個專案:一個是 Spring AI 專案,另一個是 Spring AI Alibaba 專案(負責工作流部分)。啟動這兩個專案後,我們可以直觀地觀察它們在實際執行中的表現。

總結

在本系列教程中,我們深入探討了Spring AI及其在國內版本Spring AI Alibaba的實戰應用,重點關注瞭如何構建一個功能豐富、智慧高效的AI助理。透過詳細講解從工作流的基本流程設計到實際操作實現的全過程,我們逐步揭開了AI助理開發的神秘面紗,使得Java開發者能夠輕鬆上手並應用最新的AI技術。

首先,我們回顧了構建個人AI助理Agent的全過程,涵蓋了諸如旅遊攻略、天氣查詢和個人待辦事項等多個實用功能模組。在這一部分,我們不僅介紹了相關功能的設計和實現,還探討了如何將這些功能模組無縫整合到一個綜合性的AI助理中,確保使用者體驗的流暢與智慧。此外,我們深入分析了工作流的實現細節,重點討論了事件封裝最佳化、工作流節點的建立與組織、以及如何高效規劃和管理複雜的工作流。

在教程的最後,我們透過一個實際的專案啟動與執行測試環節,生動展示了AI助理的實際效果。透過這些實際測試,我們不僅驗證了系統的穩定性與可擴充套件性,還為後續的最佳化和功能擴充打下了堅實的基礎。最終,我們的目標是讓開發者不僅理解Spring AI的核心技術和應用框架,更能透過實際操作掌握AI助理開發的精髓。


我是努力的小雨,一名 Java 服務端碼農,潛心研究著 AI 技術的奧秘。我熱愛技術交流與分享,對開源社群充滿熱情。同時也是一位騰訊雲創作之星、阿里雲專家博主、華為云云享專家、掘金優秀作者。

💡 我將不吝分享我在技術道路上的個人探索與經驗,希望能為你的學習與成長帶來一些啟發與幫助。

🌟 歡迎關注努力的小雨!🌟

相關文章