2014年に個人でアプリ開発を始めてから、累計 5,000 万ダウンロードを超えるアプリを運用してきました。途中から AdMob とサーバ系プロンプトを支える主軸に Gemini API が入り、長く付き合うほど「ある日突然、出力の品質が下がっている」感覚に何度か出会いました。エラーは出ません。応答も返ってきます。ただ、ユーザーから届く問い合わせの肌触りが、なぜか少しだけ重くなります。
そのたびに過去のログを掘り返し、プロンプトを少しずつ調整して回復させてきましたが、毎回同じ反応的な対処を繰り返している自分に違和感がありました。アーティスト活動で 17 の国際芸術賞をいただく過程でも、作品の質を保つには、完成後に「外から見る目」を仕組みとして持つことが欠かせないと学んできました。同じことが API 運用にも当てはまります。本記事は、その「外から見る目」を Gemini API に対して設けるための評価基盤を、個人開発の現実的な制約のなかでどう組むかをまとめたものです。
「ある日突然」を防ぐために必要なのは、定常的な監視レーン
個人で運用しているとモデル切り替え、プロンプト改善、SDK のマイナーアップデート、ライブラリ更新と、変更要因が静かに積み重なります。問題は、これらの変更が同じ入力でも違う出力を返してくる、その差分に気づけないことです。
私が運用しているアプリ群では、Gemini API の代表的な呼び出しが 14 種類あります。プッシュ通知の文面生成、レビュー要約、コンテンツのモデレーション、ユーザー文章のトーン推定など、用途はばらばらです。これらすべてを人間が定期的に目視レビューするのは現実的ではありません。そこで、運用とは独立した「評価レーン」を設けて、本番に影響を与えない場所で同じ入力を流し続け、出力の変化を追跡する仕組みを置いています。
評価レーンの目的は単純で、3 つに絞れます。第一に、モデルやプロンプトを更新したとき、既知の入力に対する出力が悪化していないか確認する回帰検出。第二に、未更新でも静かに劣化していないかを定期確認する継続監視。第三に、新規追加するプロンプトの初期品質を客観的に測る入口検査です。
Golden Dataset の作り方とラベル運用の現実的な妥協点
評価レーンの背骨が Golden Dataset です。代表的な入力と「望ましい出力像」をセットで保管したコレクションで、私は現在 32 件で運用しています。多ければ多いほどよいというものでもなく、評価コストと実用上の被覆率が釣り合う規模に落ち着きました。
データセットを作るとき、最初は「正解の出力」を一字一句固定しようとしましたが、これは長続きしませんでした。LLM の出力には自然な揺れがあり、正解を一意に固定するとほぼ毎回不一致になります。
そこで運用しているのが、3 段階のラベルです。
# golden_dataset/push_notification_001.yaml
id : push_notification_001
category : push_generation
input :
user_segment : "lapsed_30d"
app_context : "wallpaper_app"
recent_actions : [ "downloaded_5_wallpapers" , "browsed_landscape_category" ]
expected :
# 厳密一致は求めない。以下の3つを満たせばOK
must_include_intent : "lapsed_user_reactivation"
must_mention_category : [ "landscape" , "風景" , "自然" ]
forbidden_phrases : [ "最強" , "神アプリ" , "限定オファー" ]
tone : "warm_neutral"
max_chars : 60
language : ja
last_verified : 2026-05-10
verified_by : hirokawa
must_include_intent は意味的な意図、must_mention_category は同義語を含むキーワード集合、forbidden_phrases はブランドを毀損するワード、tone は雰囲気のラベル、max_chars は字数上限です。完全一致ではなく「制約の集合」として定義すると、出力に多少の揺れがあっても運用が続きます。
ラベル運用で最も苦労したのが verified_by の責任所在です。個人開発でも、四半期に一度は「このサンプルは本当にまだ価値判断として正しいか」を見直さないと、Golden Dataset 自体が古びます。私は四半期ごとに 1〜2 時間だけ時間を確保し、すべての ID を順番にチェックする運用にしています。
LLM-as-Judge の役割を分解して 3 観点に分ける
制約の集合だけでは「全体としての違和感」を捉えきれません。そこで第二の評価レーンとして LLM-as-Judge を入れます。私の構成では、ジャッジを 3 つの観点に分けて、それぞれ別の呼び出しで判定させています。
第一の観点は「制約準拠」で、Golden Dataset の must/forbidden ルールを満たしているかを確認します。これはルールが明確なので低温度で十分です。第二は「意図一致」で、入力から期待される行動目的と出力の方向性が揃っているかを評価します。第三は「自然さ」で、日本語としての読みやすさや、機械臭の有無を判定します。
なぜ 3 つに分けるかというと、過去の実験で「総合的に評価してください」というプロンプトを使うとジャッジが落ち着かず、再現性のあるスコアが取れなかったからです。観点を分けると各ジャッジの認知負荷が下がり、再実行時のスコアのばらつきが私の計測で約 47% から 18% に下がりました。
# judge.py — 観点別ジャッジを多数決で集約する最小実装
from dataclasses import dataclass
from typing import Literal
import google.generativeai as genai
JUDGE_MODEL = "gemini-2.5-pro"
@dataclass
class JudgeVerdict :
aspect: Literal[ "constraint" , "intent" , "naturalness" ]
score: int # 1-5
rationale: str
CONSTRAINT_PROMPT = """あなたは厳密な制約チェッカーです。
以下の制約を満たしているかを 1〜5 で評価し、根拠を 80 字以内で書いてください。
評価する候補出力: <<< {output} >>>
制約: must_include_intent= {intent} , forbidden= {forbidden} , max_chars= {max_chars}
JSONで返してください: {{ "score": int, "rationale": str }} """
INTENT_PROMPT = """以下の入力と出力で、入力から期待される行動目的が出力に表れているかを 1〜5 で評価してください。
入力: <<< {input} >>>
出力: <<< {output} >>>
JSONで返してください: {{ "score": int, "rationale": str }} """
NATURALNESS_PROMPT = """以下の日本語出力の自然さを 1〜5 で評価してください。
評価軸: 機械的な定型句がないか、読み手に強要する語気がないか、字数バランス。
出力: <<< {output} >>>
JSONで返してください: {{ "score": int, "rationale": str }} """
def call_judge (prompt: str ) -> JudgeVerdict:
model = genai.GenerativeModel(
JUDGE_MODEL ,
generation_config = { "temperature" : 0.1 , "response_mime_type" : "application/json" },
)
resp = model.generate_content(prompt)
data = resp.parsed if hasattr (resp, "parsed" ) else parse_json_fallback(resp.text)
return JudgeVerdict(
aspect = prompt_aspect(prompt),
score = int (data[ "score" ]),
rationale = data[ "rationale" ],
)
def majority_vote (verdicts: list[JudgeVerdict]) -> int :
"""同じ観点で 3 回投票させ、中央値を採用する。極端な外れ値を吸収する設計。"""
by_aspect = {}
for v in verdicts:
by_aspect.setdefault(v.aspect, []).append(v.score)
return {a: sorted (s)[ len (s) // 2 ] for a, s in by_aspect.items()}
多数決はジャッジの「気分」による外れ値を吸収するために入れています。私は最終的に 3 観点それぞれを 3 回ずつジャッジ呼び出しし、中央値を採用しています。コストはかかりますが、後述のサンプリングで本番運用としては許容できる水準に収まります。
回帰検出: スコアの変化を「線」で捉える
毎週同じ Golden Dataset でジャッジを走らせると、各サンプルに対して観点ごとのスコア時系列が蓄積されます。一週ごとの単独スコアより、線として変化を捉えるほうが感度が高いというのが、運用 8 ヶ月で得た実感です。
具体的には、各サンプル × 観点ごとに直近 4 週間の中央値を基準値として保管し、当週のスコアがその基準値から 1.0 以上下がったら警告、1.5 以上下がったらアラートとしています。これだと小さなノイズを拾わず、傾向としての劣化だけが浮かびます。
# regression.py — 4週中央値を基準にした劣化検出
import statistics
WARN_DROP = 1.0
ALERT_DROP = 1.5
def detect_regression (history: list[ float ], current: float ) -> str :
"""history は古い順の直近4週スコア。当週分は含まない。"""
if len (history) < 4 :
return "insufficient_history"
baseline = statistics.median(history)
delta = baseline - current
if delta >= ALERT_DROP :
return f "alert (baseline= { baseline :.2f } , current= { current :.2f } )"
if delta >= WARN_DROP :
return f "warn (baseline= { baseline :.2f } , current= { current :.2f } )"
return "ok"
アラートが出たサンプルは、自動で Slack 風の個人通知(私の場合は Pushover)に流し、その週の振り返り時間で必ず開きます。実装としては数行のコードですが、これがあるかないかで、運用を継続できるかどうかが大きく変わりました。導入後、リリース起因の品質劣化を 3 回検知し、いずれも本番のユーザー影響が出る前にプロンプトを差し戻せました。
コストを抑えながら続けるための 3 つの工夫
評価基盤は走らせ続けないと意味がありません。一方で、評価のために本番より多くのトークンを使ってしまうと続きません。私の運用では、評価コストを本番 API コストの 3% 以下に抑えることを目標にしています。
第一の工夫は階層サンプリングです。32 件すべてを毎週フル評価するのではなく、過去 4 週のスコアが安定しているサンプルは隔週に減らし、揺れているサンプルは毎週評価します。これで全体評価回数を約 60% に圧縮できました。
第二は、観点別ジャッジでモデルを使い分けることです。「制約準拠」は Gemini 2.5 Flash で十分で、「意図一致」と「自然さ」だけ Gemini 2.5 Pro を使います。これで全体のジャッジコストを約 42% 削減できました。Flash を 1 回呼ぶコストと Pro を 1 回呼ぶコストでは桁が違うため、観点ごとに適材適所が成立します。
第三は、キャッシュ可能な部分をキャッシュすることです。Golden Dataset の入力部分は不変なので、ジャッジプロンプトのテンプレートでは入力部分を context cache に逃がし、観点プロンプトだけを毎回送ります。本番では Gemini の context cache 機能を併用しており、これによる削減はおおむね月あたり 8% でした。
実コストで言うと、私の構成では月あたり評価コストが約 ¥4,200 で、対応する本番呼び出しコストの 2.4% に収まっています。30 倍以上の本番ボリュームに対して、評価レーンが過剰負担にならないバランスです。
私が運用している評価パイプラインの全体像
ここまでの要素を組み合わせた、現状の私の構成を整理しておきます。動かしているのは GitHub Actions の週次ジョブで、深夜帯に走らせています。
# pipeline.py — 週次評価パイプラインのエントリポイント
import yaml
from pathlib import Path
from typing import Any
from judge import call_judge, majority_vote, CONSTRAINT_PROMPT , INTENT_PROMPT , NATURALNESS_PROMPT
from regression import detect_regression
from store import load_history, append_score, send_alert
DATASET_DIR = Path( "golden_dataset" )
def select_samples_for_week (week: int ) -> list[Path]:
"""階層サンプリング。揺れているサンプルは毎週、安定は隔週。"""
all_paths = list ( DATASET_DIR .glob( "*.yaml" ))
selected = []
for p in all_paths:
history = load_history(p.stem)
if not history or stdev_recent(history) > 0.4 :
selected.append(p)
continue
if week % 2 == 0 :
selected.append(p)
return selected
def evaluate_one (sample_path: Path) -> dict[ str , Any]:
sample = yaml.safe_load(sample_path.read_text())
output = run_production_call(sample[ "input" ])
verdicts = []
for prompt_tpl, aspect, model in [
( CONSTRAINT_PROMPT , "constraint" , "gemini-2.5-flash" ),
( INTENT_PROMPT , "intent" , "gemini-2.5-pro" ),
( NATURALNESS_PROMPT , "naturalness" , "gemini-2.5-pro" ),
]:
for _ in range ( 3 ): # 観点ごとに3回呼んで中央値
verdicts.append(call_judge(prompt_tpl.format( ** sample, output = output)))
scores = majority_vote(verdicts)
return { "id" : sample[ "id" ], "scores" : scores, "output" : output}
def main (week: int ) -> None :
targets = select_samples_for_week(week)
for path in targets:
result = evaluate_one(path)
for aspect, score in result[ "scores" ].items():
history = load_history(result[ "id" ], aspect)
status = detect_regression(history, score)
append_score(result[ "id" ], aspect, score)
if status.startswith(( "warn" , "alert" )):
send_alert(result[ "id" ], aspect, status, result[ "output" ])
この構成の良いところは、構成要素がすべて独立して差し替え可能な点です。ジャッジモデルを別のモデルに差し替えたいときも、judge.py だけを書き換えれば済みます。Golden Dataset を YAML から CSV に変えたいときも、select_samples_for_week の中だけを差し替えれば動きます。
評価基盤を持つことで変わったこと
導入前後で最も大きく変わったのは、プロンプトを更新するときの精神的負荷でした。以前は「変えたら何かが壊れているかもしれない」という不安が常にありましたが、評価基盤がスコアという形で安心を返してくれるようになり、改善のサイクルがはっきりと速くなりました。私の感覚値で、プロンプトの大幅改修にかかる時間が約 3 分の 1 に短縮されました。
副次的な効果として、ユーザーからの問い合わせを受けたとき「これは劣化なのか個別の例外なのか」を、過去の評価ログから即座に判断できるようになりました。問い合わせ対応の質も静かに上がっています。
個人開発は、限られた時間でできるだけ多くの判断を仕組みに任せる必要があります。Gemini API のような外部依存を抱えながら長く運用していくのであれば、評価基盤は早い段階で持っておく価値があると考えています。私自身、もっと早く整備しておけばよかったと振り返って感じている領域です。
同じように個人や小規模チームで Gemini API を運用している方の参考になれば嬉しいです。お読みいただきありがとうございました。