モジュール 01 · 第 3 回

温度とサンプリング:同じ質問への答えが毎回違うのはなぜか

モデルの本物の候補トークン確率を取得し、numpy で温度と top_p サンプリングを自分で実装します。さらに API で温度が回答の多様性に与える影響を実測し、温度 0 でも結果が同じになるとは限らない理由を説明します。

  • 約 40 分
  • 難易度:入門
  • 検証:2026-09-14 deepseek-flash、numpy 2.5

コードと実行結果は実際に動かしたときのまま載せているため、コメントと出力は中国語です。

同じ質問を 2 回すると、LLM の回答はよく違います。コピーを書くときにはこれは好都合で、いくつか案を出させて選べます。しかし「契約書から甲の名称を抽出する」プログラムを作っていて、今日は「某某有限公司」(○○有限会社)と抽出したのに、明日は「某某公司」(○○会社)になったら、かなり困ります。

これを制御するつまみが温度(temperature)です。この課ではまずランダム性がどこから来るのかを理解し、次にコードで温度を自分で実装し、最後に実際の API 呼び出しで効果を確かめます。

ランダム性はどこから来るか

前の課で説明したように、モデルは各ステップで確率分布を出します。次のトークンが「爬山」(山登り)である確率はいくら、「图书馆」(図書館)である確率はいくら……。そしてサンプラーと呼ばれるプログラムが、この分布に従ってトークンを一つ引きます。モデル自体が毎回計算する分布はほとんど同じで、ランダム性はこのくじ引きの部分から来ます。

この分布は直接見ることができます。deepseek-flash に「周末我打算去」(週末は~へ行くつもり)の続きを書かせ、logprobs パラメータ(使い方は前の課で説明しました)をオンにすると、返ってくる最初のトークンの上位 10 候補は次のとおりです。

候補 確率
爬山(山登り) 68.8%
图书馆(図書館) 14.9%
公园(公園) 8.0%
山里(山の中) 2.4%
郊(郊外の「郊」) 1.6%
超市(スーパー) 1.6%
逛街(街ぶら) 1.1%
逛(ぶらつく) 0.6%
书店(書店) 0.3%
露营(キャンプ) 0.1%

最も一般的なやり方は、常に確率が最も高いものを選ぶのではなく、確率に従って引くことです。68.8% の場合は「爬山」、14.9% の場合は「图书馆」を選び、たまに「露营」が当たることもあります。

実験中に、これをよく表すケースに出会いました。モデルに「我今天中午吃了」(今日の昼に食べたのは)の続きを書かせたところ、最初のトークンで最も確率が高いのは「一碗」(一杯の、70.8%)、次が「我今天」(今日私は、27.8%)でした。そのときはあいにく 2 番目が引かれ、回答全体が元の文をもう一度繰り返すものになってしまいました。1 ステップでよくないトークンを引くと、その後のすべてのステップがそれを土台に続くので、回答は別の方向へ進んでしまいます。1 回の回答の質が悪くても、生成し直すとよくなることが多いのはこのためです。

温度は何をしているのか

温度の役割は、くじを引く前に分布を尖らせたり平らにしたりすることです。

具体的なアルゴリズムはこうです。各候補の確率の対数を取り、温度で割り、改めて確率に戻す(この処理を softmax と呼びます)。式で書くと 新しい確率_i ∝ exp(log(p_i) / T) で、p_i1/T 乗して正規化するのと同じです。

  • 温度が 1 より小さいと、大きな確率はより大きく、小さな確率はより小さくなり、分布が尖って結果が安定します。
  • 温度が 1 なら、分布は変わりません。
  • 温度が 1 より大きいと、分布が平らになり、下位の候補にもチャンスが増えて、結果は多様になりますが、脱線もしやすくなります。
  • 温度が 0 に近づくと、常に確率が最も高いものを選びます。これを貪欲デコードと呼びます。

上の本物の分布を使って、自分でやってみましょう。

import numpy as np

