カスタムワークフローでAIアシスタントにパーソナライズされた推薦機能を実装する

以前、ワークフローの基本プロセスと全体のアーキテクチャ設計を完了しました。次のステップは、実際の操作と実装フェーズに入ることです。ワークフローの全体構造についてまだ理解が不十分な読者の方は、まずこの記事を参照してワークフローの各部分をより深く理解し習得してください。

本稿はSpring AIを使用してAgentを構築するシリーズの第三回実践チュートリアルです。Spring AIは現在もスナップショットバージョンであり、正式版はリリースされていませんが、最新機能と開発動向を理解する妨げにはなりません。人工知能は未来の主要な開発方向の一つであることを考えると、これから直接本題に入り、余計な話は省略して始めましょう!

今回は主にSpring AI Alibabaを使用した開発デモを行い、このフレームワークを通じてAIアシスタントを構築する方法を展示します。最後に、AIアシスタント全体のデモビデオも添付しますので、参考にしてください。このデモンストレーションにより、現在のJava開発者はPythonに移行する必要がなくても、AI Agent開発の潮流に参加し、この新しい技術トレンドを掴むことができることがわかります!

簡単な復習

これまでの作業を振り返ると、私たちはすでに個人用AIアシスタントAgentのサンプルを正常に構築し、複数の実用的な機能を備えています。次に、これらの機能の実装と特徴を簡単に復習します:

  1. 旅行ガイド:サードパーティのワークフローツールであるCozeを直接呼び出して旅行ガイドを生成し、プロセスは迅速かつ効率的です。複雑な構成は不要で、ユーザーのニーズに素早く対応し、正確な旅行推薦を提供します。
  2. 天気情報:この機能はデータベースから現在の住所のコードをクエリし、天気APIインターフェースを呼び出してリアルタイムの天気情報を取得します。ユーザーの所在地の天気予報を正確に提供し、最新の天気情報をユーザーが取得できるようにします。
  3. 個人用TODOリスト:システムはユーザーの指示に基づいて自動的にSQLクエリを生成し、関連操作を実行して直接データベースにアクセスできるため、ユーザーはタスクの管理効率を大幅に向上させることができます。

これらの機能は、AIアシスタントAgent全体で効果的に統合され、ユーザーエクスペリエンスと操作の利便性を大幅に向上させています。次に、システム全体のアーキテクチャ設計は以下の通りです:

以前、私たちは常にhunyunがOpenAIインターフェースと互換性があるものを使用してテストと連携を行っていましたが、今日も引き続きSpring AIのアプリケーション開発を進めます。ただし、今回は国内のSpring AIバージョンであるSpring AI Alibabaを使用します。以前のOpenAIインターフェースとは異なり、Spring AI Alibabaは国内の技術環境とニーズにより適合しています。ネイティブの互換インターフェースを使用したい場合は、現在、Alibabaの通義千問モデルを検討できます。これは比較的成熟し安定したAPIサポートを提供しています。

特筆すべき点として、Spring AIにおけるサードパーティのチャットモデルインターフェースの状況について、現在の私の知識の範囲では、Alibabaだけが公式開発およびサポートしているものです。安定性面でもより高い保証が得られています。一方、智普などのインターフェースは、基本的なニーズを満たすことができますが、主に個人の開発者や愛好家によって維持されており、安定性と技術サポートの面で相違があるため、使用する際は特に注意が必要です。

パーソナライズされた推薦

この機能モジュールでは、主にユーザーの履歴プロファイルに依存し、過去の行動データや興味の嗜好などの情報を分析し、AIモデルを使用して関連する検索キーワードをまとめ、ユーザーが興味を持つ可能性のある映画やニュースなどの内容を推薦します。

この推薦プロセスのアーキテクチャを示す簡単な設計図を作成しました。皆さんはこの設計図をご覧ください:

ワークフローの並列処理能力をより良く展示するために、私は意図的に百度(バイドゥ)とBingの2つの検索プラグインを選択してデモンストレーションしました。

さらに、Spring AI Alibabaフレームワーク内部ではプラグインストアの開発がすでに着手されており、現在は4つのプラグインしかありませんが、これは始まりに過ぎず、将来フレームワークはプラグインの種類と機能を絶えず拡張し、豊かにして、より多くのサードパーティプラグインの統合とアプリケーションをサポートするでしょう。図に示すように、現在のフレームワークのプラグインストアインターフェースはすでに基本的な形態を整えています:

