開発ノート

プロンプト回帰テスト:変更で壊れていないかを検知する

管理者2026.06.11 公開 ・ 25 min read
プロンプト回帰テスト:変更で壊れていないかを検知する

01TL;DR

  • プロンプトの回帰テストは「ゴールデンデータセット × 評価関数」の掛け合わせで構成されます。
  • アサーションは「決定的チェック(完全一致・正規表現・スキーマ)」と「LLMジャッジ」の2層に分けて使い分けます。
  • LLMの非決定性には、温度0固定と複数回実行の多数決で対処します。
  • GitHub Actionsに組み込む際は、コストと実行時間の予算を先に決めてからCIを設計します。
  • テストが失敗したときの運用フロー(差し戻し・緊急ロールバック・根本原因の記録)まで設計して初めて回帰テストは機能します。

02はじめに

この記事の対象読者

次のような状況にある方を想定しています。

  • LLMを使ったアプリケーションをプロダクション運用していて、プロンプト変更のたびに手動確認が必要になっている
  • プロンプトを改善しようとすると「直したケースは良くなったが別のケースが壊れる」サイクルにはまっている
  • コードのユニットテストは書いているが、プロンプトには同等の安全網がない

前提として、Node.js(v22.x)とTypeScript(v5.4以降)の基本的な読み書きができることを想定しています。 テストフレームワークはVitest(v1.6以降)を使います。 GitHub Actionsの基本的な設定ファイルの書き方を知っていると、後半のCI設定を読みやすくなります。

この記事で得られること

  • プロンプト回帰テストの設計思想と全体像
  • ゴールデンデータセットの作り方と管理方針
  • 決定的アサーションとLLMジャッジの使い分け基準
  • 非決定性への対処方法
  • GitHub ActionsへのCI組み込み手順
  • コストと実行時間の管理方法
  • 失敗時の運用フロー

この記事のスコープ

プロンプトの評価手法全般については(関連記事: 評価設計の基本:エージェントの品質をどう測るか)で扱っています。 プロンプトのバージョン管理とリリースフローについては(関連記事: プロンプトのバージョン管理とリリースフロー)を参照してください。 本記事は「変更時に自動で壊れを検知する仕組みを作る」ことに絞って解説します。


03なぜプロンプトに回帰テストが必要か

コードとプロンプトの本質的な類似性

ソフトウェア開発では、既存の機能を壊さずに変更を加えられることをリグレッション(退行)しないと言います。 コードに対してはユニットテストや統合テストを書いてこれを保証する習慣が定着しています。

プロンプトも同じです。 「明日の予定を要約してください」という指示の後ろに「ただし、場所の情報は省略してください」という1行を加えた瞬間、 これまで正しく動いていた「場所情報を含む要約」のケースが壊れることがあります。 プロンプトはコードと同様に、変更が予期しない副作用を生む成果物です。

手動確認の限界

規模が小さいうちは、変更のたびにいくつかの入力例を試して目視で確認する方法でも回ります。 ただし、ケース数が増えるにつれてその方法は破綻します。

筆者たちが関わるプロジェクトでも、最初は10ケース程度だった確認項目がいつの間にか80ケースを超えていて、 プロンプトを1文修正するたびに半日かけて手動確認する状況になったことがあります。 手動確認は、確認者の疲労によって見落としが発生しやすく、確認範囲の一貫性も保ちにくいです。

自動化できる範囲とできない範囲

すべての評価を自動化できるわけではありません。 「この回答はユーザーに寄り添っているか」といった主観的な品質は、最終的に人間の判断が必要です。 一方で、「JSON形式で出力されているか」「特定のキーを含むか」「禁止ワードが含まれていないか」といった 構造的な品質は、自動チェックに適しています。

回帰テストは「明らかな破綻を自動で検知する」ことを目的とすると割り切ると、設計が整理しやすくなります。 すべてを自動で評価しようとすると、評価システム自体の複雑さが膨れ上がります。


04設計の全体像

2軸の組み合わせ

プロンプト回帰テストは、次の2軸を組み合わせて設計します。

ゴールデンデータセット(入力の集合)
    × 評価関数(出力をどう判定するか)

ゴールデンデータセットは、テストに使う入力と期待出力のペア集合です。 重要なのは、バグが過去に発生したケースや、境界値を意図的に含めることです。 単純に「よく来る入力」だけを並べると、問題が起きやすいエッジケースを見逃します。

評価関数は、実際の出力が期待に沿っているかを判定するロジックです。 完全一致で判定できるものから、「だいたいこういう内容を含んでいればよい」という曖昧なものまで、 評価の性質に応じて複数の関数を使い分けます。

評価の2層構造

評価関数は「決定的アサーション」と「LLMジャッジ」の2層に分けます。

