2014年から個人開発で運営している壁紙アプリでは、Gemini 2.5 Flash に画像と短いプロンプトを渡して自動的にカテゴリラベルを付けるパイプラインを使っています。新しい素材を投入するたびにバッチ処理で分類し直すのですが、同じ画像と同じプロンプトを渡し直しても、たまにラベルが揺れる現象に長らく悩まされていました。App Store のレビューで「カテゴリが昨日と違う気がする」と書かれてしまうのを避けたいので、回帰テストを書きたい。そこで generationConfig.seed を固定したのですが、それでもラベルが安定しません。同じ問題に当たっている方が周りに何人かいたので、原因の切り分けと、実運用で「再現性に近い状態」を確保するために私が落ち着いた構成を書き残します。
現象を最小コードで再現する
まず、何が起きているかを確認できる短いコードを置きます。Python 用 google-genai SDK 1.x 系を前提にしています。
from google import genai
from google.genai import types
client = genai.Client(api_key="YOUR_GEMINI_API_KEY")
config = types.GenerateContentConfig(
temperature=0,
seed=42,
max_output_tokens=64,
)
for i in range(5):
res = client.models.generate_content(
model="gemini-2.5-flash",
contents="日本の四季を一行で表現してください。",
config=config,
)
print(i, res.text.strip())temperature=0 と seed=42 を両方指定しているので、直感的には 5 回とも同じ文字列が返ってきそうですが、実際には微妙に違う表現がときどき混ざります。seed を消したときと比べると揺れ幅は小さくなりますが、ゼロにはなりません。
なぜ seed だけでは決定論にならないのか
混乱の原因は、seed の役割が「結果を完全に決定する乱数の種」ではなく、「サンプリングの乱数生成器の初期値」にすぎないところにあります。サンプリング以外の経路で揺らぎが入る限り、出力は完全には固定されません。
実運用で実際に効いてくる揺らぎの源は、主に三つあります。
第一に、モデル側のバージョンが裏で更新されることです。gemini-2.5-flash のようなエイリアスは内部的に最新の安定ビルドにルーティングされ、明示的なリリース通知なしに振る舞いが変わる場合があります。日付付きの gemini-2.5-flash-001 のような固定タグを使わない限り、seed の効果は「同じモデル重みの上でのみ」維持されます。
第二に、サーバー側のバッチング・並列化に起因する浮動小数点演算の非結合性です。マルチ GPU 上でテンソルを足し合わせる順序がリクエストごとに異なると、最終ロジットがビット単位で同じにならず、temperature=0 相当の argmax でも稀にトークンが変わります。これは Gemini に限らず、現代の大規模推論バックエンド全般に共通する問題です。
第三に、temperature を 0 にしても、サーバー側で top_p や top_k のような他のサンプリング設定がデフォルト値で残っている場合があります。thinking を伴うモデルでは、内部的な思考ステップの長さによって最終応答が分岐することもあり、seed だけでは制御しきれません。
私の壁紙アプリのパイプラインでは、500 枚程度の検証セットに対して同じ設定で 3 周分類を回してみたところ、seed 未指定では 4〜6 パーセントのラベルが揺れていたのに対し、seed=42 を指定すると 1〜2 パーセント程度まで下がりました。完全に止まったわけではありませんが、効いていないわけでもない、という肌感覚は数字とよく一致します。
まず確認する三つの設定
実際に「seed が効いていない」と感じたとき、私は次の三点を必ず確認するようにしました。
config = types.GenerateContentConfig(
# 1. モデルは日付付きの固定タグにする
# (Vertex AI の場合は "gemini-2.5-flash-001" 等)
temperature=0,
top_p=1,
top_k=1,
seed=42,
max_output_tokens=64,
# 2. 思考を必要としないタスクなら thinking_budget=0 にする
thinking_config=types.ThinkingConfig(thinking_budget=0),
)ポイントは、temperature=0 だけに頼らず、top_k=1 を併用してサンプリング空間そのものを 1 トークンに絞ること、そして思考が結果を変える余地のあるモデルでは thinking_budget=0 を明示することです。これだけでも、私の壁紙分類パイプラインでの揺らぎは体感で 1〜2 割程度まで下がりました。
それでも完全にゼロにはなりません。サーバー側の並列化由来の浮動小数点の揺らぎは、クライアント側からは制御できないからです。
「ほぼ決定論」を運用で確保するパターン
完璧な再現性が API レベルで得られない以上、設計レベルで「揺らいでも壊れない」形に逃がすのが現実的です。私のパイプラインで採用している構成は次の三層です。
1. 入力ハッシュ + レスポンスキャッシュ
同じ入力に対する応答は、最初の一回だけ Gemini に投げ、結果を (モデル, プロンプトハッシュ, パラメータハッシュ) をキーにして KV または SQLite に保存しています。これで「同じ入力で違う結果が返る」現象自体が二度目以降は起きません。
import hashlib
import json
def cache_key(model: str, prompt: str, config: dict) -> str:
payload = json.dumps({"m": model, "p": prompt, "c": config}, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()開発者によっては「キャッシュは決定論ではない」と感じるかもしれませんが、本番の挙動を一意に固定する手段としてはもっとも堅牢でした。
2. 構造化出力で許容範囲を縮める
response_mime_type="application/json" と response_schema を併用して、出力空間そのものを狭めるのも効果が大きいです。文字列の言い回しの揺らぎは enum や bool のフィールドに変換することで吸収できます。
config = types.GenerateContentConfig(
temperature=0,
top_k=1,
seed=42,
response_mime_type="application/json",
response_schema={
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": ["nature", "abstract", "geometric", "cute", "minimal"],
},
"is_dark_theme": {"type": "boolean"},
},
"required": ["category", "is_dark_theme"],
},
)私の壁紙分類では、自由記述から enum に切り替えただけで、回帰テストの不安定率が大きく下がりました。
3. 数回呼んで多数決を取る
どうしても揺らぎが残るユースケースでは、3 回呼んで多数決、あるいは整合性が取れたものだけ採用する、という後段ロジックを入れています。コストは増えますが、gemini-2.5-flash クラスのモデルなら 1 件あたりの差額は無視できる水準ですし、誤分類で別のラベルに飛んでしまったときの修正コストよりずっと小さくなります。
厳密な再現性が必須な場面ではどうするか
法的な監査ログや、A/B テストの厳密な対照群のように「完全に同じ出力」が必要な場面では、API 呼び出し時点の応答そのものをアーティファクトとして保存し、再現が必要なときはモデルではなく保存されたレスポンスを参照する設計に倒すべきだと考えています。Gemini API の seed は「同じハードウェア、同じバッチ、同じビルド」での再現を補助するための機能であって、長期にわたる厳密な再現を保証するものではない、と捉えるのが安全です。
実装パイプラインを設計するときに「seed があるからテストは緑になるはず」と前提を置くと、半年後にモデルが更新されたタイミングでテストが赤くなり、原因切り分けにかなりの時間を持っていかれます。私自身、回帰テストを「期待文字列の完全一致」から「JSON スキーマの構造一致+カテゴリ一致」に書き換えた経験があり、設計を変えたほうが結局は楽でした。
次のアクション
もし今 seed を真面目に効かせたい場面が手元にあれば、まずモデル名を日付付きタグに固定し、top_k=1 と thinking_budget=0 を併用したうえで、それでも残る揺らぎはキャッシュと構造化出力で吸収する、という順番で試してみてください。私の壁紙アプリでは、この三段構えに切り替えてから、回帰テストが赤くなる頻度が体感で月に数回から年に数回に落ち着きました。同じ課題に取り組んでいる方の参考になれば幸いです。