開発ノート

LLM-as-a-Judge:モデルにモデルを評価させる手法と落とし穴

管理者2026.06.11 公開 ・ 28 min read
LLM-as-a-Judge:モデルにモデルを評価させる手法と落とし穴

01TL;DR

  • LLM-as-a-Judge は「正解が一意に定まらないタスク」の自動評価に有効な手法です。
  • 評価プロンプトの品質が採点結果に直結するため、ルーブリック(採点基準)の設計が最重要工程です。
  • 人手評価との一致率は Cohen's κ(カッパ係数)などで定量確認でき、目安として κ ≥ 0.6 を一つの指標にできます。
  • LLMジャッジには位置バイアス・冗長性バイアス・自己選好バイアスなど複数の系統的偏りが報告されており、設計段階からの緩和策が必要です。
  • サンプルコードは TypeScript(Node.js v20.x)で記述しています。

02はじめに

この記事の対象読者

エージェント開発に関わっており、評価の自動化に取り組み始めた方を想定しています。 具体的には次のような状況にある方に向けて書きます。

  • 人手評価のコストと工数に限界を感じ始めている
  • LLMジャッジを使ってみたいが、精度や信頼性の担保方法がわからない
  • バイアスという言葉は聞いたことがあるが、具体的に何が問題なのかを整理したい
  • 評価プロンプトをどう書けばよいか、参考例を探している

前提知識

LLMを使ったエージェントの基本的な動作(プロンプト・ツール呼び出し・構造化出力)を理解していることを前提とします。 評価設計の全体像については(関連記事: 評価設計の基本:エージェントの品質をどう測るか)で解説しています。 本記事はその続きとして、LLMジャッジに絞って深く掘り下げます。

