AI 很可怕。它是一场现代工业革命,正在迅速贬低一整类脑力劳动的价值。我们有充分理由为这意味着什么而感到不安。

AI 也可能出错。它可能误解问题、捏造事实,或生成一段把 bug 藏在某个糟糕角落里的代码。

但有一种常见的 AI 批评混淆了两件不同的事:错误与随机性

把同一个问题问同一个模型两次,它可能给出两个不同的答案。但这件事本身并不意味着任何一个答案是错的。人类也完全一样。让两名程序员解决同一个问题,你会得到截然不同的代码。几周后再问同一名程序员,你还可能得到第三种方案。

不同不等于错误。

更重要的是,随机性早已暗藏在许多我们每天使用和信赖的系统之中:哈希表、Redis 有序集合、概率素性测试、某些排序算法、网络冲突避免、模拟、密码学,以及数不清的优化算法。

现代计算机系统里塞满了刻意引入的随机性。

随机性总得从某处来

我要从一个多少有些颠覆认知的事实讲起:

随机性并不会自然存在于确定性的计算机内部。

如果完整的内部状态和输入都相同,一台普通数字计算机每次都会产生相同的结果。它无法凭空制造不可预测性。

它能做的,是生成一串看起来随机的序列。

我最初学习编程用的是 TI-99/4A。我玩游戏时会发现,角色每次往往都以同样的方式移动,敌人从一局到下一局总是遵循相同的模式。

朋友们会把自己用 TI BASIC 写的游戏拿给我。他们的游戏要么也有同样的问题,要么一开始就让我输入一个随机数。

人类就是那台熵外设。

后来上大学时,我学到了与下面这个微型线性同余生成器类似的东西:

#include <stdint.h>

uint8_t random_byte(void)
{
    static uint8_t x = 1;
    x = x * 5 + 1;
    return x;
}

因为 x 是一个八位无符号整数,算术会在 256 处回绕。这个生成器会产生一串看似随机的序列:

1 → 6 → 31 → 156 → 13 → 66 → 75 → 120 → 89 → ...

事实上,这个特定的递推会遍历全部 256 个可能的字节值,然后才开始重复。

我问老师:“如果想要不同的序列怎么办?”

他说:“把静态变量设成另一个值。”

“可如果每次都想要不同的序列呢?”

问题就在这里。

初始值叫作种子。给定相同的种子,伪随机数生成器每次都会产生相同的序列。对于这个微型生成器,改变种子只是在同一个包含 256 个值的循环中选择另一个起点。

这就是为什么 TI-99/4A 上的游戏有时会向我要一个数字。游戏把我的答案用作种子。

两次输入相同的数字,就会得到两次相同的游戏。

从物理世界借来随机性

计算机是确定性的,但计算机生活在一个混乱的物理世界里。

早期 PC 找到了一些方便的方法,把这种混乱引入机器。

最初的 IBM PC 和 PC/XT 没有后来成为标准配置的电池供电实时时钟。DOS 系统启动时经常要求用户输入日期和时间。与此同时,BIOS 维护着一个每秒大约前进 18.2 次的计时器。游戏可以在启动时读取这个计数器,并把它用作种子。

想想在游戏读取它以前都发生了什么:

你打开机器,等它启动,找到 DOS 磁盘,更换软盘,输入命令。也许还拼错了一次,只好重新输入。

人类操作时机上的微小差异,会产生不同的计时器低位。这算不上密码学安全的随机性,但用来决定外星人什么时候飞过屏幕,已经绰绰有余。

到了 PC/AT 世代,电池供电时钟逐渐普及,当前时间便成了更方便的游戏种子来源。

Unix 和 Linux 面对的是一个更难的问题。密码学需要的随机性,不能让攻击者仅凭程序启动时间就猜出来。

Linux 内核从设备活动和中断时序中收集环境噪声:键盘事件、磁盘操作、网络活动和其他硬件事件。它把这些信息混入熵池,再通过 /dev/random/dev/urandom 等接口提供随机数据。

历史上,当内核认为熵不足时,/dev/random 可能会阻塞;在安静的系统上,它慢得令人痛苦。

我记得读过一个极具 Slashdot 时代气息的故事:有人把摄像头对准一盆榕树,同时用台扇吹动叶子。不断变化的像素被转换成熵,送进系统的随机数池。

那盆榕树承担着密码学劳动。

从那以后,硬件少了些园艺色彩。

许多现代 x86 处理器提供 RDRANDRDSEED。前者从硬件随机位生成器返回数值,后者则用于为另一个生成器提供种子质量的熵。

Arm 的可选随机数扩展提供了类似的 RNDRRNDRRS 操作。

对于需要经过独立验证、如消防水管般源源不断提供物理熵的人,市面上也有 PCIe 量子随机数生成卡。例如 Quside Garnet、Qrypt Atlas QRNG 和 QuintessenceLabs qStream。

但大多数软件并不会为所需的每一个随机数都消耗物理熵。

它们通常只使用一次物理熵来选择种子,然后让高速确定性生成器把这个种子扩展成数十亿个伪随机值:

物理熵 → 种子 → 伪随机生成器 → 大量数值

因此,当软件表现出随机行为时,其中绝大部分通常仍是确定性的。种子决定了你会得到哪一条确定性序列。

哈希函数:确定性的混沌

哈希函数并不完全等同于随机数生成器,尽管它们往往共享一个重要性质:输出应该广泛而不规则地散布在可用输出空间中。

我们可以借用一个高速哈希函数来演示这个概念:

import xxhash

x = xxhash.xxh3_64(b"").digest()

for _ in range(10):
    x = xxhash.xxh3_64(x).digest()
    print(int.from_bytes(x, "little") / 2**64)

在我的机器上,它会产生:

