🦙

JavaでローカルLLMを動かすPart2: OpenAI API互換サーバをJHipsterで実装

2024/01/10に公開

はじめに

この記事は、JavaでローカルLLMを動かすの続編です。

前回、JavaによるRESTサーバで経由でLLMへのアクセスに成功したので、今回は、OpenAI API互換プロトコルを実装したクライアントからも接続できるようにしてみます。

さらにJHipsterというツールを使い、少ない手順でRESTサーバを構築します。

最終的には、こんなかんじで、SpringBootサーバを起動し、Chatbot UIのようなクライアントから、サーバを経由し、ローカルLLMとのチャットができます。

忙しい方のために

コードをおきました。

https://github.com/hide212131/jhipster-local-llm-sample

モデルのダウンロードは、

./mvnw verify

と打つと、mistral-7b-instruct-v0.2.Q2_K.gguf./modelsの下にダウンロードします。(ダウンロードのコードは、Java Bindings for llama.cppから持ってきました。)

その後、

./mvnw

でサーバが起動します。

(2024/1/9) Chatbot UIのGithubで本実v2がリリースされましたが、作者が前バージョンの履歴ごと削除 (!!!)。v1バージョンはforks したものからmainブランチを取得するしかない状況です。きっつー。

Chatbot UIでは、

以下の環境変数を設定し、

export OPENAI_API_HOST=http://localhost:8080
export OPENAI_API_KEY=(random key. not used)
export OPENAI_API_TYPE=local

以下でサーバ起動し、

npm run dev

http://localhost:3000 にアクセスします。

技術要素

今回は、以下の技術の組み合わせです。

開発手順

方針

Stream形式

目指すところは、Chatbot UIとの連携ですが、実装を見たところ、LLMが生成した文字をStreamで受け取る方法、Stream Server-Sent Events(SSE)のみサポートしているようですし、その方が(自分としても)UX的に満足度が高いので、そちらの実装を目指します。

OpenAPI Generatorのコード生成はある程度割り切り

OpenAPI Specからコード生成するOpenAPI Generatorですが、現時点でコードの生成は完全なものではなく(issueが4千...メンテの大変さが伺えます...)、OpenAI社が出しているSpecからはコードが生成できないものがあります。すでにこちらでクライアント側生成のために、ある程度エラーを解消してくれているので、それを利用しつつ、さらにサーバ側に必要な修正を加えていきます。

実装

JHipsterが生成したコードに手を加えていきます。ここに追加変更分があります。

JHipsterの構築

Java21とNode.js20、Gitを用意したのち、以下を実行します。

npm install -g generator-jhipster
mkdir myLlmApp && cd myLlmApp
jhipster

JHipsterはプラットフォームを選択することで様々なアプリケーションを構築できます。今回は以下を選択します。

? What is the base name of your application? myLlmApp
? Which type of application would you like to create? Monolithic application
(recommended for simple projects)

? Besides Junit, which testing frameworks would you like to use?
? Do you want to make it reactive with Spring WebFlux? Yes ...(1)
? What is your default Java package name? com.mycompany.myapp
? Which type of authentication would you like to use? JWT authentication
(stateless, with a token)

? Which type of database would you like to use? No database ...(2)
? Would you like to use Maven or Gradle for building the backend? Maven
? Which other technologies would you like to use? API first development using
OpenAPI-generator ...(3)

? Which Framework would you like to use for the client? No client ...(4)
? Would you like to enable internationalization support? No
? Please choose the native language of the application English
? Please choose additional languages to install

ポイントは以下です。

  • (1) WebFluxを使用します
  • (2) Databaseは今回は使用しません
  • (3) OpenAPI-generatorを用いてコード生成するので選択します
  • (4) JHipsterはSPAクライアントとしてReact/Angular/Vueを選択できる柔軟性が強みですが、今回は使用しません

pom.xmlへの追加

mavenでコード生成がされるように、依存関係やプラグインを入れていきます。
以下のコマンドで、コード生成が targetフォルダ配下に生成されるようになります。

./mvnw clean install

依存関係の追加。

主要な追加は、前回と同様llamaSpring AIのみです。