この記事で得られること

  • LLM-as-a-Judge の仕組みと採用すべき場面の判断基準
  • 評価プロンプトの設計手順(ルーブリック・スコアリング形式の選択)
  • TypeScript による評価パイプラインの実装例
  • 人手評価との一致率を確認する方法(Cohen's κ の考え方と計算コード)
  • 既知のバイアス5種と、それぞれの緩和策

03LLM-as-a-Judge とはなにか

基本的な仕組み

LLM-as-a-Judge(LLMジャッジ)は、エージェントの応答品質を別の LLM に採点させる評価手法です。 2023年以降、複数の研究グループが人手評価との相関を検証し、適切に設計されたプロンプト下では GPT-4 クラスのモデルが専門家評価と一定水準以上の一致を示すことを報告しました。

仕組みは単純で、次の3要素を組み合わせます。

flowchart TD
    A["ユーザー入力"] --> B["エージェントの応答(被評価対象)"]
    B --> C["評価プロンプト(ルーブリック(採点基準)を含む)"]
    C --> D["ジャッジ LLM"]
    D --> E["スコア + 採点理由"]

「LLM に LLM の採点をさせる」という構造に違和感を覚える方もいると思います。 ポイントは、ジャッジ LLM が「何が良い応答か」という基準を持っているわけではなく、あくまで「与えられたルーブリックに照らしてどのレベルに近いか」を判断していることです。 採点の品質は、ルーブリックの明確さに依存します。

人手評価との使い分け

LLMジャッジはすべての評価を置き換えられるわけではありません。 筆者たちが実務で意識している使い分けの指針を次の表に示します。

評価の性質 適した手法 理由
事実の正誤(正解データあり) 正解比較型(文字列一致・正規表現) 高速・低コスト・再現性が最高
文体・自然さ・文脈適合性 LLMジャッジ 基準化が難しく人手に近い感度が必要
安全性・倫理的配慮 人手評価 + LLMジャッジの組み合わせ 誤りの影響が大きく自動化のみでは不十分
新しいユースケースの初期評価 人手評価(少数サンプル)→ LLMジャッジへ移行 最初は基準の正しさを人間が担保する必要がある
大規模・継続的な品質モニタリング LLMジャッジ 人手では規模に追いつかない

「LLMジャッジ導入の前に人手評価を少数件実施する」という順序は特に重要です。 LLMジャッジは人手評価の代替ではなく、基準を確認済みの人手評価を規模拡大したものと捉えると、精度への期待値が適切になります。


04評価プロンプトの設計

評価プロンプトの設計は LLMジャッジの精度を左右する最重要工程です。 このセクションでは、ルーブリック設計からスコアリング形式の選択まで順を追って説明します。

スコアリング形式の選択

LLMジャッジには主に3つのスコアリング形式があります。

絶対スコアリング(Absolute Scoring / Likert Scale)

ルーブリックに定義したスコア尺度(例: 1〜5)の中から最も近いスコアを1つ選ばせる形式です。 単一の応答を評価するときに使います。

この応答を1〜5で採点してください。
5: 完全に期待に応えている
4: ほぼ期待に応えているが小さな改善余地がある
3: 部分的に期待に応えている
2: 期待にほとんど応えていない
1: 全く期待に応えていない

ペアワイズ比較(Pairwise Comparison)

2つの応答を並べて「どちらが良いか」を選ばせる形式です。 モデルのバージョン比較やプロンプト改善前後の比較に向いています。 一方で、どちらか片方を選ぶ指示により中立評価が難しいという側面があります。

リファレンス付き評価(Reference-based Evaluation)

理想的な応答(ゴールデンアンサー)を提示した上で、それとの距離を採点させる形式です。 正解の表現が複数ありうるタスクでは、「完全一致では捉えられない正しさ」を評価できます。

実務では絶対スコアリングを基本とし、A/B比較が必要な場面でペアワイズを組み合わせるパターンが扱いやすいと感じています。

ルーブリックの設計手順

ルーブリックは「採点基準」です。 LLMジャッジに渡す採点の拠り所になるため、曖昧な記述は採点のブレに直結します。

ステップ1:評価軸を1つに絞る

1つのルーブリックで複数の性質を同時に採点しようとすると、スコアの意味が不明確になります。 「関連性」と「完全性」を1スコアで表そうとすれば、「関連性は高いが完全性は低い」応答の採点が定まりません。 評価軸は1ルーブリック1軸を守り、複数軸が必要な場合はルーブリックを分割します。

よく使われる評価軸の例は次の通りです。

  • 関連性(ユーザーの質問に答えているか)
  • 完全性(必要な情報が欠けていないか)
  • 正確性(事実として誤りがないか)
  • 誠実さ(根拠なく断言していないか)
  • 簡潔さ(余分な情報が含まれていないか)
  • 有害性(問題のある表現・情報を含まないか)

ステップ2:スコアレベルを「観察可能な状態」で記述する

「良い」「普通」「悪い」のような抽象的な表現は採点のブレを生みます。 「ユーザーの質問のすべての要素に答えており、余分な情報を含まない」のように、観察可能な状態として記述します。

3段階・4段階・5段階のどれが良いかは目的によります。 3段階は高い一致率を得やすく、5段階は細かい差異を拾えます。 最初は3〜4段階から始め、採点のブレが小さいことを確認してから段階を増やす順序が安定します。

ステップ3:境界例(ボーダーラインの例)を添える

スコア3と4の違い、スコア2と3の違いなど、迷いやすい境界に具体的な例を追加します。 この工程を省くと、LLMジャッジは境界近傍のケースで一貫性を失いやすいです。

ステップ4:出力形式を JSON に固定する

採点理由と合わせて構造化出力を要求すると、パースの安定性が上がります。 「JSON だけを出力してください。説明文や前置きは含めないでください」という指示をプロンプトに明示します。


05TypeScript で評価パイプラインを実装する

型定義と基本構造

評価パイプラインを構成するデータ型を定義します。 Node.js v20.x / TypeScript 5.x を前提とします。

// evaluation/llm-judge/types.ts

export interface EvaluationCase {
  id: string;
  /** エージェントへの入力(ユーザーメッセージ) */
  userInput: string;
  /** エージェントの応答(評価対象) */
  agentResponse: string;
  /** 参照用の理想応答(リファレンス付き評価の場合のみ) */
  referenceResponse?: string;
  /** テストケースに付与するメタデータ */
  metadata?: Record<string, unknown>;
}

export interface RubricLevel {
  score: number;
  label: string;
  /** 観察可能な状態として記述した定義 */
  description: string;
  /** 境界例(任意)。スコアの上限・下限に当たる応答例 */
  borderExample?: string;
}

export interface Rubric {
  /** 評価軸の名前(例: "関連性", "完全性") */
  dimension: string;
  /** 評価軸の目的を1文で説明 */
  purpose: string;
  levels: RubricLevel[];
}

export interface JudgeResult {
  caseId: string;
  dimension: string;
  score: number;
  rationale: string;
  /** ジャッジ LLM のモデル名 */
  judgeModel: string;
  evaluatedAt: string;
}

export interface ParseError {
  caseId: string;
  dimension: string;
  rawResponse: string;
  reason: string;
}

評価プロンプトの構築

// evaluation/llm-judge/prompt-builder.ts
import type { EvaluationCase, Rubric } from "./types.js";

export interface PromptBuildOptions {
  /** ペアワイズ比較用の2番目の応答(省略時は絶対スコアリング) */
  secondResponse?: string;
  /** リファレンス付き評価を有効にするか */
  useReference?: boolean;
}

export function buildAbsoluteScoringPrompt(
  rubric: Rubric,
  evalCase: EvaluationCase,
  options: PromptBuildOptions = {}
): string {
  const levelsText = rubric.levels
    .map((l) => {
      const border = l.borderExample
        ? `\n  例: ${l.borderExample}`
        : "";
      return `スコア${l.score}(${l.label}): ${l.description}${border}`;
    })
    .join("\n");

  const referenceSection =
    options.useReference && evalCase.referenceResponse
      ? `\n[参照応答(理想的な回答の例)]\n${evalCase.referenceResponse}\n`
      : "";

  return `あなたは厳正な評価者です。
以下の採点基準に従い、エージェントの応答を評価してください。
評価は採点基準のみに基づいて行い、応答の長さや文体の好みは考慮しないでください。

[評価軸]
${rubric.dimension}: ${rubric.purpose}

[採点基準]
${levelsText}

[ユーザーの入力]
${evalCase.userInput}
${referenceSection}
[エージェントの応答]
${evalCase.agentResponse}

[出力指示]
以下のJSON形式のみで出力してください。
前置き・説明文・コードブロック記号は含めないでください。

{"score": <整数>, "rationale": "<採点理由を日本語で1〜3文>"}`.trim();
}

export function buildPairwiseScoringPrompt(
  rubric: Rubric,
  evalCase: EvaluationCase,
  secondResponse: string
): string {
  return `あなたは厳正な評価者です。
以下の評価軸に基づいて、2つの応答のどちらが優れているかを判断してください。
評価は採点基準のみに基づいて行い、応答の長さや文体の好みは考慮しないでください。

[評価軸]
${rubric.dimension}: ${rubric.purpose}

[ユーザーの入力]
${evalCase.userInput}

[応答A]
${evalCase.agentResponse}

[応答B]
${secondResponse}

[出力指示]
以下のJSON形式のみで出力してください。
前置き・説明文・コードブロック記号は含めないでください。

{"winner": "A" | "B" | "tie", "rationale": "<判断理由を日本語で1〜3文>"}`.trim();
}

LLM 呼び出しとレスポンスのパース

// evaluation/llm-judge/judge-runner.ts
import type {
  EvaluationCase,
  JudgeResult,
  ParseError,
  Rubric,
} from "./types.js";
import { buildAbsoluteScoringPrompt } from "./prompt-builder.js";

/**
 * LLM 呼び出しのインターフェース。
 * 実際の実装では OpenAI SDK / Anthropic SDK を差し込む。
 */
export interface LLMClient {
  complete(prompt: string): Promise<string>;
  modelName: string;
}

export interface JudgeRunResult {
  results: JudgeResult[];
  errors: ParseError[];
}

/** JSON レスポンスから絶対スコアを抽出する */
function parseAbsoluteScore(
  rawResponse: string,
  caseId: string,
  dimension: string,
  judgeModel: string
): JudgeResult | null {
  try {
    // LLM がコードブロック記号を含む場合に対応
    const cleaned = rawResponse
      .replace(/```json/gi, "")
      .replace(/```/g, "")
      .trim();

    // 先頭の { から末尾の } までを抽出
    const jsonStart = cleaned.indexOf("{");
    const jsonEnd = cleaned.lastIndexOf("}");
    if (jsonStart === -1 || jsonEnd === -1) return null;

    const parsed = JSON.parse(cleaned.slice(jsonStart, jsonEnd + 1)) as {
      score: unknown;
      rationale: unknown;
    };

    if (
      typeof parsed.score !== "number" ||
      !Number.isInteger(parsed.score) ||
      typeof parsed.rationale !== "string"
    ) {
      return null;
    }

    return {
      caseId,
      dimension,
      score: parsed.score,
      rationale: parsed.rationale,
      judgeModel,
      evaluatedAt: new Date().toISOString(),
    };
  } catch {
    return null;
  }
}

/** 評価ケースのリストに対してルーブリック評価を実行する */
export async function runAbsoluteScoring(
  rubric: Rubric,
  cases: EvaluationCase[],
  client: LLMClient,
  options: {
    /** 並列実行数の上限(API レート制限に合わせて調整する) */
    concurrency?: number;
    useReference?: boolean;
  } = {}
): Promise<JudgeRunResult> {
  const { concurrency = 5, useReference = false } = options;
  const results: JudgeResult[] = [];
  const errors: ParseError[] = [];

  // concurrency 件ずつまとめて並列実行する
  for (let i = 0; i < cases.length; i += concurrency) {
    const batch = cases.slice(i, i + concurrency);

    const batchResults = await Promise.all(
      batch.map(async (evalCase) => {
        const prompt = buildAbsoluteScoringPrompt(rubric, evalCase, {
          useReference,
        });

        let rawResponse = "";
        try {
          rawResponse = await client.complete(prompt);
        } catch (err) {
          return {
            type: "error" as const,
            error: {
              caseId: evalCase.id,
              dimension: rubric.dimension,
              rawResponse: "",
              reason: `LLM呼び出しエラー: ${String(err)}`,
            },
          };
        }

        const parsed = parseAbsoluteScore(
          rawResponse,
          evalCase.id,
          rubric.dimension,
          client.modelName
        );

        if (parsed === null) {
          return {
            type: "error" as const,
            error: {
              caseId: evalCase.id,
              dimension: rubric.dimension,
              rawResponse,
              reason: "JSONパース失敗",
            },
          };
        }

        return { type: "success" as const, result: parsed };
      })
    );

    for (const item of batchResults) {
      if (item.type === "success") {
        results.push(item.result);
      } else {
        errors.push(item.error);
      }
    }
  }

  return { results, errors };
}

モック LLM クライアントを使った動作確認

本番の LLM SDK を使う前に、モッククライアントでパイプライン全体の動作を確認できます。

// evaluation/llm-judge/example.ts
import type { LLMClient, JudgeRunResult } from "./judge-runner.js";
import { runAbsoluteScoring } from "./judge-runner.js";
import type { EvaluationCase, Rubric } from "./types.js";

/** テスト用のモック LLM クライアント */
const mockClient: LLMClient = {
  modelName: "mock-model-v1",
  async complete(_prompt: string): Promise<string> {
    // 実際の実装では OpenAI SDK 等を呼び出す
    // ここでは固定値を返す(実測値ではなく動作確認用)
    return `{"score": 4, "rationale": "質問のすべての要素に答えており、バージョン情報も明記されています。"}`;
  },
};

const relevanceRubric: Rubric = {
  dimension: "関連性",
  purpose: "エージェントの応答がユーザーの質問に正確に答えているか",
  levels: [
    {
      score: 4,
      label: "完全に関連している",
      description:
        "ユーザーの質問のすべての要素に答えており、余分な情報を含まない。",
      borderExample:
        "「TypeScript でオブジェクトを深くコピーする方法」に対して structuredClone の使い方とバージョン条件を説明している応答。",
    },
    {
      score: 3,
      label: "ほぼ関連している",
      description:
        "ユーザーの質問の主要部分に答えているが、補足情報が1〜2点欠けている。",
    },
    {
      score: 2,
      label: "部分的に関連している",
      description:
        "ユーザーの質問に関連する情報を含むが、中心的な問いには直接答えていない。",
    },
    {
      score: 1,
      label: "関連していない",
      description: "ユーザーの質問とほとんど無関係な内容を返している。",
    },
  ],
};

const evalCases: EvaluationCase[] = [
  {
    id: "case-001",
    userInput:
      "TypeScript でオブジェクトを深くコピーする方法を教えてください。",
    agentResponse:
      "structuredClone() 関数を使うと、ネストされたオブジェクトも含めて完全なコピーが作れます。Node.js v17 以降で使用可能です。",
  },
  {
    id: "case-002",
    userInput: "配列の重複を取り除く方法を教えてください。",
    agentResponse:
      "TypeScript はマイクロソフトが開発した静的型付き言語です。JavaScript に型システムを追加したものです。",
  },
];

const runExample = async (): Promise<void> => {
  const { results, errors } = await runAbsoluteScoring(
    relevanceRubric,
    evalCases,
    mockClient
  );

  console.log("=== 評価結果 ===");
  for (const r of results) {
    console.log(`[${r.caseId}] スコア: ${r.score} | ${r.rationale}`);
  }

  if (errors.length > 0) {
    console.log(`\n=== パースエラー: ${errors.length} 件 ===`);
    for (const e of errors) {
      console.log(`[${e.caseId}] ${e.reason}`);
    }
  }
};

runExample().catch(console.error);
実行結果(出力例):

=== 評価結果 ===
[case-001] スコア: 4 | 質問のすべての要素に答えており、バージョン情報も明記されています。
[case-002] スコア: 4 | 質問のすべての要素に答えており、バージョン情報も明記されています。

※ モッククライアントは固定値を返すため case-002 も同スコアになります。
  本番クライアントに差し替えると case-002 は低スコアになることが期待されます。

06人手評価との一致率を確かめる

なぜ一致率の確認が必要か

LLMジャッジが「良い評価をしている」と信頼するためには、人間の評価との系統的な一致を確認する工程が必要です。 プロンプトの変更、ジャッジモデルの変更、評価タスクの変更のたびに、この確認を行うことを推奨します。

「一致率を確認するのは大変だ」という感覚はよく理解できます。 ただし、検証なしに本番のモニタリングに組み込むと、LLMジャッジが系統的にズレた方向で評価し続けるリスクを見落とします。 最初の検証は 50〜100 件程度の小規模でも意味があります。

Cohen's κ(カッパ係数)の考え方

Cohen's κ は「2人の評価者が偶然の一致を超えてどれだけ一致しているか」を示す統計指標です。 LLMジャッジ評価では「人間の評価者」と「LLMジャッジ」の2評価者として使います。

κ の値の目安は研究者によって異なりますが、一つの参考として次のように解釈されることがあります。

κ の値 一致の解釈(目安)
0.81 〜 1.00 ほぼ完全な一致
0.61 〜 0.80 実質的な一致
0.41 〜 0.60 中程度の一致
0.21 〜 0.40 弱い一致
0.20 以下 わずかな一致またはそれ以下

LLMジャッジの運用目安として κ ≥ 0.6 は一つの出発点ですが、この値が何を意味するかはユースケースによります。 安全性評価のような影響の大きい軸では、より高い一致率を要求することが妥当です。

κ の計算式は次の通りです。

κ = (P_o - P_e) / (1 - P_e)

P_o: 実際の一致率(2評価者が同じスコアをつけた割合)
P_e: 偶然の一致率(各スコアの周辺分布から計算)

TypeScript で Cohen's κ を計算する

// evaluation/agreement/cohens-kappa.ts

export interface RatingPair {
  /** ケースの識別子 */
  caseId: string;
  /** 人間評価者のスコア */
  humanScore: number;
  /** LLMジャッジのスコア */
  judgeScore: number;
}

export interface KappaResult {
  /** κ 係数の値 */
  kappa: number;
  /** 実際の一致率(P_o) */
  observedAgreement: number;
  /** 偶然の一致率(P_e) */
  expectedAgreement: number;
  /** 評価に使用したペア数 */
  sampleSize: number;
  /** 完全一致したケースの一覧 */
  agreements: string[];
  /** スコアが異なったケースの一覧 */
  disagreements: Array<{ caseId: string; humanScore: number; judgeScore: number }>;
}

/**
 * Cohen's κ を計算する。
 * スコアは整数カテゴリ(名義尺度または順序尺度)として扱う。
 * 注意: この実装は非重み付き κ であり、4→3 のズレと 4→1 のズレを同等に扱います。
 * 順序尺度(1〜5点など)では、ズレの大きさを考慮した weighted κ の利用が一般的です。
 */
export function calculateCohensKappa(pairs: RatingPair[]): KappaResult {
  if (pairs.length === 0) {
    throw new Error("評価ペアが空です。");
  }

  const n = pairs.length;

  // 全スコアの種類を収集する
  const allScores = new Set<number>();
  for (const p of pairs) {
    allScores.add(p.humanScore);
    allScores.add(p.judgeScore);
  }
  const scoreCategories = Array.from(allScores).sort((a, b) => a - b);

  // P_o(実際の一致率)
  const matchCount = pairs.filter((p) => p.humanScore === p.judgeScore).length;
  const observedAgreement = matchCount / n;

  // 各カテゴリの周辺比率を計算する
  const humanCounts = new Map<number, number>();
  const judgeCounts = new Map<number, number>();

  for (const p of pairs) {
    humanCounts.set(p.humanScore, (humanCounts.get(p.humanScore) ?? 0) + 1);
    judgeCounts.set(p.judgeScore, (judgeCounts.get(p.judgeScore) ?? 0) + 1);
  }

  // P_e(偶然の一致率)= Σ (p_human_k × p_judge_k)
  let expectedAgreement = 0;
  for (const score of scoreCategories) {
    const pHuman = (humanCounts.get(score) ?? 0) / n;
    const pJudge = (judgeCounts.get(score) ?? 0) / n;
    expectedAgreement += pHuman * pJudge;
  }

  // κ の計算
  const kappa =
    expectedAgreement === 1
      ? 1
      : (observedAgreement - expectedAgreement) / (1 - expectedAgreement);

  const agreements = pairs
    .filter((p) => p.humanScore === p.judgeScore)
    .map((p) => p.caseId);

  const disagreements = pairs
    .filter((p) => p.humanScore !== p.judgeScore)
    .map((p) => ({
      caseId: p.caseId,
      humanScore: p.humanScore,
      judgeScore: p.judgeScore,
    }));

  return {
    kappa,
    observedAgreement,
    expectedAgreement,
    sampleSize: n,
    agreements,
    disagreements,
  };
}
// 使用例
import {
  calculateCohensKappa,
  type RatingPair,
} from "./evaluation/agreement/cohens-kappa.js";

const ratingPairs: RatingPair[] = [
  { caseId: "c-001", humanScore: 4, judgeScore: 4 },
  { caseId: "c-002", humanScore: 3, judgeScore: 3 },
  { caseId: "c-003", humanScore: 4, judgeScore: 3 }, // 不一致
  { caseId: "c-004", humanScore: 2, judgeScore: 2 },
  { caseId: "c-005", humanScore: 1, judgeScore: 1 },
  { caseId: "c-006", humanScore: 3, judgeScore: 4 }, // 不一致
  { caseId: "c-007", humanScore: 4, judgeScore: 4 },
  { caseId: "c-008", humanScore: 2, judgeScore: 2 },
  { caseId: "c-009", humanScore: 3, judgeScore: 3 },
  { caseId: "c-010", humanScore: 4, judgeScore: 4 },
];

const result = calculateCohensKappa(ratingPairs);

console.log(`κ 係数: ${result.kappa.toFixed(3)}`);
console.log(`実際の一致率(P_o): ${(result.observedAgreement * 100).toFixed(1)}%`);
console.log(`偶然の一致率(P_e): ${(result.expectedAgreement * 100).toFixed(1)}%`);
console.log(`サンプル数: ${result.sampleSize}`);
console.log(`不一致ケース数: ${result.disagreements.length}`);

if (result.disagreements.length > 0) {
  console.log("\n不一致の詳細(人間スコア → LLMスコア):");
  for (const d of result.disagreements) {
    console.log(`  [${d.caseId}] ${d.humanScore} → ${d.judgeScore}`);
  }
}
実行結果(出力例):

κ 係数: 0.714
実際の一致率(P_o): 80.0%
偶然の一致率(P_e): 30.0%
サンプル数: 10
不一致ケース数: 2

不一致の詳細(人間スコア → LLMスコア):
  [c-003] 4 → 3
  [c-006] 3 → 4

※ 上記は計算例です。実際のプロジェクトでの一致率はルーブリックの設計・
  ジャッジモデル・評価タスクによって大きく異なります。

一致率が低い場合の診断

κ が目標値を下回った場合、原因の診断から始めます。

ルーブリックの曖昧さ

最も多い原因です。 スコアの境界定義が抽象的だと、人間でも迷う箇所でLLMも同様に迷います。 不一致ケースを並べて「人間はなぜこのスコアをつけたか」を分析し、ルーブリックの記述を修正します。

ジャッジモデルの能力不足

採点タスクの複雑さに対してジャッジモデルの能力が追いついていない場合があります。 より高性能なモデルに切り替えるか、評価軸を細分化してタスクの複雑さを下げます。

人間評価者間のばらつき

「人間評価」の側が実は一貫していないケースもあります。 人間評価者を複数用意し、評価者間の κ を計算してみると、問題の所在が明らかになります。


07LLMジャッジの既知バイアスと緩和策

LLMジャッジには、2023年以降の研究で複数の系統的バイアスが報告されています。 ここでは特に注意が必要な5種類を取り上げます。

「バイアスがある = 使えない」ではありません。 バイアスの性質を理解した上で設計に組み込むことで、影響を許容範囲に抑えられます。

位置バイアス(Position Bias)

内容

ペアワイズ比較において、先に提示された応答(応答A)を後に提示された応答(応答B)より高く評価する傾向です。 あるいはその逆(応答Bを好む)として現れることもあり、一貫した方向性はモデルによって異なります。

緩和策

同じペアに対して提示順序を入れ替えて2回評価し、結果を比較します。 両方で同じ勝者になった場合のみ結果を採用し、勝者が変わった場合は「引き分け」または「判定保留」とします。

// evaluation/llm-judge/position-bias-mitigation.ts
import type { EvaluationCase, Rubric } from "./types.js";
import { buildPairwiseScoringPrompt } from "./prompt-builder.js";
import type { LLMClient } from "./judge-runner.js";

export type PairwiseWinner = "A" | "B" | "tie";

export interface PairwiseResult {
  winner: PairwiseWinner;
  rationale: string;
}

export interface PositionBiasAdjustedResult {
  /** 位置バイアス調整後の勝者 */
  adjustedWinner: PairwiseWinner;
  /** AB 順の結果 */
  abResult: PairwiseResult | null;
  /** BA 順の結果 */
  baResult: PairwiseResult | null;
  /** 2回の評価で勝者が一致したか */
  consistent: boolean;
}

function parsePairwiseResponse(raw: string): PairwiseResult | null {
  try {
    const cleaned = raw.replace(/```json/gi, "").replace(/```/g, "").trim();
    const jsonStart = cleaned.indexOf("{");
    const jsonEnd = cleaned.lastIndexOf("}");
    if (jsonStart === -1 || jsonEnd === -1) return null;

    const parsed = JSON.parse(cleaned.slice(jsonStart, jsonEnd + 1)) as {
      winner: unknown;
      rationale: unknown;
    };

    if (
      (parsed.winner !== "A" && parsed.winner !== "B" && parsed.winner !== "tie") ||
      typeof parsed.rationale !== "string"
    ) {
      return null;
    }

    return {
      winner: parsed.winner as PairwiseWinner,
      rationale: parsed.rationale,
    };
  } catch {
    return null;
  }
}

/**
 * 提示順序を入れ替えて2回評価し、位置バイアスの影響を緩和する。
 */
export async function evaluateWithPositionBiasControl(
  rubric: Rubric,
  evalCase: EvaluationCase,
  responseB: string,
  client: LLMClient
): Promise<PositionBiasAdjustedResult> {
  // AB 順の評価
  const promptAB = buildPairwiseScoringPrompt(
    rubric,
    evalCase,
    responseB
  );
  const rawAB = await client.complete(promptAB);
  const abResult = parsePairwiseResponse(rawAB);

  // BA 順の評価(evalCase.agentResponse と responseB を入れ替える)
  const evalCaseBA: EvaluationCase = {
    ...evalCase,
    agentResponse: responseB,
  };
  const promptBA = buildPairwiseScoringPrompt(
    rubric,
    evalCaseBA,
    evalCase.agentResponse
  );
  const rawBA = await client.complete(promptBA);
  const baResultRaw = parsePairwiseResponse(rawBA);

  // BA 順の "A" は元の "B"、"B" は元の "A" なので変換する
  let baResult: PairwiseResult | null = null;
  if (baResultRaw !== null) {
    const convertedWinner: PairwiseWinner =
      baResultRaw.winner === "A"
        ? "B"
        : baResultRaw.winner === "B"
        ? "A"
        : "tie";
    baResult = { winner: convertedWinner, rationale: baResultRaw.rationale };
  }

  // 2回の評価結果を照合する
  const abWinner = abResult?.winner ?? null;
  const baWinner = baResult?.winner ?? null;
  const consistent = abWinner !== null && baWinner !== null && abWinner === baWinner;

  let adjustedWinner: PairwiseWinner;
  if (consistent && abWinner !== null) {
    adjustedWinner = abWinner;
  } else {
    // 結果が一致しない場合は引き分けとする
    adjustedWinner = "tie";
  }

  return { adjustedWinner, abResult, baResult, consistent };
}

冗長性バイアス(Verbosity Bias)

内容

より長い応答・より詳しい応答を、内容の正確さに関わらず高く評価する傾向です。 「長い = 丁寧 = 良い」という連想がジャッジ LLM に生じやすいと報告されています。

緩和策

ルーブリックの採点基準に「長さは評価の対象ではない」という明示的な文言を加えます。 また、「簡潔さ」を独立した評価軸として立てることで、長さと品質を切り離して評価できます。

// ルーブリックプロンプトに追加する冗長性バイアス抑制文言の例

const verbosityBiasInstruction = `
[評価上の注意]
- 応答の長さ・詳しさは評価の対象外です。短い応答でも採点基準を満たしていれば最高スコアになります。
- 「長い = 良い」「詳しい = 良い」という判断をしないでください。
- 評価は採点基準に記された状態の達成度のみに基づいてください。
`.trim();

自己選好バイアス(Self-enhancement Bias / Self-preference Bias)

内容

同じモデル(または同系列のモデル)が生成した応答を、他モデルの応答より高く評価する傾向です。 例えば GPT-4 をジャッジとして使うと、GPT-4 が生成した応答を他モデルの応答より高く評価しやすいことが複数の研究で示されています。

この傾向の大きさはモデルや評価タスクによって異なり、すべての場面で顕著に現れるわけではありません。 ただし、A/B 比較において評価対象のモデルとジャッジモデルが同系列の場合は、この点に意識を向けることを推奨します。

緩和策

評価対象のモデルとジャッジモデルを異なる系列(例: 生成は Claude、ジャッジは GPT-4)にします。 あるいは複数のジャッジモデルで評価し、アンサンブルの結果を使います。

// evaluation/llm-judge/ensemble-judge.ts
import type { EvaluationCase, JudgeResult, Rubric } from "./types.js";
import { buildAbsoluteScoringPrompt } from "./prompt-builder.js";
import type { LLMClient } from "./judge-runner.js";

/**
 * 複数のジャッジモデルで評価し、中央値スコアを返す。
 * 自己選好バイアスの影響を薄める目的で使う。
 */
export async function runEnsembleJudge(
  rubric: Rubric,
  evalCase: EvaluationCase,
  clients: LLMClient[]
): Promise<{
  medianScore: number;
  individualResults: Array<JudgeResult | null>;
}> {
  const prompt = buildAbsoluteScoringPrompt(rubric, evalCase);

  const responses = await Promise.all(
    clients.map(async (client) => {
      try {
        const raw = await client.complete(prompt);
        return { raw, modelName: client.modelName };
      } catch {
        return null;
      }
    })
  );

  const individualResults = responses.map((r) => {
    if (r === null) return null;
    try {
      const cleaned = r.raw.replace(/```json/gi, "").replace(/```/g, "").trim();
      const jsonStart = cleaned.indexOf("{");
      const jsonEnd = cleaned.lastIndexOf("}");
      if (jsonStart === -1 || jsonEnd === -1) return null;
      const parsed = JSON.parse(cleaned.slice(jsonStart, jsonEnd + 1)) as {
        score: unknown;
        rationale: unknown;
      };
      if (typeof parsed.score !== "number" || typeof parsed.rationale !== "string") {
        return null;
      }
      return {
        caseId: evalCase.id,
        dimension: rubric.dimension,
        score: parsed.score,
        rationale: parsed.rationale,
        judgeModel: r.modelName,
        evaluatedAt: new Date().toISOString(),
      } satisfies JudgeResult;
    } catch {
      return null;
    }
  });

  const validScores = individualResults
    .filter((r): r is JudgeResult => r !== null)
    .map((r) => r.score)
    .sort((a, b) => a - b);

  const medianScore =
    validScores.length === 0
      ? 0
      : validScores.length % 2 === 0
      ? (validScores[validScores.length / 2 - 1] +
          validScores[validScores.length / 2]) /
        2
      : validScores[Math.floor(validScores.length / 2)];

  return { medianScore, individualResults };
}

確証バイアス(Anchoring / Confirmation Bias)

内容

評価プロンプトに含まれる文脈情報(例: 「この応答は専門家が生成しました」という記述)がスコアに影響する傾向です。 正確さとは無関係な権威や属性の情報がスコアを引き上げることがあります。

緩和策

評価プロンプトには「ユーザー入力」「エージェントの応答」「ルーブリック」のみを含め、 応答の出処・著者・モデル名などのメタ情報は含めません。

// 評価プロンプトに含めてはいけない情報の例

// 避けるべき記述の例(確証バイアスを引き起こす可能性がある):
// "以下は上級エンジニアが生成した応答です。"
// "このエージェントは優秀なモデルを使用しています。"
// "以下の応答はユーザーから高評価を受けました。"

// 評価プロンプトに含めて良い情報:
// - ユーザーの入力そのもの
// - エージェントの応答そのもの
// - ルーブリック(採点基準)
// - 参照応答(リファレンス付き評価の場合)

近接バイアス(Recency Bias)

内容

プロンプトの末尾に近い情報をより重視する傾向です。 長い評価プロンプトで、ルーブリックを冒頭に置きエージェント応答を末尾に置いた場合、 応答の評価に対してルーブリックの影響が薄れることがあります。

緩和策

ルーブリックをプロンプトの末尾に近い位置(エージェント応答の直前)に置きます。 あるいはルーブリックをプロンプトの冒頭と末尾の両方に記載するダブル配置も一定の効果があります。

// 近接バイアスを緩和するプロンプト構造の例

function buildPromptWithDoubleCriteria(
  rubric: Rubric,
  evalCase: EvaluationCase
): string {
  const criteriaText = rubric.levels
    .map((l) => `スコア${l.score}: ${l.description}`)
    .join("\n");

  return `[採点基準]
${criteriaText}

[ユーザーの入力]
${evalCase.userInput}

[エージェントの応答]
${evalCase.agentResponse}

[採点基準(再掲)]
${criteriaText}

以下のJSON形式のみで出力してください。

{"score": <整数>, "rationale": "<採点理由>"}`.trim();
}

バイアス緩和策のまとめ

バイアス 主な緩和策
位置バイアス 順序入れ替えで2回評価し、一致した場合のみ採用
冗長性バイアス 「長さは評価対象外」をプロンプトに明示
自己選好バイアス 異系列モデルをジャッジに使う / アンサンブル
確証バイアス 応答のメタ情報をプロンプトから除外する
近接バイアス ルーブリックをプロンプト末尾近くに再掲する

08評価パイプライン全体の組み立て

各コンポーネントを組み合わせた、実際の評価フローを示します。

// evaluation/pipeline.ts
import type { EvaluationCase, Rubric, JudgeResult } from "./llm-judge/types.js";
import { runAbsoluteScoring } from "./llm-judge/judge-runner.js";
import {
  calculateCohensKappa,
  type RatingPair,
} from "./agreement/cohens-kappa.js";
import type { LLMClient } from "./llm-judge/judge-runner.js";

export interface PipelineConfig {
  /** 評価するルーブリック(複数軸の場合は配列で渡す) */
  rubrics: Rubric[];
  /** LLMジャッジとして使うクライアント */
  judgeClient: LLMClient;
  /** 人手評価との一致率を確認するサンプル(任意) */
  humanRatings?: RatingPair[];
  /** 並列実行数 */
  concurrency?: number;
}

export interface PipelineOutput {
  allResults: JudgeResult[];
  errorCount: number;
  summaryByDimension: Array<{
    dimension: string;
    sampleCount: number;
    avgScore: number;
  }>;
  /** humanRatings を渡した場合のみ計算される */
  kappaByDimension?: Array<{
    dimension: string;
    kappa: number;
    sampleSize: number;
  }>;
}

export async function runEvaluationPipeline(
  cases: EvaluationCase[],
  config: PipelineConfig
): Promise<PipelineOutput> {
  const allResults: JudgeResult[] = [];
  let errorCount = 0;

  // 各ルーブリックで評価を実行する
  for (const rubric of config.rubrics) {
    const { results, errors } = await runAbsoluteScoring(
      rubric,
      cases,
      config.judgeClient,
      { concurrency: config.concurrency ?? 5 }
    );
    allResults.push(...results);
    errorCount += errors.length;
  }

  // 軸ごとにスコアを集計する
  const grouped = new Map<string, JudgeResult[]>();
  for (const r of allResults) {
    const existing = grouped.get(r.dimension) ?? [];
    grouped.set(r.dimension, [...existing, r]);
  }

  const summaryByDimension = Array.from(grouped.entries()).map(
    ([dimension, items]) => ({
      dimension,
      sampleCount: items.length,
      avgScore:
        items.reduce((s, i) => s + i.score, 0) / items.length,
    })
  );

  // 人手評価との一致率を計算する(データがある場合のみ)
  let kappaByDimension: PipelineOutput["kappaByDimension"];
  if (config.humanRatings && config.humanRatings.length > 0) {
    kappaByDimension = Array.from(grouped.entries())
      .map(([dimension]) => {
        const llmScoreMap = new Map(
          allResults
            .filter((r) => r.dimension === dimension)
            .map((r) => [r.caseId, r.score])
        );

        const pairsForDimension: RatingPair[] = (config.humanRatings ?? [])
          .filter((hp) => llmScoreMap.has(hp.caseId))
          .map((hp) => ({
            caseId: hp.caseId,
            humanScore: hp.humanScore,
            judgeScore: llmScoreMap.get(hp.caseId) ?? 0,
          }));

        if (pairsForDimension.length < 2) return null;

        const { kappa, sampleSize } = calculateCohensKappa(pairsForDimension);
        return { dimension, kappa, sampleSize };
      })
      .filter((r): r is NonNullable<typeof r> => r !== null);
  }

  return { allResults, errorCount, summaryByDimension, kappaByDimension };
}
// パイプラインの使用例(...略... 部分は実際の値で補ってください)
import { runEvaluationPipeline } from "./evaluation/pipeline.js";
import type { EvaluationCase, Rubric } from "./evaluation/llm-judge/types.js";
import type { LLMClient } from "./evaluation/llm-judge/judge-runner.js";
import type { RatingPair } from "./evaluation/agreement/cohens-kappa.js";

// 評価ケース(実際のシステムではファイルや DB から読み込む)
const evalCases: EvaluationCase[] = [
  {
    id: "case-001",
    userInput: "TypeScript で Promise.all の使い方を教えてください。",
    agentResponse:
      "Promise.all は複数のPromiseを並列実行し、すべての解決を待ちます。" +
      "例: const [a, b] = await Promise.all([fetchA(), fetchB()]);",
  },
  // ...他のケース...
];

// ルーブリック
const rubrics: Rubric[] = [
  {
    dimension: "関連性",
    purpose: "ユーザーの質問に正確に答えているか",
    levels: [
      { score: 4, label: "完全に関連", description: "すべての要素に答えており余分な情報がない。" },
      { score: 3, label: "ほぼ関連", description: "主要部分に答えているが補足が1〜2点欠ける。" },
      { score: 2, label: "部分的に関連", description: "関連情報はあるが中心的な問いに答えていない。" },
      { score: 1, label: "関連していない", description: "質問とほとんど無関係な内容を返している。" },
    ],
  },
  {
    dimension: "正確性",
    purpose: "技術的な内容に誤りが含まれていないか",
    levels: [
      { score: 4, label: "完全に正確", description: "技術的な誤りがなく、情報も最新である。" },
      { score: 3, label: "ほぼ正確", description: "軽微な不正確さはあるが、核心部分は正しい。" },
      { score: 2, label: "部分的に正確", description: "一部正確だが、重要な誤りを含む。" },
      { score: 1, label: "不正確", description: "技術的な誤りが主要部分に含まれている。" },
    ],
  },
];

// ジャッジクライアント(実際の実装では SDK を差し込む)
const judgeClient: LLMClient = {
  modelName: "judge-model-v1",
  async complete(_prompt: string): Promise<string> {
    return `{"score": 4, "rationale": "技術的に正確で、質問のすべての要素に答えています。"}`;
  },
};

// 人手評価サンプル(κ の計算用)
const humanRatings: RatingPair[] = [
  { caseId: "case-001", humanScore: 4, judgeScore: 0 }, // judgeScore は pipeline が上書きする
];

const output = await runEvaluationPipeline(evalCases, {
  rubrics,
  judgeClient,
  humanRatings,
  concurrency: 3,
});

console.log("\n=== 評価サマリー ===");
for (const s of output.summaryByDimension) {
  console.log(
    `[${s.dimension}] 件数: ${s.sampleCount}, 平均スコア: ${s.avgScore.toFixed(2)}`
  );
}

if (output.kappaByDimension) {
  console.log("\n=== 人手評価との一致率(κ) ===");
  for (const k of output.kappaByDimension) {
    console.log(
      `[${k.dimension}] κ = ${k.kappa.toFixed(3)} (n=${k.sampleSize})`
    );
  }
}

console.log(`\nエラー件数: ${output.errorCount}`);

09運用上の落とし穴

LLMジャッジの「安定性」を定期的に確認する

ジャッジモデルはプロバイダー側でモデルのバージョンが更新されることがあります。 同じプロンプトでも、モデルバージョンが変わると採点傾向が変化する場合があります。 モデルバージョンを固定できる場合は固定し、定期的にアンカーセット(スコアが決まっている基準サンプル)を使って採点傾向の変化を監視します。

採点コストの試算

LLMジャッジは API コストを消費します。 評価ケース数 × 評価軸数 × 1回の評価トークン数 で試算できます。

例として、1評価ケースあたりのプロンプトが 800 トークン、評価軸が 3 つ、評価ケースが 200 件であれば、1回の評価バッチで 480,000 トークンの入力を消費します(モデルや価格は変動するため、実際のコストは利用するサービスの料金ページを確認してください)。

評価の頻度と規模に合わせて、必要十分な軸数と件数を設計することがコスト管理の基本です。 CI への組み込みでは全ケースを毎回実行するより、影響を受けたプロンプト・コンポーネントに関連するサブセットだけを評価するという設計も検討できます。

スコアの「意味のある変化」を閾値で定義する

平均スコアが 3.42 から 3.51 に変化したとき、それは改善といえるでしょうか。 LLMジャッジのスコアには内部的なばらつきがあるため、小さい変化を改善・悪化と判断するのは慎重にします。 同じケースを複数回評価してスコアのばらつきを計測し、「ばらつきの範囲を超えた変化だけを有意とみなす」という閾値を事前に設定しておくことを推奨します。


10BizPlan での LLMジャッジ活用の考え方

私たちが開発する BizPlan(事業計画エージェント)は、ユーザーが入力する事業アイデアに対してフィードバックを生成するエージェントです。 「フィードバックが的外れでないか」「論理的一貫性が保たれているか」といった品質は、事実の正誤と違い正解比較型では評価できません。

この特性から、LLMジャッジは評価の中核に位置づけています。 ただし、ジャッジモデルと生成モデルが同系列になることを避けるため、異なるプロバイダーのモデルをジャッジとして使う設計を検討しています。 また、新しい評価軸を導入する際には必ず 50 件以上の人手評価サンプルを用意し、κ を確認してから本格稼働させる手順を想定しています。

これはあくまで設計方針の段階であり、実測値や確定した知見として紹介できる段階ではありません。 実践から得られた知見は別記事で報告する予定です。


11まとめ

LLM-as-a-Judge は「正解が一意に定まらないタスク」の自動評価において有力な選択肢です。 ただし、評価の品質はルーブリックの設計に直結し、位置バイアス・冗長性バイアス・自己選好バイアスなど複数の系統的偏りへの対策が必要です。

人手評価との一致率を Cohen's κ で定量確認する工程を省略すると、LLMジャッジが系統的にズレた方向で採点し続けるリスクを見落とします。 最初は小規模でも一致率を確認し、確認を経た評価基準を規模拡大するという順序が、信頼性の高い評価パイプラインへの近道です。

バイアスがあることを理解した上で設計に組み込めば、LLMジャッジはコスト効率よく継続的な品質モニタリングを実現する強力なツールになります。


12このカテゴリの関連記事

  • (関連記事: 評価設計の基本:エージェントの品質をどう測るか)
  • (関連記事: 評価データセットの作り方:ゴールデンケースの集め方)
  • (関連記事: 回帰テストでエージェントの品質を守る)
  • (関連記事: フィードバックループの設計)

13参考文献

  • Zheng, L. et al. "Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena." arXiv:2306.05685(2023年)
  • Wang, P. et al. "Large Language Models are not Fair Evaluators." arXiv:2305.17926(2023年)
  • Panickssery, A. et al. "LLM Evaluators Recognize and Favor Their Own Productions." arXiv:2404.13076(2024年)
  • Cohen, J. "A Coefficient of Agreement for Nominal Scales." Educational and Psychological Measurement, 1960.
  • OpenAI Evals Framework - GitHub(2026年6月時点)
  • RAGAS: Evaluation Framework for RAG Pipelines(2026年6月時点)
Author
管理者
Agent Store

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

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

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

コメント

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

コメントを投稿

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

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