0.043041244156103366
0.1970826139989619
0.03510320452129987
0.713691224496282
0.2837170914417562
0.101618905717078
0.7344280662414208
0.08816604967489124
0.47792870613605337
0.5724158319777755

重新运行整个程序,你会得到完全相同的序列。

把:

x = xxhash.xxh3_64(b"").digest()

改成:

x = xxhash.xxh3_64(b"my seed").digest()

你会得到一条不同但依然完全确定的序列。

不要以这种方式把 xxHash 用于密码学秘密。它是非密码学哈希;这里只是演示,并非建议。

哈希表内部的随机性

哈希表使用哈希函数决定对象应当放在内部存储的哪个位置。

旧版 Python 会让这种内部布局显得格外明显。Python 2.7 的字典不保留插入顺序。遍历字典时,暴露出来的是由哈希表内部位置形成的一种看似随意的次序。

例如,在我的机器上:

>>> d = {}
>>> for word in "Alfa,Bravo,Charlie,Delta,Echo,Foxtrot,Golf,Hotel,India,Juliett".split(","):
...     d[word] = word
...
>>> print list(d.keys())
['Alfa', 'Bravo', 'Hotel', 'India', 'Echo', 'Delta',
 'Juliett', 'Golf', 'Charlie', 'Foxtrot']

这个顺序既不是字母顺序,也不是我插入单词的顺序。它只是这些单词在哈希表中所处位置的自然结果。

现代 Python 又增加了一层机制:字符串和字节串的哈希会使用解释器启动时选定的种子加盐。这有助于抵御攻击者故意构造大量哈希冲突。

Python 3.7 还把字典插入顺序纳入语言规范,因此普通的字典遍历如今会隐藏内部哈希表顺序。集合仍然更容易让人观察到这种效果。

你可以显式控制 Python 的哈希种子:

PYTHONHASHSEED=1 python3 -c \
'words=set("Alfa Bravo Charlie Delta Echo Foxtrot Golf Hotel India Juliett".split()); print(list(words))'

PYTHONHASHSEED=2 python3 -c \
'words=set("Alfa Bravo Charlie Delta Echo Foxtrot Golf Hotel India Juliett".split()); print(list(words))'

两个解释器使用不同的种子,通常会以不同顺序遍历各自的集合。

再次设置相同的 PYTHONHASHSEED,就会再次得到相同的行为。

随机性无处不在

哈希表只是一个例子。

Redis 有序集合同时使用哈希表和跳表实现。跳表以概率方式决定每个新插入节点的高度,本质上就像抛硬币,决定这个节点能加入多少条快速通道。Redis 源代码中有一个名为 zslRandomLevel() 的函数来完成这项选择。

随机化快速排序会随机选择枢轴,让对抗性或不幸的输入顺序不那么容易引发灾难性的性能。并非每一种快速排序实现都采用随机化,也并非每一种排序算法都是快速排序,但随机化排序算法应用得非常广泛。

Bloom 过滤器为每个插入项使用多个由哈希派生的位置。一旦哈希函数和种子选定,它们的操作可能完全可复现,但它们依赖的仍是同一种看似随机的分布。

概率型 Miller–Rabin 素性测试在判断一个数是否可能为素数时,可以随机选择底数。对于大小有界的整数,精心选择固定底数也可以让测试变成确定性的。

经典的共享式或半双工以太网使用截断二进制指数退避。发生冲突后,每个参与者会随机选择等待多少个时隙,然后再尝试。

否则,两块曾经发生一次冲突的相同网卡可能会继续同步重试,永远彼此冲突。

现代交换式全双工以太网通常不会发生冲突,但这一古老的随机化协议仍是以太网历史的重要组成部分。

随机性并不是这些系统中令人难堪的隐藏缺陷。

正是随机性帮助它们避开病态行为、对抗性输入、同步冲突,以及不必要的复杂确定性机制。

现在回到 AI

语言模型通常不会只给出一个别无选择的下一个词。

在每个位置上,它都会为许多可能的下一个 token 计算概率分布。某一个 token 可能概率很高,另外几个也合情合理,还有数千个只有极小的可能性。

然后,采样算法从中做出选择。

这个选择通常使用伪随机数生成器完成。除非你提供种子,否则软件或服务一般会从操作系统或其他熵源获取一个。

神经网络的计算可能大体上是确定性的。采样步骤才是刻意引入随机性的地方。

这种随机性很有用。它让模型能够探索不同的措辞、不同的实现,有时甚至是解决同一问题的完全不同的方法。

在代码生成中,反复采样多种方案,可能比要求模型永远只输出概率最高的那一个答案更有成效。

但这种随机性通常可以控制。

例如,在 PyTorch 中:

generator = torch.Generator(device="cuda").manual_seed(12345)

然后,你可以在采样操作中使用这个生成器:

token = torch.multinomial(
    probabilities,
    num_samples=1,
    generator=generator,
)

PyTorch 还提供了更简单的全局形式:

torch.manual_seed(12345)

它会设置 PyTorch 随机数生成器使用的种子。

使用相同种子,并不能保证在每一种 GPU、软件版本、模型修订、批处理配置或托管服务上都获得完全相同的输出。并行执行还会引入额外的不确定性。

但在模型、提示、参数、运行时和种子都相同的情况下,你通常可以复现同一次生成。

AI 可能出错。

它可能生成胡言乱语,可能产生幻觉,也可能带着一个男人在婚礼上讲解加密货币时的那种自信,交出暗藏微妙错误的代码。

但它给你两个不同的程序,并不能证明其中任何一个是错的。这只说明采样器获准在多个选项之间做出选择。

所以,当有人抱怨 AI 不具确定性时,我的回答很简单:

别做一个马虎的程序员。读 API,自己设置种子。