AI 大模型模型部署系列(二):从 Logits 到 Token,理解 llama.cpp 采样链
在上一篇文章《AI 大模型模型部署系列(一):从 Prompt 到 Token,理解 llama.cpp 最小推理》中,我们梳理了 Model、Context、Batch、Decode 和 KV Cache 之间的数据流:Prompt 经过 Tokenize 进入模型,Decode 完成计算并将 Logits 保存到 Context。但到这里,模型还没有真正决定下一个 Token。
从 Logits 中选出 Token,是推理循环的下一环,也是本文的起点。Sampler 看起来只是一次函数调用,内部却包含候选过滤、概率调整、随机选择和状态更新。本次继续以 examples/simple/simple.cpp 为入口,拆解 llama.cpp 的采样链,并通过 Greedy、Top-K、Top-P、Temperature、Seed 五组对照实验,验证采样参数如何影响生成结果。
实验中还遇到了一个很有代表性的问题:原本想使用 Top-P,却误调用了 Min-P,导致不同参数和 Seed 得到的内容几乎完全一致。定位这个问题的过程,比单纯看到几组不同输出更能说明采样器的工作方式。
llama.cpp 的接口变化较快。本文对应本地版本
b8864-6-g82209efb7,其他版本的函数名称和实现细节可能不同。
一、实验环境
| 项目 | 配置 |
|---|---|
| 操作系统 | Windows |
| 编译工具 | Visual Studio 2022、CMake |
| GPU | NVIDIA GeForce GTX 1080 Ti,11 GB |
| llama.cpp | CUDA 后端构建 |
| 模型 | Qwen2.5-1.5B-Instruct-Q3_K_M GGUF |
| 示例程序 | examples/simple/simple.cpp |
本次实验只研究采样行为。所有实验复用同一个 Model,但每次生成都重新创建 Context 和 Sampler,避免上一轮生成的 KV Cache、Token 位置、随机数状态或惩罚历史影响下一轮结果。
二、从 Logits 到 Token
llama_decode() 成功后,Context 中保存了模型对下一个位置的预测结果。对于词表中的每个 Token,至少需要关注三个字段:
1 | |
id:候选 Token ID。logit:模型给出的原始分数,尚不是概率。p:经过 Softmax 后得到的概率。
一次典型随机采样可以表示为:
1 | |
本次使用的采样链如下:
1 | |
1. Top-K:限制候选数量
Top-K 按 Logit 从高到低排序,只保留前 K 个候选。它可以快速排除大量低分 Token,但不能单独完成最终选择。
K 越小,选择范围越集中;K 越大,保留的低概率候选越多。
2. Top-P:根据概率分布动态截断
Top-P 先按概率从高到低排序,再保留累计概率达到阈值的最小候选集合。
当模型对下一个 Token 很确定时,概率集中在少数候选上,Top-P 会保留较少的 Token;当模型判断比较模糊时,需要累加更多候选才能达到相同阈值,因此候选集合会自动扩大。
这也是 Top-P 与 Top-K 的主要区别:Top-K 固定数量,Top-P 根据当前概率分布动态决定数量。
3. Temperature:改变分布形状
Temperature 不直接决定保留多少 Token,而是缩放 Logit:
1 | |
T < 1:分布更尖锐,高概率候选更占优势,输出通常更稳定。T > 1:分布更平坦,低概率候选更容易被选中,输出通常更发散。T → 0:行为逐渐接近 Greedy。
“更稳定”或“更发散”描述的是统计倾向,并不保证一次短文本实验必然出现肉眼可见的固定差异。
4. Greedy、Dist 与 Seed
Greedy 始终选择分数最高的候选,不需要随机数和 Seed。
Dist 是最终随机选择器,根据候选概率分布抽取一个 Token。Seed 决定伪随机数序列:在模型、Prompt、参数、程序版本和计算后端相同,并且 Context 与 Sampler 状态一致时,相同 Seed 可以复现实验结果。
三、apply、selected 与 accept
llama_sampler_sample() 是采样入口。它从 Context 取得指定输出位置的 Logits,构造候选数组,然后调用采样链:
1 | |
selected 本身不是 Token ID,而是候选数组中的下标。真正的 Token ID 是:
1 | |
accept 也不只是为了“防止重复”。Grammar、Penalties 等有状态采样器需要知道最终接受了哪个 Token,才能更新语法状态、Token 历史或惩罚统计。选择和状态更新分开后,外部代码也可以把自行决定的 Token 同步给采样器。
当前版本的 llama_sampler_sample() 已经在内部调用 llama_sampler_accept(),正常使用该入口时不需要再手动 Accept 一次。
四、对照实验设计
测试 Prompt:
1 | |
每组最多生成 96 个 Token,实验参数如下:
| 实验 | 次数 | 最终选择器 | Top-K | Top-P | Temperature | Seed | 目的 |
|---|---|---|---|---|---|---|---|
| A | 2 | Greedy | - | - | - | - | 验证确定性输出 |
| B | 2 | Dist | 40 | 0.90 | 0.8 | 1234 | 验证相同 Seed 复现 |
| C | 1 | Dist | 40 | 0.90 | 0.8 | 5678 | 只改变 Seed |
| D | 1 | Dist | 40 | 0.90 | 0.2 | 1234 | 观察低温分布 |
| E | 1 | Dist | 40 | 0.95 | 1.2 | 1234 | 扩大候选并提高温度 |
为了保证实验之间相互独立,run_once() 每次都创建新的 Context 和 Sampler,结束后再释放。Model 权重保持复用,因为 Model 不保存某一轮生成的 KV Cache 和采样状态。
五、为什么不同参数最初输出完全一致
第一次运行时,A 到 E 的输出几乎没有区别。问题不在 Seed,也不是模型能力不足,而是采样链中写错了一个函数:
1 | |
Top-P 和 Min-P 的参数含义完全不同:
- Top-P 的
p = 0.9:保留累计概率达到 90% 的候选集合。 - Min-P 的
p = 0.9:只保留概率不低于“最高候选概率的 90%”的 Token。
本次 Prompt 首步的最高候选概率是 16.7526%,第二名是 7.59989%。Min-P 0.9 对应的门槛约为:
1 | |
第二名远低于这个门槛,因此候选集合几乎只剩概率最高的 Token。后面的 Temperature 和 Dist 即使仍然执行,面对唯一候选也已经没有随机选择空间,整个采样链就会退化得接近 Greedy。
修复方式是使用真正的 Top-P Sampler:
1 | |
这个问题也说明,仅仅看到“程序能运行”并不能证明采样参数已经生效。需要检查候选集合、概率分布和最终 Token,才能区分模型输出过于确定、随机数未生效和采样器配置错误。
六、修复后的实验结果
修复后的结果符合采样器预期:
- A 两次输出完全一致。Greedy 不依赖随机数,每一步都选择最高分候选。
- B 两次输出完全一致。相同 Seed、参数和初始状态复现了相同随机序列。
- C 只更换 Seed,生成内容已经与 B 明显不同,说明 Dist 的随机选择生效。
- D 将 Temperature 降到 0.2,本次输出的结构表现得比 B 更接近 Greedy,与低温强化高概率候选的预期一致。但这只是一次样本,不能单独证明稳定规律。
- E 同时提高 Top-P 和 Temperature,生成内容变化最明显,说明更大的候选范围和更平坦的分布增加了发散可能。
这些实验用于验证生成行为,不适合作为性能结论。文本较短、运行次数少,而且没有单独设计预热和统计过程,表中的耗时只能作为本次运行记录。
七、扩展实验:打印首步 Top-5
为了直接观察模型在采样前输出了什么,在 Prompt 首次 Decode 后,通过 llama_get_logits_ith(ctx, -1) 取得最后一个输出位置的 Logits。
Softmax 不能直接计算 exp(logit),否则较大的 Logit 可能导致浮点溢出。常用做法是先减去最大值:
1 | |
本机得到的首步原始 Top-5:
| 排名 | Token ID | Logit | 原始概率 | Piece |
|---|---|---|---|---|
| 1 | 14880 | 17.4679 | 16.7526% | 请 |
| 2 | 220 | 16.6775 | 7.59989% | 空格 |
| 3 | 106393 | 16.2275 | 4.84584% | 模块 |
| 4 | 8908 | 16.1544 | 4.50433% | 不能独立显示的字节片段 |
| 5 | 99329 | 16.0468 | 4.04461% | 游戏 |
这里展示的是 Top-K、Top-P 和 Temperature 生效之前的模型原始分布。B 组使用 Seed 1234 时,首步最终抽到的不是概率最高的“请”,而是 Token 106393 对应的“模块”。这说明 Dist 确实在候选概率分布中进行了抽样,而不是始终选择最大值。
另一个细节是 Token Piece 不一定是完整字符。BPE 词表中可能存在 UTF-8 字节片段,单独打印某个 Token 时可能无法显示成完整文字,只有与相邻 Token 拼接后才能正确解码。
八、采样结果何时进入 KV Cache
Sampler 选出的 Token 不会在采样结束时直接写入 KV Cache。它先被放入下一轮 Batch:
1 | |
下一次调用 llama_decode() 时,模型才会计算这个 Token 在各层产生的 Key 和 Value,并将它们追加到 Context 管理的 KV Cache 中。
1 | |
因此,Sampler 管理的是“如何选择 Token”,KV Cache 管理的是“如何复用已经计算过的 Attention 状态”,两者处在推理循环的不同阶段。
九、本次结论
通过源码阅读、错误定位和对照实验,可以得到以下结论:
- Top-K 和 Top-P 负责过滤候选,不能代替最终的 Greedy 或 Dist 选择器。
- Temperature 调整概率分布形状,不保证一次实验必然出现固定程度的文本差异。
- Seed 控制伪随机序列,可复现性还依赖相同的模型、参数、程序版本、计算后端和初始状态。
selected是候选数组下标,accept用于同步有状态采样器。- Top-P 与 Min-P 名称接近但含义不同,过大的 Min-P 会让随机采样退化得接近 Greedy。
- 观察原始 Logits 和候选概率,比只对比最终文本更容易定位采样问题。
- 采样 Token 要在下一次 Decode 后才会产生 K/V 并进入 KV Cache。
本次完成了从“模型产生 Logits”到“采样器选择 Token”的链路。下一步可以继续处理 Instruct 模型的 Chat Template、多轮上下文组织和结构化输出,为本地模型执行实际任务做准备。
参考
本文基于源码阅读和本地实验记录整理,使用 AI 辅助检查文章结构与技术表述,文中的代码、实验结果和结论均由本人复核。