LLMの出力でコードを分岐するとテストが難しくなる ― 決定論的テストと統計的評価を分離する
LLMをアプリケーションに組み込んでいると、LLMの処理結果によって後続のプログラムを分岐させたくなる。
たとえば、入力内容をLLMに評価させて、その判定結果によって処理を切り替えるようなコードだ。
var result = await llm.AnalyzeAsync(input);
if (result.Decision == "approve")
{
Approve();
}
else
{
Reject();
}
実装としては自然に見える。
ところが、このような構造を実際にテストしようとすると、従来のアプリケーションとは少し違った難しさが出てくる。
理由は単純で、
コードの分岐がLLMの出力に依存すると、コードの動作検証までLLMの挙動に依存してしまう
からだ。
この記事では、LLMを利用するアプリケーションのテストを、
- 通常のプログラムロジックを検証するテスト
- LLMそのものの品質を評価するテスト
に分けて考えてみる。
さらに、実際のLLM評価では、同じテストデータを何度も入力して結果を統計的に見る、という考え方についても触れる。
通常のプログラムなら結果は決まっている
一般的なプログラムのUnit Testでは、入力と期待する結果を決める。
入力
↓
処理
↓
期待値
たとえば、
Assert.True(IsAdult(20));
のような処理なら、同じ入力を何回実行しても結果は同じになる。
20 → true
20 → true
20 → true
20 → true
そのため、
期待値と一致した
↓
PASS
という考え方でテストできる。
LLMが間に入ると話が変わる
ところが、途中にLLMが入ると少し事情が変わる。
入力
↓
LLM
↓
判定
↓
コードの分岐
たとえば、
var result = await llm.AnalyzeAsync(input);
if (result.Decision == "approve")
{
Approve();
}
else
{
Reject();
}
という処理があったとする。
テストしたいのは、
approve
↓
Approve() が呼ばれる
というC#側のロジックかもしれない。
しかし、テスト時に実際のLLMを呼び出すと、
テスト入力
↓
LLM
↓
approve / reject
↓
C#の分岐
となる。
テストが失敗したとき、
- C#の分岐ロジックに問題がある
- LLMが想定と違う回答をした
- プロンプトに問題がある
- 入力データが曖昧だった
- 構造化出力の変換に失敗した
など、複数の原因を考えなくてはいけない。
つまり、
プログラムのテストとLLMの品質評価が混ざってしまう。
LLMは同じ入力でも結果が揺れる
LLMをテストするときに特に重要なのが、この点だ。
同じ入力を与えても、毎回完全に同じ判断になるとは限らない。
たとえば、ある入力に対して期待する回答が approve だったとしても、
1回目 approve
2回目 approve
3回目 approve
4回目 reject
5回目 approve
のようになることがある。
この場合、
1回実行して approve だった
だけでは、本当に安定して動いているかは分からない。
逆に、
1回実行して reject だった
だけを見て、
このプロンプトはダメだ
と判断するのも早い。
LLMの評価では、従来のUnit Testとは違った見方が必要になる。
実際のLLM評価では同じデータを何度も入力する
LLMの処理結果を評価するときは、同じテストデータを何度も入力し、その結果を蓄積して見る方法が使いやすい。
たとえば、期待値が approve のテストデータを100回実行したとする。
結果が、
approve 94回
reject 6回
だった場合、
期待値との一致率 = 94%
と評価できる。
通常のUnit Testでは、
PASS / FAIL
で考えることが多い。
一方、LLMでは、
どの程度の確率で期待した結果になるか
を見る。
ここが大きく違う。
複数のテストケースを統計的に見る
実際には、1つのデータだけではなく複数のテストケースを用意する。
たとえば次のような結果になったとする。
| テストケース | 期待値 | 試行回数 | 一致回数 | 一致率 |
|---|---|---|---|---|
| A | approve | 100 | 98 | 98% |
| B | reject | 100 | 91 | 91% |
| C | approve | 100 | 76 | 76% |
| D | reject | 100 | 100 | 100% |
この結果を見ると、
ケースA → 比較的安定
ケースB → 少し揺れる
ケースC → かなり不安定
ケースD → 非常に安定
ということが分かる。
単純に、
LLMが正解した
という評価では見えなかった問題が見えてくる。
ケースCについては、
なぜ24%も期待値から外れるのか
を調べることができる。
原因としては、
- プロンプトの指示が曖昧
- テストデータ自体が曖昧
- 判断基準が明確でない
- 出力形式の指示が弱い
- LLMに任せるには難しい判断だった
などが考えられる。
プロンプト変更の効果も比較できる
繰り返し評価する仕組みを作っておくと、プロンプト改善の効果も比較しやすい。
たとえば、
| プロンプト | 一致率 |
|---|---|
| Prompt v1 | 82% |
| Prompt v2 | 89% |
| Prompt v3 | 94% |
という結果になれば、
Prompt v3の方が何となく良さそう
ではなく、
同じ評価データを繰り返し実行した結果、
一致率が82%から94%まで改善した
と評価できる。
モデルを変更した場合も同じだ。
Model A
↓
評価データを100回ずつ実行
Model B
↓
同じ評価データを100回ずつ実行
として比較すれば、モデル変更による影響も見やすくなる。
ここでUnit TestとLLM Evaluationを分ける
ここまで考えると、LLMアプリケーションではテストを2種類に分けた方がよいことが分かる。
1. プログラムロジックのテスト
こちらは通常のUnit Testとして扱う。
固定されたLLM結果
↓
C#のロジック
↓
期待した処理になるか
このテストではLLMを呼ばない。
確認したいのは、
- if / switch の分岐
- 状態遷移
- データ保存
- API呼び出し
- エラー処理
- 後続処理
などである。
2. LLMの品質評価
こちらでは実際のLLMを呼び出す。
テストデータ
↓
LLM
↓
結果を記録
↓
繰り返す
↓
統計的に評価
確認したいのは、
この入力に対して、
どの程度安定して期待した判断を返すか
ということになる。
こちらは通常のUnit Testというより、
LLM Evaluation
として考えた方が自然だと思う。
LLM呼び出しとコードの分岐を分離する
そのためには、LLM呼び出し部分をアプリケーションロジックから分離しておく必要がある。
たとえば、LLMの結果を次のような型にする。
public record AnalysisResult(
string Decision,
string Reason);
LLMを呼び出す部分はInterfaceにする。
public interface IAnalyzer
{
Task<AnalysisResult> AnalyzeAsync(
string input);
}
本番環境ではLLMを利用する。
public class LlmAnalyzer : IAnalyzer
{
public async Task<AnalysisResult> AnalyzeAsync(
string input)
{
// LLMを呼び出す処理
return new AnalysisResult(
"approve",
"...");
}
}
アプリケーション側はInterfaceだけを見る。
public class ApplicationService
{
private readonly IAnalyzer _analyzer;
public ApplicationService(IAnalyzer analyzer)
{
_analyzer = analyzer;
}
public async Task ExecuteAsync(string input)
{
var result =
await _analyzer.AnalyzeAsync(input);
if (result.Decision == "approve")
{
Approve();
}
else
{
Reject();
}
}
private void Approve()
{
}
private void Reject()
{
}
}
テスト時はLLMを呼ばない
Unit Testでは、LLMの代わりにFakeを使う。
public class FakeAnalyzer : IAnalyzer
{
private readonly AnalysisResult _result;
public FakeAnalyzer(AnalysisResult result)
{
_result = result;
}
public Task<AnalysisResult> AnalyzeAsync(
string input)
{
return Task.FromResult(_result);
}
}
これなら、
本番
ApplicationService
↓
IAnalyzer
↓
LLM
に対して、
テスト
ApplicationService
↓
IAnalyzer
↓
FakeAnalyzer
と置き換えられる。
テスト時には、
var analyzer =
new FakeAnalyzer(
new AnalysisResult(
"approve",
"テスト用"));
var service =
new ApplicationService(analyzer);
のように、LLMの結果を強制的に決められる。
これでC#の動作確認にLLMの揺らぎが入ってこなくなる。
LLMに最終決定をさせる必要があるのか
もう一つ考えたいのが、LLMに何を返させるかという点だ。
たとえばLLMから、
{
"decision": "reject"
}
と返してもらい、そのままコードを分岐させることもできる。
LLM
↓
reject
↓
処理B
しかし場合によっては、LLMには最終決定ではなく、判断材料だけを返してもらった方がよい。
たとえば、
{
"hasProblem": true,
"severity": 3,
"reason": "必要な情報が不足しています"
}
という結果を返してもらう。
最終的な処理はC#で決める。
if (result.HasProblem &&
result.Severity >= 3)
{
RequestAdditionalInformation();
}
この場合は、
LLM
↓
意味的な評価
↓
構造化された結果
↓
決定論的なコード
↓
システムの処理
となる。
意味の判断とシステム制御を分ける
LLMが得意なのは、
- 文脈の理解
- 曖昧な文章の評価
- 意図の推測
- 矛盾の検出
- 回答の十分性の判断
などである。
一方で、
severity >= 3なら処理A
それ以外なら処理B
といった処理は、通常のプログラムの方が得意だ。
そこで、
意味の判断
↓
LLM
システム制御
↓
コード
と役割を分ける。
すべてをコードに寄せる必要はないし、すべてをLLMに任せる必要もない。
ただしLLMによる最終判断が必要な場合もある
実際のアプリケーションでは、
この回答は十分か
追加質問が必要か
この文章は適切か
といった、明確な数値ルールにしにくい判断もある。
そうした処理では、
continue / complete
approve / reject
safe / unsafe
のような最終的な判断そのものをLLMに任せることもある。
それ自体が悪いわけではない。
重要なのは、
LLMによる判断部分と、その判断を受けて動作するプログラム部分を分離しておくこと
だと思う。
そうすれば、
LLMの判断精度
と、
その判断を受けたコードが正しく動くか
を別々に確認できる。
LLM Evaluationでは平均だけを見ない
さらに統計的に見る場合、一致率だけでは見えないものもある。
たとえば100回中90回正解する2つのテストケースがあったとしても、
ケースA
最初の90回成功
最後の10回失敗
と、
ケースB
10回に1回程度ランダムに失敗
では、挙動の特徴が違う可能性がある。
また、分類問題であれば、
- 正解率
- False Positive
- False Negative
- クラスごとの成功率
などを見ることもできる。
特に、
問題のない入力を問題ありと判定する
のと、
問題のある入力を問題なしと判定する
では、業務上の重みが全く違うこともある。
そのため、本格的に評価する場合は、
単純な正解率
だけではなく、
どのように間違えているのか
も見る必要がある。
境界ケースをテストデータに入れる
LLMのテストデータを作るときには、分かりやすいケースだけを用意してもあまり意味がない。
たとえば、
明らかに approve
明らかに reject
だけでは高い一致率が出て当然かもしれない。
むしろ重要なのは、
approve と reject の境界にあるケース
だ。
たとえば、
ケース1
明確にapprove
ケース2
ややapprove寄り
ケース3
判断が難しい
ケース4
ややreject寄り
ケース5
明確にreject
というデータを用意すると、どこでLLMの判断が不安定になるのかが見えてくる。
このような境界データは、プロンプト改善にも役立つ。
LLMのテストでは「100%」を前提にしない
通常のUnit Testでは、
100件中99件成功
は基本的にはテスト失敗だ。
しかしLLMでは、必ずしもそうとは限らない。
たとえば、
期待一致率 95%以上
を品質基準にすることも考えられる。
もちろん、何%を合格とするかはシステムによる。
重要な処理なら、
99.9%以上
が必要かもしれない。
逆に、利用者への参考サジェスチョンであれば、
90%以上
でも十分な場合がある。
つまりLLMでは、
テストの期待値だけではなく、許容できる品質水準そのものを決める必要がある。
まとめ
LLMの処理結果によってコードを分岐すること自体は珍しくない。
ただし、その構造のままテストを行うと、
LLMの品質
と、
プログラムの品質
が混ざってしまう。
そこで、
LLM Evaluation
同じテストデータ
↓
何度もLLMへ入力
↓
結果を記録
↓
一致率などを統計的に評価
と、
Unit Test
固定されたLLM結果
↓
C#のロジック
↓
決定論的に動作確認
を分離する。
LLMを使う部分については、
- 同じテストデータを複数回実行する
- 結果を蓄積する
- 一致率を見る
- 誤判定の種類を見る
- 境界ケースを見る
- プロンプトやモデル変更前後を比較する
という評価が必要になる。
一方、通常のアプリケーションロジックについては、
- LLMをInterfaceの後ろに隠す
- FakeやMockを使う
- LLMの結果を固定する
- 分岐や状態遷移を通常のUnit Testで確認する
という従来のテスト手法が使える。
LLMアプリケーションを作っていて感じるのは、
「LLMをテストすること」と「LLMを使ったプログラムをテストすること」は別物
だということだ。
LLMの出力には揺らぎがある。
だからこそ、
LLMは統計的に評価し、プログラムは決定論的にテストする。
この2つを分けて設計することが、LLMを組み込んだアプリケーションを検証しやすくする一つの方法になる。