flowchart TD
    L1["Layer 1: 決定的アサーション(高速・安価)\n・JSON構造チェック\n・必須フィールドの存在確認\n・正規表現マッチ\n・文字数・トークン数の範囲チェック\n・禁止ワードの不在確認"]
    L2["Layer 2: LLMジャッジ(低速・高コスト)\n・内容の正確性\n・トーン・スタイルの適切さ\n・意図の充足度"]
    L1 -->|"Layer 1 通過後のみ"| L2

Layer 1で明らかな破綻を弾いてから、Layer 2で内容の品質を評価します。 Layer 1の失敗はLLM呼び出しを省略できるため、コストと時間を抑えられます。


05ゴールデンデータセットの設計

データセットの構造

ゴールデンデータセットはJSONLファイルで管理します。 1行に1ケースを記述し、Gitでバージョン管理できる形式にします。

// src/testing/types.ts

export type AssertionType =
  | "exact_match"
  | "contains"
  | "not_contains"
  | "regex"
  | "json_schema"
  | "llm_judge";

export interface Assertion {
  type: AssertionType;
  // exact_match / contains / not_contains の場合
  value?: string;
  // regex の場合
  pattern?: string;
  // json_schema の場合
  schema?: Record<string, unknown>;
  // llm_judge の場合
  criteria?: string;
  // LLMジャッジのパスしきい値(0.0〜1.0)
  threshold?: number;
}

export interface AssertionResult {
  assertionType: string;
  passed: boolean;
  message: string;
}

export interface GoldenCase {
  id: string;
  description: string;
  // どのプロンプトテンプレートを使うか
  promptId: string;
  // プロンプトテンプレートへの変数
  input: Record<string, string>;
  // 評価基準のリスト
  assertions: Assertion[];
  // このケースが追加された経緯(任意)
  addedBecause?: string;
  // タグ(特定のタグだけ実行したい場合に使う)
  tags?: string[];
}

次に、実際のデータセットファイルの例を示します。

{"id":"summarize-001","description":"基本的な要約","promptId":"meeting-summarizer","input":{"transcript":"10時から30分、田中・佐藤・山田が参加。来月の展示会出展について議論。出展ブース数は2から3に増やす方向で合意。担当は田中。予算確認は佐藤が来週木曜までに実施。"},"assertions":[{"type":"json_schema","schema":{"type":"object","required":["summary","action_items","attendees"],"properties":{"summary":{"type":"string"},"action_items":{"type":"array","items":{"type":"object","required":["owner","deadline","task"]}},"attendees":{"type":"array","items":{"type":"string"}}}}},{"type":"contains","value":"田中"},{"type":"llm_judge","criteria":"アクションアイテムに担当者・期限・タスク内容が揃っているか","threshold":0.8}],"addedBecause":"基本的な入力でのスキーマ整合性確認","tags":["smoke","schema"]}
{"id":"summarize-002","description":"アクションアイテムなしの議事録","promptId":"meeting-summarizer","input":{"transcript":"15時から45分、石井・中村が参加。先月のプロジェクト振り返り。全体的に順調だったという評価で終了。次回は2か月後を予定。"},"assertions":[{"type":"json_schema","schema":{"type":"object","required":["summary","action_items","attendees"]}},{"type":"llm_judge","criteria":"アクションアイテムが実質ない内容なのに、action_itemsが空配列または空に近い場合を合格とする","threshold":0.8}],"addedBecause":"アクションなし議事録で誤ってアイテムを生成するバグへの対処","tags":["edge-case"]}
{"id":"summarize-003","description":"個人情報を含む議事録","promptId":"meeting-summarizer","input":{"transcript":"採用面接の結果を共有。候補者A氏(連絡先: 090-XXXX-XXXX)は不採用。候補者B氏は二次面接へ進む。"},"assertions":[{"type":"not_contains","value":"090-"},{"type":"not_contains","value":"XXXX"},{"type":"llm_judge","criteria":"個人の連絡先情報がサマリーに含まれていないか","threshold":0.9}],"addedBecause":"個人情報漏洩防止の確認","tags":["security","pii"]}

ケースを増やすタイミング

ゴールデンデータセットは最初から完璧にしようとする必要はありません。 次のタイミングで追加する習慣をつけると自然に充実します。

  • バグが本番で発生したとき、その入力をそのままケースとして追加する
  • プロンプトレビュー時に「このケースは試した?」と気になったものを追加する
  • 仕様変更で新しい入力パターンが生まれたとき

「バグが発生したら必ずケースを追加する」というルールを設けると、 データセットが本番で問題になった実績のあるケースの集合になり、品質が上がります。


06Layer 1:決定的アサーション

アサーションランナーの実装

// src/testing/deterministic-assertions.ts

import Ajv from "ajv";
import type { Assertion, AssertionResult } from "./types.js";

