Tech Learning Daily

2026-10-13 (Tue) — 第 88 号
AI が毎朝届ける、ソフトウェア技術の基礎解説
AI 🌿 基礎 ⏱ 約 7 分

temperature を 0 にしたのに、なぜ LLM の答えは毎回同じにならないのか?

LLM の出力をテストで固定したくて temperature: 0 を指定した。ところが CI で同じプロンプトを流すと、たまに 1 単語だけ違う答えが返ってきてテストが落ちる。「0 にしたのにランダムなのか」と思ったことはないだろうか。この揺れの原因はサンプリングの乱数ではなく、サーバーの混み具合にある。

🎯 3 行まとめ
  • LLM は 1 トークンごとに「次に来る候補の確率の表」を計算し、その表からサイコロを振って 1 つ選ぶ。temperature・top_k・top_p はこのサイコロの振り方を変える設定だ。
  • temperature を 0 にすると、サイコロを振らずに毎回確率最大の候補を選ぶ。乱数はここで消える。
  • それでも答えが揺れるのは、同時に処理される他人のリクエスト数によって GPU 上の足し算の順序が変わり、確率の表そのものが微妙に変わるからだ。

LLM は毎回「確率の表」を作っている

以前の記事で見たとおり、LLM は文章を 1 トークンずつ生成する。1 ステップでモデルが出力するのは「次のトークン」そのものではなく、語彙に含まれる全トークンへの点数の列だ。この点数をロジット(logit)と呼ぶ。ロジットは負にもなる生の値で、まだ確率ではない。

ロジットを確率に直すのが softmax という関数だ。各ロジットを指数関数 exp に通してから全体の合計で割る。指数関数を通すので点数の差が確率では大きな差に広がり、合計はちょうど 1 になる。

最後に、この確率の表に従って乱数で 1 つを選ぶ。これをサンプリングと呼ぶ。選ばれたトークンを入力の末尾に足して、また次のロジットを計算する。生成はこの繰り返しだ。

 入力「日本の首都は」
     │
     ▼
 ┌────────────┐
 │   モデル   │ 行列演算の塊
 └────────────┘
     │ ロジット(点数)
     ▼
 東京 4.0 / 大阪 3.0 / 京都 2.0 …
     │ ÷ temperature
     ▼
 ┌────────────┐
 │  softmax   │ 点数 → 確率
 └────────────┘
     │ 0.65 / 0.24 / 0.09 …
     ▼
 top_k / top_p で候補を足切り
     │
     ▼
 乱数で 1 つ選ぶ ─▶ 入力に足して次へ

temperature は確率の表の「傾き」を変える

temperature は、softmax に通す前にロジットを割る数だ。1 より小さい値で割ると点数の差が広がり、上位の候補に確率が集中する。1 より大きい値で割ると差が縮み、下位の候補にも確率が回ってくる。

上の図の点数(東京 4.0・大阪 3.0・京都 2.0・札幌 0.5)で計算すると、temperature 1.0 では東京 0.652・大阪 0.240・京都 0.088 になる。0.5 にすると東京 0.866・大阪 0.117 まで偏り、0.1 では東京がほぼ 1.000 になる。表の中身の順位は変わらず、偏り方だけが変わる。

temperature をちょうど 0 にすると 0 で割ることになるため、実装は割り算をやめて「確率最大のトークンを選ぶ」処理に切り替えるのが普通だ。これを貪欲デコーディング(greedy decoding)と呼ぶ。ここにはもう乱数が入らない。Anthropic の Messages API リファレンスでは、temperature のデフォルトは 1.0、範囲は 0.0〜1.0 で、分析的なタスクは 0 寄り、創作的なタスクは 1 寄りが推奨されている。

{
  "model": "...",
  "max_tokens": 1024,
  "temperature": 0.0,
  "messages": [{"role": "user", "content": "..."}]
}

なお同リファレンスには、Claude Opus 4.6 より後に出たモデルでは temperature の指定自体ができず、1.0 以外の値は 400 エラーになると明記されている。設定を書く前に、使うモデルがこのパラメータを受け付けるかを確認したい。

🍱 たとえるなら

確率の表は、候補ごとに幅の違う区画を描いたルーレット盤だ。temperature を下げるのは、一番広い区画を広げてほかを細くする盤の描き直しにあたる。0 にすると玉を投げるのをやめて、一番広い区画を指差すだけになる。ただし盤を描く人の手元がわずかに揺れると、幅がほぼ同じ 2 つの区画のどちらが広いかが入れ替わりうる。温度 0 で残る揺れはこの描き直しの側の揺れで、玉の投げ方とは関係がない。

top_k と top_p は候補を足切りする

temperature が表の傾きを変えるのに対し、top_k と top_p は表から候補を削る。top_k は確率の高い順に K 個だけを残す。Anthropic のリファレンスでは、確率の低い「ロングテール」の応答を除くための設定と説明されている。

top_p は nucleus sampling と呼ばれる方式で、確率の高い順に累積して、合計が指定値に達したところで打ち切る。先ほどの temperature 1.0 の表で top_p: 0.9 とすると、東京 0.652 → 大阪まで 0.892 → 京都まで 0.980 で 0.9 に達するので、東京・大阪・京都の 3 つが残り、札幌は候補から消える。表が一つの候補に偏っていれば残る候補は少なくなり、平らなら多くなる。K 個固定の top_k と違い、残す数が場面ごとに伸び縮みする。

