模块 01 · 第 3 课

温度和采样:同一个问题为什么每次回答不一样

拿到模型真实的候选词元概率,用 numpy 亲手实现温度和 top_p 采样,再用 API 实测温度对回答多样性的影响,并解释温度为 0 为什么也不能保证结果一样。

  • 约 40 分钟
  • 难度:入门
  • 实测:2026-09-14 deepseek-flash,numpy 2.5

同一个问题问两遍,大模型的回答常常不一样。写文案时这是好事,你可以多要几个版本挑一挑。可如果你在做一个"从合同里提取甲方名称"的程序,今天提取出"某某有限公司",明天变成"某某公司",就很麻烦了。

控制这件事的旋钮叫温度(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%)。那次它偏偏抽中了第二名,于是整个回答变成了把原句又复述一遍。一步抽中了不太好的词元,后面每一步都在它的基础上继续,回答就朝另一个方向去了。这也是为什么一次回答质量不好时,重新生成一次常常就好了。

温度做了什么

温度的作用是在抽签之前,把这个分布压尖或者摊平。

具体的算法是:把每个候选的概率取对数,除以温度,再重新变成概率(这一步叫 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 次

前三个候选的概率加起来刚过 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 次里出现了两种回答。原因在服务器那一侧:你的请求和别人的请求被放在一起批量计算,GPU 上浮点数运算的顺序不同,结果会有极小的差别。平时无所谓,但当两个候选的概率非常接近时("写满离别的"和"缓缓飘落的"大概就是这样),这点差别就足以让选择翻转。一旦前面某一步选了不同的词元,后面就全不一样了。所以别指望温度 0 能让结果百分之百可复现。需要稳定输出的程序,要在程序里做校验,而不是赌模型每次都一样。

模型很有把握的时候,调高温度也没用。我另外试过让模型"给一家卖手工咖啡的小店起一个名字",温度 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 次,还是出现了两种不同的回答。这正常吗?

正常。服务器批量处理请求时,浮点运算的顺序会变,导致概率有极小的差别。当两个候选的概率非常接近时,这点差别足以让选择翻转,后面的输出就都不一样了。所以不能指望温度 0 带来完全可复现的结果。

3. 在 DeepSeek 上开着思考模式,把温度从 1.0 调到 0,输出没有变得更稳定。为什么?

DeepSeek 在思考模式下会忽略 temperature 参数,设了也不起作用。要用温度控制输出,需要通过 extra_body={"thinking": {"type": "disabled"}} 关掉思考。

提问与讨论

这一课没看懂的地方,在这里问。看到别人的问题,也欢迎你来回答。

提问 +3 积分,回答别人 +6 积分。内容经审核后公开。

正在加载讨论…