LLM 是怎么预测下一个 token 的:把候选表打出来看

LLM 原理第 1 / 2 篇

本文的每个数字都是跑出来的。 环境是 Ollama 0.33.2 + qwen2.5:0.5b-instruct-q8_0,Apple Silicon / Metal,验证代码在仓库 experiments/next-token/(run.mjs 四段 + serving-config.mjs 两段)。 用 0.5B 模型是因为本文引用的全部是字节是否相同,不是回答好不好。

「LLM 就是在预测下一个 token」这句话人人会说,但它是一句口号还是一件能看见的事, 取决于你有没有把那张候选表打出来过。打出来之后,temperature、top-p、 「为什么同一个问题两次答得不一样」这些问题会同时变得具体。

先把要看的东西摆出来。这个模型的一次前向是:

阶段 形状
tokenize 一串 token id
embedding 每个 token → 896 维向量
24 层 transformer block 14 个注意力头(KV 头只有 2 个,GQA)· FFN 中间层 4864 维
最后一层 → logits 词表上每个 token 一个实数
采样 从这些实数里挑一个 token

(这几个数来自 ollama show 的 model_info:block_count=24、 embedding_length=896、head_count=14、head_count_kv=2、 parameter_count=494,032,768。这些字段在模型文件里是怎么存的, 见下一篇。)

整篇文章只关心最后一步。 前面那些层做的事这一篇不碰。

一、候选表长什么样

Ollama 的 OpenAI 兼容端点支持 logprobs + top_logprobs, 所以第一个 token 的候选表可以直接拿到:

const r = await fetch(`${HOST}/v1/chat/completions`, {
  method: 'POST', headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    model: MODEL, messages: [{ role: 'user', content: prompt }],
    max_tokens: 1,        // 只要第一步,logprobs 里就只有一步
    logprobs: true, top_logprobs: 10,
  }),
});

同样是「只输出一个词」,两个 prompt 的分布形状完全不同:

「法国的首都是哪座城市?」

 #  token       概率      累计
 1  "巴黎"      99.54%   99.54%
 2  "法国"       0.12%   99.67%
 3  " Paris"    0.05%   99.72%
 4  "伦敦"       0.05%   99.76%
 …
10  "罗马"       0.01%   99.91%

「随便说一个颜色」

 #  token       概率      累计
 1  "橙"        20.72%   20.72%
 2  "蓝色"      15.12%   35.84%
 3  "紫"         5.75%   41.59%
 4  "红色"       4.95%   46.54%
 …
10  "蓝"         2.72%   67.63%

⭐ 第二张表的 top-10 只盖住 67.63% 的概率质量 —— 剩下 32% 散在词表其余部分。

这两张表的差别在输出上是看不见的:两个 prompt 都会给你一个词。 但「模型知道答案」和「模型本来就没有一个答案」是完全不同的两种状态, 只有候选表能分辨。凡是打算给模型输出加置信度、加兜底、加人工复核的地方, 判据都该建在这张表上,而不是建在输出文本上。

顺带注意第 3 名 " Paris" 带一个前导空格 —— token 不是字, "Paris" 和 " Paris" 是两个不同的 token。

二、temperature 改的是哪一步

口头说法是「temperature 缩放 logits」。那么一个可以直接测的问题是: 接口返回的这些概率,是缩放前的还是缩放后的?

同一个 prompt,三档温度各取一次 top-5:

token T=0 T=0.7 T=1.5
橙 20.78% 20.78% 20.78%
蓝色 15.09% 15.09% 15.09%
紫 5.77% 5.77% 5.77%
红色 4.94% 4.94% 4.94%
深 4.73% 4.73% 4.73%

一字不变。再加上 top_p=0.1 跑一次 —— 还是一字不变。

⇒ 返回的 logprob 是采样前的原始分布,不是采样时实际用的那个分布。

⭐ 这个结论需要两个参数一起测才成立。只测 temperature, 「它不改返回值」有两种解释:要么返回的是原始分布,要么这个接口的返回值 干脆和采样参数无关。加上 top-p(它的机制完全不同 —— 截断候选集而不是缩放) 之后仍然不变,才排除掉后一种。一个变量测不出「无关」,只能测出「这个变量无关」。

实际意义:拿 logprob 当置信度用的时候,它反映的是模型本身的分布, 不随你调 temperature 而变。想让置信度跟着采样策略走,得自己算。

那 temperature 到底改了什么?改的是从这张表里怎么挑。 五档温度各采样 50 次,统计输出的分布:

temperature 不同输出数 熵 (bit) 最高频输出占比
0 1 0.00 100%
0.3 4 1.06 72%
0.7 16 3.21 32%
1.0 26 4.28 16%
1.5 36 4.98 10%

top-p 则是另一个机制 —— 固定 T=1.5,只动 top_p:

top_p 不同输出数 熵 (bit)
1.0 34 4.85
0.5 4 1.88
0.1 1 0.00

top_p=0.1 在 T=1.5 下把输出压回了唯一 —— 因为按第一张表, 累计概率到 0.1 的时候候选集里只剩 橙 一个。温度再高也没得挑。

⚠️ 这两张表里的数是 50 个样本的估计值,不是常数。同一份脚本跑第二轮, T=0.7 那格从 16 变成 20,熵从 3.21 变成 3.45。别把它们当成精确值引用。 熵的上界还被样本量顶死在 log₂(50) ≈ 5.64,所以 T=1.5 的 4.98 不代表真实分布的熵接近 5,只代表「在 50 个样本这个尺度上已经散得差不多了」。

三、判据:temperature=0 到底可不可复现

stub 给这一篇定的判据是:

temperature=0 若不能复现,先找到那个随机源再往下写 —— 否则后面所有实验都不可比。