const ajv = new Ajv();

export function runDeterministicAssertions(
  output: string,
  assertions: Assertion[]
): AssertionResult[] {
  const deterministicTypes: Assertion["type"][] = [
    "exact_match",
    "contains",
    "not_contains",
    "regex",
    "json_schema",
  ];

  return assertions
    .filter((a) => deterministicTypes.includes(a.type))
    .map((assertion) => runSingleAssertion(output, assertion));
}

function runSingleAssertion(
  output: string,
  assertion: Assertion
): AssertionResult {
  switch (assertion.type) {
    case "exact_match": {
      const passed = output.trim() === (assertion.value ?? "").trim();
      return {
        assertionType: "exact_match",
        passed,
        message: passed
          ? "完全一致"
          : `期待値: "${assertion.value}" / 実際: "${output.substring(0, 100)}..."`,
      };
    }

    case "contains": {
      const passed = output.includes(assertion.value ?? "");
      return {
        assertionType: "contains",
        passed,
        message: passed
          ? `"${assertion.value}" を含む`
          : `"${assertion.value}" が含まれていません`,
      };
    }

    case "not_contains": {
      const passed = !output.includes(assertion.value ?? "");
      return {
        assertionType: "not_contains",
        passed,
        message: passed
          ? `"${assertion.value}" を含まない`
          : `"${assertion.value}" が含まれています(含まれてはいけない)`,
      };
    }

    case "regex": {
      const regex = new RegExp(assertion.pattern ?? "");
      const passed = regex.test(output);
      return {
        assertionType: "regex",
        passed,
        message: passed
          ? `パターン /${assertion.pattern}/ にマッチ`
          : `パターン /${assertion.pattern}/ にマッチしません`,
      };
    }

    case "json_schema": {
      let parsed: unknown;
      try {
        // JSONを含むテキストからJSON部分を抽出
        const jsonMatch = output.match(/\{[\s\S]*\}/);
        if (!jsonMatch) throw new Error("JSONが見つかりません");
        parsed = JSON.parse(jsonMatch[0]);
      } catch (e) {
        return {
          assertionType: "json_schema",
          passed: false,
          message: `JSONのパースに失敗しました: ${(e as Error).message}`,
        };
      }

      const validate = ajv.compile(assertion.schema ?? {});
      const passed = validate(parsed) as boolean;
      return {
        assertionType: "json_schema",
        passed,
        message: passed
          ? "スキーマに適合"
          : `スキーマ違反: ${ajv.errorsText(validate.errors)}`,
      };
    }

    default:
      return {
        assertionType: assertion.type,
        passed: false,
        message: `未知のアサーションタイプ: ${assertion.type}`,
      };
  }
}

このコードは ajv(JSON Schema バリデーター)を使っています。 インストールが必要な場合は次のコマンドを実行してください。

npm install ajv

実行結果の例

✓ json_schema: スキーマに適合
✓ contains: "田中" を含む
✗ not_contains: "090-" が含まれています(含まれてはいけない)

Layer 1のアサーションはLLM呼び出しを行わないため、実行速度が速く、コストもかかりません。 CIで毎回回すのに適しています。


07Layer 2:LLMジャッジ

なぜLLMジャッジが必要か

決定的アサーションで確認できるのは「形式の正しさ」です。 「内容の正確さ」や「表現の適切さ」は、文字列のマッチングでは捉えられません。

たとえば「アクションアイテムの担当者と期限が正しく対応しているか」という確認は、 出力のテキストに「田中」と「来週木曜」が含まれていれば通過しますが、 田中が担当者として期限と紐づいているかはわかりません。 この種の判断には、LLMジャッジが有効です。

LLMジャッジの実装

// src/testing/llm-judge.ts

import OpenAI from "openai";
import type { Assertion, AssertionResult } from "./types.js";

// LLMジャッジはシングルトンで初期化する
const client = new OpenAI();

const JUDGE_SYSTEM_PROMPT = `あなたは出力品質の評価者です。
与えられた評価基準に基づいて、AIの出力を0.0〜1.0のスコアで評価してください。

回答は必ず次のJSON形式で返してください。
{
  "score": <0.0から1.0の数値>,
  "reason": "<評価の根拠を1〜2文で>"
}

- 1.0: 基準を完全に満たしている
- 0.8: 基準をほぼ満たしている(軽微な問題あり)
- 0.6: 基準を部分的に満たしている
- 0.4: 基準を一部しか満たしていない
- 0.0: 基準を満たしていない`;

interface JudgeResponse {
  score: number;
  reason: string;
}

