07|大模型怎么一个字一个字写出来:概率、采样与 temperature

《大模型原理通俗讲》系列第 7 篇,共 16 篇。不需要数学基础,会用大模型聊天就能读。上一篇,我们把 Transformer 拼了起来,最后得到一张“下一个词的概率表”。这一篇回答最后一个问题:表有了,下一个词到底怎么选?

同一个问题,问大模型两次,答案常常不一样。你可能还在一些产品的设置里,见过两个奇怪的词:temperature 和 top_p。

它们是什么?为什么调它们,回答就会变“稳”或者变“跑偏”?

答案其实很简单,只是前面的文章一直没有展开:概率表只是“候选名单”,从名单里怎么挑出一个词,是另一件事。 而这件事,有几个可以调的超参数/可调参数。

读完这一篇,你能回答三个问题:

  1. 概率表是怎么变成“一个词”的?
  2. temperature 调的到底是什么?
  3. 为什么有时答案每次都不同,有时又几乎一模一样?

一、先看全貌:写出一个词的四步

先回忆:上一篇的最后,模型把最后一个位置的向量,乘一张“输出表”,得到词表里每个词各一个分数,再变成百分比,就是概率表(第 01 篇那张)。

接下来,“选一个词”的完整过程是这样:
在这里插入图片描述

四步:

  1. 分数 ÷ temperature(超参数/可调参数 ①):先把分数除以一个数 T,调整概率表是“尖”还是“平”。
  2. 变成百分比:得到概率表。
  3. 截断(超参数/可调参数 ②):把靠后的、离谱的词先踢出去(top-k / top-p)。
  4. 按概率抽签:在留下的词里,按概率抽一个,接到末尾,再从头来一遍。

两个超参数/可调参数,都是在模型“外面”动手脚。 它们只改变“怎么从候选里挑”,不改变模型学到的任何东西。下面一个一个讲。


二、最直接的选法:永远选最高的

最容易想到的办法:概率表里谁最高,就选谁。这叫贪心。

好处很明显:稳定。同样的输入,每次都得到同样的输出。

但它有几个问题:

  • 很呆板。 同一个问题,永远只有一种说法。想要“换个说法”“再来一个”,做不到。
  • 容易写得重复。 有一种常见现象:一路选最高,容易绕进一个圈子,一句话反复重复。(常见,但不是必然。)
  • 每一步最高,不等于整体最好。 就像下棋,每一步都选“当下最划算”的,不一定能赢。

所以,多数时候不是简单地选最高的,而是按概率抽签。


三、按概率抽签:为什么答案会不一样

抽签的规则很简单:概率越高,越容易被抽中,但低概率的也有机会。

比如“法国的首都是”后面,“巴黎”占 73.7%,“法国”占 10%,“里昂”占 6.1%……抽 100 次,大约 74 次是“巴黎”,其余的次数,是别的词。

这就是“同一个问题,答案会不一样”的原因: 它不是“不稳定”,而是设计成这样,抽签本来就会抽出不同的结果。

而且还有一个细节:问题越有标准答案,概率越集中,答案就越稳定(像“巴黎”);问题越开放(比如“写一句开场白”),概率越分散,每次的结果就越不一样。

但纯抽签也有一个麻烦,叫长尾:

词表里有几万到十几万个词。大多数词放在这里都是离谱的,比如“法国的首都是”后面接“香蕉”。每一个离谱词的概率都低到几乎为零,可是它们数量太多,加起来的概率不一定小。

在后面的代码里,我设了 500 个“离谱词”,每个概率都很低,合起来却占了 4.6%。也就是说,抽 100 次,平均要抽到 4~5 次离谱词。 一旦抽到,这个词就被接到末尾,成为后面的“上文”,后面的词可能就接着跑偏了。

所以,要在“抽签”之前,加上一些限制。这就是两个超参数/配置项的由来。


四、超参数 ①:temperature(温度)

temperature,字面意思是“温度”。它做的事情,是在变成百分比之前,把每个分数除以一个数 T。

  • T 小于 1: 分数之间的差距被放大,概率表变“尖”:高的更高,低的更低。模型更倾向选最可能的词,结果更稳、更保守。
  • T 等于 1: 保持原样。
  • T 大于 1: 差距被缩小,概率表变“平”:高的没那么高,低的没那么低。结果更随机、更“野”。
  • T 趋近于 0: 几乎只剩第一名,就是“永远选最高的”。(数学上不能真的除以 0,实际系统一般会把 T = 0 单独处理成“选最高的”。)

