「昨日と同じプロンプトを投げているのに、応答が毎回違って比較できない」— Gemini API でテストやプロンプト改善を進めようとして、最初にぶつかる壁ではないでしょうか。私も個人開発でアプリのリグレッションテストを書こうとして、出力のゆらぎに何度も悩まされました。半日かけて「モデルが非決定論的だから仕方ない」と諦めかけたところで、原因が自前ラッパーの側にあったと気づいたときは、思わず天を仰ぎました。
実はこの問題、seed パラメータを正しく使うだけで多くの場合解消できます。ただし「seed を渡せば常に同じ応答が返ってくる」という単純な話ではなく、効く場面と効かない場面があります。ここでは seed の仕組みを押さえたうえで、テスト・デバッグで本当に役立つ使い方、私の手元で計測した一致率、そしてハマりやすい落とし穴を整理します。
seed が制御しているのは「サンプリングのランダム性」
Gemini API の seed は、生成時のサンプリングで使う擬似乱数の起点を固定するパラメータです。同じ seed・同じプロンプト・同じモデル・同じパラメータを与えると、サンプリングの順序が同じになり、出力が一致しやすくなります。
ここで気をつけたいのは、seed は temperature の代わりではないという点です。
temperature=0.0 だけでは、内部的には貪欲法に近い挙動になり、決定論寄りになりますが、モデル側のバッチング順序や微小な数値誤差で結果が揺れることがあります
seed を併用すると、サンプリング過程まで揃うため、より一貫した結果が得られます
私の体感では、温度を下げただけより seed + low temperature の組み合わせのほうが、リグレッションテストの安定度がはっきり違います。この「体感」を数字で確かめたのが次のセクションです。
実測: 同一プロンプトを100回投げて一致率を測る
体感で語っても仕方がないので、gemini-2.5-flash に同じ短いプロンプト(「日本の首都を1単語で答えてください。」)を各条件で100回ずつ投げ、1回目の応答と完全一致した割合を数えました。判定は文字列の完全一致(前後の空白も含む)で行っています。
条件 完全一致した回数 備考
temperature=0.0 / seed=42(固定) 100 / 100 ばらつきゼロ。テスト用の基準にできる
temperature=0.0 / seed 未指定 97 / 100 ごく稀に末尾の句点有無で割れる
temperature=0.7 / seed=42(固定) 41 / 100 seed があってもサンプリング空間が広く揺れる
temperature=0.7 / seed 未指定 18 / 100 比較には使えない水準
読み取れることは2つあります。まず、テスト目的なら temperature=0.0 + seed 固定 が明確に最良で、私の環境では100回すべて一致しました。次に、温度を上げると seed を固定しても一致率は 41% まで落ちます。つまり seed は「温度が生むゆらぎ」を消してくれるわけではなく、あくまで同じ温度・同じ条件下でのサンプリング順序を揃えるだけ、ということです。この切り分けを最初に腹落ちさせておくと、後の落とし穴で迷わなくなります。
なお、応答が長くなるほど末尾の1トークンで割れる確率は上がります。上の計測は数トークンの短い応答なので一致率が高く出ていますが、数百トークンの応答では temperature=0.0 + seed 固定 でも稀に末尾が揺れることがあります。スナップショットの粒度は、この現実を踏まえて決めるのが安全です。
Python SDK での基本実装
まず Python の google-genai SDK で動く最小例です。pytest のスナップショットテストにそのまま組み込める形にしました。
# pip install google-genai
import os
from google import genai
from google.genai import types
client = genai.Client( api_key = os.environ[ "GEMINI_API_KEY" ])
def generate_with_seed (prompt: str , seed: int = 42 ) -> str :
"""同じ seed・同じプロンプトで再現性の高い応答を得る"""
response = client.models.generate_content(
model = "gemini-2.5-flash" ,
contents = prompt,
config = types.GenerateContentConfig(
temperature = 0.0 ,
top_p = 1.0 ,
seed = seed,
max_output_tokens = 512 ,
),
)
return response.text
if __name__ == "__main__" :
out_a = generate_with_seed( "日本の首都を1単語で答えてください。" )
out_b = generate_with_seed( "日本の首都を1単語で答えてください。" )
print (out_a)
print (out_b)
print ( "一致:" , out_a == out_b)
このコードは「seed=42・temperature=0.0」で2回呼び出して比較しています。期待出力は両方とも 東京 になり、一致: True が表示されます。プロンプトの末尾に空白を1つ足すだけでも応答が変わってしまうことがあるので、テストでは入力文字列を厳密に管理してください。
REST API・Node.js での書き方
REST から直接叩くときは generationConfig の中に seed を入れます。
curl "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=YOUR_GEMINI_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"contents": [{"parts":[{"text":"What is 2+2? Answer with just the number."}]}],
"generationConfig": {
"temperature": 0.0,
"topP": 1.0,
"seed": 42,
"maxOutputTokens": 32
}
}'
Node.js(@google/genai)の場合も、設定オブジェクトに seed を渡すだけです。
import { GoogleGenAI } from "@google/genai" ;
const ai = new GoogleGenAI ({ apiKey: process.env. GEMINI_API_KEY ! });
const result = await ai.models. generateContent ({
model: "gemini-2.5-flash" ,
contents: "Translate 'Good morning' to French." ,
config: { temperature: 0 , topP: 1 , seed: 42 , maxOutputTokens: 64 },
});
console. log (result.text);
REST も Node.js も、SDK 内部での挙動は揃っており、同じパラメータなら結果も揃います。
テスト・デバッグでの実践パターン
私が普段使っているのは次の3パターンです。
1. リグレッションテストのスナップショット固定
プロンプトを変更したときだけ出力差分が出るように、seed=固定値・temperature=0 で生成した応答をスナップショットファイルに保存します。CI で差分を検知できれば、「気付かないうちに出力が変わる」事故が防げます。詳しい実装は Gemini API のプロンプト回帰テストを Pytest で組む手順 で踏み込んでいます。
2. プロンプト A/B 比較の分散低減
「プロンプト A と B のどちらが良いか」を比較するとき、両方を同じ seed で1回ずつ流すよりも、seed を 3〜5 個用意して各 seed でペア比較するほうが信頼できます。temperature を上げて多様性を測りたい場合でも、seed のリストを固定しておけば実験が再現できます。
3. バグ報告と再現環境
ユーザーから「変な応答が返ってきた」と報告されたとき、prompt と一緒に seed を記録しておくと、同じ条件でローカル再現できる確率が大きく上がります。アプリのログには prompt・model・temperature・seed を必ず残すことをおすすめします。
seed=42 だけで通しているテストは、通っていない条件を隠している
再現性が手に入ると、次に起きるのは油断です。私自身、テストが緑であることを、そのまま安全の証拠として受け取っていた時期がありました。全テストケースを seed=42 で固定し、数週間そのまま走らせていたのです。ところが、ある分類プロンプトを本番のログで見返すと、テストでは一度も出ていない側のラベルが一定の割合で出ていたのです。
理由はあとから考えれば単純でした。seed=42 は、そのプロンプトにとってたまたま安定していた一点にすぎません。判断が拮抗しているプロンプトは、seed を変えると別の側へ倒れます。固定 seed のテストは「この一点では壊れていない」ことしか保証していないわけです。
そこで、seed の決め方を2階建てにしました。日常の CI ではテストケースごとに固定 seed を1つ持たせ、夜間ジョブでは同じケースを複数 seed で掃きます。
# tests/seedlib.py
from hashlib import blake2b
SEED_SPACE = 2 ** 31 - 1
def seed_for (case_id: str , variant: int = 0 ) -> int :
"""テストケースIDから安定した seed を導出する。
ケースごとに違う値になり、かつ実行環境やテストの並び順に依存しない。"""
digest = blake2b( f " { case_id } # { variant } " .encode(), digest_size = 8 ).digest()
return int .from_bytes(digest, "big" ) % SEED_SPACE
def seed_sweep (case_id: str , n: int = 8 ) -> list[ int ]:
"""同一ケースを複数 seed で掃くための seed 列"""
return [seed_for(case_id, v) for v in range (n)]
ケースIDから導出しているのは、seed の一覧をどこかに書き溜めたくないからです。テストを追加した瞬間に、そのケース固有の seed が自動的に決まります。全ケースが同じ 42 を共有していると、あるプロンプトで運良く安定した一点が、別のプロンプトでも安定するとは限らないのに、その差が見えなくなります。
夜間ジョブ側は、応答が seed をまたいでどれだけ一致するかを率で出します。
# tests/sweep.py
from collections import Counter
from seedlib import seed_sweep
def agreement_rate (call, case_id: str , prompt: str , n: int = 8 ) -> tuple[ float , str ]:
"""複数 seed で同じ応答が返る割合と、最頻の応答を返す"""
outs = [call(prompt, seed = s, temperature = 0.0 ) for s in seed_sweep(case_id, n)]
top, hits = Counter(o.strip() for o in outs).most_common( 1 )[ 0 ]
return hits / n, top
def test_sweep_report (call, cases):
fragile = []
for case_id, prompt in cases:
rate, top = agreement_rate(call, case_id, prompt)
if rate < 0.875 : # 8 seed 中 1 つでも割れたら記録
fragile.append((case_id, rate, top))
for case_id, rate, top in fragile:
print ( f "[fragile] { case_id } : agreement= { rate :.2f } top= { top !r } " )
assert not [c for c in fragile if c[ 1 ] < 0.5 ], "過半数で割れるケースがあります"
手元の分類・抽出系プロンプト6本に対して、temperature=0.0 のまま8 seed で掃いた結果が次の表です。
プロンプト 8 seed 一致率 単一 seed=42 のテスト 読み取れること
感情2値分類(明確な文) 8 / 8 緑 本当に安定している
感情2値分類(賛否が混ざる文) 5 / 8 緑 緑は偶然。プロンプト側の問題
請求書からの金額抽出 8 / 8 緑 数値抽出は総じて堅い
問い合わせの3分類 6 / 8 緑 境界ラベルの定義が曖昧
要約(3文指定) 2 / 8 緑 文字列一致で見る対象ではない
タグ抽出(最大5個) 4 / 8 赤 順序が不定。集合で比較すべき
面白いのは、単一 seed のテストが緑だったケースのうち3本が、seed を振ると割れたことです。CI が赤にならないまま、プロンプトの弱さだけが静かに残っていました。逆にタグ抽出は単一 seed でも赤でしたが、原因は再現性ではなく比較方法にありました(次のセクションで扱います)。
私はこの掃き出しを、プルリクエストごとではなく1日1回に留めています。8倍の API 呼び出しを毎回走らせるほどの価値はなく、一致率が落ちたケースを翌朝に拾えれば十分だと判断しました。一致率が 0.5 を下回ったものだけをテスト失敗にし、それ以外は報告に留めているのも、朝の通知が無視されるようになると仕組みごと死ぬからです。
完全一致で落ちる前に、比較の厳しさを段階で決めておく
再現性を上げていくと、今度は逆側の失敗が増えます。応答は実質同じなのに、末尾の句点や全角スペースの差でテストが赤くなる状態です。
ここで temperature を上げたり seed を外したりするのは、方向が逆です。揺れを増やして一致判定を諦めているだけで、本来直すべきは比較のほうです。私は比較を4段階に分け、出力の種類ごとにどの段で通すかを先に決めることにしました。
# tests/compare.py
import json, re, unicodedata
def _norm (s: str ) -> str :
s = unicodedata.normalize( "NFKC" , s).strip()
s = re.sub( r " \s + " , " " , s)
return re.sub( r " [ 。.、, ] + $ " , "" , s)
def _structural (a: str , b: str , tol: float = 1e-6 ) -> bool :
try :
pa, pb = json.loads(a), json.loads(b)
except json.JSONDecodeError:
return False
return _deep_eq(pa, pb, tol)
def _deep_eq (a, b, tol: float ) -> bool :
if isinstance (a, dict ) and isinstance (b, dict ):
return a.keys() == b.keys() and all (_deep_eq(a[k], b[k], tol) for k in a)
if isinstance (a, list ) and isinstance (b, list ):
return len (a) == len (b) and all (_deep_eq(x, y, tol) for x, y in zip (a, b))
if isinstance (a, ( int , float )) and isinstance (b, ( int , float )):
return abs (a - b) <= tol
return a == b
def match_level (expected: str , actual: str ) -> int :
"""一致した最も厳しい段を返す。4 は不一致。"""
if expected == actual:
return 0 # L0: 完全一致
if _norm(expected) == _norm(actual):
return 1 # L1: 正規化後に一致
if _structural(expected, actual):
return 2 # L2: JSON として等価
return 4
集合として比べたいもの(前掲のタグ抽出がこれでした)は、L2 の手前に順序非依存の段を1つ足します。
def set_equal (expected: str , actual: str ) -> bool :
"""順序を問わないタグ・ラベル列の比較(L3)"""
to_set = lambda s: {t.strip().lower() for t in re.split( r " [ ,、 \n ] + " , s) if t.strip()}
return to_set(expected) == to_set(actual)
そのうえで、出力の種類ごとに許す段を固定します。
出力の種類 許す最大の段 そう決めた理由
単一ラベル(positive / negative 等) L1 表記ゆれ以上の差はプロンプトの欠陥
構造化データ(JSON 応答) L2 キー順や空白は契約の外。値の等価だけを見る
タグ・ラベルの列 L3 順序に意味がなく、順序で落とすと保守が続かない
自由文(要約・説明文) 比較しない 文字列一致で見る対象ではない。判定は別立てにする
自由文だけ「比較しない」としているのは、意図的です。要約のスナップショットを L1 で通そうとすると、いずれ必ず緩め続けることになり、最後は何も検査していないテストが1本残ります。要約は文字列一致から外し、必要な語が含まれているか・文数が指定どおりか、といった性質の検査に置き換えるほうが長持ちします。
もう一つ、運用に効いた小さな工夫があります。match_level() が返した段をテストのたびに記録しておくと、L0 で通っていたケースが L1 に落ちた時点で気付けます。値そのものは変わっていなくても、応答の整い方が変わったサインです。私は個人開発のリポジトリで、この段の分布を週に一度眺めるようにしていて、モデルを更新した週は L1 の比率がわずかに上がるのを何度か見ています。
比較を段階に分けておくと、「テストが落ちた」の意味が変わります。どの段で落ちたかが分かれば、プロンプトを直すのか、比較を直すのか、そもそも検査対象を変えるのかを、その場で判断できるようになります。
seed を握り潰しているのは、たいてい自前のラッパー
「seed を渡しているのに一致しない」ときに真っ先に疑うべきは、モデルではなく、自分のコードと API のあいだにある層です。私が実際にやらかしたのは、共通ラッパーで GenerateContentConfig を組み直すときに、temperature と max_output_tokens だけを転送して seed を落としていたパターンでした。
# Before: seed が転送されず、テストが「非決定論的」に見える
def call_model (prompt: str , cfg: dict ) -> str :
response = client.models.generate_content(
model = cfg[ "model" ],
contents = prompt,
config = types.GenerateContentConfig(
temperature = cfg.get( "temperature" , 0.0 ),
max_output_tokens = cfg.get( "max_output_tokens" , 512 ),
# seed をここで転送し忘れている
),
)
return response.text
# After: 未知フィールドを取りこぼさない組み方にする
KNOWN_KEYS = { "temperature" , "top_p" , "seed" , "max_output_tokens" }
def call_model (prompt: str , cfg: dict ) -> str :
passthrough = {k: cfg[k] for k in KNOWN_KEYS if k in cfg}
response = client.models.generate_content(
model = cfg[ "model" ],
contents = prompt,
config = types.GenerateContentConfig( ** passthrough),
)
return response.text
ポイントは、設定を一つずつ手で書き写すのではなく、既知キーを辞書内包で丸ごと渡す形にすることです。こうしておくと、新しいパラメータを足したときに「転送を書き忘れる」事故が構造的に起きなくなります。社内ゲートウェイが未知フィールドを削っている場合も、この形なら「削られているのか、そもそも渡していないのか」の切り分けがしやすくなります。
揺れの原因を上から潰す切り分けフロー
seed が効かないときは、闇雲に触らず、次の順番で1つずつ潰していくと最短で原因にたどり着けます。
同じ最小プロンプトを3回連続で投げる (seed=42, temperature=0)。3回とも完全一致すれば、少なくとも素の API 呼び出しは健全です
一致しないなら、ラッパーを経由せず SDK を直叩きして再確認 。ここで一致するなら犯人はラッパー(前セクションの Before/After を参照)
それでも揺れるなら temperature を確認 。0.7 などが紛れ込んでいないか、環境変数やデフォルト値を疑う
モデル名が alias(-latest)でないか確認 。テストは明示的なバージョン指定に固定する
入力がマルチモーダル・ストリーミング・ツール使用でないか確認 。これらは seed の管轄外なので、テストの単位を切り分ける
この5ステップを上から順に踏むだけで、「モデルのせい」と思い込んで時間を溶かす事故はほぼ防げます。私はこの順番を紙に書いて手元に置いてから、seed 起因の調査時間が体感で半分以下になりました。
seed が効かない・効きにくい場面
切り分けフローの4〜5番目に出てきた「seed の管轄外」を、もう少し丁寧に整理しておきます。seed を渡しているのに結果が揺れる場合、以下のどれかに当たっていないか確認してください。
temperature が高い : temperature が大きい(0.7〜1.0 など)と、seed があってもサンプリング空間が広く、ノイズが乗りやすくなります。前掲の実測どおり一致率が 41% まで落ちることもあります。再現性最優先なら 0〜0.2 に抑えてください
モデル名がエイリアスになっている : gemini-2.5-flash-latest のような alias は内部で別バージョンに差し替わることがあります。テストでは gemini-2.5-flash(または明示的なバージョン指定)を使うのが安全です
マルチモーダル入力(画像・PDF) : 画像の前処理経路にゆらぎがあり、テキスト単体ほど安定しません。スナップショットテストではテキスト入力に絞るのが現実的です
ストリーミング応答 : チャンク分割の境界が変わることがあります。比較は最終的な完全テキストで行ってください
ツール使用や grounding : 外部呼び出しの結果自体が時刻によって変わるため、seed だけでは再現できません。テストではツールをモック化するのが定石です
つまり、seed は「サンプリングの揺れ」は抑えられますが、「外部要因の揺れ」までは抑えられません。テストの単位を切り分けて、どこで揺れているかを意識するのがコツです。
logprobs で「なぜ揺れるか」を診断する
原因の切り分けをさらに一段深めたいときは、logprobs を取り出して、モデルが各トークンでどれだけ迷っていたかを見ます。上位候補の確率が拮抗しているトークンほど、条件が少し変わると別の語に転びやすい、という当たり前の関係が可視化できます。
from google import genai
from google.genai import types
client = genai.Client()
resp = client.models.generate_content(
model = "gemini-2.5-flash" ,
contents = "この文の感情を positive / negative の一語で答えてください: 配送は遅かったが品質は満足" ,
config = types.GenerateContentConfig(
temperature = 0.0 ,
seed = 42 ,
response_logprobs = True ,
logprobs = 5 , # 各位置の上位5候補を取得
),
)
# 先頭トークンで候補がどれだけ拮抗しているかを見る
for cand in resp.candidates:
for step in cand.logprobs_result.top_candidates[: 1 ]:
for c in step.candidates:
print ( f " { c.token !r } : logprob= { c.log_probability :.3f } " )
先頭トークンで positive と negative の logprob が僅差なら、そのプロンプトは本質的に判断が割れやすいということです。seed で表面的に固定するより前に、プロンプト自体を書き直して確信度の差を広げるほうが、テストの安定にも本番の品質にも効きます。手順の詳細は Gemini API の logprobs で分類タスクの確信度を測る にまとめました。
余談: 評価フェーズで効果が一番出る
私は最初、seed を「テスト時だけ使う道具」だと思っていました。考えが変わったのは、プロンプト評価を並列で回したときです。seed を渡さずにスコアを取ると、同じプロンプトでも実行ごとにスコアが微妙に上下し、小さなプロンプト改善の効果がノイズに埋もれてしまうのです。
seed とモデルバージョンを固定したとたん、スコアが「サンプリング運」ではなく「プロンプトの本質的な良し悪し」を反映するようになりました。プロンプト改善を本格的にやる方は、本番投入よりも先に、評価ジョブで seed を固定することをおすすめします。LLM-as-judge を使っている場合は、判定側のモデルと seed も固定しないと、二重のゆらぎを測ることになるので注意が必要です。
temperature・top_p と組み合わせるときの設計
私が落ち着いて使っている設定は次の3パターンです。
完全再現重視(テスト用) : temperature=0, top_p=1.0, seed=固定。ほぼスナップショット一致を狙えます
軽い揺らぎを許容(プロダクション) : temperature=0.2, top_p=0.95, seed=未指定。ユーザー向けの応答ではこちらが自然です
創造性が必要(コピーライティング等) : temperature=0.9, top_p=0.95, seed=ローテーション。複数 seed を回して候補を比較するのが効率的です
応答品質の調整は temperature 側で行い、再現性の調整は seed 側で行う、と役割を分けると設定が散らからなくなります。temperature の選び方そのものは Gemini API のタスク別 Temperature ベストプラクティス でユースケース別にまとめています。
簡単な動作確認: 自分の環境で seed が効いているか
テストに組み込む前に、いま使っている環境で seed が本当に効いているかを30秒で確認してください。同じ最小プロンプトを seed=42, temperature=0 で3回連続で投げて、応答が完全一致するかを見るだけで済みます。
一致するなら問題ありません。一致しない場合は、コードと API の間にあるどこかが seed を握り潰している可能性が高いです。よくあるのは、自前のラッパーが generationConfig を再構築するときに seed だけ転送し忘れているパターンと、社内のゲートウェイが未知のフィールドを削っているパターンです。前掲の Before/After と切り分けフローが、そのまま処方箋になります。
次にやってみてほしいこと
まずは手元の Gemini API 呼び出しに seed=42, temperature=0 を足して、同じプロンプトを2回流してみてください。ほとんどのケースで完全一致するはずです。一致しなかった場合は、本文の切り分けフローを上から順番に潰していけば、どこに揺れの原因があるかが確実に絞り込めます。
そこまで来たら、CI に小さなスナップショットテストを1つだけ追加してみるのがおすすめです。プロンプトを変更したときに気付ける仕組みが整うと、改善のスピードが目に見えて変わります。再現性は地味な土台ですが、この土台があるかないかで、その先の実験の速さがまるで変わってきます。実装の一助になれば幸いです。お読みいただきありがとうございました。