export async function runLlmJudge(
  input: string,
  output: string,
  assertion: Assertion
): Promise<AssertionResult> {
  if (assertion.type !== "llm_judge") {
    throw new Error("LLMジャッジ以外のアサーションが渡されました");
  }

  const userPrompt = `## 評価基準
${assertion.criteria}

## 入力
${input}

## 評価対象の出力
${output}

上記の出力を評価基準に照らして評価してください。`;

  const response = await client.chat.completions.create({
    model: "gpt-5.4-mini",
    response_format: { type: "json_object" },
    messages: [
      { role: "system", content: JUDGE_SYSTEM_PROMPT },
      { role: "user", content: userPrompt },
    ],
  });

  const content = response.choices[0]?.message.content ?? "{}";
  let judgeResult: JudgeResponse;
  try {
    judgeResult = JSON.parse(content) as JudgeResponse;
  } catch {
    return {
      assertionType: "llm_judge",
      passed: false,
      message: `ジャッジの応答をパースできませんでした: ${content}`,
    };
  }

  const threshold = assertion.threshold ?? 0.7;
  const passed = judgeResult.score >= threshold;

  return {
    assertionType: "llm_judge",
    passed,
    message: `スコア: ${judgeResult.score.toFixed(2)} / しきい値: ${threshold} — ${judgeResult.reason}`,
  };
}

LLMジャッジのしきい値の決め方

しきい値は「このケースに対して何を保証したいか」で決めます。

品質レベル しきい値の目安 使う場面
最低限の整合性 0.6 内容が大外れしていないかの確認
標準的な品質 0.8 通常の回帰テストに使う値
高い安全性要件 0.9 個人情報・機密情報関連のチェック

しきい値を高く設定しすぎると、ジャッジ自身の評価のブレで偽陽性(本来パスすべきものが落ちる)が増えます。 0.8前後から始めて、テストを運用しながら調整するのが現実的です。


08非決定性への対処

LLMの出力はなぜブレるか

LLMは同じ入力に対して毎回同じ出力を返すとは限りません。 確率的なサンプリングや、クラウドAPIの負荷分散・内部実装の差異によって、出力がブレます。

GPT-5系(gpt-5.5 / gpt-5.4-mini / gpt-5.4-nano)はreasoningモデルのため、temperature などのサンプリングパラメータは原則指定できません(指定すると400エラーになります)。 そのため揺れ対策は、複数回実行・多数決、構造化出力(response_format)の利用、決定的なアサーションの充実が中心になります。 なお、reasoning_effort: "none" を指定した場合に限り temperature を併用できます。その場合でも完全な再現性は保証されないため、複数回実行との組み合わせを推奨します。

複数回実行と多数決

完全な再現性が保証できない場合、複数回実行した結果の多数決でアサーションを判定します。

なお、以下の実装では LLMジャッジのみを複数回実行し、その判定の揺れを多数決で吸収します。 テスト対象のプロンプト呼び出し自体は1回だけです。 対象モデルの出力の揺れにも対処したい場合は、各runでテスト対象プロンプトの呼び出しも再実行する必要があります。

// src/testing/multi-run.ts

import type { GoldenCase, AssertionResult } from "./types.js";
import { runDeterministicAssertions } from "./deterministic-assertions.js";
import { runLlmJudge } from "./llm-judge.js";

export interface MultiRunConfig {
  // 実行回数(3または5が一般的)
  runs: number;
  // この割合以上パスしたら成功とする(例: 0.6 = 3回中2回以上)
  passRatio: number;
}

export async function runCaseWithMultiRun(
  goldenCase: GoldenCase,
  actualOutput: string,
  config: MultiRunConfig
): Promise<{ passed: boolean; results: AssertionResult[][] }> {
  const allResults: AssertionResult[][] = [];

  for (let i = 0; i < config.runs; i++) {
    const runResults: AssertionResult[] = [];

    // 決定的アサーションは1回だけ実行(毎回同じ結果のため)
    if (i === 0) {
      const deterministicResults = runDeterministicAssertions(
        actualOutput,
        goldenCase.assertions
      );
      runResults.push(...deterministicResults);
    }

    // LLMジャッジは複数回実行する
    const llmAssertions = goldenCase.assertions.filter(
      (a) => a.type === "llm_judge"
    );
    for (const assertion of llmAssertions) {
      const result = await runLlmJudge(
        JSON.stringify(goldenCase.input),
        actualOutput,
        assertion
      );
      runResults.push(result);
    }

    allResults.push(runResults);
  }

  // 決定的アサーションの結果(1回目)は単純判定
  const deterministicPassed = allResults[0]
    ?.filter((r) => r.assertionType !== "llm_judge")
    .every((r) => r.passed) ?? true;

  if (!deterministicPassed) {
    return { passed: false, results: allResults };
  }

  // LLMジャッジは多数決で判定
  // 各ランの「LLMジャッジをすべてパスした回数」を集計する
  const llmPassCount = allResults.filter((runResults) =>
    runResults
      .filter((r) => r.assertionType === "llm_judge")
      .every((r) => r.passed)
  ).length;

  const llmPassed = llmPassCount / config.runs >= config.passRatio;

  return { passed: deterministicPassed && llmPassed, results: allResults };
}

