SamplingParams 与 token selection#
有了 ForwardBatch 和 ModelRunner,执行层已经能把一轮前向跑出来。但"模型已经前向"还不等于"下一个 token 已经选出来"。这一节处理的就是这一层:采样参数怎样进入执行链,又怎样真正影响 token selection。
这一节解决什么问题#
这一节主要回答三件事:
SamplingParams到底承载了哪些语义;- 为什么采样参数不只是用户接口上的便利字段;
- token selection 为什么必须和 stop、schema、tool constraint 等控制一起理解。
SamplingParams 不是参数袋,而是运行时约束集合#
SamplingParams
的字段范围已经说明,它承载的不只是传统采样超参:
temperaturetop_ptop_kfrequency_penaltypresence_penaltymax_new_tokensjson_schemaregexebnfsampling_seed
这说明 SamplingParams 不是"采样超参对象"这么简单,而是执行层在选 token 时必须同时看的约束集合。
如果把这层关系先压成一张图,会更容易看清参数到底是在什么时候进入执行链的:
flowchart LR
A["OpenAI / frontend request"] --> B["to_sampling_params(...)"]
B --> C["SamplingParams"]
C --> D["token selection"]
C --> E["stop / schema / tool constraints"]
E --> D这张图最重要的一点是:采样参数不是执行层事后读取的配置,而是在进入 token selection 之前就已经被统一收拢好了。
为什么 SamplingParams 必须先 normalize / verify#
如果采样参数只是原样下传,执行层就必须同时处理:
- 用户输入的宽松表示;
- 默认值补全;
- stop / schema / regex 的兼容关系;
- 参数边界是否合法。
现在这层逻辑被压在 SamplingParams.normalize() 和 verify() 里,意味着执行层在真正选 token 之前,先看到的是已经被规整过的一组约束。
从代码看,这层规整至少包括:
if 0 <= self.temperature < _SAMPLING_EPS:
self.temperature = 1.0
self.top_k = 1
if sum(x is not None for x in grammars) > 1:
raise ValueError("Only one of regex, json_schema, or ebnf can be set.")这段代码说明,执行层在真正看这些参数之前,已经先把"近似 greedy sampling"和"结构化约束互斥"这类边界收掉了。
这也是这本书一直强调的边界原则:调度层解决"这一轮谁来跑",执行层解决"这一轮跑出来以后怎样选 token",参数规整不该再污染更下层的 token 选择逻辑。
为什么结构化约束要和采样参数放在一起#
很多人第一次看结构化生成时,会下意识把:
json_schemaregexebnf
看成另外一层功能。但在执行层视角,它们和 temperature、top_p 一样,都是影响 token selection 的输入条件。
这也是为什么后面第七章会讲结构化生成,但这里仍然要先把这层关系说清楚:结构化约束不是"模型生成完再检查",而是在选 token 时就进入了同一组约束里。
这一点在 ChatCompletionRequest.to_sampling_params
里也写得很直接:
sampling_params = {
"temperature": get_param("temperature"),
"stop": stop,
"regex": self.regex,
"ebnf": self.ebnf,
"n": self.n,
}
if self.response_format and self.response_format.type == "json_schema":
sampling_params["json_schema"] = convert_json_schema_to_str(...)这段代码足够说明,结构化约束不是在执行层之后才附加上去,而是在采样参数对象建立时就已经被塞进来了。
token selection 真正依赖什么#
如果把这一层压缩成一句话,token selection 实际上同时依赖:
- 模型前向给出的 logits;
SamplingParams提供的采样约束;- 停止条件;
- 结构化生成约束;
- tool / function calling 约束。
所以执行层的"下一个 token"从来都不是单纯根据 logits 最大值或概率分布抽样出来的,而是根据这组运行时条件共同决定的。
温度缩放的实际效果#
知道了 logits 是什么以及约束怎样进入执行链,接下来看温度缩放具体怎样改变概率分布。
公式#
温度缩放的操作极为简单:把 logits 除以 temperature,再做 softmax:
logits_scaled = logits / temperature
probs = softmax(logits_scaled)softmax 的定义:
softmax(x_i) = exp(x_i) / sum(exp(x_j) for all j)具体数值追踪#
假设词表只有 3 个 token,模型前向给出的原始 logits 为:
logits = [2.0, 1.0, 0.5]不做缩放(temperature=1.0):
exp([2.0, 1.0, 0.5]) = [7.389, 2.718, 1.649]
sum = 11.756
probs = [0.629, 0.231, 0.140]temperature=0.5(分布变尖锐):
logits_scaled = [2.0/0.5, 1.0/0.5, 0.5/0.5] = [4.0, 2.0, 1.0]
exp([4.0, 2.0, 1.0]) = [54.598, 7.389, 2.718]
sum = 64.705
probs = [0.844, 0.114, 0.042]token 0 的概率从 62.9% 上升到 84.4%,分布集中了。
temperature=2.0(分布变平坦):
logits_scaled = [2.0/2.0, 1.0/2.0, 0.5/2.0] = [1.0, 0.5, 0.25]
exp([1.0, 0.5, 0.25]) = [2.718, 1.649, 1.284]
sum = 5.651
probs = [0.481, 0.292, 0.227]token 0 的概率从 62.9% 下降到 48.1%,其他 token 的机会增大了。
三种情形对比:
| temperature | token 0 | token 1 | token 2 |
|---|---|---|---|
| 0.5 | 0.844 | 0.114 | 0.042 |
| 1.0 | 0.629 | 0.231 | 0.140 |
| 2.0 | 0.481 | 0.292 | 0.227 |
temperature → 0 时等价于 greedy argmax#
当 temperature 趋近 0,logits 被除以一个极小的数,最大 logit 对应的项在 softmax 后概率趋近 1.0,其他项趋近 0。这等价于:
next_token = argmax(logits)这也是 normalize 代码里那段处理的原因:当 temperature < _SAMPLING_EPS 时,直接把 top_k 设为 1,避免数值不稳定的极小除法,同时语义完全等价。
top-k 过滤的算法#
top-k 的作用是把候选 token 的范围限制在 logits 最高的 k 个里,阻止低分 token 被采样到。
算法步骤#
def top_k_filter(logits, k):
if k <= 0 or k >= len(logits):
return logits # 不过滤
# 找到第 k 大的值作为阈值
threshold = sorted(logits, reverse=True)[k - 1]
# 把低于阈值的 logit 设为 -inf
filtered = [x if x >= threshold else float('-inf') for x in logits]
return filtered把不在 top-k 的 logit 设为 -inf 而不是 0,是因为 softmax 对 -inf 的处理结果是 exp(-inf) = 0,等价于把这些 token 从候选集里彻底移除,而直接设为 0 会保留一个错误的非零概率。
数值示例#
延续前面的例子,logits = [2.0, 1.0, 0.5],k=2:
sorted descending: [2.0, 1.0, 0.5]
threshold = logits[k-1] = 1.0
filtered = [2.0, 1.0, -inf]做 softmax:
exp([2.0, 1.0, -inf]) = [7.389, 2.718, 0]
sum = 10.107
probs = [0.731, 0.269, 0.000]token 2 被彻底排除。
top_k=1 时始终是 greedy#
当 k=1 时,top-k 过滤只保留 logit 最大的那个 token,其他全设为 -inf。softmax 之后那个 token 概率为 1.0,采样结果必然确定。这是比 temperature 更直接的"强制 greedy"机制,因为它不依赖数值极限,而是直接把选择范围收窄到 1 个。
top-k 在流水线中的位置#
top-k 和 temperature 的应用顺序在不同实现里有差异,但 SGLang 采用的标准顺序是:先做 temperature 缩放,再做 top-k 过滤。
原因在于 temperature 不改变 token 的排名(只线性缩放 logits,相对大小不变),所以先做 temperature 或先做 top-k 最终保留的候选集是完全相同的。但如果有 penalty(frequency_penalty、presence_penalty)参与,penalty 是直接加减 logit 数值、可能改变排名的,此时顺序就重要了。标准顺序把 penalty 放在 temperature 之前,保证了 top-k 在最终排名上过滤。
top-p(nucleus sampling)的算法#
top-k 是固定候选集大小,top-p 是根据累积概率动态确定候选集大小,因此更能适应模型在不同位置的信心程度。
算法步骤#
def top_p_filter(logits, p):
# 1. 先做 softmax 得到概率
probs = softmax(logits)
# 2. 按概率降序排列(保留原始下标)
sorted_indices = sorted(range(len(probs)), key=lambda i: probs[i], reverse=True)
sorted_probs = [probs[i] for i in sorted_indices]
# 3. 计算累积概率
cumsum = 0.0
cutoff_idx = 0
for idx, prob in enumerate(sorted_probs):
cumsum += prob
cutoff_idx = idx
if cumsum >= p:
break
# 4. 保留排名 <= cutoff_idx 的 token,其余设为 -inf
kept = set(sorted_indices[:cutoff_idx + 1])
filtered = [logits[i] if i in kept else float('-inf') for i in range(len(logits))]
return filtered数值示例#
假设词表 4 个 token,logits 已经过 temperature 缩放,softmax 后概率为:
probs = [0.4, 0.3, 0.2, 0.1]设 top_p = 0.7:
按概率降序排列:[(token0, 0.4), (token1, 0.3), (token2, 0.2), (token3, 0.1)]
累积过程:
加 token0: cumsum = 0.4 < 0.7,继续
加 token1: cumsum = 0.7 >= 0.7,停止,cutoff_idx = 1
保留 token0、token1,token2 和 token3 设为 -inf如果概率分布更集中,比如 probs = [0.8, 0.1, 0.06, 0.04],同样 top_p = 0.7:
加 token0: cumsum = 0.8 >= 0.7,停止,cutoff_idx = 0
保留 token0,其余全部排除分布越集中,nucleus 越小;分布越平坦,nucleus 越大——这就是 top-p 被称为 nucleus sampling 的原因:它自动捕捉分布的"核心质量",而不是固定一个数字。
top-k 和 top-p 不等价#
top-k 的候选集大小固定;top-p 的候选集大小随分布变化。一个极端情形:
- probs = [0.99, 0.005, 0.003, 0.002],top_k=3 保留 3 个候选;top_p=0.9 只保留 1 个(因为 token0 已经超过 0.9)。
组合应用顺序#
实际使用中 temperature、top-k、top-p 往往同时设置,执行顺序决定了最终结果。
标准流水线#
原始 logits
│
▼
[penalty 调整] frequency_penalty / presence_penalty 直接加减 logit 值
│
▼
[temperature 缩放] logits = logits / temperature
│
▼
[top-k 过滤] 保留前 k 个,其余设 -inf
│
▼
[top-p 过滤] 累积概率超过 p 后截断
│
▼
[softmax] 将 logits 转换为概率分布
│
▼
[多项式采样] 按概率分布随机抽取 token用伪代码写出来:
def select_token(logits, params):
# 1. penalty
logits = apply_penalties(logits, params.frequency_penalty,
params.presence_penalty)
# 2. temperature
logits = logits / params.temperature # temperature 已由 normalize 确保 > 0
# 3. top-k
if params.top_k > 0:
logits = top_k_filter(logits, params.top_k)
# 4. top-p
if 0.0 < params.top_p < 1.0:
logits = top_p_filter(logits, params.top_p)
# 5. softmax + sample
probs = softmax(logits)
return multinomial_sample(probs)同时设置 top-k 和 top-p#
两个过滤器串联时,取的是更严格的交集:top-k 先把词表截断到 k 个候选,top-p 再在这 k 个里按累积概率进一步收窄。结果是候选集 <= min(k, nucleus_size)。
常见默认配置是 top_k=-1(不限制)、top_p=1.0(不限制),这时只有 temperature 起作用。
greedy sampling 的两种等价形式#
| 方式 | 设置 | 机制 |
|---|---|---|
| 极低温度 | temperature → 0 | normalize 将其转换为 top_k=1 |
| 直接限制 | top_k=1 | 过滤后只剩 1 个候选,采样退化为确定性选择 |
两种方式在 normalize 之后语义完全相同。
调试 token selection 时先看哪里#
如果你看到的现象是:
- 采样结果和参数预期明显不符;
- stop / schema / regex 看起来没生效;
- 同样的 prompt 在不同参数下行为差异不清楚;
- 采样输出完全确定性,但你期望有随机性;
- 不同 seed 给出相同结果,温度设置明明不是 0。
更稳的顺序通常是:
- 先看
SamplingParams最终被 normalize 成了什么,特别确认top_k是否被隐式设为了 1(temperature 过低会触发这个路径); - 确认
top_k是否显式设置为 1——这会让任何非零 temperature 都失效,采样结果必然确定; - 再确认 stop / schema / regex 是否已经进入这组参数;
- 最后才回头追 token selection 下游的具体执行逻辑。
这样做的好处是,你能先确认"约束是不是已经进执行层",再追"执行层怎样实现这些约束"。
小结#
这一节真正要建立的是一个判断:
SamplingParams承载的不只是采样超参;- 它也是执行层的约束输入;
- token selection 从来都不是只看 logits 本身。
温度缩放、top-k、top-p 这三个参数各自的算法很简单,但它们的组合顺序和边界情形(temperature→0 等价于 top_k=1、top-p 的 nucleus 大小随分布变化)才是真正影响生成质量的细节。理解了这些机制,后面再读结构化生成时,就不会把 schema、tool constraint 和采样逻辑分裂成几条互不相干的线。
叶王 © 2013-2026 版权所有。如果本文档对你有所帮助,可以请作者喝饮料。