JavaでローカルLLMを動かすPart2: OpenAI API互換サーバをJHipsterで実装
はじめに
この記事は、JavaでローカルLLMを動かすの続編です。
前回、JavaによるRESTサーバで経由でLLMへのアクセスに成功したので、今回は、OpenAI API互換プロトコルを実装したクライアントからも接続できるようにしてみます。
さらにJHipsterというツールを使い、少ない手順でRESTサーバを構築します。
最終的には、こんなかんじで、SpringBootサーバを起動し、Chatbot UIのようなクライアントから、サーバを経由し、ローカルLLMとのチャットができます。
忙しい方のために
コードをおきました。
モデルのダウンロードは、
./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 にアクセスします。
技術要素
今回は、以下の技術の組み合わせです。
-
OpenAPI spec for the OpenAI API
- 言い回しが非常にややこしいですが、OpenAI社から、OpenAI API仕様が、OpenAPI仕様で出ているので、これを利用します。
-
OpenAPI Generator
- OpenAPI仕様からコードを出力するツールです。
-
OpenAI API Java Client generated from OpenAPI specification with openapi-generator
- OpenAPI仕様からクライアント側のコードを出力した例。今回作るのはサーバ側ですが参考にさせていただきます。
-
ChatBot UI
- ChatGPTの画面ライクな、OpenAPI APIクライアント
-
JHipster
- Spring Bootアプリケーションを迅速に開発するツールです。上述のOpenAPIの開発環境や、文字をStream形式で受け取るWebflux環境も用意できるため、今回はそれを組み合わせてみます。
- JHipsterについての情報は、手前味噌ですが以下を参照ください。
開発手順
方針
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
依存関係の追加。
主要な追加は、前回と同様llama
とSpring AI
のみです。
OpenAPIについては、前述のとおり、JHipsterがすでに用意してくれて楽ですが、一部変更します:
<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にしたコードを新たに作成します。
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からの回答
-
-
それらを踏まえて、以下の実装をします。
- RESTパラメータを、Spring AIの
Prompt
型に変更 -
Prompt
型をLLMが解釈できる指示文に変更
まず1. は単純な型のmapです。ChatCompletionRequestSystemMessage
などは、OpenAPIから生成したクラスです。
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>
で囲うのが良さそうです。
以下のコードで文字列を形成します。
@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を修正します。
.pathMatchers("/v1/**").permitAll()
動作確認
JHipsterにより、以下のコマンドだけで、OpenAPIのコード生成、全体のビルドが行われ、Spring Bootアプリケーションが起動します。
./mvnw
チャットからアクセスすると、
良い感じに動いていますね!(画面では見にくいですが、過去の履歴も抑えています)
おわりに
今回は、JHipsterとOpenAPIを使って、ローカルLLMチャットサーバの実装を行いました。
JHipsterには、今回省略したDB系の環境構築が簡単にでき、Spring AIと組み合わせると、RAGの実現ができそうです。次回はそのあたりにチャレンジします。
Discussion