次に、私たちはさらに深く議論を進めます。この部分では、百度検索はリアルタイムで行われ、つまりシステムは直接百度の検索インターフェースを呼び出します。一方、Bing検索は個人APIキーを使用して呼び出す必要があります。現在APIキーの設定がまだ完了していないため、デモンストレーションとテストを容易にするために、Bing検索の戻り値は固定された結果にハードコーディングし、実際のAPI呼び出しは行いません。この目的は、デモンストレーションプロセスを簡素化し、他の部分が正常に動作することを確保することです。

特別な注意点として、現在Spring AI Alibabaフレームワーク自体は完全なワークフロー機能をサポートしていません。フレームワークは絶えずイテレーションと最適化が行われていますが、現在ワークフロー編成機能を実装したい場合は、開発者は関連する機能モジュールを独自に開発するか、将来のバージョンでフレームワーク公式がワークフローのサポートを追加するのを待つ必要があります。したがって、現在のワークフロー編成では、タスクの順次実行と論理制御を実現するために手動コーディングに依存します。

ワークフローの実装

以前提供したのは主にいくつかの大まかなフレームワークと考え方ですが、具体的な内部実装については詳細に展開していません。ここで、いくつかの重要なコード実装を共有します。各人が実装する際には異なる方法を採用する可能性があります。より効率的または適切なソリューションを持っている場合は、もちろん自分の理解に基づいて調整および最適化できます。

次に、全体のフレームワークの簡単な概要図を提示し、後の議論で混乱が生じないように、より明確に理解できるようにします。図に示す通り:

次に、主要な核心部分に深く掘り下げていきましょう。まず、スキャンステップのstepメソッドをカプセル化し、タスクに変換し、それをワークフローに管理・保存させる必要があります。

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("終了するには何かキーを押してください...");
            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として処理し、将来的にもオブジェクトカプセル化をしやすいようにしています。
  • ワークフロータスクを実行:ここでは単純にすべてのタasksスレッドタスクを起動します。
  • UI表示またはファイルエクスポート:私はフロントエンド開発者ではないため、技術的に限界があり、HTMLコードを生成する出力を使用するのではなく、graphstreamクラスを使用して迅速に画像またはポップアップUIを生成しました。そして、各イベントの実行時間を簡単に記録しました。
  • 結果を返す:最後にワークフローの結果をすべてresultに格納し、呼び出し側に返します。これでワークフローは完了です。

残りの部分は特に複雑なコードはあまりありません。LlamaIndexのワークフローコアコードを参考にし、このワークフローをカスタマイズし、適切に最適化しました。次に、実際のテストフェーズに入り、その安定性と完全性を重点的に検証します。

現在実装されているバージョンにはまだ多くの最適化の余地と改善点がありますが、少なくとも正常に実行でき、基本的な使用可能性を備えています。それでは、実際の実行テストを始め、効果を見てみましょう。

準備作業

APIキーの申請

接続アドレスは次の通りです:https://bailian.console.aliyun.com/?spm=a2c4g.11186623.0.0.140f3048QPbIUu#/model-market

アクセス後、個人のニーズに基づいて任意の千問モデルを選択してください。次に、個人のAPIキー(key)を確認して保存してください。キーがない場合は、案内に従って新しいキーを自分で作成できます。具体的な操作手順は図に示されています。

保存した後、このAPIキーを使用する必要があります。

プロジェクトの作成

公式のデモを直接複製し、依存関係はすべて変更せず、他の部分は私たちのニーズに基づいて変更・調整できます。プロジェクト全体の構造は図の通りです:

次に、進んでいきます。まず、百度検索と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>Spring AI Alibabaのデモプロジェクト</description>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.sourceEncoding>UTF-8</project.reporting.sourceEncoding>
        <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: ご自身のキー、例:sk-
      chat:
        options:
          model: qwen-plus

ここでは、qwen-plusモデルを使用していますが、実際のニーズに基づいて他の利用可能なモデルを選択することもできます。現在のオプションでは、qwen-turbobailian-v1dolly-12b-v2qwen-plusqwen-maxから選択できます。

イベントカプセル化の最適化

