LLM 是怎么预测下一个 token 的:把候选表打出来看
本文的每个数字都是跑出来的。 环境是 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 版。
⇒ 它不是随机,是隐藏输入。 你的请求不是模型的全部输入 —— 缓存里有什么也是输入,而那取决于你上一个问题问了什么。 一旦把缓存状态也算进来,它完全可复现。
这件事的实际后果比听上去大:
- 「我这边跑出来是对的」在开了 KV 量化的服务上不构成证据, 因为对方的缓存状态和你不同。
- 压测和线上会系统性地不一致 —— 压测反复打同一个 prompt(次次命中), 线上是各种 prompt 交替(经常未命中)。
- 这一类问题不会报错,只会表现为「偶尔答得不一样」, 而你去查 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 头部时会给出这个数。