Java×Spring AIで始めるAIプログラミングの入門連載。前回は、Spring AIでのプロンプトの扱い方や、Spring AI全体のクラス構造について簡単に説明しました。今回は、AIからのレスポンスをプログラムで扱いやすい形式に変換する方法を解説します。
この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。
本連載のサンプルコードをGitHubで公開しています。こちらからダウンロードしてみてください。
多くの開発現場で用いられるJavaとSpring Frameworkを対象に、「Spring AI」を活用してニーズが高まるAI(人工知能)機能をシステムに組み込んでいくための基本的な手法を紹介する本連載「Spring AIで始める生成AIプログラミング」。前回は、AIから取得したテキストをそのまま出力しましたが、AIアプリ開発では、AIからのレスポンスに対してシステム側で追加で処理を施すケースがほとんどです。しかしながら、AIが生成する自由なテキスト形式では、プログラムでそのまま扱うのが困難です。そこで、AIの出力フォーマットを制御する必要が出てきます。Spring AIでは、この手法を「構造化出力」と呼んでいます。本稿では、この構造化出力の実装方法を解説します。
Spring AIにおける構造化出力の流れは、図1のようになります。
まず、入力となるプロンプト用のテキストを準備します。次に、このテキストをAIモデルが利用するプロンプトテキストに変換します。この変換には、FormatProviderとPromptTemplateを利用します。
FormatProviderでは、変換したい形式に合わせてリストやマップ形式などを指定するためのプロンプト文字列を提供します。FormatProviderを利用することで、プログラマーがどのようなプロンプトを記述すればよいかなどを悩む必要がなく、AIモデル側でより正確なレスポンスを生成する助けになるわけです。あとは、ProjectTemplateを利用して、FormatProviderが生成したプロンプトと、本来の質問文を結合して、最終的なプロンプトを作成し、送信します。
AIモデルから返されるレスポンステキストは、Converterを利用することで、そのままリストやマップなどに変換できます。
ここまでが基本的なクラスの関係ですが、実際にはFormatProviderとConverterを個別に使う機会はさほどありません。一般的には、両方の役割を兼ね備えたStructuredOutputConverterを利用します。Spring AIには以下の継承クラスが用意されており、これらを利用していくことになるでしょう。
PromptTemplateは、テンプレート文字列に変数を埋め込むためのクラスです。構造化出力に特化したクラスではありませんが、AIアプリではこのようなちょっとした差し込み操作は頻繁に生じるので、その際に重宝するはずです。
リスト1は、PromptTemplateを単独で使う場合のコード例です。
// ・・(省略) @ShellMethod(key = "template-prompt") public String tempalte(String message){ // (1) テンプレート文字列を定義 String templateText = """ あなたは勉強をサポートするAIロボットです。以下の質問に対して丁寧な返答を手順を追って説明してください。その際にはそれぞれの手順は200文字以内で説明してください。 {question} """; // (2) テンプレートクラスを作成 PromptTemplate template = PromptTemplate.builder() .template(templateText) // (3) 定義したテンプレート文字列を設定 .variables(Map.of("question",message)) // (4) 変数に差し込む値を指定 .build(); // (5) プロンプトを作成し、実行 Prompt prompt = template.create(); return client.prompt(prompt).call().content(); }
(1)は、テンプレート文字列です。{...}はプレースホルダーで、あとから変数の値を埋め込む場所を表します。この例であれば、{question}の部分にユーザーからの具体的な質問が差し込まれることになります。
続いて、PromptTemplate.builderメソッドでテンプレートを組み立てていきます。templateメソッドでテンプレート文字列(3)を、variablesメソッドで割り当てる変数(4)をそれぞれ設定し、最終的にbuildメソッドでPromptTemplateインスタンスを生成します。
準備したPromptTemplateオブジェクトからはcreateメソッドを呼び出すことで、テンプレート文字列に変数値がバインドされ、最終的なプロンプト(リクエストデータ)が生成されます(5)。Promptの生成からリクエストまでは前回ともほとんど共通した流れですね。
コードの流れを理解できたら、コマンドラインから実行してみましょう。
shell:>template-prompt 三角形の内積の和はどうして180度なのですか 三角形の内角の和が180度である理由を説明します。以下の手順で解説します。 ### 手順1: 三角形の定義 三角形は、3つの辺と3つの角を持つ平面の図形です。三角形にはさまざまな形がありますが、内角の和は常に一定です。 ### 手順2: 平行線と角度 ・・(省略)
このように、PromptTemplateを利用することで、利用者からの入力だけでは不足する補足情報や注意事項などを、アプリ側で追加できるようになるわけです。
PromptTemplateの使い方が理解できたところで、続いて、構造化出力を得る方法を見ていきます。まずは、レスポンスデータをList形式に変換するためのListOutputConverterからです。
// ・・(省略) @ShellMethod(key = "output-list") public List<String> listOutput(String message){ // List形式出力用のStructuredOutputConverterの継承クラス ListOutputConverter converter = new ListOutputConverter(); // (1) フォーマットを取得 String format = converter.getFormat(); String template = """ 質問に対して回答を5つ作成してください {subject} {format} """; PromptTemplate promptTemplate = PromptTemplate.builder() .template(template) .variables(Map.of("subject",message,"format",format)) .build(); Prompt prompt = promptTemplate.create(); String response = client.prompt(prompt).call().content(); // (3) List形式への変換 List<String> list = converter.convert(response); return list; }
まず、ListOutputConverter#getFormatメソッドで、応答のフォーマットを指定するための追加のプロンプトテキストを取得します(1)。このテキストは、具体的には以下のような内容です(下は日本語訳)。
Respond with only a list of comma-separated values, without any leading or trailing text Example format: foo, bar, baz --------------------------------------------------------------------------- 先頭や末尾にテキストを付けず、カンマ区切りの値のリストのみで応答してください。 例:foo、bar、baz
以降、AIモデルに対してプロンプトを実行するまでは、リスト1で紹介した流れと同じです。(1)で取得したプロンプトも追加しておきましょう。
レスポンスを取得できたら、あとはconvertメソッドを呼び出すことで、結果をList形式へと変換できます(2)。
以下は、リスト2を実行したコマンドと、その結果例です。
shell:>output-list 日本で有名なお城の名称をおしえてください [姫路城, 大阪城, 名古屋城, 熊本城, 松本城]
ここではListをそのまま表示しているだけですが、応答がList型となっているので、以降に何らかの処理を続ける場合にも処理しやすくなりましたね。
次に、Key-Value形式で値を取得する際に利用するMapOutputConverterです。
使い方は、リスト形式(ListOutputConverter)と変わらず、ListOutputConverterをMapOutputConverterに置き換えるだけです。詳細のコード内容については配布サンプルを参照してください。
// ・・(省略) String template = """ 質問に対してもっとも典型的な答えを1つと、質問のカテゴリー名称とその回答に対するおすすめを理由を50文字以内で作成してください {subject} {format} """;
このコードを実行すると、以下のような結果が得られます。
shell:>output-map 海が見えるおすすめの観光地はどこですか {カテゴリー=観光地, おすすめ=美しいビーチと文化が楽しめるため。, 答え=沖縄}
ただし、Mapの場合、Keyの値が完全に保証されているわけではありません。これでは処理しにくい場合も多いでしょう。その場合には、次に紹介するJavaオブジェクト形式を使うとよいでしょう。
以下は、MapOutputConverterの例をBeanOutputConverterを使って書き換えた例です。
// (2) JSON出力時のプロパティの並び順を指定(不要であれば省略可) @JsonPropertyOrder({"name","reason","access"}) // (1) AIの回答を変換するJavaオブジェクトの型を定義 public record Spot(String name,String reason, String access) {} @ShellMethod(key = "output-bean") public Spot beanOutput(String message){ // Javaオブジェクト形式出力用のStructuredOutputConverterの継承クラス BeanOutputConverter<Spot> converter = new BeanOutputConverter<>(Spot.class); // フォーマットを取得 String format = converter.getFormat(); // (省略(ListOutputConverterやMapOutputConverterと全く同様)) // Javaオブジェクトへ変換 Spot spot = converter.convert(response); return spot; }
まずは、AIの回答形式を表すJavaクラス(record)を定義します(1)。Java開発に慣れているのであれば、POJO(Plain Old Java Object)形式やJavaBeanと表現した方が伝わりやすいかもしれません。実際にConverterのクラス名にもBeanとあるのはそのためです。
JSON出力時のプロパティの並び順を指定する場合には、JsonPropertyOrderアノテーションで宣言しておきましょう(2)。出力結果を他のシステムにAPIとして提供する場合や、デバッグ時などにデータを参照したい場合など、並び順が決まっていた方が便利な場合も多いからです。ただし、この指定は必須ではありません。
ここまでの準備ができたら、以降の流れはこれまでとほぼ同じです。getFormatメソッドで得られるテキストを確認すると、非常に細かい出力指示が追加されていることが分かります(下は日本語訳)。
Your response should be in JSON format. Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation. Do not include markdown code blocks in your response. Remove the ```json markdown from the output. Here is the JSON Schema instance your output must adhere to: ```{ "$schema" : "https://json-schema.org/draft/2020-12/schema", "type" : "object", "properties" : { "name" : { "type" : "string" }, "reason" : { "type" : "string" }, "access" : { "type" : "string" } }, "additionalProperties" : false }``` ---- レスポンスはJSON形式で記述してください。 説明は含めず、RFC8259に準拠したJSONレスポンスのみを、この形式に厳密に従って記述してください。 レスポンスにはマークダウンコードブロックを含めないでください。 出力から```json`マークダウンを削除してください。 出力が準拠する必要があるJSONスキーマインスタンスは次のとおりです。 ...後略...
ただし、実際に出力されるJSONスキーマの定義は、変換するJavaクラスによって異なります。以上のコードを実行するコマンドと、その実行結果は以下の通りです。
shell:>output-bean 海が見えるおすすめの観光地はどこですか Spot[name=沖縄, reason=美しいビーチと豊かな自然が魅力。, access=飛行機で直行便が多く便利。]
JavaオブジェクトのList形式で取得したい場合は、リスト5のようにParameterizedTypeReferenceを用いたコードにします。
@ShellMethod(key = "output-bean-list") public List<Spot> beanOutputList(String message){ // (1)Object型のList形式にする BeanOutputConverter<List<Spot>> converter = new BeanOutputConverter<>( new ParameterizedTypeReference<List<Spot>>() {} ); String format = converter.getFormat(); // (2) テンプレート側のメッセージも合わせる String template = """ 質問に対してもっとも典型的な答えを3つと、質問のカテゴリー名称とその回答に対するおすすめを理由を50文字以内で作成してください {subject} {format} """; // (省略) }
Bean型のオブジェクトをListに変換したい場合は、ParameterizedTypeReferenceの型パラメーターを使ってJavaクラスを指定します(1)。また、テンプレート側のメッセージもList型になるように調整しておきましょう(2)。以下は、これを実行した結果です。
shell:>output-bean-list 海が見えるおすすめの観光地はどこですか [Spot[name=沖縄, reason=美しいビーチと文化が楽しめます。, access=飛行機で直行便が多い。], Spot[name=ハワイ, reason=絶景の夕日と豊かな自然があります。, access=直行便が便利で観光地も充実。], Spot[name=サンフランシスコ, reason=ゴールデンゲートブリッジが有名です。, access=公共交通機関が発展している。]]
生成AIを用いたフォーマット形式の利用方法についてイメージしにくい人のために、簡単な応用例を紹介します。例えば、チャットbotにおいて、利用者からの入力の目的に応じて返答の方向性を変えたい場合があります。「こんにちは」のようなあいさつが入力された際には、ちょっとしたトピックを交えて返事をしたい、といったケースです。
利用者からのテキストがどのような目的や種類に属するのかをAI側に判断させ、それに応じて返答を作成することで、機械的ではない、より自然な応答が可能になります。
例えばリスト6は、入力されたテキストの目的に応じて返答を変える例です。
@ShellComponent public class RecommendCommand { // (省略) public RecommendCommand(ApplicationContext context){ // (省略) // (1) ChatClientを目的に応じて準備する botClient = ChatClient.builder(model) .defaultAdvisors(chatAdvisor) .defaultSystem("あなたはお客様の旅行を支援するアドバイザーロボットAIです。お客様に失礼のないような答えを作成してください") .build(); checkClient = ChatClient.builder(model) .defaultSystem("あなたはお客様と旅行支援をするオペレータの会話について指導する先輩アドバイザーロボットAIです。分かりやすく簡潔にお願いします") .build(); } record Question(String category,String description){}; @ShellMethod(key = "recommend") public String recommend(String message){ BeanOutputConverter<Question> converter = new BeanOutputConverter<>(Question.class); String format = converter.getFormat(); // (2) 入力された文字列の目的をAI側に判断してもらう String template = """ 与えられたメッセージを以下の分類項目のいずれかで分類し、内容を要約してください。 {subject} {format} 分類項目:あいさつ、質問、要望、その他 """; // (省略) String response = checkClient.prompt(prompt).call().content(); Question q = converter.convert(response); if(q.category.equals("あいさつ")){ // (3) あいさつの場合 String replyTemplate = """ お客様から以下の挨拶がありました。以下の状況を参考に最大100文字以内にて、丁寧に挨拶をかえしてください。 天気:晴れ 温度:連日高温が続いている {subject} """; // (省略) return botClient.prompt(reply.create()).call().content(); } else if(q.category.equals("質問")){ // (4) 質問の場合 String replyTemplate = """ お客様から以下の質問を受けました。質問の文脈に合わせて目的地や旅行日程が明確になるように質問してください。 {subject} """; // (省略) return botClient.prompt(reply.create()).call().content(); } else{ // カテゴリ別に返事の方向性を決める } return "すいません、もう一度、表現をかえて入力していただけますか?"; } }
まず、役割別にChatClientのインスタンスを複数作成します(1)。例えば、利用者と対面でチャットする役割の場合には履歴を維持し、システム判断で使う場合には履歴などを使わないといったように、それぞれの目的に応じたシナリオを複数持つことで、生成AIシステムは構築しやすくなります。
次に、入力されたメッセージがどのような趣旨であるかの判断自体をAI側に任せます(2)。このとき、その判断結果をシステムで扱いやすいようにBeanOutputConverterを利用します。
そして、入力された分類に応じて、あいさつの場合(3)、質問の場合(4)など、プロンプトを変えて返答を作成することで、より自然なやりとりを実現しつつ、会話の方向性を制御することが可能になります。
今回は、AIモデルからの返答を処理しやすい形に変換する方法について紹介しました。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.