実際のところ、通常の回帰テストでは runs: 1 から始めて、 フラッキー(不安定)なケースが出てきた場合だけ runs: 3 に増やす段階的な運用が現実的です。 最初から全ケースを複数回実行するとコストが3倍になるためです。


09Vitestを使ったテストの組み立て

テストランナーの実装

// src/testing/regression-runner.ts

import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import OpenAI from "openai";
import type { GoldenCase } from "./types.js";
import { runDeterministicAssertions } from "./deterministic-assertions.js";
import { runLlmJudge } from "./llm-judge.js";

const client = new OpenAI();

// ゴールデンデータセットを読み込む
export function loadGoldenDataset(datasetPath: string): GoldenCase[] {
  const absolutePath = resolve(datasetPath);
  const content = readFileSync(absolutePath, "utf-8");
  return content
    .split("\n")
    .filter((line) => line.trim().length > 0)
    .map((line) => JSON.parse(line) as GoldenCase);
}

// テスト対象のプロンプトを呼び出す
// この関数はプロジェクトごとに実装を差し替える
async function callPrompt(
  promptId: string,
  input: Record<string, string>
): Promise<string> {
  // ここでは例としてOpenAIのAPIを直接呼び出す
  // 実際には自社のプロンプトローダーやエージェントを呼び出す
  const promptTemplates: Record<string, string> = {
    "meeting-summarizer": `以下の会議議事録を要約し、アクションアイテムを抽出してください。

議事録:
{transcript}

次のJSON形式で回答してください:
{
  "summary": "会議の要約(2〜3文)",
  "attendees": ["参加者名のリスト"],
  "action_items": [
    {
      "owner": "担当者名",
      "task": "タスクの内容",
      "deadline": "期限"
    }
  ]
}`,
  };

  const template = promptTemplates[promptId];
  if (!template) throw new Error(`プロンプトID "${promptId}" が見つかりません`);

  // テンプレートの変数を置換
  const prompt = Object.entries(input).reduce(
    (acc, [key, value]) => acc.replace(`{${key}}`, value),
    template
  );

  const response = await client.chat.completions.create({
    model: "gpt-5.4-mini",
    messages: [{ role: "user", content: prompt }],
  });

  return response.choices[0]?.message.content ?? "";
}

export interface CaseRunResult {
  caseId: string;
  description: string;
  passed: boolean;
  assertionResults: Array<{
    assertionType: string;
    passed: boolean;
    message: string;
  }>;
  durationMs: number;
}

export async function runGoldenCase(
  goldenCase: GoldenCase
): Promise<CaseRunResult> {
  const start = performance.now();

  // テスト対象のプロンプトを呼び出す
  const actualOutput = await callPrompt(
    goldenCase.promptId,
    goldenCase.input
  );

  // Layer 1: 決定的アサーション
  const deterministicResults = runDeterministicAssertions(
    actualOutput,
    goldenCase.assertions
  );

  // Layer 1が全て通過した場合のみ Layer 2 を実行
  const deterministicAllPassed = deterministicResults.every((r) => r.passed);

  const llmResults = deterministicAllPassed
    ? await Promise.all(
        goldenCase.assertions
          .filter((a) => a.type === "llm_judge")
          .map((a) =>
            runLlmJudge(JSON.stringify(goldenCase.input), actualOutput, a)
          )
      )
    : goldenCase.assertions
        .filter((a) => a.type === "llm_judge")
        .map((a) => ({
          assertionType: "llm_judge",
          passed: false,
          message:
            "Layer 1 の失敗により評価をスキップしました",
        }));

  const allResults = [...deterministicResults, ...llmResults];
  const passed = allResults.every((r) => r.passed);

  return {
    caseId: goldenCase.id,
    description: goldenCase.description,
    passed,
    assertionResults: allResults,
    durationMs: Math.round(performance.now() - start),
  };
}

Vitestのテストファイル

// src/testing/regression.test.ts

import { describe, it, expect } from "vitest";
import { loadGoldenDataset, runGoldenCase } from "./regression-runner.js";

// テスト対象のデータセットファイルパス
const DATASET_PATH = "src/testing/fixtures/golden-dataset.jsonl";

// タグフィルター(環境変数で制御する)
const TAG_FILTER = process.env["TEST_TAGS"]?.split(",") ?? [];

