qinfengge

qinfengge

醉后不知天在水,满船清梦压星河
github
email
telegram

スプリング AI (六) 1.0バージョンおよびqwen2

しばらくの間、spring ai は 1.0 に達しました。0.8.1 と比較すると、かなりの違いがあります。ちょうど最近、時間がたっぷりあるので、いじくり回すことができます。

image

プロジェクト設定#

プロジェクトの設定を初期化する方法は 2 つあります。一つは作成時に直接対応する依存関係を選択する方法です。

image

もう一つは手動で設定する方法です。
maven に以下を追加します。

  <repositories>
    <repository>
      <id>spring-milestones</id>
      <name>Spring Milestones</name>
      <url>https://repo.spring.io/milestone</url>
      <snapshots>
        <enabled>false</enabled>
      </snapshots>
    </repository>
    <repository>
      <id>spring-snapshots</id>
      <name>Spring Snapshots</name>
      <url>https://repo.spring.io/snapshot</url>
      <releases>
        <enabled>false</enabled>
      </releases>
    </repository>
  </repositories>

次に、依存関係管理を追加します。

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

最後に、対応する大規模言語モデルの依存関係を追加します。

 <!--  OpenAI依存   -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
        </dependency>

        <!--  Ollama依存   -->
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
        </dependency>

次に、設定ファイルを作成します。

spring:
  ai:
    ollama:
      base-url: http://127.0.0.1:11434/
      chat:
        model: qwen2:7b
    openai:
      base-url: https://xxx
      api-key: sk-xxx
      chat:
        options:
          model: gpt-3.5-turbo
server:
  port: 8868

私は設定ファイルの中に 2 つのモデルを設定しました。一つは ollama のもので、もう一つは openai のものです。他のモデルについては、自分でドキュメントを見て設定できます。

呼び出し#

1.0 バージョンでは呼び出し方が変更され、主にインスタンス化されるオブジェクトに変化があります。
最新バージョンでは新たに Chat Client API が追加されましたが、前のバージョンの Chat Model API もまだ存在しています。
彼らの違いは以下の通りです。

api範囲作用
Chat Client API単一モデルに適しており、グローバルにユニークです。複数モデルの設定は衝突を引き起こします最上位の抽象化であり、この API はすべてのモデルを呼び出すことができ、迅速な切り替えが可能です
Chat Model APIシングルトンパターンであり、各モデルはユニークです各モデルには具体的な実装があります

Chat Client#

Chat Client はデフォルトでグローバルにユニークであるため、設定ファイルでは単一のモデルしか設定できません。そうでないと、初期化時に bean を作成する際に衝突が発生します。
以下は公式のサンプルコードです。

@RestController
class MyController {

    private final ChatClient chatClient;

    public MyController(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder.build();
    }

    @GetMapping("/ai")
    String generation(String userInput) {
        return this.chatClient.prompt()
            .user(userInput)
            .call()
            .content();
    }
}

同時に、作成時にいくつかのモデルのデフォルトパラメータを指定することもできます。

設定クラスを作成します。

@Configuration
class Config {

    @Bean
    ChatClient chatClient(ChatClient.Builder builder) {
        return builder.defaultSystem("あなたは友好的なチャットボットで、海賊の声で質問に答えます")
                .build();
    }

}

使用時には @Autowired で注入します。

複数モデルの設定を使用するには、ChatClient.Builder の自動設定を無効にする必要があります。

spring:
  ai:
    chat:
      client:
        enabled: false

次に、対応する設定ファイルを作成します。openai を例にします。

/**
 * @author LiZhiAo
 * @date 2024/6/19 20:47
 */

@Component
@RequiredArgsConstructor
public class OpenAiConfig {

    private final OpenAiChatModel openAiChatModel;

    public ChatClient openAiChatClient() {
        ChatClient.Builder builder = ChatClient.builder(openAiChatModel);
        builder.defaultSystem("あなたは友好的な人工知能で、ユーザーの質問に基づいて回答します");

        return ChatClient.create(openAiChatModel);
    }
}

これで呼び出すモデルを指定できます。

// 注入
private final OpenAiConfig openAiConfig;
// 呼び出し
Flux<ChatResponse> stream = openAiConfig.openAiChatClient().prompt(new Prompt(messages)).stream().chatResponse();

Chat Model#

各モデルには対応する Chat Model があり、同様に設定ファイルに基づいて自動装配されます。
OpenAiChatModelを例にすると、ソースコードから装配プロセスを見ることができます。

image

したがって、呼び出しも非常に簡単です。

// 注入
private final OpenAiChatModel openAiChatModel;
// 呼び出し
Flux<ChatResponse> stream = openAiChatModel.stream(new Prompt(messages));

qwen2#

以前のある時期に、私は LM Studio を使用して llama3 をインストールし、 Local Inference Server を起動してデバッグを試みました。

image

残念ながら、簡単な呼び出しは確かに成功しましたが、ストリーミング出力に関しては常にエラーが発生しました。

仕方がないので、最終的には ollama + Open WebUI の方法でローカルモデル API を起動しました。

image

インストール手順#