どちらも乱数で選ぶ前の段階の設定なので、temperature 0 のときは結果に影響しない。確率最大の候補は足切りされないからだ。Anthropic のリファレンスはどちらも「高度な用途向け」としており、Claude Opus 4.6 より後のモデルでは top_k は値にかかわらず拒否され、top_p は 0.99 以上だけが受け付けられる。

温度 0 でも揺れる本当の理由

Anthropic のリファレンスには「temperature が 0.0 でも結果は完全には決定的にならない」と書かれている。サイコロを振らないのに揺れるなら、揺れているのは確率の表、つまりロジットの計算結果のほうだ。

出発点は浮動小数点数の性質だ。浮動小数点数の足し算は、足すたびに丸めが入るため結合法則が成り立たない。(0.1+0.2)+0.3 と 0.1+(0.2+0.3) は違う値になる。モデルの計算は巨大な行列の掛け算と足し算の集まりなので、足す順序が変われば結果の末尾の桁が変わる。

では順序を変えているのは何か。「GPU の並列スレッドが終わる順番が毎回ばらばらだから」と考えたくなる。しかし Thinking Machines Lab の分析によれば、LLM の推論の順伝播はそうした実行順に依存する演算を使っておらず、同じ入力・同じ条件なら毎回同じ値を返す。原因は別にある。GPU の計算カーネルは、一度にまとめて処理するリクエストの数(バッチサイズ)に応じて、効率のよい分割方法や足し算の順序を切り替える。そしてバッチサイズは、その瞬間にサーバーに届いている他人のリクエストの数で決まる。

つまり自分のリクエストから見ると、「サーバーの混み具合」が隠れた入力になっている。混み具合でロジットの末尾が変わり、上位 2 候補のロジットが拮抗している場面ではどちらが最大かが入れ替わる。生成は 1 トークンずつ積み重なるので、1 か所で違うトークンが選ばれるとそれ以降の文章は丸ごと別物になる。同記事の実験では、Qwen3-235B に temperature 0 で同じ質問を 1,000 回投げると 80 通りの回答が返り、最初の 102 トークンは全て一致し、103 トークン目で分岐していた。バッチサイズに結果が左右されないバッチ不変(batch-invariant)なカーネルに差し替えると、1,000 回すべてが一致した。代償は速度で、同記事が Qwen3-8B で行った計測では、1,000 本の生成にかかる時間が 26 秒から 42 秒に延びている。

💼 実務でどう出会うか

LLM を組み込んだ機能のテストで、出力文字列の完全一致を assert すると、temperature 0 でもまれに落ちる不安定なテストになる。対策は、出力を JSON などの構造で受けて「必須フィールドがある」「分類ラベルが期待値のどれかに入る」のように意味で検証するか、単体テストでは API の応答を記録して再生し、モデル呼び出しを外すことだ。OpenAI の API には seed パラメータがあるが、公式 Cookbook でも決定性は保証されず、バックエンドの構成が変わったかを system_fingerprint で監視するよう案内されている。どの方法でも、同じ入力から同じ文字列が返ることを前提に設計しないのが安全だ。

⌨️ 手を動かす(5 分)

temperature で確率の表の偏りが変わること、そして足す順序で浮動小数点の結果が変わることを Mac 標準の python3 で確かめる。ターミナルにそのまま貼り付けて実行する。

python3 - <<'EOF'
import math
logits = {"東京": 4.0, "大阪": 3.0, "京都": 2.0, "札幌": 0.5}
for T in (1.0, 0.5, 0.1):
    z = {k: math.exp(v / T) for k, v in logits.items()}
    s = sum(z.values())
    print(f"T={T}:", "  ".join(f"{k} {v/s:.3f}" for k, v in z.items()))
print((0.1 + 0.2) + 0.3, 0.1 + (0.2 + 0.3))
EOF

T=1.0 では東京 0.652・大阪 0.240 と分散していた確率が、T=0.1 では東京 1.000 に集中するはずだ。最終行は 0.6000000000000001 0.6 と表示され、同じ 3 つの数でも足す順序で値が変わることが分かる。モデルの中ではこの差がロジットに乗り、拮抗した候補の順位をひっくり返す。

🙅 よくある誤解
  • temperature を 0 にすれば出力は完全に再現できる — 0 で消えるのはサンプリングの乱数だけだ。ロジットの計算自体が、同時に処理されるリクエスト数によって変わりうるため、API 経由では同じ入力でも出力が揺れる。
  • temperature を上げるとモデルが創造的に「考える」ようになる — temperature はすでに計算された確率の表の偏りを変えるだけで、モデルの知識や推論の中身は変わらない。上げるほど確率の低い候補、つまり誤りを含む候補も選ばれやすくなる。
  • 温度 0 で揺れるのは GPU スレッドの実行順が毎回ばらばらだからだ — Thinking Machines Lab の分析では、推論の順伝播は同じ条件なら毎回同じ値を返す。揺れの主因は、バッチサイズによって計算カーネルが足し算の順序を変えることだ。
📖 用語ミニ辞典
ロジット
モデルが語彙の各トークンに付ける、確率に直す前の生の点数。
softmax
点数の列を指数関数に通して正規化し、合計 1 の確率に変換する関数。
temperature
softmax の前にロジットを割る値。小さいほど上位の候補に確率が集中する。
貪欲デコーディング
乱数を使わず、毎ステップで確率最大のトークンを選ぶ生成方法。
nucleus sampling(top_p)
確率の高い順に累積し、指定した合計に達するまでの候補だけから選ぶ方式。
バッチ不変性
同時に処理するリクエスト数が変わっても、個々の計算結果が変わらない性質。
🔗 もっと深く