describe("プロンプト回帰テスト", () => {
  const cases = loadGoldenDataset(DATASET_PATH);

  // タグフィルターが指定された場合はフィルタリングする
  const targetCases =
    TAG_FILTER.length > 0
      ? cases.filter(
          (c) =>
            c.tags?.some((tag) => TAG_FILTER.includes(tag)) ?? false
        )
      : cases;

  for (const goldenCase of targetCases) {
    it(
      `[${goldenCase.id}] ${goldenCase.description}`,
      async () => {
        const result = await runGoldenCase(goldenCase);

        // 失敗時に詳細を出力する
        if (!result.passed) {
          const failedAssertions = result.assertionResults
            .filter((r) => !r.passed)
            .map((r) => `  - [${r.assertionType}] ${r.message}`)
            .join("\n");
          console.error(
            `\nケース "${goldenCase.id}" 失敗:\n${failedAssertions}`
          );
        }

        expect(result.passed, `ケース "${goldenCase.id}" が失敗しました`).toBe(
          true
        );
      },
      // タイムアウトは長めに設定する(LLM呼び出しがあるため)
      { timeout: 30_000 }
    );
  }
});

実行結果の例

 RUN  v1.6.0 /path/to/project

 ✓ src/testing/regression.test.ts (3)
   ✓ プロンプト回帰テスト (3)
     ✓ [summarize-001] 基本的な要約 (2841ms)
     ✓ [summarize-002] アクションアイテムなしの議事録 (1923ms)
     ✗ [summarize-003] 個人情報を含む議事録 (1204ms)

ケース "summarize-003" 失敗:
  - [not_contains] "090-" が含まれています(含まれてはいけない)

 Test Files  1 failed (1)
      Tests  2 passed | 1 failed (3)
   Start at  14:32:01
   Duration  6.12s (transform 89ms, setup 0ms, collect 48ms, tests 6.07s)

10GitHub ActionsへのCI組み込み

CIへの組み込みで押さえるべき3点

  1. APIキーの安全な取り扱い: OpenAIのAPIキーはGitHub Secrets経由で渡します
  2. 実行トリガーの設計: 全プッシュで全ケースを回すとコストが膨らみます
  3. タイムアウトの設定: LLM呼び出しを含むジョブは通常のテストより時間がかかります

ワークフローファイルの例

# .github/workflows/prompt-regression.yml

name: Prompt Regression Tests

on:
  # プロンプト関連ファイルの変更時のみ実行する
  push:
    paths:
      - "src/prompts/**"
      - "src/testing/fixtures/**"
      - "src/testing/**"
  pull_request:
    paths:
      - "src/prompts/**"
      - "src/testing/fixtures/**"
      - "src/testing/**"
  # 手動実行も許可する
  workflow_dispatch:
    inputs:
      test_tags:
        description: "実行するタグ(カンマ区切り。例: smoke,security)"
        required: false
        default: ""

jobs:
  smoke-tests:
    name: スモークテスト(smoke タグのみ)
    runs-on: ubuntu-latest
    timeout-minutes: 10
    # PR時はスモークテストのみ実行してコストを抑える
    if: github.event_name == 'pull_request'
    env:
      OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
      TEST_TAGS: smoke
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"

      - name: 依存パッケージのインストール
        run: npm ci

      - name: スモークテスト実行
        run: npx vitest run src/testing/regression.test.ts

  full-regression:
    name: 全回帰テスト
    runs-on: ubuntu-latest
    timeout-minutes: 30
    # mainブランチへのマージ時とスケジュール実行時のみ全件実行する
    if: |
      github.event_name == 'push' && github.ref == 'refs/heads/main' ||
      github.event_name == 'workflow_dispatch'
    env:
      OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
      TEST_TAGS: ${{ github.event.inputs.test_tags || '' }}
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "22"
          cache: "npm"

      - name: 依存パッケージのインストール
        run: npm ci

      - name: 全回帰テスト実行
        run: npx vitest run src/testing/regression.test.ts

      - name: テスト結果をアーティファクトとして保存
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: regression-test-results
          path: test-results/
          retention-days: 30

ポイントの解説

paths フィルターは重要な節約ポイントです。 プロンプトに無関係なファイル(UIコンポーネント、APIルートなど)を変更したときに LLMを呼び出すテストが走るのはコストの無駄です。 プロンプトファイルとデータセットの変更時だけに絞ります。

PRとマージ後の2段階は、よく使われる設計です。 PRレビュー時はスモークテスト(全体の動作確認だけ)に留め、 mainマージ後に全件テストを走らせます。 これにより、PRのフィードバックサイクルを速く保ちつつ、本番品質を担保できます。


11コストと実行時間の管理

コストを見積もる

テスト1回あたりのコスト感を掴むには、次の計算が参考になります(あくまで目安です)。

1ケースあたりのコスト ≈ 
  (テスト対象プロンプトの入出力トークン数)× モデル単価
  + LLMジャッジの入出力トークン数 × ジャッジモデル単価