インストール環境は Windows コンピュータを例にし、NVIDIA グラフィックカードを備えています。他の方法については、Open WebUI のインストール方法を参照してください。

  1. ollamaをインストールします(オプション)。
  2. Docker Desktop をインストールします。
  3. イメージを実行します。
    もし 1 ステップを実行している場合は、コンピュータに ollama をインストールします。
    docker run -d -p 3000:8080 --gpus all --add-host=host.docker.internal:host-gateway -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:cuda
    
    もし 1 ステップをスキップした場合は、以下の ollama を含むイメージを選択できます。
    docker run -d -p 3000:8080 --gpus=all -v ollama:/root/.ollama -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:ollama
    
  4. モデルをダウンロードします。
    コンテナが実行された後、ウェブ管理ページにアクセスしてモデルをダウンロードします。
    qwen2を例にすると、モデルを引き出す際に qwen2:7b を入力して qwen2 の 7B バージョンをダウンロードします。
    image

2、3 ステップの実行中に CUDA の問題が発生する可能性があります。

 Unexpected error from cudaGetDeviceCount(). Did you run some cuda functions before calling NumCudaDevices() that might have already set an error? Error 500: named symbol not found

検索したところ、N カードドライバのバージョンが 555.85 で発生する可能性があることがわかりました。

image

解決方法も非常に簡単で、Docker Desktop を最新バージョンに更新するだけです。

実際にテストしたところ、 qwen2:7b の中国語の返信は llama3:8b よりもはるかに良く、残りの欠点はマルチモーダルをサポートしていないことですが、どうやら開発チームはすでに取り組んでいるようです🎉

まとめ#

バックエンドコード#

完全な Controller は以下の通りです。

@RestController
@RequestMapping("/llama3")
@CrossOrigin
@RequiredArgsConstructor
public class llama3Controller {

    private final OpenAiConfig openAiConfig;
    private final OllamaConfig ollamaConfig;


    private static final Integer MAX_MESSAGE = 10;

    private static Map<String, List<Message>> chatMessage = new ConcurrentHashMap<>();

    /**
     * 提示詞を返す
     * @param message ユーザーが入力したメッセージ
     * @return Prompt
     */
    private List<Message> getMessages(String id, String message) {
        String systemPrompt = "{prompt}";
        SystemPromptTemplate systemPromptTemplate = new SystemPromptTemplate(systemPrompt);

        Message userMessage = new UserMessage(message);

        Message systemMessage = systemPromptTemplate.createMessage(MapUtil.of("prompt", "あなたは役立つAIアシスタントです"));

        List<Message> messages = chatMessage.get(id);


        // メッセージが取得できなかった場合、新しいメッセージを作成し、システムの提示とユーザーの入力メッセージをメッセージリストに追加します
        if (messages == null){
            messages = new ArrayList<>();
            messages.add(systemMessage);
            messages.add(userMessage);
        } else {
            messages.add(userMessage);
        }

        return messages;
    }

    /**
     * 接続を作成します
     */
    @SneakyThrows
    @GetMapping("/init/{message}")
    public String init() {
        return String.valueOf(UUID.randomUUID());
    }

    @GetMapping("chat/{id}/{message}")
    public SseEmitter chat(@PathVariable String id, @PathVariable String message, HttpServletResponse response) {

        response.setHeader("Content-type", "text/html;charset=UTF-8");
        response.setCharacterEncoding("UTF-8");

        SseEmitter emitter = SseEmitterUtils.connect(id);
        List<Message> messages = getMessages(id, message);
        System.err.println("chatMessageのサイズ: " + messages.size());
        System.err.println("chatMessage: " + chatMessage);

        if (messages.size() > MAX_MESSAGE){
            SseEmitterUtils.sendMessage(id, "会話回数が多すぎます。後で再試行してください🤔");
        }else {
            // モデルの出力ストリームを取得します
            Flux<ChatResponse> stream = ollamaConfig.ollamaChatClient().prompt(new Prompt(messages)).stream().chatResponse();

            // ストリーム内のメッセージをSSEで送信します
            Mono<String> result = stream
                    .flatMap(it -> {
                        StringBuilder sb = new StringBuilder();
                        Optional.ofNullable(it.getResult().getOutput().getContent()).ifPresent(content -> {
                            SseEmitterUtils.sendMessage(id, content);
                            sb.append(content);
                        });

                        return Mono.just(sb.toString());
                    })
                    // メッセージを文字列に結合します
                    .reduce((a, b) -> a + b)
                    .defaultIfEmpty("");

            // メッセージをchatMessage内のAssistantMessageに保存します
            result.subscribe(finalContent -> messages.add(new AssistantMessage(finalContent)));

            // メッセージをchatMessageに保存します
            chatMessage.put(id, messages);

        }
        return emitter;

    }
}

フロントエンドコード#

gpt にフロントエンドページを少し変更してもらい、MD のレンダリングとコードハイライトをサポートするようにしました。

image

<!doctype html>
<html>

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script src="https://cdn.tailwindcss.com"></script>
    <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.3.1/styles/default.min.css">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.3.1/highlight.min.js"></script>