candidates = ["爬山", "图书馆", "公园", "山里", "郊", "超市", "逛街", "逛", "书店", "露营"]
probs = np.array([0.6879, 0.1489, 0.0797, 0.0244, 0.0158, 0.0157, 0.0107, 0.0062, 0.0029, 0.0013])
probs = probs / probs.sum()  # 只取了前 10 个,重新归一化让它们加起来等于 1


def apply_temperature(p, t):
    # 温度作用在对数概率上:先取对数,除以温度,再变回概率(softmax)
    logits = np.log(p) / t
    e = np.exp(logits - logits.max())  # 减去最大值是为了防止 exp 溢出,不影响结果
    return e / e.sum()


def show(title, p, n=1000, seed=0):
    rng = np.random.default_rng(seed)
    draws = rng.choice(len(p), size=n, p=p)
    counts = np.bincount(draws, minlength=len(p))
    print(title)
    for word, prob, count in zip(candidates, p, counts):
        if prob > 0.0005 or count:
            print(f"  {word: <4} 概率 {prob:6.1%}  抽中 {count:4d} 次")
    print()


show("原始分布(温度 1.0),抽 1000 次:", probs)
show("温度 0.5:", apply_temperature(probs, 0.5))
show("温度 1.5:", apply_temperature(probs, 1.5))

実行結果です。

原始分布(温度 1.0),抽 1000 次:
  爬山   概率  69.2%  抽中  673 次
  图书馆  概率  15.0%  抽中  166 次
  公园   概率   8.0%  抽中   83 次
  山里   概率   2.5%  抽中   29 次
  郊    概率   1.6%  抽中   11 次
  超市   概率   1.6%  抽中   15 次
  逛街   概率   1.1%  抽中   12 次
  逛    概率   0.6%  抽中    8 次
  书店   概率   0.3%  抽中    2 次
  露营   概率   0.1%  抽中    1 次

温度 0.5:
  爬山   概率  94.1%  抽中  944 次
  图书馆  概率   4.4%  抽中   41 次
  公园   概率   1.3%  抽中   14 次
  山里   概率   0.1%  抽中    0 次
  超市   概率   0.0%  抽中    1 次

温度 1.5:
  爬山   概率  49.6%  抽中  467 次
  图书馆  概率  17.9%  抽中  188 次
  公园   概率  11.8%  抽中  127 次
  山里   概率   5.4%  抽中   64 次
  郊    概率   4.0%  抽中   40 次
  超市   概率   4.0%  抽中   40 次
  逛街   概率   3.1%  抽中   30 次
  逛    概率   2.1%  抽中   21 次
  书店   概率   1.3%  抽中   16 次
  露营   概率   0.8%  抽中    7 次

温度 0.5 では「爬山」が 69% から 94% に上がり、後ろの候補にはほとんどチャンスがなくなります。温度 1.5 では「爬山」が半分ほどに下がり、「露营」は 1000 回中 1 回から 7 回に増えます。

これは最初のトークンだけの話だという点に注意してください。回答には数十から数百のトークンがあり、各ステップで毎回こうして引いています。ステップごとの差が積み重なるので、温度が高いと回答全体の違いは非常に大きくなります。

top_p:ロングテールを切り落とす

もう一つよく使われるパラメータが top_p で、核サンプリング(nucleus sampling)とも呼ばれます。確率の相対的な大きさは変えず、候補を確率の高い順に並べて先頭から足していき、top_p に達したところで止めて、残りはすべて捨て、残ったものの中から引きます。

def top_p_filter(p, top_p):
    order = np.argsort(p)[::-1]
    cumulative = np.cumsum(p[order])
    # 保留累计概率刚好达到 top_p 的那几个,其余的概率清零
    keep = order[: np.searchsorted(cumulative, top_p) + 1]
    q = np.zeros_like(p)
    q[keep] = p[keep]
    return q / q.sum()


show("温度 1.0 + top_p 0.9:", top_p_filter(probs, 0.9))
温度 1.0 + top_p 0.9:
  爬山   概率  75.1%  抽中  733 次
  图书馆  概率  16.2%  抽中  183 次
  公园   概率   8.7%  抽中   84 次