ケース数が増えると、この計算が重要になります。 たとえば100ケースのデータセットで1ケースあたり平均2,000トークンを消費するなら、 1回の全件テストで20万トークンを消費します。 毎プッシュで実行する設計にする前に、月のコスト上限を決めることをお勧めします。

コスト削減の工夫

タグによる分割実行は、最も即効性のある対策です。

// package.json

{
  "scripts": {
    // スモークテスト(基本ケースのみ)
    "test:smoke": "TEST_TAGS=smoke vitest run src/testing/regression.test.ts",
    // セキュリティ関連ケースのみ
    "test:security": "TEST_TAGS=security,pii vitest run src/testing/regression.test.ts",
    // エッジケースのみ
    "test:edge": "TEST_TAGS=edge-case vitest run src/testing/regression.test.ts",
    // 全件
    "test:regression": "vitest run src/testing/regression.test.ts"
  }
}

ジャッジモデルの選択も効いてきます。 回帰テストのLLMジャッジに最高性能のモデルを使う必要はありません。 gpt-5.4-mini や同等クラスのモデルでも、構造的な品質チェックには十分な判定精度が得られるケースが多いです。 ただし、しきい値の調整が必要になることがあるため、最初は小さいデータセットで検証してから切り替えることを推奨します。

実行結果のキャッシュは、ゴールデンデータセットとプロンプトが変更されていない場合に有効です。 入力のハッシュをキーにして前回の実行結果をキャッシュし、変更がない場合はLLMを呼ばない設計にすることで、 CIのコストを大幅に抑えられます。

// src/testing/cache.ts

import { createHash } from "node:crypto";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { resolve } from "node:path";

const CACHE_DIR = ".regression-cache";

function buildCacheKey(
  promptId: string,
  input: Record<string, string>,
  assertions: unknown[]
): string {
  const content = JSON.stringify({ promptId, input, assertions });
  return createHash("sha256").update(content).digest("hex").substring(0, 16);
}

export interface CachedResult {
  output: string;
  passed: boolean;
  cachedAt: string;
}

export function loadFromCache(
  promptId: string,
  input: Record<string, string>,
  assertions: unknown[]
): CachedResult | null {
  const key = buildCacheKey(promptId, input, assertions);
  const cachePath = resolve(CACHE_DIR, `${key}.json`);

  if (!existsSync(cachePath)) return null;

  try {
    return JSON.parse(readFileSync(cachePath, "utf-8")) as CachedResult;
  } catch {
    return null;
  }
}

export function saveToCache(
  promptId: string,
  input: Record<string, string>,
  assertions: unknown[],
  result: Omit<CachedResult, "cachedAt">
): void {
  const key = buildCacheKey(promptId, input, assertions);
  const cachePath = resolve(CACHE_DIR, `${key}.json`);

  mkdirSync(CACHE_DIR, { recursive: true });
  writeFileSync(
    cachePath,
    JSON.stringify({ ...result, cachedAt: new Date().toISOString() })
  );
}

このキャッシュは .gitignore に追加し、ローカル開発時の再実行を高速化するためのものです。 CIでは CACHE_DIRactions/cache で管理すると効果的ですが、 キャッシュのヒット率はプロジェクトの変更頻度によって大きく変わります。


12失敗時の運用フロー

「失敗したらどうするか」を先に決める

回帰テストは「落ちたら止まる」だけでは機能しません。 テストが落ちたときに誰が何をするかのフローを決めておくことが、運用の現実的な課題です。

筆者たちが経験した典型的な失敗は、回帰テストを入れたものの「落ちたら確認します」という運用になってしまい、 実際に落ちても「またフレーキーなのかな」と無視するカルチャーが定着してしまったケースです。 テストの信頼性は、失敗に対して一貫して対処することで育ちます。

3種類の失敗と対処

パターン1: プロンプト変更による既知ケースの破綻

最も多いケースです。プロンプトを修正した結果、別のケースが落ちた状態です。

対処の優先順位は次の通りです。

  1. 落ちたケースが意図通りの変更による副作用か、それとも予期しない破綻かを確認します
  2. 意図通りの場合:ゴールデンデータセットの期待値を更新してPRを通す
  3. 予期しない破綻の場合:プロンプト変更を見直すか、新しい変更で両立する方法を探す

パターン2: モデルアップデートによる挙動変化

APIプロバイダーがモデルをアップデートした際に、以前は通過していたケースが落ちることがあります。 この場合、プロンプト自体は変わっていないのにテストが落ちるため、原因の切り分けが必要です。

// テスト実行時にモデルのバージョンを記録しておく
const response = await client.chat.completions.create({
  model: "gpt-5.4-mini",
  messages: [...],
});

// model フィールドで実際に使われたモデルを確認できる
console.log(`使用モデル: ${response.model}`);
// 例: gpt-5.4-mini-2025-07-18