次に、私たちが作成した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("イベントを{}に送信、イベント:{}", 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 = """
                - 役割: パーソナライズされた情報検索専門家
                - 背景: ユーザーは個人の興味や嗜好などの情報に基づいて、カスタマイズされた推薦内容(映画、ニュースなど)を取得したいと考えています。
                - プロフィール: あなたはパーソナライズされた情報検索の専門家であり、ユーザーの個人情報や嗜好に基づいて、関連するコンテンツを効率的に検索・推薦することを得意としています。
                - スキル: 強力な情報フィルタリング能力、各種情報源への深い理解、およびユーザーのニーズに迅速に対応する能力を備えています。
                - 目標: ユーザーの興味や嗜好などの情報に基づき、2つのキーワードを提供し、ユーザーが検索エンジンで今日の映画推薦やリアルタイムニュースを迅速に見つけられるように支援します。
                - 制約: キーワードは簡潔で関連性が高く、検索エンジンクエリに直接使用できる必要があります。
                - 出力形式: 2つの配列要素を返し、各要素に1つのキーワードを含みます。
                - ワークフロー:
                  1. ユーザーの興味や個人情報を分析します。
                  2. 分析結果に基づき、映画やニュースに関連するキーワードを決定します。
                  3. キーワードを配列形式でユーザーに返します。
                - 例:
                  - 例1: ユーザーがSF映画や国際ニュースが好き
                    - ['今日のSF映画推薦', '今日の国際ニュースヘッドライン']
                  - 例2: ユーザーが歴史ドキュメンタリーや経済ニュースが好き
                    - ['今日の歴史ドキュメンタリー推薦', '今日の経済ニュースヘッドライン']
                  - 例3: ユーザーがアクション映画やスポーツニュースが好き
                    - ['今日のアクション映画推薦', '今日のスポーツニュース速報']
                """;
    private ChatClient chatClient;

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

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

        String prompt = """
                提供されたユーザープロファイルに基づき、配列を返してください。配列には2つのキーワードが含まれ、各キーワードは簡潔で関連性が高く、検索エンジンクエリに直接使用できる必要があります。
                ---
                """ + 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

ワークフローの並列処理能力をデモンストレーションするために、「検索イベント」部分で2つの独立したノードを作成しました。これらのノードは、複数のタスクを同時に実行することを示すために作成されました。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());
    }
}

ご注意ください、上記のコードには2つのクラスが含まれています。コードの量を減らすために、それらを一緒にまとめました。特筆すべき点として、公式プラグインの百度検索結果オブジェクトには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");
        //文字列配列に変換
        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");
        //文字列配列に変換
        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、この機能に直接アクセスできます。具体的な内容は図の通りです。

最終的な戻り結果は図の通りです:

正常な状況では、プロジェクトは自動的に画像を生成し出力し、システム内でその画像の表示効果を見ることができます。図に示す通り:

効果をより良く展示するために、UIスイッチ機能を有にしました。有効にした後のインターフェース効果は以下の通りです:

AIアシスタントへの統合

現在の市場の多くのエージェントプラットフォームの設計モードのように、ワークフローは通常単独で呼び出すことができるモジュールです。したがって、ワークフローをマイクロサービスの一部として展開し、対応するインターフェースを公開して、私たちのAIエージェントが呼び出しやすくすることができます。このインターフェースは通常、関数(function)の形式で現れます。注意すべき点は、AIチャットQ&Aプロジェクトとワークフロープロジェクトは2つの独立したモジュールであるということです。それにもかかわらず、これらのモジュールを1つの全体に統合することも完全に可能であり、プロジェクトのニーズに応じて決定できます。他のエージェントプラットフォームとの一貫性を保つため、このデモンストレーションでは、これらを2つの独立したプロジェクトとして提示することを選択します。

全体のアーキテクチャをより明確に理解してもらうために、私は簡単にフレームワーク図を作成しました。皆さんはこれを参考にし、後の説明で混乱が生じないようにしてください。

次に、既存のシステムにいくつかの調整を行う必要があります。具体的には、以前使用していたOpenAI依存関係を私たちの千問モデルに置き換えることです。

大モデルの統合

ここで特筆すべき点は、現在千問モデルは公式のSpring AIフレームワークに直接統合されていないため、他の一般的なモデルのようにSpring AIの依存関係を通じて直接使用することはできないということです。

したがって、プロジェクトで千問モデルを依存関係として使用したい場合は、Spring AI Alibabaの統合方式に従い、公式デモの指導に従って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 Q&Aプロジェクトはproperties設定ファイルを使用して構成情報を管理しているため、application.propertiesファイルに千問モデルを統合するために必要な関連構成情報を追加する必要があります。

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

これにより、システムを正常に起動して実行することができます。図に示すように、基本的なチャットQ&Aフローは正常に機能し、すべての機能が期待通りに動作しています。

プロジェクトは正常に起動して実行されており、次に私たちは関数呼び出しを直接記述できます。この段階では、私たちは直接HTTP呼び出しを使用して通信し、サービス発見と登録メカニズムにはまだ関与しません。この目的はデモンストレーションを簡素化することです。

FunctionCall

この変更を行った後、多くの互換性の問題が発生し、以前正常に機能していたフローもエラーを発生し始めました。そのため、私はシステム全体を全面的に最適化しました。調整と修復を経て、最終的に残ったプラグイン呼び出し構成が現在のバージョンです。