T=0 是贪心解码,理论上应该逐字节相同。但「理论上」是这一篇要拆掉的东西。 跑 20 次,三个档:

档 输出长度 20 次里的版本数
① 短输出 · 串行 54 字符 1
② 长输出 · 串行 1189 字符 1
③ 长输出 · 20 路并发 1189 字符 1

三档全部逐字节相同,判据成立。

⭐ 为什么不能只跑 ①。第一版我写的就是 ①,54 个字符、串行 20 次、全同, 正准备写「T=0 可复现」。但那一档是三档里最容易通过的:

  • ② 换长输出:分歧机会多一个数量级。贪心解码里只要有一步的 top-1 和 top-2 差距落进浮点噪声,后面就全岔开了 —— 输出越长,撞上这种一步的概率越大。
  • ③ 换并发:真实服务里请求是被批在一起算的,批的组成不同, 矩阵乘的归约顺序就不同,浮点加法不满足结合律。串行完全绕开了这一类。

这三个不是「同一个测试的更严格版本」,是三个测的东西不一样的测试。 ①通过不蕴含②通过。

(③ 在 Ollama 这套配置下也全同,但这只说明它这里的批处理没引入差异, 不能外推到别的运行时。vLLM 那类连续批处理的实现是另一回事。)

四、可复现,但和别人不一样

上一节的结论有一个很容易被忽略的限定词:在同一个服务进程内。

模型文件不动、prompt 不动、temperature: 0 不动,只改起服务时的环境变量, 各跑 5 次:

配置 5 次里的版本数 分组 与默认档相同
默认(无 flag) 1 {1,2,3,4,5} —
OLLAMA_FLASH_ATTENTION=1 1 {1,2,3,4,5} ✅ 逐字节相同
↑ + OLLAMA_KV_CACHE_TYPE=q8_0 2 {1} {2,3,4,5} ❌ 第 33 字起分歧
↑ + OLLAMA_KV_CACHE_TYPE=q4_0 2 {1} {2,3,4,5} ❌ 第 0 字起分歧

后两档同一个 prompt 在 5 次里给出了 2 个版本。T=0,没有 seed 可调, 而它就是变了。

到这里我差点写下「量化 KV cache 引入了不确定性」。那是错的, 救回来的是表格里的第三列。

⭐ {1} {2,3,4,5} 这个分组形状,和「5 次里有 2 个版本」信息量差得远: 前者说明第 1 次和后面 4 次不同,后者可以是任意的随机抖动。 第一版脚本只跑 3 次、只打印一个布尔值「自比稳定=否」, 恰好把唯一能区分这两者的信息扔掉了。

「第 1 次不同」有个现成的解释:第 1 次缓存未命中,后面 4 次命中 prompt 的 KV 缓存, 而量化过的缓存是有损的 —— 从缓存里取出来的前缀和现算的前缀数值不一样。

这个解释可以证伪。两个假说对同一次观测给出不同预言:

  • 若是真随机 → 中间插一个别的 prompt 把缓存挤掉,再问原问题,应该给出第三个版本
  • 若是缓存 → 应该精确地回到第 1 版
步骤 长度 = 第 1 次 = 第 2 次
① P(缓存未命中) 846 是 否
② P(缓存命中) 457 否 是
③ P(缓存命中) 457 否 是
⑤ 插入别的 prompt 后再问 P 846 是 否

逐字节回到了第 1 版。

⇒ 它不是随机,是隐藏输入。 你的请求不是模型的全部输入 —— 缓存里有什么也是输入,而那取决于你上一个问题问了什么。 一旦把缓存状态也算进来,它完全可复现。

这件事的实际后果比听上去大:

  1. 「我这边跑出来是对的」在开了 KV 量化的服务上不构成证据, 因为对方的缓存状态和你不同。
  2. 压测和线上会系统性地不一致 —— 压测反复打同一个 prompt(次次命中), 线上是各种 prompt 交替(经常未命中)。
  3. 这一类问题不会报错,只会表现为「偶尔答得不一样」, 而你去查 temperature 和 seed,那两个地方都是干净的。

顺带一个可用的结论:默认档和只开 flash attention 的档逐字节相同, KV 量化才是那个分界线。省显存的代价不只是「精度略降」, 还包括丢掉了可复现性这条性质。

五、回到判据

原判据「T=0 若不能复现,先找到随机源」在本机成立了,但跑完这一轮, 它需要补一句:

可复现是相对于一组固定条件而言的。 报「T=0 可复现」的时候, 必须同时报那组条件 —— 运行时版本、服务端 flag、是否串行、 以及缓存状态。少报一项,这个结论就可能在别人机器上不成立, 而对方复现失败时,你们会去查 temperature。

这一轮里被数据推翻的判断有两个,都曾看起来很对:

我先以为 实测
返回的 logprob 会随 temperature 变 不变;连 top-p 也不改它 —— 那是采样前的原始分布
KV cache 量化引入了不确定性 不是。是缓存命中与否这个隐藏输入,插一个别的 prompt 就能让它逐字节回到原样

本文没有回答的三个问题:

一是 前四个阶段。开头那张形状表来自元数据,不是我跑出来的中间张量 —— 本文没有打印过任何一层的输出。要看权重和张量形状在文件里怎么存, 见下一篇。

二是 闭源 API 的行为。第四节的机制是在 Ollama 上量的。 商业 API 的批处理策略和缓存策略都不可见,结论不能搬,能搬的只有提问方式 —— 「我这次和上次之间,是不是有一个没写在请求里的输入变了」。

三是 词表到底多大。第一节说 top-10 之外「散在词表其余部分」, 那部分具体有多少个 token,本文没数。下一篇读 GGUF 头部时会给出这个数。