モデルのバージョンを固定できるAPIプロバイダーを使っている場合は、 本番環境とテスト環境で同じバージョンを指定することで、この種の問題を減らせます。

パターン3: フレーキーな失敗(不安定な挙動)

GPT-5系ではサンプリングパラメータを原則指定できないため、稀に結果がブレることがあります。 同じケースが時々落ちて時々通る場合は、フレーキーとして特定し対処します。

// vitest.config.ts

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    // フレーキーなテストの再試行設定
    retry: 2,
    // ただし、コスト管理の観点から全テストへの適用は慎重に
  },
});

フレーキーなケースは「一時的に無効化する」のではなく、根本原因を調べて対処することを優先してください。 無効化が続くと、データセットが形骸化します。

緊急ロールバックの手順

プロンプト変更を本番にデプロイした後に回帰テストが落ちた場合の手順を事前に決めておきます。

(関連記事: プロンプトのバージョン管理とリリースフロー)で解説しているロールバック手順と組み合わせる形で、 「テスト失敗 → ロールバック判断 → 緊急修正またはリバート」のフローを整備することをお勧めします。

基本的な判断基準として、次のものが参考になります。

  • 安全性・個人情報関連のケースが1件でも落ちたら即ロールバックを検討する
  • 内容品質のケースが複数落ちた場合は、影響範囲を確認してから判断する
  • スコアがしきい値をわずかに下回る「ギリギリ失敗」は、しきい値の見直しも視野に入れる

13段階的な導入の進め方

フェーズ1:ゴールデンデータセットだけを作る(1〜2日)

まずLLM呼び出しなしの回帰テストから始めます。 既存のケースをJSONLに書き起こし、Layer 1の決定的アサーションだけで動くテストを作ります。 これだけでも「JSONが壊れていないか」「必須フィールドがあるか」の確認が自動化されます。

フェーズ2:LLMジャッジを追加する(3〜5日)

Layer 2を追加し、内容品質のチェックを加えます。 最初は最も重要な2〜3ケースだけにLLMジャッジを設定し、しきい値を調整しながら安定させます。

フェーズ3:CIに組み込む(1〜2日)

GitHub Actionsに組み込み、PRごとにスモークテストが走る状態を作ります。 この時点でコストの監視も始めます。

フェーズ4:データセットを育てる(継続)

バグが発生するたびにケースを追加し、データセットを充実させます。 ケース数が30〜50程度になると、回帰テストの安心感が体感できるようになると感じています。


14BizPlan(事業計画エージェント)での設計方針

筆者たちが開発しているBizPlan(事業計画エージェント)では、 プロンプト回帰テストを「リリースフローの必須ゲート」として位置づけています。

設計の方針として、いくつかの点を一般化して紹介します。

複数のプロンプトが連鎖するパイプラインでは、個別のプロンプトテストだけでなく、 パイプライン全体を通したエンドツーエンドのケースも一部含めます。 途中のプロンプト改善が最終出力に想定外の影響を与えるパターンは、 個別テストだけでは検知しにくいためです。

評価の難しい出力(事業計画の実現可能性など)は、 構造チェックと禁止ワードチェックを主体にして、 LLMジャッジは「明らかに品質の低い出力を弾く」目的に限定しています。 評価が難しいものに高い精度のLLMジャッジを要求すると、 ジャッジ自体の一貫性のなさが問題になることを経験したためです。


15まとめ

プロンプト回帰テストの核心は、「ゴールデンデータセット × 評価関数」という構造にあります。 評価関数は決定的アサーションとLLMジャッジの2層に分けることで、コストと精度のバランスを取れます。

導入のステップを整理すると次のようになります。

  1. ゴールデンデータセットを作り、決定的アサーションだけで動くテストを作る
  2. 重要ケースにLLMジャッジを追加してしきい値を調整する
  3. GitHub ActionsのCIに組み込み、プロンプト変更時に自動実行する
  4. バグが発生するたびにケースを追加してデータセットを育てる

最初から完璧なカバレッジを目指す必要はありません。 「バグが起きたらケースを追加する」を習慣にするだけで、データセットは自然と実績のある危険地帯を網羅します。 手動確認が「何かが壊れていないかの不安から来るもの」だと感じている方には、 まず10ケースだけのデータセットから始めることをお勧めします。


16参考文献

Author
管理者
Agent Store

記事で紹介した技術を、実際の業務でお試しください。

業務に合うエージェントを条件で絞り込んで選べます。すべて無料で、今すぐ利用できます。

エージェント一覧を見る →

コメント

まだコメントはありません。最初のコメントを投稿してみましょう。

コメントを投稿

ゲストコメントは管理者の承認後に公開されます。 ログインするとすぐにコメントが公開されます。

当サイトではCookieを使用しています。詳しくはCookieポリシーをご覧ください。