@Bean
public FunctionCallbackWrapper weatherFunctionInfo(AreaInfoPOMapper areaInfoPOMapper) {
  return FunctionCallbackWrapper.builder(new BaiDuWeatherService(areaInfoPOMapper))
      .withName("CurrentWeather") // (1) 関数名
      .withDescription("指定された場所の天気状況を取得") // (2) 関数の説明
      .build();
}
@Bean
@Description("旅行計画")
public FunctionCallbackWrapper travelPlanningFunctionInfo() {
    return FunctionCallbackWrapper.builder(new TravelPlanningService())
            .withName("TravelPlanning") // (1) 関数名
            .withDescription("ユーザーの旅行目的地に基づいて観光地、ホテルを推薦し、リアルタイムの航空券、高速鉄道チケット情報を提供") // (2) 関数の説明
            .build();
}
@Bean
@Description("TODOリスト管理")
public FunctionCallbackWrapper toDoListFunctionWithContext(ToDoListInfoPOMapper toDoListInfoPOMapper, JdbcTemplate jdbcTemplate) {
    return FunctionCallbackWrapper.builder(new ToDoListInfoService(toDoListInfoPOMapper,jdbcTemplate))
            .withName("toDoListFunctionWithContext") // (1) 関数名
            .withDescription("TODOを追加、crud:c は追加を表す;r:は照会を表す、u:は更新を表す、d:は削除を表す") // (2) 関数の説明
            .build();
}
@Bean
@Description("ユーザーが今日の推奨コンテンツを照会")
public FunctionCallbackWrapper myWorkFlowServiceCall() {
    return FunctionCallbackWrapper.builder(new MyWorkFlowService())
            .withName("myWorkFlowServiceCall") // (1) 関数名
            .withDescription("ユーザーが今日の推奨コンテンツを照会、パラメータ:usernameがユーザー名") // (2) 関数の説明
            .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;
    }
}



ここでは、私は単純な操作を行い、ローカルのワークフローインターフェースを直接呼び出し、入力パラメータを出力しました。現在の実装ではこれらのパラメータを必要としないため、私はその出力を表示しただけです。しかし、実際のアプリケーションでこれらのパラメータを使用する必要がある場合は、それらをワークフローインターフェースに渡して対応する処理を行うことが完全に可能です。

次に、すべての準備が整いました。このプラグインを私たちのQ&Aモデルに統合するだけです。具体的なコード実装は以下の通りです:

@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で、それをプロジェクトに統合しました。プロセスは比較的簡単で、構成項目も基本的です。開発者は公式の提供ドキュメントを参照し、案内に従って操作するだけで簡単に統合を完了できます。ここでは詳しく説明しません。

次に、2つのプロジェクトを起動します:1つはSpring AIプロジェクト、もう1つはSpring AI Alibabaプロジェクト(ワークフロー部分を担当)です。この2つのプロジェクトを起動した後、それらの実際の動作を直感的に観察できます。

まとめ

このシリーズのチュートリアルでは、Spring AIとその国内バージョンであるSpring AI Alibabaの実践的応用を深く探求し、機能豊富で効率的なAIアシスタントを構築する方法に焦点を当てました。ワークフローの基本プロセス設計から実際の操作実装に至るまでの全過程を詳細に説明し、Java開発者が最新のAI技術を簡単に使いこなし、適用できるようにしました。

まず、個人用AIアシスタントAgentの構築プロセス全体を振り返り、旅行ガイド、天気情報、個人のTODOタスクなど複数の実用的な機能モジュールをカバーしました。この部分では、関連機能の設計と実装だけでなく、これらの機能モジュールをシームレスに統合し、包括的なAIアシスタントにする方法についても議論しました。これにより、ユーザーエクスペリエンスの流暢さとインテリジェンスが確保されます。さらに、ワークフローの実装の詳細について深く分析し、特にイベントカプセル化の最適化、ワークフローノードの作成と組織、そして複雑なワークフローを効率的に計画・管理する方法について重点的に議論しました。

チュートリアルの最後には、実際のプロジェクトの起動と実行テストのセクションを通じて、AIアシスタントの実際の効果を生き生きと展示しました。これらの実際のテストを通じて、私たちはシステムの安定性とスケーラビリティを検証すると同時に、今後の最適化と機能拡張の基礎を築きました。最終的な目標は、開発者がSpring AIのコア技術と応用フレームワークを理解するだけでなく、実際の操作を通じてAIアシスタント開発の本質を習得することです。

タグ: Spring AI Spring AI Alibaba ワークフロー AIアシスタント Java

7月2日 20:33 投稿