SamplingParams 与 token selection#

有了 ForwardBatchModelRunner,执行层已经能把一轮前向跑出来。但"模型已经前向"还不等于"下一个 token 已经选出来"。这一节处理的就是这一层:采样参数怎样进入执行链,又怎样真正影响 token selection。

这一节解决什么问题#

这一节主要回答三件事:

  1. SamplingParams 到底承载了哪些语义;
  2. 为什么采样参数不只是用户接口上的便利字段;
  3. token selection 为什么必须和 stop、schema、tool constraint 等控制一起理解。

SamplingParams 不是参数袋,而是运行时约束集合#

SamplingParams 的字段范围已经说明,它承载的不只是传统采样超参:

  • temperature
  • top_p
  • top_k
  • frequency_penalty
  • presence_penalty
  • max_new_tokens
  • json_schema
  • regex
  • ebnf
  • sampling_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_schema
  • regex
  • ebnf

看成另外一层功能。但在执行层视角,它们和 temperaturetop_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 的机会增大了。

三种情形对比:

temperaturetoken 0token 1token 2
0.50.8440.1140.042
1.00.6290.2310.140
2.00.4810.2920.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 → 0normalize 将其转换为 top_k=1
直接限制top_k=1过滤后只剩 1 个候选,采样退化为确定性选择

两种方式在 normalize 之后语义完全相同。

调试 token selection 时先看哪里#

如果你看到的现象是:

  • 采样结果和参数预期明显不符;
  • stop / schema / regex 看起来没生效;
  • 同样的 prompt 在不同参数下行为差异不清楚;
  • 采样输出完全确定性,但你期望有随机性;
  • 不同 seed 给出相同结果,温度设置明明不是 0。

更稳的顺序通常是:

  1. 先看 SamplingParams 最终被 normalize 成了什么,特别确认 top_k 是否被隐式设为了 1(temperature 过低会触发这个路径);
  2. 确认 top_k 是否显式设置为 1——这会让任何非零 temperature 都失效,采样结果必然确定;
  3. 再确认 stop / schema / regex 是否已经进入这组参数;
  4. 最后才回头追 token selection 下游的具体执行逻辑。

这样做的好处是,你能先确认"约束是不是已经进执行层",再追"执行层怎样实现这些约束"。

小结#

这一节真正要建立的是一个判断:

  • SamplingParams 承载的不只是采样超参;
  • 它也是执行层的约束输入;
  • token selection 从来都不是只看 logits 本身。

温度缩放、top-k、top-p 这三个参数各自的算法很简单,但它们的组合顺序和边界情形(temperature→0 等价于 top_k=1、top-p 的 nucleus 大小随分布变化)才是真正影响生成质量的细节。理解了这些机制,后面再读结构化生成时,就不会把 schema、tool constraint 和采样逻辑分裂成几条互不相干的线。