OpenAPIについては、前述のとおり、JHipsterがすでに用意してくれて楽ですが、一部変更します:

pom.xml
<plugin>
                    <!--
                        Plugin that provides API-first development using openapi-generator-cli to
                        generate Spring-MVC endpoint stubs at compile time from an OpenAPI definition file
                    -->
                    <groupId>org.openapitools</groupId>
                    <artifactId>openapi-generator-maven-plugin</artifactId>
                    <version>${openapi-generator-maven-plugin.version}</version>
                    <configuration>
                        <typeMappings>integer=Long,int=Long</typeMappings> ...(1)
                    </configuration>
                    <executions>
                        <execution>
                            <goals>
                                <goal>generate</goal>
                            </goals>
                            <configuration>
                                <inputSpec>${project.basedir}/src/main/resources/swagger/api.yml</inputSpec>
                                <generatorName>spring</generatorName>
                                <apiPackage>com.mycompany.myapp.web.api</apiPackage>
                                <modelPackage>com.mycompany.myapp.service.api.dto</modelPackage>
                                <supportingFilesToGenerate>ApiUtil.java</supportingFilesToGenerate>
                                <skipValidateSpec>false</skipValidateSpec>
                                <configOptions>
                                    <reactive>true</reactive>
                                    <delegatePattern>true</delegatePattern>
                                    <title>my-llm-app</title>
                                    <useSpringBoot3>true</useSpringBoot3>
                                    <interfaceOnly>true</interfaceOnly> ...(2)
                                    <openApiNullable>false</openApiNullable> ...(3)
                                </configOptions>
                            </configuration>
                        </execution>
                    </executions>
                </plugin>
  • (1) integer=Long,int=Long: そのままだと32bit整数になってしまうため、64bit整数に変更
  • (2) interfaceOnly: 生成コードをそのままは使えず、カスタマイズ多めのため、インターフェースのみの生成
  • (3) openApiNullable: 試行錯誤の結果ですが、falseにしないと肝心のLLMからの回答フィールドがうまくコード生成できなくなるため

OpenAPI Specの追加

すでにクライアントコード生成のために修正されているSpecファイル に、若干手を加えて、サーバサイドもコンパイルできるようにします。修正点はこちら

修正したSpecファイルを、src/main/resources/swagger/api.ymlに置くことによって、JHipsterで用意されたビルド設定により、コードが自動生成されるようになります。

FluxControllerの追加

JHipsterの設定で「WebFluxを使う」としたため、OpenAPIから生成するコードは、Reactive(WebFlux)なものが生成されます。ただし生成されたコードはMonoベースであり、今回の目的であるLLMの流れるような回答を生成するには、戻り値をFluxにする必要があります。

そこで、target/generated-sources/openapi/src/main/java/com/mycompany/myapp/web/api/ChatApi.javaに生成されたコードをほぼコピーし、戻り値だけFluxにしたコードを新たに作成します。

src/main/java/com/mycompany/myapp/web/api/FluxChatApi.java
default Flux<CreateChatCompletionStreamResponse> createChatCompletion(
        //default Mono<ResponseEntity<CreateChatCompletionResponse>> createChatCompletion(
        @Parameter(name = "CreateChatCompletionRequest", description = "", required = true) @Valid @RequestBody Mono<
            CreateChatCompletionRequest
        > createChatCompletionRequestMono,
        @Parameter(hidden = true) final ServerWebExchange exchange
    ) {
        ...
    }

実装コードも同様、ChatApiController を微修正したFluxChatApiControllerを作成します。
一点ハマったポイントとして、生成コードのままでは、WebFlux関連のエラーMulti-value reactive types not supported in view resolution.エラーが発生しました。対処法はここに書かれているように @RestControllerアノテーションの付与で解決します。

チャット機能の実装

前回では、LLMとの会話は、単一の質問・回答のみ対応していました。一方で、LLMとのチャット機能を実現するには、過去の会話履歴も含めLLMへの入力として与え、LLMから経緯を踏まえた回答してもらう方法が一般的です。

Q: ジョークを教えて 。
A: ◯が△で□です。
Q: さっきのジョークは何が面白いの?  (←「さっきの」を理解してもらうためには、その前のQAもLLMのインプットとする必要がある)
A: それは...です。 

