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

この記事は、[Zenn記事](https://zenn.dev/hide212131/articles/4fbeca981db5f8)の転載です。

## はじめに
この記事は、[JavaでローカルLLMを動かす](https://zenn.dev/hide212131/articles/2916417bfa82f4)の続編です。

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

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

最終的には、こんなかんじで、SpringBootサーバを起動し、[Chatbot UI](https://github.com/mckaywrigley/chatbot-ui)のようなクライアントから、サーバを経由し、ローカルLLMとのチャットができます。

![](https://storage.googleapis.com/zenn-user-upload/501f623370e8-20240110.gif)


## 忙しい方のために
コードをおきました。

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

モデルのダウンロードは、
```bash
./mvnw verify
```
と打つと、`mistral-7b-instruct-v0.2.Q2_K.gguf` を`./models`の下にダウンロードします。（ダウンロードのコードは、[Java Bindings for llama.cpp](https://github.com/kherud/java-llama.cpp)から持ってきました。）

:::message
前回記事と同様、ELYZAを使う場合は、`pom.xml`を修正します。
```
<integration.test.model>ELYZA-japanese-Llama-2-13b-fast-instruct-q4_K_M.gguf</integration.test.model>
<integration.test.model.url>https://huggingface.co/mmnga/ELYZA-japanese-Llama-2-13b-fast-instruct-gguf/resolve/main/${integration.test.model}</integration.test.model.url>
```
:::

その後、
```bash
./mvnw
```
でサーバが起動します。

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

Chatbot UIでは、

以下の環境変数を設定し、
```bash
export OPENAI_API_HOST=http://localhost:8080
export OPENAI_API_KEY=(random key. not used)
export OPENAI_API_TYPE=local
```

以下でサーバ起動し、
```bash
npm run dev
```
http://localhost:3000 にアクセスします。

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

- [OpenAPI spec for the OpenAI API](https://github.com/openai/openai-openapi)
	- 言い回しが非常にややこしいですが、OpenAI社から、OpenAI API仕様が、OpenAPI仕様で出ているので、これを利用します。
- [OpenAPI Generator](https://github.com/OpenAPITools/openapi-generator)
	- OpenAPI仕様からコードを出力するツールです。
- [OpenAI API Java Client generated from OpenAPI specification with openapi-generator](https://github.com/ainoya/openai-java-generated-client)
	- OpenAPI仕様からクライアント側のコードを出力した例。今回作るのはサーバ側ですが参考にさせていただきます。
- [ChatBot UI](https://github.com/mckaywrigley/chatbot-ui)
	- ChatGPTの画面ライクな、OpenAPI APIクライアント
- [JHipster](https://jhipster.tech/jp)
	- Spring Bootアプリケーションを迅速に開発するツールです。上述のOpenAPIの開発環境や、文字をStream形式で受け取るWebflux環境も用意できるため、今回はそれを組み合わせてみます。
	- JHipsterについての情報は、手前味噌ですが以下を参照ください。
		- [JHipsterに関する株式会社アルファシステムズのブログ](https://www.alpha.co.jp/blog/tag/jhipster)
		- [JJUG CCC 2023 Fallの資料](https://speakerdeck.com/hide212131/jhipsterdebao-su-katujian-lao-naentapuraizu-spring-boot-kai-fa-woshi-xian-suru)

## 開発手順

### 方針

#### Stream形式
目指すところは、Chatbot UIとの連携ですが、[実装を見たところ](https://github.com/mckaywrigley/chatbot-ui/blob/5248f25b5dc313316d6dc125ef469966c652e21b/utils/server/index.ts#L87)、LLMが生成した文字をStreamで受け取る方法、[Stream Server-Sent Events（SSE）](https://zenn.dev/hide212131/articles/2916417bfa82f4#%E6%B5%81%E3%82%8C%E3%82%8B%E3%82%88%E3%81%86%E3%81%AA%E6%96%87%E7%AB%A0%E3%82%92%E7%94%9F%E6%88%90%E3%81%97%E3%81%A6%E3%81%BF%E3%82%8B)のみサポートしているようですし、その方が（自分としても）UX的に満足度が高いので、そちらの実装を目指します。

#### OpenAPI Generatorのコード生成はある程度割り切り
OpenAPI Specからコード生成するOpenAPI Generatorですが、現時点でコードの生成は完全なものではなく（issueが4千...メンテの大変さが伺えます...）、OpenAI社が出しているSpecからはコードが生成できないものがあります。すでに[こちらで](https://github.com/ainoya/openai-java-generated-client#modifications-and-testing)クライアント側生成のために、ある程度エラーを解消してくれているので、それを利用しつつ、さらにサーバ側に必要な修正を加えていきます。

### 実装

JHipsterが生成したコードに手を加えていきます。[ここに追加変更分があります。](https://github.com/hide212131/jhipster-local-llm-sample/commit/8efe3c8c6e754850a48d54817b966746fabd118e)

#### JHipsterの構築
Java21とNode.js20、Gitを用意したのち、以下を実行します。
```bash
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`フォルダ配下に生成されるようになります。
```bash
./mvnw clean install
```
依存関係の追加。

主要な追加は、[前回と同様](https://zenn.dev/hide212131/articles/2916417bfa82f4#%E4%BE%9D%E5%AD%98%E9%96%A2%E4%BF%82%E3%81%AE%E8%BF%BD%E5%8A%A0)`llama`と`Spring AI`のみです。

OpenAPIについては、前述のとおり、JHipsterがすでに用意してくれて楽ですが、一部変更します：
```xml: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ファイル](https://github.com/ainoya/openai-java-generated-client/blob/main/openapi.yaml) に、若干手を加えて、サーバサイドもコンパイルできるようにします。[修正点はこちら](https://github.com/hide212131/jhipster-local-llm-sample/commit/583205e4aee1f70c4da313ccd43a186d856c1239?diff=split&w=1)

修正した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にしたコードを新たに作成します。

```java: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.`エラーが発生しました。対処法は[ここに書かれているように](https://robinedwardellis.medium.com/reactive-mysql-with-spring-boot-1b184b9ea58a#:~:text=Multi%2Dvalue%20reactive%20types%20not%20supported%20in%20view%20resolution.) `@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から生成したクラスです。
```java: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については、[このあたりに書かれているように](https://zenn.dev/link/comments/be227cfff47e0d)、`<<SyS>>`, `
`, `
`で囲うのが良さそうです。
以下のコードで文字列を形成します。

```java: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("
");
            if (i == 0) {
                sb.append("
\n").append(systemMessage.getContent()).append("\n
");
            }
            sb.append("
");
            sb.append(userMessage.getContent());
            sb.append("
 ");
            sb.append(assistantMessage != null ? assistantMessage.getContent() : "");

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

#### その他
JHipsterが生成したコードは最初から認証認可によるアクセス制限機能も設けられています。今回は認証せずアクセスできるよう、SecurityConfigurationを修正します。
```java:src/main/java/com/mycompany/myapp/config/SecurityConfiguration.java
.pathMatchers("/v1/**").permitAll()
```

### 動作確認

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

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

![](https://storage.googleapis.com/zenn-user-upload/501f623370e8-20240110.gif)

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

## おわりに

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