上位 3 候補の確率を足すとちょうど 90% を超えるので、その中からだけ引き、「露营」や「超市」はもう出てきません。確率はとても低いけれど、一度引かれると回答を脱線させるようなトークンを防げるのが利点です。

温度と top_p は、たいていどちらか一つを調整すれば十分です。両方同時に調整すると、どちらが効いているのか判断しにくくなります。

API で実測する

ここまでは手元でのシミュレーションでした。実際の呼び出しではどうなるでしょうか。deepseek-flash に秋についての比喩を一文書かせ、三つの温度でそれぞれ 20 回聞いてみました。

import os
from collections import Counter
from concurrent.futures import ThreadPoolExecutor

from openai import OpenAI

client = OpenAI(
    api_key=os.environ["LLM_API_KEY"],
    base_url=os.environ.get("LLM_BASE_URL", "https://api.deepseek.com"),
)
MODEL = os.environ.get("LLM_MODEL", "deepseek-flash")
QUESTION = "用一句话写一个关于秋天的比喻,不超过十五个字,只输出这句话。"


def ask(temperature):
    response = client.chat.completions.create(
        model=MODEL,
        messages=[{"role": "user", "content": QUESTION}],
        temperature=temperature,
        # 思考模式下 temperature 不起作用,所以要关掉思考
        extra_body={"thinking": {"type": "disabled"}},
    )
    return response.choices[0].message.content.strip()


for t in [0, 0.7, 1.3]:
    # 10 个线程同时发请求,省点时间
    with ThreadPoolExecutor(10) as pool:
        answers = list(pool.map(ask, [t] * 20))
    counts = Counter(answers)
    print(f"temperature={t}: 20 次里有 {len(counts)} 种")
    for text, n in counts.most_common(5):
        print(f"   {n:2d}× {text}")

私が実行した結果です(あなたの結果は違うはずです)。

temperature=0: 20 次里有 2 种
   11× 秋天像一封写满离别的信。
    9× 秋天像一封缓缓飘落的信。
temperature=0.7: 20 次里有 6 种
   10× 秋天像一封写满离别的信。
    4× 秋天像一封慢慢变黄的信。
    3× 秋天像一封缓缓飘落的信。
    1× 秋天像一封缓缓拆开的旧信。
    1× 秋天像一封缓缓展开的旧信。
temperature=1.3: 20 次里有 15 种
    4× 秋天像一封缓缓飘落的信。
    2× 秋天像一封写满离别的信。
    2× 秋天像一封慢慢变黄的信。
    1× 秋天像一封写给大地的金色信笺。
    1× 秋天是把金色小提琴,风一拉就落叶。

(各温度で最も多かった 5 種類だけを表示しています。)

温度を 0 から 1.3 に上げると、20 回中の異なる回答の数は 2 種類から 15 種類に増えました。温度 1.3 では「秋天是把金色小提琴,风一拉就落叶」(秋は金色のバイオリン、風が弾けば葉が落ちる)という、それまで一度も出てこなかった書き方が現れています。

二つの現象について触れておきます。

温度 0 でも、結果が毎回同じとは限らない。理論上、温度 0 なら常に確率が最も高いトークンを選び、結果は完全に決まるはずです。しかし実際には 20 回中 2 種類の回答が出ました。原因はサーバー側にあります。あなたのリクエストと他の人のリクエストがまとめてバッチ計算され、GPU 上の浮動小数点演算の順序が変わると、結果にごくわずかな差が出ます。ふだんは問題になりませんが、二つの候補の確率が非常に近いとき(「写满离别的」(別れで満ちた)と「缓缓飘落的」(ゆっくり舞い落ちる)はおそらくそうでした)、そのわずかな差で選択が入れ替わるには十分です。一度どこかのステップで違うトークンが選ばれれば、その後はすべて変わってしまいます。ですから、温度 0 で結果が 100% 再現できるとは期待しないでください。安定した出力が必要なプログラムでは、モデルが毎回同じになることに賭けるのではなく、プログラムで検証を行うべきです。

