LLM出力は毎回変わる:Contract Testingで「変動に強い」テストを作る

この記事は、Growth Lab編集部 が AI / Contract Testing / LLM の観点から検証結果を整理したものです。
読了前に全体像を掴み、その後に目次から必要な節へ進める構成を想定しています。
目次を表示
TL;DR
- LLMの出力は毎回変わるが「守るべき契約」は定義できる
- スキーマ検証・セマンティック類似度・ゴールデンテストの3層で対処する
- プロンプト変更のリグレッションを自動検知する仕組みが必要
はじめに
本記事は、AI生成コードのテスト戦略を3層で整理した親記事の「層2: Contract Testing」を深掘りします。 👉 AI生成コードのテスト戦略:品質を「祈り」から「仕組み」に変える
LLMを組み込んだ機能のテストで、多くのエンジニアが最初に書くのはこんなテストです。
test("要約機能が正しい結果を返す", () => {
const result = summarize("長い記事テキスト...");
expect(result).toBe("AIは開発を加速する");
});
このテストは、書いた瞬間は通ります。しかし次の実行では「AIは開発速度を向上させる」が返ってきて失敗する。LLMの出力は非決定的だからです。
「じゃあLLMのテストは書けないのか?」。答えはNoです。テストすべきは「文字列の完全一致」ではなく「契約(Contract)の遵守」。マイクロサービス間のContract Testingと同じ発想を、LLM統合の境界に適用します。
1. LLM統合の「テスト不可能性」という幻想
非決定的 ≠ テスト不可能
LLM出力は非決定的ですが、以下の要素は安定しています。
- 構造: JSONレスポンスのフィールド名・型・ネスト構造
- 制約: 出力の文字数上限、言語、フォーマット
- 意味: 入力に対する出力の意味的な関連性
天気予報を考えてみてください。「明日の気温は23.4度です」と「明日は23度前後になるでしょう」は文字列としては異なりますが、伝えている意味は同じです。LLMのテストも同様に、「意味のレベル」で検証すればいい。
Contract Testingの基本思想
マイクロサービスアーキテクチャでは、サービス間の「契約」を定義し、各サービスが契約を遵守しているかを独立にテストします。
この考え方をLLM統合に適用すると、以下のようになります。
[あなたのアプリ] <-- Contract --> [LLM API]
Contract:
- レスポンスはJSON形式であること
- summaryフィールドが存在すること
- summaryは200文字以内であること
- summaryは入力テキストの主要なトピックを含むこと
2. 3層のContract Testing
層1: スキーマ検証(Structure Contract)
目的: レスポンスの構造が期待通りであることを保証する。最もシンプルで、最も確実な層。
import { z } from "zod";
// LLMレスポンスの契約をスキーマで定義
const SummaryResponseSchema = z.object({
summary: z.string().max(200),
keywords: z.array(z.string()).min(1).max(10),
sentiment: z.enum(["positive", "negative", "neutral"]),
confidence: z.number().min(0).max(1),
});
test("LLMレスポンスがスキーマに準拠する", async () => {
const response = await callLLM({
prompt: summarizePrompt,
input: sampleArticle,
});
// スキーマ検証 — 構造の契約を確認
const parsed = SummaryResponseSchema.safeParse(response);
expect(parsed.success).toBe(true);
});
スキーマ検証で検出できるもの:
- 必須フィールドの欠落(LLMがフィールドを省略した)
- 型の不一致(数値であるべき場所に文字列が入った)
- enum値の逸脱(想定外のsentiment値が返った)
- 制約違反(200文字以内の制約を超えた)
スキーマ検証の限界:
構造は正しくても、内容が的外れな場合を検出できません。「summaryフィールドは存在するが、入力と無関係な文章が入っている」ケースは、次の層で対処します。
層2: セマンティック検証(Semantic Contract)
目的: 出力の「意味」が入力と適切に関連しているかを検証する。
import { cosineSimilarity, getEmbedding } from "./embedding-utils";
test("要約が元の記事と意味的に関連している", async () => {
const article = "TypeScriptの型システムは...(長い記事)";
const summary = await summarize(article);
// 元の記事と要約の埋め込みベクトルを取得
const articleEmbedding = await getEmbedding(article);
const summaryEmbedding = await getEmbedding(summary.summary);
// コサイン類似度で意味的な関連性を検証
const similarity = cosineSimilarity(articleEmbedding, summaryEmbedding);
// 閾値: 0.7以上なら「意味的に関連している」と判定
expect(similarity).toBeGreaterThan(0.7);
});
閾値の設定と調整:
セマンティック検証の最大の課題は「閾値をどう設定するか」です。
- 初期値: 0.7からスタート(経験的に妥当な値)
- 調整方法: 10〜20件のサンプルで類似度を計測し、「人間が見てOKと判断した出力」の最低値を閾値にする
- モニタリング: 閾値ギリギリの結果が増えたら、プロンプトの品質劣化を疑う
セマンティック検証の限界:
埋め込みベクトルの類似度は「トピックの関連性」を測れますが、「事実の正確性」は検証できません。「TypeScriptの型システムは素晴らしい」と「TypeScriptの型システムは不要だ」は、トピックとしては近いため高い類似度が出ます。事実の正確性が重要な場合は、層3のゴールデンテストで対処します。
層3: ゴールデンテスト(Golden Test)
目的: 人間が承認した「期待出力」をスナップショットとして保存し、プロンプト変更時にリグレッションを検知する。
describe("要約プロンプトのゴールデンテスト", () => {
// テストデータ: 入力と人間が承認した期待出力のペア
const goldenCases = [
{
input: "TypeScriptの型安全性について...",
expectedTopics: ["TypeScript", "型安全", "静的型付け"],
expectedSentiment: "positive",
},
{
input: "レガシーコードの移行戦略...",
expectedTopics: ["レガシー", "移行", "リファクタリング"],
expectedSentiment: "neutral",
},
];
goldenCases.forEach(({ input, expectedTopics, expectedSentiment }) => {
test(`ゴールデンケース: ${input.slice(0, 20)}...`, async () => {
const result = await summarize(input);
// トピックの包含を検証(完全一致ではなく包含)
expectedTopics.forEach((topic) => {
expect(result.keywords).toContain(topic);
});
// 感情分析の一致を検証
expect(result.sentiment).toBe(expectedSentiment);
});
});
});
ゴールデンテストの運用フロー:
- 初回: LLMの出力を人間が確認し、「これが正しい」と承認
- 通常時: CIでゴールデンテストが自動実行される
- プロンプト変更時: ゴールデンテストが失敗 → 差分を確認 → 新しい出力を承認 or プロンプトを修正
3. プロンプト変更のリグレッション検知
プロンプトはコードと同じように変更されます。しかし多くのチームでは、プロンプトの変更がテストなしで本番に反映されています。
プロンプトのバージョン管理
プロンプトもコードと同じくGitで管理し、変更にはテストを伴わせます。
prompts/
summarize/
v1.0.0.md # 初期バージョン
v1.1.0.md # キーワード抽出を追加
v2.0.0.md # 出力形式をJSON化
golden/
case-001.json # ゴールデンテストデータ
case-002.json
変更時の自動検証フロー
graph LR
A["プロンプト変更"] --> B["CI: ゴールデンテスト実行"]
B --> C{"すべてパス?"}
C -->|はい| D["自動マージ可能"]
C -->|いいえ| E["差分レビュー"]
E --> F{"意図した変更?"}
F -->|はい| G["ゴールデンデータ更新"]
F -->|いいえ| H["プロンプト修正"]
この仕組みにより、「プロンプトを少し変えただけで、既存機能が壊れた」というリグレッションを、本番デプロイ前に検知できます。
CIパイプラインでContract Testingをどう組み込むか、リスクベースでテスト深度を変える設計については、次の記事で解説します。 👉 AI生成PRの品質ゲートを自動化する:リスクベースCI設計
まとめ
LLMの出力は非決定的ですが、「テスト不可能」ではありません。テストの粒度を変えることで、品質を仕組みとして担保できます。
- 層1(スキーマ検証): 構造の契約を守らせる。最もシンプルで確実
- 層2(セマンティック検証): 意味の関連性を埋め込みベクトルで検証
- 層3(ゴールデンテスト): 人間が承認した期待出力でリグレッション検知
3層を組み合わせることで、「文字列が一致しないからテストが書けない」という思い込みから脱却できます。
Growth Lab編集部
AI / Contract Testing / LLM
AIエージェント開発、記事制作フロー、デザインシステム運用の接続を実装ベースで検証し、再現可能な手順へ落とし込むことを目的に運営しています。
あわせて読む
同じテーマや近い文脈の記事を続けて読めるようにする。
AIエージェントによるブログ自動運用の教科書:マルチエージェントで実現する戦略的コンテンツ生成
AIエージェントとマルチエージェントアーキテクチャを活用して、ブログ運用を半自動で仕組み化し、高品質なコンテンツを継続的に生み出すための戦略と実践フローを解説します。
プロンプトからエージェントへ:AI駆動開発の新領域「エージェントエンジニアリング」への転換
AIへのアプローチを『指示(Prompt)』から『仕組み(Agent Engineering)』へ転換。SDDの重要性を理解し、AIを真の自律的な開発パートナーにするためのマインドセットを解説します。
継続接点
更新を追いかける
新着記事、特集、検証ログをまとめて追える入口として使う。メール購読導線の本実装前でも、継続接点を切らさない。
- 新着記事をまとめて確認できる
- 関連記事や特集ページへつながる
- 実験ログを継続的に追える
本実装ではメール購読や通知機能へ差し替え可能。