はじめに
LLMを組み込んだアプリケーションを本番に載せようとすると、必ずぶつかるのが「出力品質をどう担保するか」という問題です。同じプロンプトでも毎回違う文章が返ってくる非決定的な相手に対して、従来の「入力Xに対して出力はYと等しい」というアサーションは通用しません。
そこで近年定着してきたのが LLM-as-a-Judge(LLMに別のLLMの出力を採点させる)というアプローチです。Spring AIはこれを2つの層で提供しています。ひとつは標準の評価用API(RelevancyEvaluator / FactCheckingEvaluator)を使ったJUnitでの品質テスト、もうひとつは実験的機能である Recursive Advisors を使った実行時の自動リトライ評価です。
本記事では、筆者が公式ドキュメント・公式ブログ・公式サンプルを読み込み、あわせてSpring AI本体のソースに照合して整理した内容をまとめます。単なる機能紹介ではなく、どちらをいつ使うかという判断軸と、実際に踏みやすい落とし穴に重点を置きます。
この記事で分かること
- CI/テスト用の
Evaluatorと、実行時用の Recursive Advisors の使い分け -
RelevancyEvaluator/FactCheckingEvaluatorをJUnitに組み込む具体的な書き方 - 公式リファレンスのサンプルコードが現行バージョンでは通らない箇所と、その修正版
- LLM-as-a-Judge を本番に入れる前に押さえておきたい7つの落とし穴
背景・課題:なぜ「評価」を別立てで考えるのか
LLMアプリの品質問題は、大きく2つのタイミングに分かれます。
ひとつは CI/テストのタイミングです。「RAGパイプラインを改修したら、想定質問に対する回答の関連性が落ちていないか」を、デプロイ前に自動で検知したい。ここで欲しいのは、人手のレビューを待たずに回帰を検出できる仕組みです。
もうひとつは 実行時(ランタイム)のタイミングです。ユーザーからのリクエストに対して生成した回答が、そのまま返すには品質が低いとき、返す前に自動で作り直したい。ここで欲しいのは、生成→評価→フィードバックを織り込んで再生成、というループです。
Spring AIはこの2つに対して、それぞれ性格の異なる仕組みを用意しています。前者には安定版の Evaluator インターフェース、後者には実験的な Recursive Advisors です。混同すると「テストで使いたいだけなのに実験的機能に手を出してしまう」といった事故につながるので、まず両者を分けて理解するのが近道です。
本論1:JUnitで回帰を検知する — Evaluatorインターフェース
Spring AIの評価の中核は、シンプルな関数型インターフェース Evaluator です。
@FunctionalInterface
public interface Evaluator {
EvaluationResponse evaluate(EvaluationRequest evaluationRequest);
}
入力となる EvaluationRequest は「ユーザーの入力」「文脈データ(RAGで取得したドキュメントなど)」「モデルの回答」の3つを束ねたものです。
public class EvaluationRequest {
private final String userText; // ユーザーの生入力
private final List<Content> dataList; // RAGなどの文脈データ
private final String responseContent; // モデルの回答
// ...
}
RelevancyEvaluator — 回答が文脈に沿っているか
RelevancyEvaluator は、回答がユーザーの質問と取得した文脈に照らして「関連しているか」をYES/NOで判定します。RAGフローの品質を測る定番です。内部では以下のようなプロンプトテンプレートで、評価用モデルに問い合わせています(デフォルト)。
Your task is to evaluate if the response for the query
is in line with the context information provided.
You have two options to answer. Either YES or NO.
...
公式リファレンスに載っている統合テストの例をそのまま引くと、RetrievalAugmentationAdvisor を通したRAGの回答を評価する流れはこうなります。
@Test
void evaluateRelevancy() {
String question = "Where does the adventure of Anacletus and Birba take place?";
RetrievalAugmentationAdvisor ragAdvisor = RetrievalAugmentationAdvisor.builder()
.documentRetriever(VectorStoreDocumentRetriever.builder()
.vectorStore(pgVectorStore)
.build())
.build();
ChatResponse chatResponse = ChatClient.builder(chatModel).build()
.prompt(question)
.advisors(ragAdvisor)
.call()
.chatResponse();
EvaluationRequest evaluationRequest = new EvaluationRequest(
question, // 元の質問
chatResponse.getMetadata().get(RetrievalAugmentationAdvisor.DOCUMENT_CONTEXT), // 取得した文脈
chatResponse.getResult().getOutput().getText() // モデルの回答
);
RelevancyEvaluator evaluator = new RelevancyEvaluator(ChatClient.builder(chatModel));
EvaluationResponse evaluationResponse = evaluator.evaluate(evaluationRequest);
assertThat(evaluationResponse.isPass()).isTrue();
}
ポイントは、evaluationResponse.isPass() が普通のJUnitアサーションに落ちるところです。つまり「LLMの出力品質」を、既存のテスト基盤(@Test・CI)の中に自然に組み込めます。
関連性の判定基準を変えたい場合は、独自のプロンプトテンプレートを差し込めます。ただし .promptTemplate() はインスタンスのメソッドではなくビルダー側のメソッドなので、上のコンストラクタ生成では指定できません。ビルダー経由で組み立てます。
RelevancyEvaluator evaluator = RelevancyEvaluator.builder()
.chatClientBuilder(ChatClient.builder(chatModel))
.promptTemplate(myPromptTemplate)
.build();
差し替えるテンプレートには query / response / context の3つのプレースホルダを必ず含めます。デフォルトのテンプレートも、この3つを埋めて評価モデルに渡す作りです。
FactCheckingEvaluator — 回答が文脈から論理的に支持されるか
FactCheckingEvaluator は、回答(claim)が与えられた文脈(document)から論理的に支持されるかを検証します。ハルシネーションの検出用途です。
ここで筆者が「なるほど」と思ったのは、評価には小型の専用モデルを使うと安上がりという設計思想でした。公式はBespoke Labsの bespoke-minicheck(Ollama経由で利用可能)を例に挙げています。
ただし、公式リファレンス(Evaluation Testing)に載っているサンプルコードは、この Evaluator が導入された当時の書き方のままで、現行バージョンではコンパイルが通りません。筆者が v1.1.0 / v2.0.0 / v2.0.1 のソースを確認したところ、次の3点が変わっていました。
| 公式リファレンスの記述 | 現行の実装 | 現行での書き方 |
|---|---|---|
new OllamaApi(baseUrl) |
コンストラクタが private
|
OllamaApi.builder().baseUrl(...).build() |
new OllamaChatModel(api, options) |
2引数のコンストラクタが無い | OllamaChatModel.builder()...build() |
new FactCheckingEvaluator(builder) |
public コンストラクタが無い(protected の2引数のみ) |
FactCheckingEvaluator.builder(cb).build() |
修正すると次のようになります。
@Test
void testFactChecking() {
// bespoke-minicheck は Spring AI に定数が用意されていないので自前で定義する
// 事前に `ollama pull bespoke-minicheck` が必要
final String BESPOKE_MINICHECK = "bespoke-minicheck";
OllamaApi ollamaApi = OllamaApi.builder()
.baseUrl("http://localhost:11434")
.build();
ChatModel chatModel = OllamaChatModel.builder()
.ollamaApi(ollamaApi)
.options(OllamaChatOptions.builder()
.model(BESPOKE_MINICHECK)
.numPredict(2)
.temperature(0.0d)
.build())
.build();
// bespoke-minicheck 用のプロンプトを使うファクトリメソッドを選ぶ
var factCheckingEvaluator =
FactCheckingEvaluator.forBespokeMinicheck(ChatClient.builder(chatModel));
String context = "The Earth is the third planet from the Sun and the only astronomical object known to harbor life.";
String claim = "The Earth is the fourth planet from the Sun.";
EvaluationRequest evaluationRequest =
new EvaluationRequest(context, Collections.emptyList(), claim);
EvaluationResponse evaluationResponse = factCheckingEvaluator.evaluate(evaluationRequest);
assertFalse(evaluationResponse.isPass(), "The claim should not be supported by the context");
}
汎用の builder(...).build() ではなく forBespokeMinicheck() をあえて選んでいるのは、この2つでプロンプトが違うためです。デフォルトは「この claim が document に支持されるか評価し、yes か no で答えよ」という指示文つきのテンプレートです。一方、bespoke-minicheck 向けは Document: と Claim: だけを渡す指示文なしのテンプレートになっています。Spring AIがわざわざファクトリメソッドを分けているのは、この専用モデルが指示文なしの入力形式を前提としているからだと読めます(公式に理由の明記はないので、ここは筆者の解釈です)。
EvaluationRequest の引数の使われ方にも注意が必要です。FactCheckingEvaluator は第1引数(userText)を document、第3引数(responseContent)を claim として読みます。RelevancyEvaluator のように「第1引数=ユーザーの質問」ではないので、同じコンストラクタでも意味が入れ替わります。
temperature(0.0d) を指定して判定を安定させているところにも注目です。評価は「創造性」ではなく「一貫性」が命なので、温度を0に寄せるのは定石です。numPredict(2) で出力トークン数を絞っているのも同じ方向の設定ですが、こちらには後述の「ハマりどころ」で触れる別の意味もあります。
本論2:実行時に作り直す — Recursive AdvisorsによるLLM-as-a-Judge
ここからは実験的機能です。Spring AIの Recursive Advisors は、Advisorチェーンの中でループを回せる仕組みです。これを使うと「生成→評価→ダメならフィードバックを足して再生成」という自己改善ループを実装できます。
ひとつ先に断っておくと、公式ブログとガイドに登場する SelfRefineEvaluationAdvisor は Spring AIが提供するクラスではありません。公式サンプルのREADMEにも "a custom SelfRefineEvaluationAdvisor" と書かれているとおり、サンプル側で定義された自作Advisorです。フレームワークが提供しているのは再帰の土台にあたる CallAdvisorChain#copy(CallAdvisor)(v2.0.1のソースで確認)で、Advisor本体は自分で書くことになります。import しようとして見つからず戸惑わないよう、先に押さえておくとよい点です。
処理の流れを図にすると次のようになります。
採点は1〜4の整数スケールで、Judge用のプロンプトには各点数の意味(1=まったく役に立たない、4=完璧)まで書き込んでおきます。評価結果は Spring AI の構造化出力で record にマッピングされます。
@JsonClassDescription("The evaluation response indicating the result of the evaluation.")
public record EvaluationResponse(int rating, String evaluation, String feedback) {}
名前が紛らわしいのですが、この EvaluationResponse は前章で使った org.springframework.ai.evaluation.EvaluationResponse(isPass() を持つSpring AI本体のクラス)とは別物です。サンプル側が構造化出力の受け皿として独自に定義したrecordで、両方を1つのプロジェクトに置くなら、片方をリネームするか完全修飾名で書く必要があります。
Advisorの組み込みはこのように、生成モデルと別のモデルをJudgeに割り当てるのが肝です。
ChatClient chatClient = ChatClient.builder(anthropicChatModel) // 生成はAnthropic
.defaultTools(new MyTools())
.defaultAdvisors(
SelfRefineEvaluationAdvisor.builder()
.chatClientBuilder(ChatClient.builder(ollamaChatModel)) // 評価はOllama(別モデル)
.maxRepeatAttempts(15)
.successRating(4)
.order(0)
.build(),
new MyLoggingAdvisor(2))
.build();
公式サンプルは、意図的に不正な値を返すツールを仕込んでいます。天気ツールが -125 / 15 / -255 からランダムに気温を返すため、3回に2回は物理的にありえない値になる作りです。Judgeがそれを検出し、「気温 -255℃は物理的に不可能」というフィードバックとともにリトライさせる挙動が確認できます。評価が非決定的な生成を「番人」として律する構図が分かりやすい例です。
なお、このサンプルはツール呼び出しのレスポンス自体は評価をスキップし、最終的なテキスト回答だけを評価する作りになっています。ツール実行の途中結果までは採点対象にしない、という切り分けです。
なお公式は、Judgeには汎用モデル(GPT-4やClaude)よりも評価特化モデルの方が概して高精度だとし、専用モデルの利用を勧めています。公式サンプルが実際に使っているのはOllama上の avcodes/flowaicom-flow-judge:q4 で、設定は次の3行だけです。
spring.ai.ollama.chat.model=avcodes/flowaicom-flow-judge:q4
spring.ai.ollama.chat.temperature=0
spring.ai.chat.client.enabled=false
Judge側の温度を0に固定していることが、設定からも見てとれます。
ハマりどころ
筆者がドキュメントとサンプルを読み込んだうえで、実戦投入前に気をつけるべきと感じた点を挙げます。
1. 生成モデルと評価モデルは分ける。 同じモデルに自分の出力を採点させると、自分に甘い評価(narcissistic bias / 自己選好バイアス)が入り込みます。公式も生成用と評価用でモデル・ChatClientインスタンスを分けることを明確に推奨しています。
2. 評価は temperature=0 に寄せる。 評価の役割は一貫性です。温度が高いと同じ回答でも採点がぶれ、テストがフレーキーになります。
3. Recursive Advisorsは実験的機能。 公式ブログ(2025-11-10)には「Spring AI 1.1.0-M4+ の新しい実験的機能」と明記されています。その後リリースされた2.0系でも実験的な位置づけは変わっておらず、公式サンプルの pom.xml は現在 Spring AI 2.0.0 / Spring Boot 4.0.7 を参照しています。制約は、ストリーミング非対応・Advisorの順序に注意が必要・LLM呼び出しが増えることでコストが増える、の3点です。安定版の Evaluator(テスト用途)と混同しないことが大切です。
4. 合否判定は「yes」の完全一致。 RelevancyEvaluator も FactCheckingEvaluator も、評価モデルの応答を strip() したうえで "yes".equalsIgnoreCase(...) で判定しています。つまり Yes. や YES, because the response covers... のように返された瞬間、判定内容が正しくても不合格になります。判定用には指示に忠実な小型モデルを選ぶこと、そして余計な説明を吐かせないことが効いてきます。公式サンプルの numPredict(2) は、コスト削減だけでなくこの判定と相性のよい設定に見えます(公式に意図の明記はないので、これは筆者の解釈です)。テストがなぜか常に落ちるときは、まず評価モデルの生の応答をログに出して確認するのが早道です。
5. 無限ループ対策を必ず入れる。 「品質基準を満たすまでリトライ」は、基準が厳しすぎると永遠に終わりません。maxRepeatAttempts のような打ち切り条件は必須です。公式サンプルも最大到達時は最後の回答をそのまま返す設計になっています。
6. コストの見積もりを忘れない。 実行時の自動リトライは、1リクエストあたり最悪で「生成N回+評価N回」のLLM呼び出しになります。安価な評価特化モデルをJudgeに使う、リトライ上限を現実的な値に抑える、といった設計上の工夫が効いてきます。
7. 最終判断は人に残す。 高リスクな用途(医療・金融など)では、LLM-as-a-Judgeを自動ゲートにしきらず、人のレビューを残すことが公式でも推奨されています。
まとめ
Spring AIでLLMの出力品質に向き合うとき、選択肢は大きく2つあります。
CI/テストで回帰を検知したいなら、安定版の Evaluator(RelevancyEvaluator / FactCheckingEvaluator)を使うのが素直です。isPass() がそのまま普通のJUnitアサーションに落ちます。
実行時に品質の低い回答を自動で作り直したいなら、実験的な Recursive Advisors による自己改善ループが選択肢になります。ただしコスト・バイアス・ループ制御の設計がセットで必要です。
共通する原則は、「生成と評価のモデルを分ける」「評価は温度0で一貫させる」「打ち切り条件を必ず持つ」「最終判断は人に残す」の4点です。
そしてもうひとつ、本記事を書きながら実感したことがあります。公式リファレンスのサンプルコードであっても、実装から取り残されていることがある、ということです。評価まわりのように動かして初めて分かる領域では、利用中のバージョンのソースやJavadocまで一度は降りて確認しておくと安全です。
まずはテスト用途の Evaluator から始め、RAGの回帰検知をCIに組み込むところから手を付ける。費用対効果の面でも、これがいちばん入りやすい第一歩だと筆者は考えています。
参考
-
Evaluation Testing :: Spring AI Reference(
RelevancyEvaluator/FactCheckingEvaluatorの公式リファレンス) - LLM Response Evaluation with Spring AI: Building LLM-as-a-Judge Using Recursive Advisors(Spring公式ブログ, 2025-11-10)
- LLM-as-a-Judge Evaluation :: Spring AI Reference(ガイド)
- evaluation-recursive-advisor-demo(公式サンプル, GitHub)
- Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena(arXiv:2306.05685)