一个形象的类比:点菜。 T 很低,你只点招牌菜,每次都一样,稳;T 很高,你愿意尝试各种没点过的菜,有惊喜,也可能点到难吃的。T 就是“爱冒险的程度”。

要说明白的是:模型并没有“爱冒险”的心理,这只是个比喻。temperature 在数学上只是“把分数除以一个数”,仅此而已。

经验上,人们常这样用:

  • 需要准确、稳定的任务(提取信息、写代码、回答事实问题):T 调低一些;
  • 需要多样、有变化的任务(起标题、头脑风暴、写故事):T 调高一些。

这只是常见的经验,没有一个统一的“最佳值”,不同产品的默认值也不一样。


五、超参数 ②:top-k 和 top-p(截断)

temperature 是整体调“尖”还是“平”,但对付“长尾”还不够直接。截断就是:抽签之前,先把靠后的词直接踢出去,只在靠前的候选里抽。

两种常见的截断:

  • top-k: 只留分数最高的 k 个词。比如 k = 3,不管概率表什么样,都只留前 3 个。
  • top-p: 从高到低累加概率,累加到 p(比如 90%)就停,只留这些词。

两者的区别在于:

  • top-k 的个数是固定的;
  • top-p 的个数会随概率表的形状变:如果第一名就占了 99.8%,一个词就够了;如果概率很分散,就要多留一些词,才能凑够 90%。

后面的代码里,会数给你看。

提示:不少产品里同时有 temperature 和 top_p 两个超参数/可调参数。有的平台文档建议不要两个同时大幅度调整。这里只需要理解:两者都是在“控制随机的程度”,只是切入点不同。


六、动手:先看超参数怎么改概率表

我们做一个迷你实验(需要 numpy):给“法国的首都是”后面的候选词,设一份示意的分数,再加上 500 个“离谱词”。

先说清楚:这里的分数是我手工设定的示意数据,不是某个真实模型的输出。 目的是让你看清超参数/可调参数的作用。

# mini_temperature.py —— 概率表怎么变成"一个词":temperature、抽签、top-k、top-p
import numpy as np

words = ["巴黎", "法国", "里昂", "马赛", "北京", "一个", "的"]
z = np.array([5.0, 3.0, 2.5, 1.8, 1.0, 0.5, 0.0] + [-4.0] * 500)      # 示意的"分数":7 个像样的词,外加 500 个离谱词(分数都很低)
names = words + ["离谱词"] * 500

def probs(z, T=1.0):                                   # 分数 ÷ temperature,再变成百分比
    e = np.exp((z - z.max()) / T)
    return e / e.sum()

print("① 同一份分数,换不同的 temperature(T):概率表怎么变")
for T in (0.3, 1.0, 2.0):
    p = probs(z, T)
    print(f"  T={T}  " + "  ".join(f"{w} {p[i]:.1%}" for i, w in enumerate(words[:4])) + f"  | 500 个离谱词合计 {p[7:].sum():.1%}")

print("\n② 每个 T 各抽 10000 次:抽到了谁")
rng = np.random.default_rng(0)
for T in (0.3, 1.0, 2.0):
    pick = rng.choice(len(z), size=10000, p=probs(z, T))
    print(f"  T={T}  抽到「巴黎」{(pick == 0).mean():.1%}   抽到离谱词 {(pick >= 7).mean():.1%}")

def keep(p, k=None, top_p=None):                       # 截断:只在"靠前的候选"里抽
    order = np.argsort(-p)
    if k: order = order[:k]                            # top-k:只留分数最高的 k 个
    if top_p: order = order[:int(np.searchsorted(np.cumsum(p[order]), top_p)) + 1]    # top-p:从高到低累加,够 top_p 就停
    return order

print("\n③ 截断:top-k = 3;top-p = 0.9(从高到低累加到 90% 就停)")
for T in (0.3, 1.0, 2.0):
    p = probs(z, T)
    k3, p9 = keep(p, k=3), keep(p, top_p=0.9)
    print(f"  T={T}  top-k=3 留下 {[names[i] for i in k3]}   top-p=0.9 留下 {len(p9)} 个词")

