はじめに
僕はGemini APIを使って、動画や画像を分析し0〜100点のスコアを返すシステムを開発しています。
ところが、同じ入力を2回分析しても、スコアが ±15〜20点ブレるという問題に直面しました。
1回目: B (68点)
2回目: A (80点)
これではユーザーの信頼を得られません。
この記事では、試行錯誤の末にAPIコスト増なしでブレを ±5点以内に抑えた5つのテクニックを紹介します。
前提: パラメータだけでは解決できない
最初に試したのは Gemini API の生成パラメータの調整でした。
generationConfig: {
temperature: 0,
topK: 1,
seed: 42,
}
結果: 最高評価が連発しました。
ランダム性を完全に排除すると、モデルが「最も確率の高いトークン」だけを選ぶため、高スコア方向に偏ります。Gemini の seed パラメータもベストエフォートであり、決定論的な出力は保証されません。
パラメータチューニングだけでスコア安定化は不可能というのが最初の学びでした。
テクニック1: スコアを5点刻みに制限する
最もインパクトが大きかった変更です。
LLMにとって「67点と73点の違い」を一貫して判断するのは難しい。しかし「65点と75点のどちらか」なら、判断の分解能が下がる分、一貫性が上がります。
プロンプト側
**重要**: スコアは必ず5の倍数で返すこと(例: 65, 70, 75)。中間値(67, 73等)は禁止。
サーバー側(バリデーション)
LLMがプロンプトを無視して中間値を返すことがあるので、サーバー側でも丸めます。
const roundTo5 = (score: number): number => Math.round(score / 5) * 5;
for (const item of result.items) {
if (typeof item.score === "number") {
const rounded = roundTo5(item.score);
if (item.score !== rounded) {
item.score = rounded;
}
}
}
プロンプトで指示 + サーバーで強制の二重保証がポイントです。
テクニック2: CoT(Chain-of-Thought)スコアリング
通常のプロンプト:
各項目にスコアとコメントを付けてください。
これだと、LLMは「なんとなく」スコアを決めがちです。
改善後: 思考の手順を明示的に指定します。
## スコアリング手順(この順序に従うこと)
1. まず対象を観察し、各評価軸ごとに「何が見えたか」を事実として列挙する
2. 各事実をルーブリックの段階(S/A/B/C/D)に照合し、該当する段階を決定する
3. 該当段階のスコアレンジ内で、5点刻みのスコアを1つ選ぶ
4. 全軸のスコア平均を計算し、overallScore とする
5. overallScore に対応するグレードを判定する
「観察 → ルーブリック照合 → スコア決定」の順序を強制することで、根拠に基づいたスコアリングになり、ブレが減ります。
テクニック3: 複数グレードの出力例でアンカリング
改善前のプロンプトには中間グレードの出力例が1つだけありました。
// Bグレードの例のみ
{ "overallGrade": "B", "overallScore": 68, ... }
これだとLLMは「Bグレードの雰囲気」しか掴めず、S〜Dのスケール感が不安定になります。
改善後: S(高評価)・B(中間)・D(低評価)の3パターンの出力例を追加しました。
// Sグレード(90〜100点)の例
{ "overallGrade": "S", "overallScore": 95,
"items": [{ "category": "項目A", "score": 95, "comment": "..." }] }
// Bグレード(60〜74点)の例
{ "overallGrade": "B", "overallScore": 65,
"items": [{ "category": "項目A", "score": 75, "comment": "..." }] }
// Dグレード(0〜39点)の例
{ "overallGrade": "D", "overallScore": 30,
"items": [{ "category": "項目A", "score": 25, "comment": "..." }] }
スケールの「両端と中央」を例示することで、LLMがスコアレンジ全体を正しく認識できるようになります。これをアンカリングと呼びます。
テクニック4: カテゴリ別ルーブリック(採点基準表)
「良いかどうか」の基準が曖昧だと、LLMは毎回違う観点で採点します。
解決策として、評価カテゴリごとの詳細なルーブリックを作成しました。
### 評価項目X
- S(90-100): [具体的な観察可能な基準]
- A(75-89): [具体的な観察可能な基準]
- B(60-74): [具体的な観察可能な基準]
- C(40-59): [具体的な観察可能な基準]
- D(0-39): [具体的な観察可能な基準]
各スコアレンジに具体的な観察可能な基準を紐づけることで、「この状態はB(60-74)の範囲だ」という判断が安定します。
ルーブリックの設計は手間がかかりますが、スコア安定化への効果は絶大です。
テクニック5: overallScoreをサーバー側で再計算する
LLMに overallScore と各項目の score を両方返させると、しばしば矛盾が生まれます。
{
"overallScore": 82,
"items": [
{ "score": 70 }, { "score": 65 }, { "score": 75 },
{ "score": 60 }, { "score": 70 }, { "score": 65 }
// 平均: 67.5 なのに overallScore が 82...
]
}
これを防ぐため、overallScore はLLMの出力を信用せず、サーバー側で各項目の平均から再計算します。
const avgScore = items.reduce(
(sum, item) => sum + item.score, 0
) / items.length;
result.overallScore = roundTo5(avgScore);
// グレードもスコアから再計算
const scoreToGrade = (score: number): string => {
if (score >= 90) return "S";
if (score >= 75) return "A";
if (score >= 60) return "B";
if (score >= 40) return "C";
return "D";
};
result.overallGrade = scoreToGrade(result.overallScore);
LLMの出力は「各項目のスコア」だけを信頼し、集計はコードで行う。これにより、少なくとも「個別スコアと総合スコアの矛盾」は完全に排除できます。
temperature の設定
最終的に temperature: 0.1 に落ち着きました。
| 値 | 結果 |
|---|---|
| 0 (+ topK:1, seed:42) | 高評価に偏る。使い物にならない |
| 0.1 | 安定性と多様性のバランスが良い |
| 0.3 | やや安定。ブレは残る |
| 1.0(デフォルト) | ブレが大きい |
temperature: 0 がダメだったのは意外でした。完全に貪欲なデコーディングにすると、モデルが「最も無難な(=高い)スコア」に偏る傾向があるようです。
まとめ
| テクニック | 効果 | コスト |
|---|---|---|
| 5点刻みスコア | ◎ ブレ幅を物理的に制限 | なし |
| CoTスコアリング | ◎ 根拠ベースの採点 | 出力トークン微増 |
| 複数グレードの出力例 | ○ スケール全体を固定 | プロンプト長増加 |
| カテゴリ別ルーブリック | ◎ 採点基準の統一 | 設計工数 |
| overallScore再計算 | ○ 矛盾を排除 | なし |
最も重要な学び: LLMのスコアリング安定化は、パラメータチューニングではなくプロンプト設計で解決する問題でした。
「何を見て、何を基準に、どう判断するか」を明確に指示すれば、LLMは驚くほど一貫した採点者になります。