Spring AIの実戦投入で見えてくる課題――複数モデル環境で「コスト最適化」するテクニック:Spring AIで始める生成AIプログラミング(11)
Java×Spring AIで始めるAIプログラミングの入門連載。前回までは、基本的な実装からMCPの活用まで、できるだけ「マニュアル」に沿って、問題が生じにくい標準的な使い方を解説してきました。最終回となる今回は、「マニュアルの先」にある泥臭くもはまりやすい部分について、皆さんが自走するための支援となるようなトピックを解説します。
本連載のサンプルコードをGitHubで公開しています。こちらからダウンロードしてみてください。
目次
はじめに
Spring AIは、Java/Spring Bootエコシステムに生成AIを統合するための強力なフレームワークです。その最大の利点は「抽象化」と「自動設定」(Auto Configuration)にあります。これらは、開発者が生成AI(人工知能)アプリケーションを迅速に構築し、AIモデルの複雑な詳細を意識することなく利用できる「武器」となります。
しかしながら、複雑な本番環境のプロジェクトにおいては、この強力な「抽象化」や「自動設定」が意図せずデバッグの障壁となったり、ベンダーごとに異なる細かなAIモデルの仕様差を隠蔽してしまったりする側面もあります。これまでは「まずは動かすこと」を優先し、Spring Bootの持つ高い利便性に身を委ねてきましたが、最終回ではあえてその裏側に踏み込みます。本番運用を見据えた際に直面する「プロバイダー(AIベンダー)間の挙動の差異」や「トークン管理」など、公式マニュアルを読むだけでは見えてこない、より実践的な知見の一部を紹介します。
マルチベンダー構成の必要性とSpring Bootの壁
現在の生成AI開発において、単一のAIベンダー(例えばOpenAIのみ)に依存した運用は、リスクや制約となりつつあります。かつては「最強のモデル一つ」を選べば事足りた時代もありましたが、現在は以下のような理由から、複数のAIベンダーを同じシステム内で共存させる「マルチベンダー構成」が求められています。
(1)適材適所によるコスト・性能最適化
複雑な推論タスクには高性能なGPT-4o、単純な要約にはコスト効率の良いGemini Flashのように、用途に応じてAIモデルを使い分け、コストを最適化する。
(2)BCP(事業継続計画)と可用性の向上
特定のAPIベンダーのシステム障害やサービス停止に備え、代替プロバイダーを用意することで、システムの可用性を高める。
(3)ローカルLLMとのハイブリッド活用
機密性の高い情報はセキュリティを考慮しローカルのLLM(大規模言語モデル)で処理し、公開情報にはクラウドベースのLLMを利用するなど、セキュリティと利便性を両立する。
Spring AIは、これらの異なるAIプロバイダーをChatModelという共通のインタフェースで抽象化しているため、一見するとプロバイダーの切り替えは容易に思えます。しかし、ここで課題となるのが、Spring Bootの基本的な特性である「単一のBeanを自動登録する」という挙動です。複数のAIプロバイダーを同時にSpring Bootアプリケーションに組み込もうとすると、この特性によりBean定義の競合が発生し、ビルドや起動の失敗、または設定の衝突が生じる可能性があります。
Bean定義の競合を解消し明示的に使い分ける
OpenAI、Google Gemini、Amazon Bedrockの3つのAIプロバイダーを同時に利用するケースを例に説明します。まず、プロジェクトのビルドファイル(build.gradle.ktsなど)に、それぞれのプロバイダー用の依存関係を追加します(リスト1)。
dependencies {
// (省略)
implementation("org.springframework.ai:spring-ai-starter-model-openai")
// Gemini APIを使う場合
implementation("org.springframework.ai:spring-ai-starter-model-vertex-ai-gemini")
// Amazon AWS (Bedrock)を使う場合
implementation("org.springframework.ai:spring-ai-starter-model-bedrock-converse")
}
これまでは、ChatModel型を指定するだけで、Spring Bootが自動登録したBeanを迷うことなく取得できていました。
ChatModel model = this.context.getBean(ChatModel.class);
しかし、複数のAIプロバイダーの依存関係をビルドファイルに追加した状態でリスト2のコードを実行すると、次のようなエラーが発生します。
org.springframework.beans.factory.NoUniqueBeanDefinitionException: No qualifying bean of type 'org.springframework.ai.chat.model.ChatModel' available: expected single matching bean but found 3: bedrockProxyChatModel,googleGenAiChatModel,openAiChatModel
このNoUniqueBeanDefinitionExceptionは、Spring Frameworkが、指定されたChatModelインタフェースを実装するBeanをDIコンテナ内で複数(この場合は3つ)見つけるものの、どれを注入すべきか判断できない場合に発生します。具体的には、bedrockProxyChatModel、googleGenAiChatModel、openAiChatModelの3つのBeanが見つかり、単一のBeanを期待しているSpring Bootが「どのBeanを使えば良いのか分からない」と伝えている状態です。
この問題を解決するには、SpringのDIコンテナからBeanを取得する際に、インタフェースの型だけでなく、Beanの名前を明示的に指定する必要があります。リスト3は、各AIプロバイダーに対応するChatModelを個別に取得するコード例です。
// OpenAIの場合
ChatModel openAiChatModel = this.context.getBean("openAiChatModel", ChatModel.class);
// Google Gemini(GenAI)の場合
ChatModel googleGenAiChatModel = this.context.getBean("googleGenAiChatModel", ChatModel.class);
// Amazon Bedrockの場合
ChatModel bedrockProxyChatModel = this.context.getBean("bedrockProxyChatModel", ChatModel.class);
それぞれのBean名は、各AIプロバイダーに対応するSpring AIスターターの自動設定クラス(AutoConfigurationクラス)で定義されています。最も確実かつ手軽な確認方法は、エラーメッセージに出力される"found 3: ..."のリストを見ることです。このリストに示された名前が、BeanのIDとして使用できるため、これを基に特定のBeanを取得します。
この方法を用いることで、複数のAIプロバイダーをSpring AIアプリケーション内で共存させ、必要に応じて適切なモデルを使い分けることが可能になります。
自動設定に頼らず「手動構築」へ切り替える
これまでの回では、Spring AIの自動設定に頼り、その都度ChatClientを作成して利用してきました。しかし、実際のプロジェクトでは、複数のAIプロバイダー(例:OpenAI、Google Gemini、Amazon Bedrockなど)を使い分けることが一般的です。そのような場合、役割ごとに事前にChatClientをBeanとして定義し、再利用するのが標準的なアプローチとなります。
リスト4に、単一ベンダー構成と複数ベンダー構成におけるChatClientの作成方法の違いを示します。
// (省略)
// (1) 1つのAIベンダーのAPIを使う場合の書き方
public ChatClient createChatClient(ChatClient.Builder builder){
return builder.clone()
.defaultOptions(
OpenAiChatOptions.builder()
.model("gpt-4o-mini")
.build()
).build();
}
// (2) 複数AIベンダーのAPIを使う場合の書き方
@Bean(name = "customOpenAIChatClient")
public ChatClient createOpenAIChatClient(@Qualifier("openAiChatModel") ChatModel chatModel){
return ChatClient.builder(chatModel)
.defaultOptions(
OpenAiChatOptions.builder()
.model("gpt-4o-mini")
.build()
).build();
}
// (省略)
単一のAIベンダーのみを利用している状況では、(1)のコードのように、Spring Bootが自動的に準備してくれるChatClient.Builderをインジェクションするだけで、ChatClientを構築できました。これは、Builderの背後にある「唯一のChatModel」インスタンスをSpringが自動的にひも付けてくれていたためです。
しかし、複数のAIベンダーを共存させる環境では、話が変わってきます。既に述べたように、ChatModelインタフェースを実装するBeanが複数存在するため、SpringはどのChatModelインスタンスをChatClient.Builderの基盤として使用すべきか判断できません。この結果、単一ベンダーの場合と同じ方法ではエラーとなってしまいます。
そのため、(2)のコードのように、以下の変更が必要です。
- @Qualifierによる明示的な指定:@Qualifier("openAiChatModel")アノテーションを使用し、DIコンテナ内で同型のBeanが複数ある中から、特定のChatModel(この場合はopenAiChatModel)をこのメソッドに注入するよう明示的に指示する
- ChatClient.builder(chatModel)による直接インスタンス渡し:ChatClientを初期化する際に、ChatClient.builderメソッドに直接、明示的に指定したchatModelインスタンスを渡す形に書き換える必要がある
このように、Spring AIで複数のプロバイダーを扱う際には、自動設定に任せ切りにするのではなく手動でBean定義を調整し、どのChatModelを使用するかを明確にすることが重要です。
困ったときにはソースコードを参照する
ChatModel自体をSpring Bootの自動設定に頼らず、完全に自作するケースはまれですが、「どうしても接続がうまくいかない」といった問題の発生時や、「APIレベルで何が起きているのか詳細に調査したい」という場面は必ず訪れます。
残念ながら、ChatModelは各プロバイダー固有のAIモデルAPIと直接通信を担うコンポーネントであるため、Spring AI共通の汎用(はんよう)的なカスタマイズ手法が存在するわけではありません。このような状況では、Spring AIがどのように各プロバイダーを初期化しているかを知るために、その自動設定(AutoConfiguration)のソースコードを確認することが最も有効な手段となります。
以下に、今回使用した3つの主要プロバイダーにおけるChatModelの自動設定に関するソースコードのリンクを示します。これらのソースコードには、各ChatModelのBean名や、初期設定時に利用されるプロパティ、デフォルトの挙動などの情報が詰まっています。
OpenAI互換のAPIを使いたい場合
OpenAI本家ではなく、自前で構築したLocalLLM(Ollama、vLLMなど)やその他のOpenAI互換サービスを利用するケースも多いでしょう。Spring AIでは、アプリケーションのプロパティ設定を変更するだけで、これらのOpenAI互換APIへの接続先を簡単に切り替えられます(リスト5)。
spring.ai.openai.chat.base-url=http://192.168.100.1 spring.ai.openai.chat.completions-path=/v1/chat/completions
ただし、この設定には重要な注意点があります。上記のプロパティはあくまで「Spring AIが標準で用意しているopenAiChatModelの接続先を上書きする」ものです。そのため、Spring Bootの自動設定機能だけを使用して、本家のOpenAI APIとOpenAI互換サービスを同時に使い分けることはできません。
もし「本家のGPT-4oを使いつつ、特定のタスクだけローカルのOpenAI互換LLMに投げたい」といった並行運用を実現したい場合は、Spring Bootの自動設定に頼らず、ChatModelオブジェクトを自身でインスタンス化し、専用のBeanとしてDIコンテナに登録する必要があります。
このような場合にこそ、先ほど紹介したOpenAiChatAutoConfiguration.javaのソースコードが参考になります。自動設定の内部ロジックを理解することで、ChatModelを手動でインスタンス化する際に必要なコンポーネント(APIクライアント、認証情報など)や設定オブジェクトを適切に組み立てる方法を学べます。
コストと品質を制御する実行結果のメタデータ解析
AIモデルを使い分ける大きな理由の一つは、「コスト最適化」です。生成AIの利用料金は、多くの場合、テキストの最小単位であるトークン量に依存します。そのため、トークン使用の詳細を把握し、制御することは、生成AIを実運用する上で不可欠なステップです。
消費トークンの把握と中断理由
AIに推論を実行させた際に、実際にどれだけのトークンが消費されたかを知るには、ChatResponseのメタデータを参照します。リスト6にそのコード例を示します。
// (省略)
@ShellMethod( key = "openai-prompt")
public String prompt(String message){
ChatModel openAiChatModel = this.context.getBean("openAiChatModel", ChatModel.class);
ChatClient client = ChatClient.create(openAiChatModel);
Prompt prompt = Prompt.builder()
.chatOptions(ChatOptions.builder()
.maxTokens(10) // (1) 出力トークンを制限
.model("gpt-4o-mini")
.build()
)
.content(message)
.build();
var response = client.prompt(prompt).call().chatResponse();
var usage = response.getMetadata().getUsage();
// (2) 入力時に使用されたトークン数
log.info("input token : {}",usage.getPromptTokens());
// (3) 出力時の使用されたトークン数
log.info("output token : {}",usage.getCompletionTokens());
// (4) 全体のトークン数
log.info("total token : {}",usage.getTotalTokens());
// (5) 出力結果の状態
String reason = response.getResult().getMetadata().getFinishReason();
log.info("reason : {}",reason);
return response.getResult().getOutput().getText();
}
// (省略)
このコードを実行すると、次のような結果が得られます。
shell:>openai-prompt こんにちは input token : 8 output token : 10 total token : 18 reason : LENGTH こんにちは!どのようにお手伝い
(2)(3)(4)で、それぞれ入力トークン数、出力トークン数、そしてこれらを合計したトークン数が確認できます。getPromptTokens()はリクエスト内のトークン、getCompletionTokens()は応答で生成されたトークンを示します。AIの利用料金は、この入力トークン数と出力トークン数に応じて課金されることが一般的です。
また、(5)のreasonは「なぜ生成が停止したのか」を示す終了理由(停止理由)です。OpenAIの場合、生成が最後まで完了すればSTOPが返されますが、上記の例では(1)でmaxTokensを10に制限したため、出力トークン数の上限に達したことを示すLENGTHが返されています。そのため、結果の文章も途中で途切れています。
この終了理由は、システムの安定稼働や、途中で出力が打ち切られた場合のハンドリングにおいて重要です。しかし、残念ながらAIベンダー間で値が統一されておらず、現在もSpring AIプロジェクト内で標準化に向けた議論が続いています。
現時点では、利用するAIモデル(プロバイダー)ごとの仕様を意識し、個別にハンドリングする必要があります。例えば、異なるモデルでは同じ理由でも異なる値が返される可能性があるため、これらを吸収するロジックの実装が求められます。
JTokkitを使って入力トークンを事前に見積もる
「あまりに長大な入力はエラーにしたい」「AI APIに送信する前に概算の料金を見積もりたい」といった場合、実際のAPIを呼び出す前に入力トークン数を把握したくなることがあります。
Spring AI自体には汎用的なトークン計算機能は含まれていません。しかし、Javaエコシステムにおいては、JTokkitというライブラリがOpenAIトークン関連の処理において定番として活用されています。
例えば、入力文字列のトークン数を計算する際には、リスト7のコードのようにJTokkitを利用します。
// (省略)
@ShellMethod( key = "token-count")
public int tokenCount(String message){
EncodingRegistry registry = Encodings.newDefaultEncodingRegistry();
// (1) "gpt-4o-mini"の場合
Encoding enc = registry.getEncoding(ModelType.GPT_4O_MINI.getEncodingType());
return enc.countTokens(message);
}
// (省略)
(1)では、使用するモデルとしてgpt-4o-miniを指定しています。注意すべきは、全ての最新モデルがJTokkitの選択肢にあるわけではないという点です。JTokkitは、OpenAIが公開しているトークナイザーのアルゴリズムに基づいているため、常に最新のモデルに対応しているとは限りません。モデル(エンコーディング方式)によってトークン計算の結果は異なるため、利用するAIモデルに最も近いエンコーディング方式を選択する必要があります。
さらに重要な注意点として、JTokkitで得られる数値はあくまで「純粋なテキスト文字列のトークン数」だということです。実際のAPI実行時には、メッセージの役割(role)やsystem/user/assistantといったプロンプトの構造を示すための「目に見えないオーバーヘッド」がトークンとして加算されます。
例えば、先ほどの「こんにちは」という文字列はJTokkitでは1トークンと計算されますが、API経由で送信された際には8トークンとしてカウントされた例がありました。このオーバーヘッドは、モデルのタイプやAPIのバージョンによって異なります。
正確な「実績トークン数」はAPI実行後にしか分かりませんが、「送信前の入力制限の参考値」としてJTokkitの数値を利用する際は、実際のAPI呼び出しで発生するオーバーヘッドを考慮したバッファーを十分に持たせて設計することが重要です。これにより、予期せぬエラー発生や無駄なAPIコストの発生を効果的に防ぐことが可能です。
まとめ
Spring AIは、基本的な機能に関しては各AIベンダーのAPIを抽象化し、開発のスピードを劇的に上げてくれる非常に強力なフレームワークです。しかし、本番環境で「コスト管理」や「安定性」「セキュリティ」を重視して運用しようとすると、まだフレームワークが抽象化し切れていない部分や、開発者自身が詳細を制御、管理しなければならない領域が残されています。
AIの進化は目覚ましく、それに伴いSpring AIもまた絶えず進化を続けています。このAI技術が急速に発展している「黎明(れいめい)期」に、フレームワークの「裏側」にあるメカニズムや「AIならではの問題」に直接触れることは、技術を深く理解する絶好の機会です。この経験は、他のエンジニアに対して一歩先を行くアドバンテージとなるでしょう。
フレームワークが自動的に解決してくれない「最後のひと手間」や、地道な検証を通じて課題を解決していくプロセスこそが、ソフトウェアの品質を向上させ、そしてエンジニアとしての真の価値を高める鍵となります。
筆者紹介
WINGSプロジェクト
有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティー(代表山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手掛ける。2021年10月時点での登録メンバーは55人で、現在も執筆メンバーを募集中。興味のある方は、どしどし応募頂きたい。著書、記事多数。
・サーバーサイド技術の学び舎 - WINGS(https://wings.msn.to/)
・RSS(https://wings.msn.to/contents/rss.php)
・X: @WingsPro_info(https://x.com/WingsPro_info)
・Facebook(https://www.facebook.com/WINGSProject)
Copyright © ITmedia, Inc. All Rights Reserved.