</head>

<body class="bg-zinc-100 dark:bg-zinc-800 min-h-screen p-4">
<div class="flex flex-col h-full">
    <div id="messages" class="flex-1 overflow-y-auto p-4 space-y-4">
        <div class="flex items-end">
            <img src="https://placehold.co/40x40" alt="avatar" class="rounded-full">
            <div class="ml-2 p-2 bg-white dark:bg-zinc-700 rounded-lg w-auto max-w-full">こんにちは~(⁄ ⁄•⁄ω⁄•⁄ ⁄)⁄</div>
        </div>
    </div>
    <div class="p-2">
        <input type="text" id="messageInput" placeholder="メッセージを入力してください..."
               class="w-full p-2 rounded-lg border-2 border-zinc-300 dark:border-zinc-600 focus:outline-none focus:border-blue-500 dark:focus:border-blue-400">
        <button onclick="sendMessage()"
                class="mt-2 w-full bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700 text-white p-2 rounded-lg">送信</button>
    </div>
</div>
<script>
    let sessionId; // セッションIDを保存するための変数
    let markdownBuffer = ''; // バッファ

    // markedとhighlight.jsを初期化します
    marked.setOptions({
        highlight: function (code, lang) {
            const language = hljs.getLanguage(lang) ? lang : 'plaintext';
            return hljs.highlight(code, { language }).value;
        }
    });

    // HTTPリクエストを送信し、応答を処理します
    function sendHTTPRequest(url, method = 'GET', body = null) {
        return new Promise((resolve, reject) => {
            const xhr = new XMLHttpRequest();
            xhr.open(method, url, true);
            xhr.onload = () => {
                if (xhr.status >= 200 && xhr.status < 300) {
                    resolve(xhr.response);
                } else {
                    reject(xhr.statusText);
                }
            };
            xhr.onerror = () => reject(xhr.statusText);
            if (body) {
                xhr.setRequestHeader('Content-Type', 'application/json');
                xhr.send(JSON.stringify(body));
            } else {
                xhr.send();
            }
        });
    }

    // サーバーからのSSEストリームを処理します
    function handleSSEStream(stream) {
        console.log('ストリームが開始されました');
        const messagesContainer = document.getElementById('messages');
        const responseDiv = document.createElement('div');
        responseDiv.className = 'flex items-end';
        responseDiv.innerHTML = `
    <img src="https://placehold.co/40x40" alt="avatar" class="rounded-full">
    <div class="ml-2 p-2 bg-white dark:bg-zinc-700 rounded-lg w-auto max-w-full"></div>
  `;
        messagesContainer.appendChild(responseDiv);

        const messageContentDiv = responseDiv.querySelector('div');

        // 'message'イベントをリッスンし、バックエンドが新しいデータを送信するとトリガーされます
        stream.onmessage = function (event) {
            const data = event.data;
            console.log('受信したデータ:', data);

            // 受信したデータをバッファに追加します
            markdownBuffer += data;

            // バッファをMarkdownとして解析し、表示を試みます
            messageContentDiv.innerHTML = marked.parse(markdownBuffer);

            // highlight.jsを使用してコードをハイライトします
            document.querySelectorAll('pre code').forEach((block) => {
                hljs.highlightElement(block);
            });

            // スクロールバーを底に保ちます
            messagesContainer.scrollTop = messagesContainer.scrollHeight;
        };
    }

    // メッセージを送信します
    function sendMessage() {
        const input = document.getElementById('messageInput');
        const message = input.value.trim();
        if (message) {
            const messagesContainer = document.getElementById('messages');
            const newMessageDiv = document.createElement('div');
            newMessageDiv.className = 'flex items-end justify-end';
            newMessageDiv.innerHTML = `
          <div class="mr-2 p-2 bg-green-200 dark:bg-green-700 rounded-lg max-w-xs">
            ${message}
          </div>
          <img src="https://placehold.co/40x40" alt="avatar" class="rounded-full">
        `;
            messagesContainer.appendChild(newMessageDiv);
            input.value = '';
            messagesContainer.scrollTop = messagesContainer.scrollHeight;

            // 最初のメッセージを送信する際に、initリクエストを送信してセッションIDを取得します
            if (!this.sessionId) {
                console.log('初期化');
                sendHTTPRequest(`http://127.0.0.1:8868/llama3/init/${message}`, 'GET')
                    .then(response => {
                        this.sessionId = response; // セッションIDを保存します
                        return handleSSEStream(new EventSource(`http://127.0.0.1:8868/llama3/chat/${this.sessionId}/${message}`))
                    });

            } else {
                // その後のリクエストは直接chatインターフェースに送信します
                handleSSEStream(new EventSource(`http://127.0.0.1:8868/llama3/chat/${this.sessionId}/${message}`))
            }
        }
    }
</script>
</body>

</html>

Spring AI
Open WebUI
2024 最新 Spring AI 零基礎入門から精通までのチュートリアル(一つのセットで AI 大モデルアプリケーション開発を簡単に解決)
上記のビデオに対応するドキュメント(パスワード:wrp6)

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。