运行结果(我实际跑过,随机种子固定):

① 同一份分数,换不同的 temperature(T):概率表怎么变
  T=0.3  巴黎 99.8%  法国 0.1%  里昂 0.0%  马赛 0.0%  | 500 个离谱词合计 0.0%
  T=1.0  巴黎 73.7%  法国 10.0%  里昂 6.1%  马赛 3.0%  | 500 个离谱词合计 4.6%
  T=2.0  巴黎 12.9%  法国 4.8%  里昂 3.7%  马赛 2.6%  | 500 个离谱词合计 71.8%

② 每个 T 各抽 10000 次:抽到了谁
  T=0.3  抽到「巴黎」99.8%   抽到离谱词 0.0%
  T=1.0  抽到「巴黎」73.3%   抽到离谱词 4.6%
  T=2.0  抽到「巴黎」13.2%   抽到离谱词 71.9%

③ 截断:top-k = 3;top-p = 0.9(从高到低累加到 90% 就停)
  T=0.3  top-k=3 留下 ['巴黎', '法国', '里昂']   top-p=0.9 留下 1 个词
  T=1.0  top-k=3 留下 ['巴黎', '法国', '里昂']   top-p=0.9 留下 4 个词
  T=2.0  top-k=3 留下 ['巴黎', '法国', '里昂']   top-p=0.9 留下 438 个词

对照着看:

① temperature 改变概率表:
在这里插入图片描述

  • T = 0.3:“巴黎”独占 99.8%,几乎只剩它;
  • T = 1.0:原样,“巴黎”73.7%,500 个离谱词合计 4.6%;
  • T = 2.0:差距被拉平,“巴黎”只剩 12.9%,而 500 个离谱词合起来,涨到了 71.8%。

注意:T 不改变名次(“巴黎”永远是第一),只改变差距。

② 抽 10000 次的结果和概率表基本吻合:T = 1.0 时抽到“巴黎”73.3%、离谱词 4.6%;T = 2.0 时,抽到的多半是离谱词(71.9%)。这就是 T 太高、再加上长尾的后果。

③ 截断:

在这里插入图片描述

  • top-k = 3:三种 T 下都是“巴黎、法国、里昂”,个数固定;
  • top-p = 0.9:T = 0.3 时只留 1 个词;T = 1.0 时留 4 个词;T = 2.0 时要留 438 个词,因为分布平到要累加这么多才够 90%。

最后一行也说明了一件事:T 调得太高时,光靠截断也救不回来,超参数/可调参数要配合着用。(T = 2.0 是我为了让现象明显而设的极端值,实际使用中,一般不会调到这么高。)


七、再看一遍“真正写句子”

我们回到第 01 篇的迷你接龙,加上 temperature:每一步,把候选词出现的次数,取 1/T 次方(这等价于把概率表除以 T),再抽签。语料做了小调整,让某些句子出现得更多,这样 T 的效果更明显。

# mini_generate.py —— 第 01 篇的迷你接龙(语料略作调整),换不同的 temperature 写句子
import random
from collections import Counter, defaultdict

corpus = {"今天 天气 很 好 。": 6, "今天 天气 不 错 。": 2, "今天 天气 很 冷 。": 1,       # 句子 → 在"语料"里出现的次数
          "我们 去 公园 玩 。": 4, "我们 去 公园 散步 。": 2, "我们 去 海边 玩 。": 2, "我们 去 吃 火锅 。": 1}
table = defaultdict(Counter)
for line, n in corpus.items():
    ws = ["<开头>", "<开头>"] + line.split() + ["<结束>"]
    for a, b, nxt in zip(ws, ws[1:], ws[2:]):
        table[(a, b)][nxt] += n

def write(T, seed):                                   # T = 0 表示"每次都选最高的"
    rnd, out = random.Random(seed), ["<开头>", "<开头>"]
    while out[-1] != "<结束>":
        c = table[(out[-2], out[-1])]
        if T == 0: out.append(max(c, key=c.get))                  # 贪心:永远选次数最多的
        else: out.append(rnd.choices(list(c), weights=[n ** (1 / T) for n in c.values()])[0])   # 次数的 1/T 次方,相当于概率表 ÷ T
    return " ".join(out[2:-1])

