Appearance
第9章 采样与输出处理
"The beauty of randomness lies not in chaos, but in controlled unpredictability."
本章要点
- 理解 Logits 到 Token 的完整处理管线:Logit 处理器 → 采样 → 停止条件检查
- 掌握温度、top-p、top-k 的数学定义及其对生成质量的影响
- 深入 SamplingParams 的设计:如何用一个对象描述所有采样需求
- 理解批量采样的 GPU 优化:为什么不能简单地逐请求采样
- 认识重复惩罚、频率惩罚等 Logit 处理器的实现
9.1 从 Logits 到 Token
模型前向传播的输出是一个 Logits 张量,形状为 [batch_size, vocab_size]。每一行是一个请求对应的 Logits——词表中每个位置的"得分"。
从 Logits 到最终输出的 Token,要经过三个阶段:
源码版本:本章基于 vLLM v0.8.5,核心文件
vllm/v1/sample/sampler.py。
让我们看 V1 采样器 Sampler.forward(sampler.py:23)的真实处理流水线:
python
# vllm/v1/sample/sampler.py:23-72 (简化)
class Sampler(nn.Module):
def forward(self, logits, sampling_metadata):
# 0. 保存原始 logits 用于 logprobs(在任何修改之前)
if num_logprobs is not None:
raw_logprobs = self.compute_logprobs(logits)
# 1. 转为 float32 确保精度
logits = logits.to(torch.float32)
# 2. 应用 allowed_token_ids(白名单过滤)
logits = self.apply_allowed_token_ids(logits, sampling_metadata)
# 3. 应用 bad_words 排除
logits = self.apply_bad_words(logits, sampling_metadata)
# 4. 应用 logits_bias(偏置调整)
logits = self.apply_logits_bias(logits, sampling_metadata)
# 5. 应用惩罚(min_tokens, freq/presence penalty)
logits = self.apply_penalties(logits, sampling_metadata)
# 6. 执行采样(贪心 or 随机)
sampled = self.sample(logits, sampling_metadata)
return SamplerOutput(sampled_token_ids=sampled.unsqueeze(-1), ...)注意一个精妙的设计:logprobs 是在任何修改之前从原始 logits 计算的(sampler.py:30-36),而采样使用的是经过所有处理器修改后的 logits。这意味着 API 返回的 logprobs 反映的是模型原始的置信度,而非经过温度/惩罚调整后的分布。
Logit 处理器
在采样之前,Logits 会被一系列处理器修改:
- 温度缩放:
logits = logits / temperature。温度 < 1 让分布更"尖锐"(更确定),> 1 让分布更"平坦"(更随机) - 重复惩罚:降低已生成 Token 的 Logits,抑制重复
- 频率惩罚:根据 Token 出现频率施加惩罚
- 存在惩罚:只要 Token 出现过就惩罚,不管频率
- Logit 偏置:直接给某些 Token 的 Logits 加上偏置值
这些处理器按顺序应用,每个都可以独立启用或禁用。
采样策略
处理后的 Logits 被转换为概率分布(通过 Softmax),然后根据策略选择 Token:
贪心采样(Greedy):直接选概率最高的 Token。确定性输出,适合需要精确答案的场景(如代码生成、数学题)。
随机采样:从概率分布中随机抽取。温度控制随机程度。
Top-p(Nucleus Sampling):只在概率累积和达到 p 的最小 Token 集合中采样。例如 top_p=0.9 意味着只考虑概率和为 90% 的那些 Token,忽略长尾。
Top-k:只在概率最高的 k 个 Token 中采样。例如 top_k=50 只考虑前 50 个最可能的 Token。
Min-p:只保留概率 ≥ min_p × max_prob 的 Token。自适应地根据最大概率调整候选集大小。
Top-p 和 Top-k 可以同时使用——先 Top-k 缩小范围,再 Top-p 进一步过滤。
9.2 SamplingParams:一个对象描述一切
vLLM 用 SamplingParams(vllm/sampling_params.py)封装所有采样相关的参数。这个类的设计值得学习——它在灵活性和易用性之间找到了很好的平衡:
python
# 常见用法
params = SamplingParams(
temperature=0.8,
top_p=0.95,
max_tokens=256,
stop=["</s>", "\n\n"],
)SamplingParams 的字段覆盖了 OpenAI API 的所有采样选项,确保 vLLM 的 OpenAI 兼容层不需要做任何参数转换。
一个关键的设计决策是:每个请求可以有不同的 SamplingParams。这意味着同一个批次中,请求 A 可以用 temperature=0 做贪心解码,请求 B 可以用 temperature=1.0, top_p=0.9 做创意写作。调度器不需要关心采样参数——它只管调度,采样是 ModelRunner 在 GPU 上做的事。
9.3 批量采样的 GPU 实现
朴素的实现是逐请求采样:遍历批次中每个请求,根据其 SamplingParams 执行对应的采样逻辑。但这在 GPU 上效率极低——GPU 的优势在于并行处理相同的操作。
vLLM 的做法是将采样操作向量化:
- 将所有请求的温度组成一个向量
temperatures = [0.8, 1.0, 0.0, 0.7, ...] - 批量执行温度缩放:
logits = logits / temperatures.unsqueeze(1) - 批量执行 Softmax
- 分组处理 Top-p/Top-k(参数相同的请求分在一组)
- 批量随机采样:
torch.multinomial
这种向量化方式将 N 个请求的采样合并为几次 GPU 内核调用,而不是 N 次。
对于贪心采样(temperature=0),甚至更简单:torch.argmax(logits, dim=-1) 一行代码处理整个批次。
sample 方法的真实实现
V1 采样器的 sample 方法(sampler.py:85-131)展示了如何在一个批次中同时处理贪心和随机请求:
python
# vllm/v1/sample/sampler.py:85-131
def sample(self, logits, sampling_metadata):
assert not (sampling_metadata.all_greedy
and sampling_metadata.all_random)
# 快速路径:如果全部是随机采样
if sampling_metadata.all_random:
greedy_sampled = None
else:
# 先对所有请求做贪心采样
greedy_sampled = self.greedy_sample(logits) # argmax
if sampling_metadata.all_greedy:
return greedy_sampled # 快速路径:全部贪心
# 对随机请求执行温度→min_p→top_k/top_p→multinomial
logits = self.apply_temperature(logits, sampling_metadata.temperature)
if sampling_metadata.min_p is not None:
logits = self.apply_min_p(logits, sampling_metadata.min_p)
random_sampled = self.topk_topp_sampler(
logits, sampling_metadata.generators,
sampling_metadata.top_k, sampling_metadata.top_p,
)
# 关键:用 torch.where 合并贪心和随机结果
# temperature < eps 的请求用贪心结果,其余用随机结果
if greedy_sampled is None:
return random_sampled
sampled = torch.where(
sampling_metadata.temperature < _SAMPLING_EPS,
greedy_sampled, # temp≈0 → 贪心
random_sampled, # temp>0 → 随机
out=greedy_sampled, # 原地操作,复用张量
)
return sampled这段代码的精妙之处:
- 两个快速路径——
all_greedy和all_random跳过不必要的分支 torch.where合并——一次 GPU 操作完成贪心/随机的混合选择,无需 Python 循环out=greedy_sampled原地操作——复用已有张量,避免内存分配- 温度判断而非参数类型判断——
temperature < eps作为贪心条件,比检查参数类型更统一
贪心采样的实现
贪心采样在 GPU 上极其高效(sampler.py:82-83):
python
def greedy_sample(self, logits: torch.Tensor) -> torch.Tensor:
return logits.argmax(dim=-1).view(-1)一行代码——argmax 在 GPU 上是高度优化的原子操作,处理 1000 个请求和处理 1 个请求的延迟几乎相同。
9.4 Min-p:自适应的候选过滤
Min-p 是一种相对较新的采样策略,正在被越来越多的推理框架采用。它的思想很优雅:
传统的 Top-k 用一个固定的数字(如 50)限制候选集大小,不管最高概率是 0.9 还是 0.01。Top-p 用一个固定的概率阈值(如 0.9)限制,但当概率分布很平坦时,候选集可能非常大。
Min-p 的规则是:只保留概率 ≥ min_p × max_prob 的 Token。
假设 min_p = 0.1:
- 如果最高概率 Token 的概率是 0.8(模型很"确定"),则只保留概率 ≥ 0.08 的 Token——候选集很小
- 如果最高概率 Token 的概率是 0.02(模型很"犹豫"),则只保留概率 ≥ 0.002 的 Token——候选集较大
这种自适应特性让 Min-p 在不同确定度的位置自动调整多样性——确定的地方保持确定,不确定的地方允许探索。
在 vLLM 中,Min-p 作为一个 Logit 处理器实现,在 Top-k/Top-p 之后额外应用。
9.5 结构化输出
近年来,LLM 应用越来越多地需要模型输出结构化数据——JSON、XML 或符合特定 Schema 的格式。vLLM 通过 Logit 偏置(Logit Bias) 和 Guided Decoding 支持结构化输出。
Guided Decoding 的原理是:在每步采样时,根据当前的解析状态(已生成的部分 JSON),只允许合法的 Token 被采样。例如,在 JSON 对象的键名后,只允许 : 被采样;在数组中,只允许值或 ] 被采样。
这本质上是在采样前增加了一个语法约束掩码——将所有不合法 Token 的 Logits 设为负无穷。
9.6 停止条件
生成 Token 后,需要检查是否满足停止条件:
- EOS Token——模型输出了特殊的结束标记
- 最大长度——达到了
max_tokens限制 - 停止词——生成的文本包含了用户指定的停止字符串(如
\n\n、</s>) - 停止 Token ID——生成了用户指定的特殊 Token ID
停止词的检查比看起来复杂——因为一个停止词可能跨越多个 Token。例如停止词 "\n\n",在某些分词器中是两个 Token "\n" + "\n"。第一个 "\n" 生成时还不能判断是否匹配,需要等第二个 Token。
vLLM 通过维护一个部分匹配状态来处理这种情况——类似 KMP 字符串匹配算法的失败函数。
9.7 本章小结
采样是 LLM 推理的最后一步,也是用户感知最直接的一步:
- 三阶段管线——Logit 处理器(惩罚、偏置)→ 采样(温度、top-p/k)→ 停止条件检查
- SamplingParams——一个对象封装所有采样参数,每请求独立配置
- 批量向量化——将采样操作向量化到 GPU 上,避免逐请求的串行处理
- 停止条件——支持 EOS、最大长度、停止词、停止 Token ID,处理跨 Token 的停止词匹配
至此,我们走完了一个 Token 从"请求到达"到"生成输出"的完整旅程。接下来的四章,我们将聚焦于让这个旅程更快的四把利器:前缀缓存、分块预填充、投机解码和量化。
源码导航
- SamplingParams:
vllm/sampling_params.py- Logit 处理器:
vllm/logits_process.py- V1 采样模块:
vllm/v1/sample/- 模型层 Logits 处理:
vllm/model_executor/layers/logits_processor.py