先に結論です。temperature を 0 にしても、seed を固定しても、LLMは同じプロンプトに対して同じ答えを返しません。私は5モデルに同じ質問を100回ずつ投げて、これを実測しました。一番安定したローカルモデルでも100回中9回は別の出力になり、API経由では半分以上が揺れました。
「temperature 0 なら決定的」。私はずっとそう思っていました。テストでLLMを使うときも、それを前提にしていました。ですが前提が間違っていました。この記事はその100回×5モデルの記録と、なぜ揺れるのかの話です。
この記事は「非決定性」の話です
線を引いておきます。ローカルLLMの記事は前にも書きました。量子化でVRAMをどう節約するか(FP16は無駄という話)や、モデルAとモデルBのどちらが賢いかという対決ものです。
この記事はそのどちらでもありません。扱うのは「同じモデルに、同じ入力を、同じ設定で投げたとき、出力が一致するか」だけです。見るのは性能の高低ではありません。同じ入力に同じ出力を返すか、その一点を数字で確かめます。
計測条件
推測は混ぜていません。条件を先に出します。
- ローカル: Ollama経由、
temperature 0、seed固定、リクエストは1件ずつ逐次 - API: 2種のクラウドモデル、
temperature 0(seed指定は不可) - プロンプト: 「1から100までの偶数の和を返すPython関数を書いて」固定
- 試行: 各モデル100回、出力テキストを完全一致で比較
- 集計: 最多だった出力の回数(一致率)と、ユニークな出力の種類数
「完全一致」は1文字でも違えば別物として数えています。コメントの言い回しや空行1つの差も、別出力です。厳しすぎる基準に見えるかもしれません。ですが、出力をそのままテストの期待値やキャッシュのキーに使う場面では、1文字の差がそのまま不一致になります。実務で困るのはこの粒度なので、あえて完全一致で数えました。
なお、ローカルのseed固定は「同じ乱数の種を与えれば再現するはず」という期待で入れています。本来これで決定的になるべき設定です。それでも揺れたという点が、今回の検証で一番伝えたいところです。
結果: 5モデル × 100回
| モデル | 実行環境 | 最多出力の一致 | ユニーク出力数 |
|---|---|---|---|
| Llama系 70B | Ollama (seed固定) | 91/100 | 6 |
| Gemma系 27B | Ollama (seed固定) | 84/100 | 9 |
| Qwen系 32B | Ollama (seed固定) | 68/100 | 21 |
| Claude系 | API (temp0) | 55/100 | 33 |
| GPT系 | API (temp0) | 49/100 | 38 |
一番安定したLlama系でも、100回中9回は別の出力でした。Qwen系は3回に1回ずれ、API経由の2モデルは半分以上が揺れました。GPT系はユニーク出力が38種類、つまり100回投げて38通りの微妙に違う関数が返ってきたことになります。
揺れの中身は、たとえばこういう差でした。
# 出力パターンA(91回中の多数派)
def sum_even(n: int) -> int:
return sum(i for i in range(2, n + 1, 2))
# 出力パターンB(同じ100回の中で出た別解)
def sum_even(n: int) -> int:
total = 0
for i in range(1, n + 1):
if i % 2 == 0:
total += i
return total
どちらも正しく動きます。ですが「同じ入力に同じ出力」を期待していると、この2つは別物です。出力をそのまま文字列比較するテストなら、パターンBが返った瞬間に落ちます。ロジックは正しいのに、です。
seedを固定しても、ローカルでも、完全な再現はできませんでした。「temperature 0 = 決定的」は、設定としては正しくても、結果としては成り立ちません。
短い事実質問(「日本の首都は」など)も試しましたが、こちらはほぼ全モデルで100/100一致でした。揺れるのは、ある程度の長さを生成するときです。出力が長くなるほど、途中で別の単語を選ぶ分岐が増えます。
つまり「揺れるかどうか」を決めたのは、モデルの賢さよりも出力の長さと実行環境でした。賢いモデルほど安定する、という単純な話ではなかったのが意外でした。むしろAPI経由の高性能モデルの方が、ローカルの小さいモデルより大きく揺れています。これは性能の差というより、後で説明する実行環境の差です。
temperature 0 は「サイコロを止めた」だけ
そもそも temperature とは何か。LLMは次の単語の候補それぞれに確率を持っています。temperature はその確率分布の「とがり具合」を調整するつまみです。0にすると、毎回いちばん確率の高い単語を選ぶ(貪欲法)動きになります。
ここまでは決定的に見えます。実際、サンプリングのランダム性は消えています。だから多くの人(私を含む)が「temperature 0 = 決定的」と理解していました。間違いではありません。サイコロは、たしかに止まっています。
ですが揺れる。理由はサンプリングの手前、 確率を計算する段階 にありました。サイコロを振る前の、各目に確率を割り当てる計算そのものが、毎回わずかに違う数字を出していたのです。確率1位の単語が僅差で2位と入れ替われば、貪欲法は別の単語を選びます。サイコロを止めても、出目の表が書き換わっていたわけです。
本当の原因は「足し算の順番」
2025年にThinking Machines Labが公開した分析(Defeating Nondeterminism in LLM Inference)が、この原因をはっきりさせました。犯人はGPU上の計算順序です。
浮動小数の足し算は、順番を変えると結果がわずかに変わります。(a + b) + c と a + (b + c) が、最後の桁で一致しないことがあるからです。GPUは大量の数を並列で足すので、どの順で足し合わさるかが、その時々のスレッドの動き方で変わります。
さらに厄介なのが バッチ不変性の欠如 です。サーバは複数ユーザーのリクエストをまとめて処理(バッチ)します。このバッチのサイズは、その瞬間の混雑具合で変わります。バッチサイズが変わると、内部の計算戦略が変わり、足し算の順番が変わり、確率がほんのわずかに変わります。
そのわずかな差で、いちばん確率の高い単語が入れ替わる。一度入れ替わると、その後の生成は別の道を進みます。文章の最初のほうで1単語ずれると、そこから先がまるごと変わるわけです。これが、出力が長いほど揺れが大きくなった理由です。短い事実質問が安定していたのは、分岐する余地がほとんどなかったからです。
そして、これが、API経由でより大きく揺れた理由でもあります。クラウドは他人のリクエストと相乗りでバッチ処理されます。自分の隣にどんなリクエストが何件来るかは、自分では制御できません。混雑する時間帯と空いている時間帯で、同じプロンプトの結果が変わりうるということです。ローカルが相対的に安定していたのは、バッチに他人が混ざらないぶん、計算の条件が揃いやすいからでした。
ローカルでseedを固定しても揺れたのは、1件ずつ投げてもGPU内部の並列計算順序までは固定できないからです。揺れているのは乱数のせいではありません。原因はハードウェアの計算のしかたにあります。
直す方法はある(ただし遅くなる)
Thinking Machines Labは、これを直す方法も示しています。バッチサイズが変わっても計算順序が変わらない「バッチ不変カーネル」を、正規化・行列積・アテンションの3か所に実装し、オープンソースの推論エンジンvLLMに組み込みました。揺れの源だった3つの演算を、バッチの大小によらず同じ順番で計算するように作り直したわけです。
結果、1,000回の実行が1,000回とも完全一致するようになったそうです。代償は速度で、同じ処理が約62%遅くなったと報告されています(26秒が42秒)。再現性と速度は、ここでもトレードオフでした。完全な再現が要る監査や評価では42秒を選び、速さが要る本番では26秒を選ぶ。場面で選ぶしかありません。
逆に、効かない対策もはっきりしました。リトライです。「揺れるなら何回か投げて多数派を採ればいい」と最初は考えました。ですがこれは揺れを平均化するだけで、決定性は得られません。投げるたびに多数派が変わりうるからです。根本にあるのが計算順序である以上、上から回数で殴っても消えませんでした。
なぜこれが全エンジニアの問題なのか
「出力が少し揺れるくらい、いいのでは」と思うかもしれません。私もそう思っていました。ですが、3つの場面で実害になります。
- テスト: LLMの出力を期待値と比較するテストは、たまたま通っているだけかもしれません。CIで急に落ちて、再実行したら通る。あの不安定さの一因です
- LLM-as-Judge: AIにAIの出力を採点させる評価では、同じ答えに違う点が付きます。評価の土台が揺れます
- キャッシュ: 入力をキーに出力をキャッシュする設計は、同じ入力なら同じ出力という前提に乗っています。その前提が崩れると、「キャッシュにある答え」と「今生成した答え」が食い違い、デバッグが難しいバグになります
3番目は地味に怖いところです。たとえば同じ質問へのLLM回答をキャッシュしておき、2回目以降はキャッシュを返す設計はよくあります。ですがキャッシュ作成時とすり抜け時で別の回答だったら、ユーザーには「同じことを聞いたのに違う答えが返る」ように見えます。原因が非決定性だと気づかないと、キャッシュのバグを延々と疑うことになります。
特にテストは、ローカルLLMだけでなくAPIを使う全エンジニアに関わります。「自分のテストはたまたま通っているだけかもしれない」。この視点を持つかどうかで、不安定なCIへの向き合い方が変わります。
実際、私もこれで一度ハマりました。LLMの出力を期待値と完全一致で比較するテストを書いて、ローカルでは通っていたのに、CIでだけたまに落ちる。何度も再実行して、再実行したら通るので「環境のせいだろう」と片付けていました。原因はこの非決定性でした。テスト自体が、揺れる対象を揺れない前提で検証していたわけです。気づくまで数時間溶かしました。
実務でどうするか
私が変えたのは3つです。
- LLMの出力を「完全一致」で比較するテストをやめ、構造や数値の範囲で検証する
- 再現性が要るとき(評価・監査)は、バッチ不変な推論を選ぶか、遅さを受け入れる
- 「temperature 0 だから決定的」という前提を、設計から外す
1番目が一番効きました。テストの考え方を「同じ文字列が返るか」から「満たすべき性質を満たすか」に変えます。
# 揺れに弱いテスト(完全一致を期待)
def test_sum_even_brittle():
code = llm_generate("偶数の和を返す関数 sum_even を書いて")
assert code == EXPECTED_STRING # 別解が返ると落ちる
# 揺れに強いテスト(性質を検証)
def test_sum_even_robust():
code = llm_generate("偶数の和を返す関数 sum_even を書いて")
ns = {}
exec(code, ns) # 生成コードを実行し
assert ns["sum_even"](10) == 30 # 振る舞いだけ検証する
検証するのは出力の文字列ではありません。出力が満たすべき振る舞いです。こうすれば、パターンAが返ってもパターンBが返ってもテストは通ります。LLMの出力をテストするときは、表記より結果を見る。これが基本姿勢になりました。
評価パイプライン(LLM-as-Judge)では、もう一段気をつけます。採点が揺れるなら、同じ出力を複数回採点して多数決を取るか、点数の差が誤差の範囲かを見ます。1回の採点を絶対視しないことです。
知識は人生の難易度を下げると私は思っています。「temp 0 は決定的」という思い込みを1つ捨てるだけで、CIの謎の不安定さに振り回される時間が減ります。私が数時間溶かした穴に、誰かがはまらずに済むなら、この検証をした甲斐があります。
まとめ
- temperature 0 でもseed固定でも、LLMは同じ入力に同じ出力を返さなかった
- 一番安定したローカルモデルでも100回中9回ずれ、API経由は半分以上が揺れた
- 原因はサンプリングではなく、GPU上の浮動小数の足し算の順番とバッチ不変性の欠如
- 直す方法(バッチ不変カーネル)はあるが、約62%遅くなる
- テスト・評価・キャッシュに実害が出る。完全一致前提の設計を見直す
最後に1つ。この非決定性は「バグ」というより、現在の高速な推論の仕組みが生む副作用です。速さを取るか再現性を取るかは、私たちが場面ごとに選ぶ設計判断になりました。どちらが正解という話ではありません。何を優先するかを意識して選べばいい。それを知っているかどうかが、振り回されるかどうかの分かれ目です。
LLMは賢いが、気まぐれです。その気まぐれの正体を知っておくと、振り回されずに付き合えます。面白くいきましょう。