for T in (0, 1.0, 5.0):
    print(f"T = {T}")
    for seed in range(5):
        print("   ", write(T, seed))

运行结果(我实际跑过):

T = 0
    今天 天气 很 好 。
    今天 天气 很 好 。
    今天 天气 很 好 。
    今天 天气 很 好 。
    今天 天气 很 好 。
T = 1.0
    我们 去 公园 玩 。
    今天 天气 很 好 。
    我们 去 公园 玩 。
    今天 天气 很 好 。
    今天 天气 很 好 。
T = 5.0
    我们 去 海边 玩 。
    今天 天气 不 错 。
    我们 去 公园 玩 。
    今天 天气 很 冷 。
    今天 天气 很 好 。

在这里插入图片描述

  • T = 0: 五次完全一样,因为永远选最高的。
  • T = 1.0: 按原来的概率抽,常见的句子经常出现,偶尔出现别的。
  • T = 5.0: 原来很少出现的“今天 天气 很 冷”“我们 去 海边 玩”也冒出来了。

要如实说明:这个迷你语料很小,每一步能选的词本来就少,所以即使 T = 5,也写不出太怪的句子。 真实模型的词表有几万到十几万个词,T 调得太高时,更容易冒出奇怪的词。


八、“一个词一个词写”带来的几个后果

理解了抽签,再回头看“逐词生成”,有几件事就想得通了:

  1. 每抽到一个词,它就变成上文。 所以前面抽到的词,会影响后面。一旦早期抽到了一个怪词,后面可能会顺着它越走越偏。
  2. 它只往后写,不会自动回头删。 (除非它在后面的文字里,自己写出“更正”。)这也是为什么有时它会“一本正经地编下去”。
  3. 每个词,整座“大楼”要跑一遍(第 06 篇)。所以生成越长,花的时间就越长,也就是你看到的“一点一点蹦出来”。
  4. “结束”也是抽出来的。 第 01 篇讲过,“结束”是词表里的一个特殊符号,抽到它,或者到了长度上限,就停下来。

九、点菜这个比喻,到哪里为止

  1. 模型就是个纯函数,没有状态,也没有"性格"。 T 只是把分数除以一个数。所谓“爱冒险”,是我们看结果后加上的说法。
  2. T 不会让模型“更聪明”或“更笨”。 它不改变模型知道什么,只改变从候选里怎么挑。
  3. 点菜时你知道哪道菜难吃,模型不知道。 T 高的时候,它抽到离谱词,不是“明知故犯”,而是概率表就是这样,抽签就是这样。

十、三个常见误解

误解 1:“temperature 越高,创造力越强。”
不对。T 高只是“更随机”。适度的随机,能让输出更多样;太高,就是胡言乱语。“创造力”来自模型学到的东西,T 只是影响它在多个候选里怎么选。

误解 2:“把 temperature 调成 0,就不会出错了。”
也不对。T = 0 只是每次选最可能的词,而最可能的词,也可能是错的(第 11 篇会讲“幻觉”)。另外,即使 T = 0,在一些实际系统里,同样的输入也不一定 100% 得到完全一样的输出,不要把它当成严格的保证。

误解 3:“同一个问题,答案不一样,说明它不靠谱。”
不一定。答案不同,很多时候是抽签的正常结果。更要看的是:事实的部分有没有变。像“法国首都”这种问题,概率高度集中,答案基本稳定;如果事实本身每次都不一样,那才要警惕。


十一、本篇要点

  1. 概率表只是候选名单,还要选一个。常见做法是按概率抽签,所以同一个问题,每次答案可以不同。
  2. temperature:把分数除以 T 再变百分比。T 小,更稳;T 大,更野。它不改变名次,只改变差距。top-k / top-p:抽签之前,先把靠后的词踢出去,对付“长尾”。
  3. 超参数/可调参数都在模型外面,不改变模型学到的东西;T = 0 也不等于不会出错。

下一篇预告: 概率表里的这些数字,是哪里来的?模型是怎么“学会”把“巴黎”排在第一的?从下一篇开始,进入“怎么练出来”的部分:预训练。

系列目录

在这里插入图片描述

文中的图、代码,欢迎自己动手改一改,比如把 z 里的分数换掉,或者把 top-p 改成 0.5,看看会留下几个词。有哪里没讲明白,欢迎在评论区告诉我。

Logo

更多推荐