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
2
3
4
5
struct llama_token_data {
llama_token id;
float logit;
float p;
};
  • id:候选 Token ID。
  • logit:模型给出的原始分数,尚不是概率。
  • p:经过 Softmax 后得到的概率。

一次典型随机采样可以表示为:

1
2
3
4
5
6
7
8
Context 中的 Logits
→ 构造候选 Token 数组
→ Top-K 粗筛
→ Top-P 动态截断
→ Temperature 调整分布
→ Dist 按概率抽样
→ 得到 Token ID
→ accept 更新采样器状态

本次使用的采样链如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
llama_sampler * create_sampler(
int32_t k,
float top_p,
size_t min_keep,
float temp,
uint32_t seed) {
llama_sampler * sampler =
llama_sampler_chain_init(llama_sampler_chain_default_params());

llama_sampler_chain_add(sampler, llama_sampler_init_top_k(k));
llama_sampler_chain_add(sampler, llama_sampler_init_top_p(top_p, min_keep));
llama_sampler_chain_add(sampler, llama_sampler_init_temp(temp));
llama_sampler_chain_add(sampler, llama_sampler_init_dist(seed));
return sampler;
}

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
p_i = softmax(logit_i / T)
  • 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
2
3
apply:过滤候选或修改概率分布
selected:最终选择器写入候选数组下标
accept:通知有状态采样器最终接受了哪个 Token

selected 本身不是 Token ID,而是候选数组中的下标。真正的 Token ID 是:

1
cur_p.data[cur_p.selected].id

accept 也不只是为了“防止重复”。Grammar、Penalties 等有状态采样器需要知道最终接受了哪个 Token,才能更新语法状态、Token 历史或惩罚统计。选择和状态更新分开后,外部代码也可以把自行决定的 Token 同步给采样器。

当前版本的 llama_sampler_sample() 已经在内部调用 llama_sampler_accept(),正常使用该入口时不需要再手动 Accept 一次。

四、对照实验设计

测试 Prompt:

1
请设计一个C++贪吃蛇游戏,给出模块划分、核心数据结构和主循环流程。

每组最多生成 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
2
3
4
// 原本想使用 Top-P,实际调用成了 Min-P
llama_sampler_chain_add(
sampler,
llama_sampler_init_min_p(p, min_keep));

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
16.7526% × 0.9 = 15.0773%

第二名远低于这个门槛,因此候选集合几乎只剩概率最高的 Token。后面的 Temperature 和 Dist 即使仍然执行,面对唯一候选也已经没有随机选择空间,整个采样链就会退化得接近 Greedy。

修复方式是使用真正的 Top-P Sampler:

1
2
3
llama_sampler_chain_add(
sampler,
llama_sampler_init_top_p(p, min_keep));

这个问题也说明,仅仅看到“程序能运行”并不能证明采样参数已经生效。需要检查候选集合、概率分布和最终 Token,才能区分模型输出过于确定、随机数未生效和采样器配置错误。

六、修复后的实验结果

修复后的结果符合采样器预期:

  1. A 两次输出完全一致。Greedy 不依赖随机数,每一步都选择最高分候选。
  2. B 两次输出完全一致。相同 Seed、参数和初始状态复现了相同随机序列。
  3. C 只更换 Seed,生成内容已经与 B 明显不同,说明 Dist 的随机选择生效。
  4. D 将 Temperature 降到 0.2,本次输出的结构表现得比 B 更接近 Greedy,与低温强化高概率候选的预期一致。但这只是一次样本,不能单独证明稳定规律。
  5. E 同时提高 Top-P 和 Temperature,生成内容变化最明显,说明更大的候选范围和更平坦的分布增加了发散可能。

这些实验用于验证生成行为,不适合作为性能结论。文本较短、运行次数少,而且没有单独设计预热和统计过程,表中的耗时只能作为本次运行记录。

七、扩展实验:打印首步 Top-5

为了直接观察模型在采样前输出了什么,在 Prompt 首次 Decode 后,通过 llama_get_logits_ith(ctx, -1) 取得最后一个输出位置的 Logits。

Softmax 不能直接计算 exp(logit),否则较大的 Logit 可能导致浮点溢出。常用做法是先减去最大值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const float * logits = llama_get_logits_ith(ctx, -1);
const int32_t n_vocab = llama_vocab_n_tokens(vocab);

float max_logit = logits[0];
for (int32_t id = 1; id < n_vocab; ++id) {
max_logit = std::max(max_logit, logits[id]);
}

double sum_exp = 0.0;
for (int32_t id = 0; id < n_vocab; ++id) {
sum_exp += std::exp(
static_cast<double>(logits[id] - max_logit));
}

std::vector<llama_token_data> candidates;
candidates.reserve(n_vocab);

for (int32_t id = 0; id < n_vocab; ++id) {
const float probability = static_cast<float>(
std::exp(static_cast<double>(logits[id] - max_logit)) /
sum_exp);

candidates.push_back({ id, logits[id], probability });
}

本机得到的首步原始 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
batch = llama_batch_get_one(&new_token_id, 1);

下一次调用 llama_decode() 时,模型才会计算这个 Token 在各层产生的 Key 和 Value,并将它们追加到 Context 管理的 KV Cache 中。

1
2
3
4
5
本轮 Decode 产生 Logits
→ Sampler 选出 Token
→ Token 放入下一轮 Batch
→ 下一轮 Decode 计算该 Token 的 K/V
→ 写入 KV Cache

因此,Sampler 管理的是“如何选择 Token”,KV Cache 管理的是“如何复用已经计算过的 Attention 状态”,两者处在推理循环的不同阶段。

九、本次结论

通过源码阅读、错误定位和对照实验,可以得到以下结论:

  1. Top-K 和 Top-P 负责过滤候选,不能代替最终的 Greedy 或 Dist 选择器。
  2. Temperature 调整概率分布形状,不保证一次实验必然出现固定程度的文本差异。
  3. Seed 控制伪随机序列,可复现性还依赖相同的模型、参数、程序版本、计算后端和初始状态。
  4. selected 是候选数组下标,accept 用于同步有状态采样器。
  5. Top-P 与 Min-P 名称接近但含义不同,过大的 Min-P 会让随机采样退化得接近 Greedy。
  6. 观察原始 Logits 和候选概率,比只对比最终文本更容易定位采样问题。
  7. 采样 Token 要在下一次 Decode 后才会产生 K/V 并进入 KV Cache。

本次完成了从“模型产生 Logits”到“采样器选择 Token”的链路。下一步可以继续处理 Instruct 模型的 Chat Template、多轮上下文组织和结构化输出,为本地模型执行实际任务做准备。

参考

本文基于源码阅读和本地实验记录整理,使用 AI 辅助检查文章结构与技术表述,文中的代码、实验结果和结论均由本人复核。


AI 大模型模型部署系列(二):从 Logits 到 Token,理解 llama.cpp 采样链
https://suntfly.github.io/2026/08/26/AI大模型模型部署系列(二):从Logits到Token/
作者
sunTFly
发布于
2026年8月26日
许可协议