OpenAI API仕様もこれにならい、会話履歴を含めてのRESTの入力パラメータが送られる仕様となっています。

また、「LLMと会話するチャットの指示文」の型は、ある程度標準的な作法があります。
Spring AIでは、このあたりのデータ構造を抽象化しています。

  • パッケージ: org.springframework.ai.prompt, org.springframework.ai.prompt.messages
  • Prompt: 以下のMessageのリスト
    • SystemMessage: 前提条件を書いたもの。一つ存在。
    • 会話履歴分、時系列に並べる:
      • UserMessage: ユーザの会話
      • AssistantMessage: LLMからの回答

それらを踏まえて、以下の実装をします。

  1. RESTパラメータを、Spring AIのPrompt型に変更
  2. Prompt型をLLMが解釈できる指示文に変更

まず1. は単純な型のmapです。ChatCompletionRequestSystemMessageなどは、OpenAPIから生成したクラスです。

src/main/java/com/mycompany/myapp/web/api/FluxChatApiController.java
var prompt = new LlamaPrompt(
	messages
	    .stream()
	    .map(message ->
		(Message) switch (message) {
		    case ChatCompletionRequestSystemMessage systemMessage -> new SystemMessage(systemMessage.getContent());
		    case ChatCompletionRequestUserMessage userMessage -> new UserMessage(userMessage.getContent());
		    case ChatCompletionRequestAssistantMessage assistantMessage -> new AssistantMessage(
			assistantMessage.getContent()
		    );
		    case null, default -> throw new RuntimeException("Unknown message type");
		}
	    )
	    .toList()
            );

続いて 2. ですが、LLMそのものへの入力は「単一の文字列」として渡す必要があり、LLMによって解釈しやすさが若干異なるようです。
Llmaa2については、このあたりに書かれているように<<SyS>>, [INST], <s>で囲うのが良さそうです。
以下のコードで文字列を形成します。

src/main/java/com/mycompany/myapp/web/api/LlamaPrompt.java
@Override
public String getContents() {
        var sb = new StringBuilder();
        var messages = getMessages();
        var systemMessage = messages
            .stream()
            .filter(m -> m instanceof SystemMessage)
            .map(m -> (SystemMessage) m)
            .reduce((first, second) -> second)
            .orElseThrow();
        var userMessages = messages.stream().filter(m -> m instanceof UserMessage).map(m -> (UserMessage) m).toList();
        var assistantMessages = messages.stream().filter(m -> m instanceof AssistantMessage).map(m -> (AssistantMessage) m).toList();

        for (int i = 0; i < userMessages.size(); i++) {
            var userMessage = userMessages.get(i);
            var assistantMessage = i < assistantMessages.size() ? assistantMessages.get(i) : null;

            sb.append("<s>");
            if (i == 0) {
                sb.append("<<SYS>>\n").append(systemMessage.getContent()).append("\n<</SYS>>");
            }
            sb.append("[INST]");
            sb.append(userMessage.getContent());
            sb.append("[/INST] ");
            sb.append(assistantMessage != null ? assistantMessage.getContent() : "");

            if (i < userMessages.size() - 1) {
                sb.append(" </s>");
            }
        }
        return sb.toString();
    }

その他

JHipsterが生成したコードは最初から認証認可によるアクセス制限機能も設けられています。今回は認証せずアクセスできるよう、SecurityConfigurationを修正します。

src/main/java/com/mycompany/myapp/config/SecurityConfiguration.java
.pathMatchers("/v1/**").permitAll()

動作確認

JHipsterにより、以下のコマンドだけで、OpenAPIのコード生成、全体のビルドが行われ、Spring Bootアプリケーションが起動します。

./mvnw

チャットからアクセスすると、

良い感じに動いていますね!(画面では見にくいですが、過去の履歴も抑えています)

おわりに

今回は、JHipsterとOpenAPIを使って、ローカルLLMチャットサーバの実装を行いました。
JHipsterには、今回省略したDB系の環境構築が簡単にでき、Spring AIと組み合わせると、RAGの実現ができそうです。次回はそのあたりにチャレンジします。

Discussion