「昨日と同じプロンプトを投げているのに、応答が毎回違って比較できない」— 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 を握り潰しているのは、たいてい自前のラッパー
「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つだけ追加してみるのがおすすめです。プロンプトを変更したときに気付ける仕組みが整うと、改善のスピードが目に見えて変わります。再現性は地味な土台ですが、この土台があるかないかで、その先の実験の速さがまるで変わってきます。実装の一助になれば幸いです。お読みいただきありがとうございました。