モデルが確信しているときは、温度を上げても効果がない。別の実験で、モデルに「手作りコーヒーの小さな店に名前を付けて」と頼み、温度 1.5 で 5 回聞いたところ、5 回とも「豆语咖啡」(豆語コーヒー)でした。私の推測では、最初のトークン「豆语」の確率がすでに 100% 近くまで高く、分布を平らにしても圧倒的に優勢なままなのでしょう。多様な結果がほしいなら、温度を上げるより、プロンプトで「スタイルの違う名前を 10 個挙げて」と直接頼むほうがよいでしょう。

思考モードでは温度が効かない

DeepSeek のドキュメントには、思考モードでは temperature パラメータが効かない(設定してもエラーにはならず、単に無視される)こと、top_p も 0.95 未満の値は自動的に 0.95 に引き上げられることが明記されています。そのため、上のコードはすべて思考をオフにしています。

つまり、温度で出力を制御したいなら、思考モードをオフにしなければなりません。逆に、思考をオンにしているときは、温度を調整して出力を安定させることは期待しないでください。

どれくらいにすべきか

DeepSeek 公式の推奨値は次のとおりです(2026 年 9 月時点)。

用途 温度
コードを書く、数学の問題を解く 0.0
データクレンジング、データ分析 1.0
日常会話 1.3
翻訳 1.3
創作、詩を書く 1.5

DeepSeek の既定の温度は 1.0 です。この表は DeepSeek にだけ当てはまります。他のサービスのモデルは温度への敏感さが違い、同じ数値でも効果が大きく異なることがあります。

私のやり方はこうです。抽出、分類、書式変換のように正解があるタスクは温度 0。文章、命名、ブレインストーミングは既定値か、それより上げる。迷ったらまず既定値を使い、満足できなければ調整する。

練習問題

  1. sampling.py の温度を 0.1 と 3.0 に変えて、サンプリング結果がどうなるか見てください。温度 3.0 では「露营」は何回引かれますか。
  2. top_p を 0.7 と 0.99 に変えて、候補がいくつ残るか見てください。
  3. temperature_api.py で質問を替えてください。「The quick brown fox jumps over the lazy dog」を中国語に翻訳させ、温度 0 と 1.3 でそれぞれ 20 回聞いて、翻訳が何種類あるか数えます。翻訳タスクは比喩を書くタスクと比べて、温度の影響が大きいでしょうか、小さいでしょうか。理由も考えてみてください。
  4. 前の課で説明した logprobs パラメータを使って、モデルに「手作りコーヒーの小さな店に名前を付けて」と頼んだときの最初のトークンの候補と確率を見てください。「豆语咖啡」現象についての私の推測を裏付けられるでしょうか。

確認テスト

1. 温度を下げると、モデルの回答が安定するのはなぜですか?

温度は各ステップの候補の確率分布に作用します。温度が低いほど、確率の高い候補はより高く、低い候補はより低くなり、分布が「尖り」ます。くじを引くとほぼ毎回同じトークンが当たるので、回答は自然と安定します。温度が 0 に近づくと、各ステップで確率が最も高いものを選ぶことになります。

2. 温度を 0 にしたのに、同じ質問を 20 回したら 2 種類の回答が出ました。これは正常ですか?

正常です。サーバーがリクエストをバッチ処理するとき、浮動小数点演算の順序が変わり、確率にごくわずかな差が生じます。二つの候補の確率が非常に近いと、そのわずかな差で選択が入れ替わり、その後の出力がすべて変わります。ですから、温度 0 で完全に再現できる結果は期待できません。

3. DeepSeek で思考モードをオンにしたまま温度を 1.0 から 0 に下げても、出力は安定しませんでした。なぜですか?

DeepSeek は思考モードでは temperature パラメータを無視するので、設定しても効きません。温度で出力を制御するには、extra_body={"thinking": {"type": "disabled"}} で思考をオフにする必要があります。

質問と議論

このレッスンでつまずいたところは、ここで質問してください。他の人の質問に答えるのも歓迎です。

質問で 3 ポイント、回答で 6 ポイント。審査を通過すると公開されます。

議論を読み込んでいます…