vLLM 推理内核深度解析
第9章 采样与输出处理:从 Logits 到 Token
采样是 LLM 推理的最后一公里——一个 150K 维的概率分布,压缩成一个整数 token id。这最后一步的工程质量,决定了模型"看起来有多聪明"。
本章要点
- 理解模型前向输出的 Logits 如何经过 logit 处理器 → 采样 → 停止检查三阶段变成最终 token
- 读懂
Sampler.forward的 6 步流水线:dtype 转换、allowed_token_ids 白名单、bad_words 排除、logits_bias 偏置、penalty 惩罚、sample - 掌握 temperature / top-k / top-p / min-p 四种策略的数学定义与使用场景
- 看懂
torch.where如何在一个 batch 里无缝混合贪心和随机采样 - 理解 V1 的
logprobs为什么从修改前的 logits 计算(V0 恰好相反)——这是一个容易被忽视但很关键的设计 - 掌握 per-request
torch.Generator如何让seed的随机数与 batch 内其他请求无关,以及可复现性的边界 - 学会结构化输出(JSON Schema / XGrammar)的约束采样原理
- 看懂 stop 词跨 token 匹配如何靠尾部缓冲和
StopChecker处理 - 理解 stop 字符串检测为什么会"晚一拍"(overshoot):前端 detokenize 与后端 EngineCore 分离的代价
- 拿到六个典型场景的采样参数 preset:代码生成 / 创意写作 / JSON 结构化输出 / RAG 问答 / 多轮对话 / 翻译
9.1 从 Logits 到 Token:一条三阶段流水线
9.1.1 150,000 维的选择题
每一次模型前向(forward pass),最终输出的不是 token,而是一个巨大的张量——Logits,形状为 [batch_size, vocab_size]。对于 Llama-3,vocab_size=128,256。对于 Qwen-2.5,vocab_size 按尺寸不同是 151,936(0.5B 等)或 152,064(7B、72B 等)。每一行代表一个请求在词表上的"得分"分布(未归一化)。
想象一下:模型把整个词表打分,每个 token 得到一个实数值。现在要从这 15 万个候选中选一个。怎么选?
- 选得分最高的那个?——会得到 deterministic 但可能重复、呆板的输出
- 按得分比例随机选?——保留多样性但可能选到离谱的 token
- 只在前 K 名里选?先设个质量门槛,再随机选?
每种策略背后都对应一个数学原理和一组用户场景。这就是采样层要解决的问题。
9.1.2 三阶段流水线
从 Logits 到最终采样出的 Token,要走完一条严格的流水线:
flowchart LR
L["Logits<br/>[batch, vocab]"] --> LP["Logit 处理器层<br/>· allowed_token_ids<br/>· bad_words<br/>· logits_bias<br/>· penalties"]
LP --> S["采样层<br/>· temperature<br/>· min_p / top_k / top_p<br/>· 随机采样 / argmax"]
S --> T["Token ID"]
T --> SC["停止检测<br/>· EOS<br/>· max_tokens<br/>· stop strings<br/>· stop token ids"]
L -.->|"分支:原始 logprobs<br/>(用于 API 返回)"| LG["compute_logprobs"]
style L fill:#7c3aed,color:#fff,stroke:none
style LP fill:#f59e0b,color:#333,stroke:none
style S fill:#2563eb,color:#fff,stroke:none
style T fill:#10b981,color:#333,stroke:none
style SC fill:#db2777,color:#fff,stroke:none
这张图是整章的骨架——前两个方框在 V1 源码里对应 vllm/v1/sample/(sampler.py 与 ops/),停止检测则在 vllm/v1/core/sched/utils.py(token 级)和 vllm/v1/engine/detokenizer.py(字符串级),见 §9.7.4。
三阶段的职责分工:
| 阶段 | 输入 | 输出 | 关键操作 |
|---|---|---|---|
| Logit 处理器 | logits | 修改后的 logits | 掩码、偏置、惩罚 |
| 采样 | 修改后的 logits | token id | 温度、截断、随机/贪心 |
| 停止检测 | token id + 历史 | 继续 / FINISH | EOS、长度、字符串匹配 |
9.1.3 Sampler.forward 的 6 步
# vllm/v1/sample/sampler.py:17(概念性简化)
class Sampler(nn.Module):
def forward(self, logits: torch.Tensor, sampling_metadata) -> SamplerOutput:
# 0. 先保存"原始 logprobs"(在任何修改前)
# 注意条件是 is not None 而不是 > 0——max_num_logprobs=0 也要返回
# 被采样 token 自己的 logprob(见 §9.5.5)
num_logprobs = sampling_metadata.max_num_logprobs
if num_logprobs is not None:
raw_logprobs = self.compute_logprobs(logits)
# 1. 转 float32 —— 后面的 penalty 运算精度敏感
logits = logits.to(torch.float32)
# 2. 白名单过滤
logits = self.apply_allowed_token_ids(logits, sampling_metadata)
# 3. 禁词排除
logits = self.apply_bad_words(logits, sampling_metadata)
# 4. logit_bias 偏置
logits = self.apply_logits_bias(logits, sampling_metadata)
# 5. 惩罚(min_tokens / repetition / frequency / presence)
logits = self.apply_penalties(logits, sampling_metadata)
# 6. 采样(贪心 or 随机)
sampled = self.sample(logits, sampling_metadata)
return SamplerOutput(
sampled_token_ids=sampled.unsqueeze(-1),
logprobs_tensors=make_logprobs(raw_logprobs, sampled, ...),
)
这 6 步的源码锚点在 vllm/v1/sample/sampler.py:23-72。几个细节比"采样就是 softmax 后抽一个 token"更重要:raw_logprobs 在任何惩罚和温度缩放前计算;logits 先转成 float32;allowed_token_ids、bad_words、logit_bias、min_tokens 和三类 penalty 都在采样前改 logits;FlashInfer 采样可能返回 int32,所以最终会先转 long 兼容后续索引,再转回 int32 减少输出张量体积。不要给这段逻辑写固定毫秒数,采样开销会随 vocab size、batch、是否请求 logprobs、是否启用 top-p 排序、是否有 seed、是否走 FlashInfer 变化。
9.1.4 一个关键设计:logprobs 来自原始 logits
注意步骤 0——在任何修改之前就先算了 raw_logprobs。这是一个容易被忽视但非常重要的设计决策。
如果用户请求返回 logprobs(OpenAI API 的 logprobs=true),他们期望看到的是模型自己的置信度,而不是经过温度缩放、惩罚调整后的"假"概率。如果在 apply_penalties 后再算 logprobs,返回的数值就会被 temperature、penalty 污染,对下游应用(比如用 logprobs 做 confidence scoring 的系统)是误导。
V1 的选择:logprobs 反映模型原始的数值,采样用的是调整后的分布。两条路径独立,接口契约清晰。
这是 V1 相对 V0 的一处行为变化,源码注释里写得很直白(vllm/v1/sample/sampler.py:28-33 的 NOTE(woosuk)):V0 的 Sampler(vllm/model_executor/layers/sampler.py)是在 penalty、温度缩放、top-k/top-p/min-p 之后才算 log_softmax,返回的是采样所用分布的 logprobs;V1 改为原始 logits,并留了 TODO(rob) 打算将来提供"采样后 logprobs"的选项。还有一个边界:结构化输出的 grammar bitmask 在 model runner 里、调用 Sampler 之前就已经施加到 logits 上(vllm/v1/worker/gpu_model_runner.py 的 apply_grammar_bitmask()),所以对启用了结构化输出的请求,V1 的"原始" logprobs 里非法 token 已是 -inf。
9.1.5 SamplingParams 先把用户输入规范化
采样层不是直接拿 OpenAI JSON 字段开干。用户参数会先进入 vllm/sampling_params.py,在那里被规范化和校验。这个文件当前 598 行,是采样 API 的契约层。
| 输入情况 | 源码处理 | 为什么重要 |
|---|---|---|
temperature=None |
from_optional() 改成 1.0 |
API 层可以传空,engine 层看到稳定默认值 |
seed=-1 |
__post_init__() 改成 None |
兼容"禁用 seed"的调用习惯 |
stop 是字符串 |
统一包装成 list | 后续 stop 检查只处理列表 |
stop_token_ids=None |
统一变成空列表 | 避免热路径反复判空 |
bad_words=None |
统一变成空列表 | 后续 tokenizer 转换逻辑更简单 |
logprobs=True |
转成 1 | 兼容布尔式 OpenAI 参数 |
prompt_logprobs=True |
转成 1 | 同上 |
logit_bias token key 是字符串 |
from_optional() 里 int(token),bias clamp 到 [-100, 100] |
兼容 JSON 对象 key 只能是字符串的场景 |
0 < temperature < 0.01 |
__post_init__() 抬到 0.01 并打 warning |
过小的温度会让除法产生 nan/inf |
temperature < _SAMPLING_EPS(1e-5) |
top_p=1.0、top_k=-1、min_p=0.0,且要求 n == 1 |
贪心采样下截断策略无意义 |
校验同样在这一层完成:presence/frequency penalty 必须在 [-2, 2],repetition_penalty 必须大于 0,temperature 不能为负,top_p 必须在 (0, 1],top_k 只能是 -1 或至少 1 的整数,min_p 必须在 [0, 1],min_tokens 不能超过 max_tokens。这让 GPU sampler 可以假设输入已经合法,不在热路径里做业务级参数解释。V1 还在 vllm/v1/engine/processor.py 里多挡了几项:best_of > 1 直接报错(SamplingParams 文档写明 best_of 只在 V0 支持),逐请求的 logits_processors 也不支持;allowed_token_ids 不能为空列表,allowed_token_ids 与 logit_bias 里的 token id 都必须落在词表内。
9.2 四种核心采样策略
9.2.1 Temperature:分布的"尖锐度"调节
温度缩放是 LLM 采样里最基础的旋钮:
- :分布变尖锐。高概率 token 的相对优势被放大,生成更确定但可能呆板
- :分布不变(softmax 标准形态)
- :分布变平。低概率 token 的相对机会增加,生成更多样但可能胡言乱语
- :退化为 argmax(贪心采样)
生产中的典型值:
| 场景 | temperature | 理由 |
|---|---|---|
| 代码生成 | 0.2-0.4 | 要求准确,允许很小的多样性 |
| 数学推理 | 0.0-0.3 | 答案通常唯一,高 T 会引入错误 |
| 问答系统 | 0.5-0.7 | 平衡准确与表达多样 |
| 创意写作 | 0.8-1.2 | 鼓励新颖表达 |
| 对话 / 角色扮演 | 0.7-1.0 | 对话需要自然的不确定性 |
| 摘要提取 | 0.1-0.3 | 忠实原文,低创造 |
| 翻译 | 0.2-0.5 | 语义准确 > 创意表达 |
注意:temperature=0(严格说是 < 1e-5)在 vLLM 里取的是 argmax 结果;整个 batch 都是贪心时直接返回 argmax,不走 exp/softmax。详见 9.3.3。
9.2.2 Top-k:硬截断候选集
从 Logits 里只保留得分最高的 k 个 token,其余置为负无穷:
top_k = 50 → 只在 top 50 个候选中采样
top_k = 1 → 等价于 greedy
top_k = -1 → 不启用(使用全词表)
Top-k 的好处是"防止低概率 token 的长尾噪音"——大模型词表 150K+,即使每个 token 的概率很小,长尾累计起来的概率质量也不可忽略;逐 token 采样几百步,总有机会采到一个离谱的 token,把后文带偏。
Top-k 的缺点是不自适应——固定 k=50 在"模型非常确定"的位置(top-1 概率 0.95)显得多余,在"模型不确定"的位置(top-1 概率 0.01)又可能砍掉太多好选项。
9.2.3 Top-p (Nucleus Sampling):动态的累计阈值
不固定候选数量,而是按累计概率阈值切:排序后从大到小累计,累计概率到 p 就停。
top_p = 0.9 → 保留累计概率达到 90% 的最小候选集
top_p = 0.5 → 只保留最有可能的那一半概率质量
top_p = 1.0 → 不启用(保留全词表)
Top-p 自适应:
- 模型确定时(top-1 概率 0.9):候选集可能只有 1-2 个 token
- 模型不确定时(top-1 概率 0.05):候选集可能有几百个 token
相比 Top-k 更"聪明"。生产实践里 top-p=0.9 或 0.95 是安全选择。
9.2.4 Min-p:更激进的自适应阈值
Min-p 是 2024 年提出的相对较新策略(Nguyen et al., arXiv:2407.01082):
其中 。
- 模型确定时(, min_p=0.1)→ 只保留 的 token(其余 token 概率总和只有 0.1,至多再有 1 个过线,候选最多 2 个)
- 模型不确定时(, min_p=0.1)→ 保留 的 token(候选可达上百个,上限 )
相比 top-p,min-p 在"尾部平坦"场景下更严格(top-p 会让累计 90% 包含一大片差不多概率的 token,min-p 按相对最大值卡,自动排除"相对较差"的那些)。
生产实战里 min-p=0.05 或 0.1 常和较高 temperature 搭配使用,目的是在保留表达多样性的同时过滤相对最大概率太低的尾部 token。vLLM 的 SamplingParams 里 min_p 默认是 0.0,取值范围在 _verify_args() 中被限制为 [0, 1];当 temperature 进入贪心路径时,__post_init__() 会把 min_p 重置为 0.0,因为贪心采样下相对概率阈值没有意义。
9.2.5 四种策略总对比
| 策略 | 候选集大小 | 自适应 | 开销 | 最佳搭档 |
|---|---|---|---|---|
| Greedy (T=0) | 1 | 否 | 最低 | max_tokens |
| Temperature | 全词表 | 否 | 中 | top-p / top-k |
| Top-k | 固定 k | 否 | 中(需排序) | temperature |
| Top-p | 动态 | 是 | 中(需排序) | temperature |
| Min-p | 动态 | 是 | 中 | temperature=0.7+ |
9.2.6 组合使用
top-k、top-p、min-p 可以串联使用。V1 的顺序是:temperature → min-p → (top-k + top-p)。V0 的 Sampler 顺序不同:temperature → top-k/top-p → min-p(见 vllm/model_executor/layers/sampler.py 中 Sampler.forward() 的温度缩放之后那几行)。
# V1:Sampler.sample() 里(示意)
logits = apply_temperature(logits, temperature)
logits = apply_min_p(logits, min_p)
sampled = topk_topp_sampler(logits, generators, top_k, top_p) # top-k 和 top-p 在同一个模块里处理并直接采样
为什么 top-k 和 top-p 合在同一个函数里?看 vllm/v1/sample/ops/topk_topp_sampler.py:170-210:如果只启用 top-k,会走 apply_top_k_only(),避免对整个词表排序;如果启用 top-p,函数需要先按 logits 排序、算 softmax 和累计概率,再把 mask scatter 回原词表顺序。两者放在一起不是为了抽象好看,而是为了按参数组合选择代价更低的路径。
9.3 批量采样的 GPU 向量化
9.3.1 朴素实现的问题
最直观的写法是"for 每个请求,按它的参数采样":
for i, req in enumerate(batch):
p = softmax(logits[i] / req.temperature)
p = apply_top_k(p, req.top_k)
p = apply_top_p(p, req.top_p)
sampled[i] = torch.multinomial(p, 1)
看起来清楚,但 GPU 上的致命问题是:每个请求单独处理会破坏 batch,带来更多 Python 调度、张量切片和小 kernel 调用。采样层的目标不是把公式写得最直观,而是把同一个 batch 里的不同采样参数打包成张量,让 GPU 一次处理尽可能多的行。
9.3.2 向量化思路
vLLM 的做法:把 per-request 参数打包成向量,一次 kernel 处理全 batch:
# temperatures: [B](每请求一个 scalar)
# min_p_values: [B]
# top_k_values: [B]
# top_p_values: [B]
logits = logits / temperatures.unsqueeze(1) # broadcasting
logits = apply_min_p(logits, min_p_values) # 按 batch broadcast
logits = apply_top_k_top_p(logits, top_k_values, top_p_values)
probs = logits.softmax(dim=-1, dtype=torch.float32)
sampled = random_sample(probs, generators) # 一次完成全 batch(不是 torch.multinomial,见 §9.5.3)
V1 源码里的实现不是所有步骤都写成自定义 CUDA kernel。apply_min_p() 直接用 PyTorch softmax、amax、broadcast 和 bool mask;apply_top_k_top_p() 用 PyTorch sort/topk/cumsum/masked_fill;最终随机采样由 TopKTopPSampler 选择普通 PyTorch 路径或 FlashInfer 路径。重点是这些操作都以 batch 张量为单位执行,而不是在 Python 里逐请求循环。
走哪条路径在 TopKTopPSampler.__init__()(vllm/v1/sample/ops/topk_topp_sampler.py:29-77)里一次定死:CUDA 平台上装了 FlashInfer、版本低于 0.2.3、且 VLLM_USE_FLASHINFER_SAMPLER 没有显式设成 0 时走 forward_cuda()——batch 里有 top-k/top-p 就调 FlashInfer 的拒绝采样 kernel(免排序,但源码注释说它含一次 CPU-GPU 同步),没有则仍用 random_sample();FlashInfer ≥ 0.2.3 因采样 API 不再返回 success 而被强制回退到 PyTorch 原生的 forward_native();没装 FlashInfer 或其他非 TPU 平台同样走 forward_native(),TPU 走 forward_tpu()。同一个环境变量在 V0 里语义相反:未设置(None)时 V0 不用 FlashInfer 采样,只有设为 1 才启用。
9.3.3 混合 batch:贪心 + 随机的并存
一个 batch 里有些请求 temperature=0(贪心),另一些 temperature=0.8(随机)。怎么办?
朴素思路 1:分组处理——按温度把 batch 分两组,各自跑。这引入了分组的 CPU 开销,还可能导致内存重排。(V0 的 Sampler 正是这么做的:_sample_with_torch() 按 SamplingType 把请求分成 GREEDY / RANDOM / RANDOM_SEED 几组,逐组用索引取出 logprobs[...]、probs[...] 采样。)
朴素思路 2:统一当随机采样处理——对 temperature=0 的请求,把温度改成 1e-10,让它"几乎" argmax。但这会触发真的 multinomial 采样,数值不稳定。
V1 的精妙解法:两条路径都跑一遍,torch.where 混合:
# 按 vllm/v1/sample/sampler.py:85-131 改写的示意(源码先算 greedy 再判 all_greedy,
# min_p 仅在 batch 里有请求启用时才应用)
def sample(self, logits, sampling_metadata):
# 快速路径:全贪心
if sampling_metadata.all_greedy:
return self.greedy_sample(logits)
# 快速路径:全随机
if sampling_metadata.all_random:
logits = self.apply_temperature(logits, sampling_metadata.temperature)
logits = self.apply_min_p(logits, sampling_metadata.min_p)
return self.topk_topp_sampler(logits, ...)
# 混合路径:两条路径都跑,用 where 合并
greedy_sampled = self.greedy_sample(logits) # argmax,极快
logits_for_random = self.apply_temperature(logits, sampling_metadata.temperature)
logits_for_random = self.apply_min_p(logits_for_random, sampling_metadata.min_p)
random_sampled = self.topk_topp_sampler(logits_for_random, ...)
# temperature < eps 的请求用贪心结果,其余用随机结果
return torch.where(
sampling_metadata.temperature < _SAMPLING_EPS,
greedy_sampled,
random_sampled,
out=greedy_sampled, # 原地操作
)
几个精妙之处:
1. torch.where 把选择留在张量层完成——不用把 batch 拆成 greedy 组和 random 组,也不用在 Python 层重排请求。贪心请求那几行同样会被除以温度、走一遍随机采样,只是结果被丢弃;为此 InputBatch.add_request() 把贪心请求的温度填成 -1.0(vllm/v1/worker/gpu_input_batch.py:269-272,注释 "Avoid later division by zero"),既避免除零,又让 temperature < _SAMPLING_EPS 恰好选中它们。
2. out=greedy_sampled——原地写入,复用已有张量,避免额外内存分配。
3. 两条路径都跑看起来浪费,但:
- greedy 路径只是一次
argmax - 相比拆 batch、重排索引、再合并结果,多做一次 greedy 候选通常更简单,也更符合 GPU 批处理形态
这种"不优化最少 FLOPs,而优化 kernel 数"的思路,是 GPU 编程和 CPU 编程的根本差异。
9.3.4 all_greedy 与 all_random 快速路径
极端情况下整个 batch 参数一致(比如 API server 场景下大多数请求都用默认参数),vLLM 提供两条超快通道:
if sampling_metadata.all_greedy:
return logits.argmax(dim=-1).view(-1) # 1 个 kernel 调用
if sampling_metadata.all_random:
... # 直接走随机采样路径,不做 where
通过在 SamplingMetadata 里维护 all_greedy: bool 和 all_random: bool 标志,一次判断就能跳过不需要的半条路径。全贪心直接返回 argmax,全随机不创建 greedy 候选;只有混合 batch 才同时算两条候选再用 torch.where 合并。
9.3.5 GPU 编程的"三条心法"
本节的所有优化技巧可以浓缩为三条心法:
- kernel 数比 FLOPs 重要:多做重复计算换一次少 launch 是赚的
- 分支是敌人:用
where替代if,用mask替代filter - batch 维度优先向量化:永远先问"能不能 batch 一起做"
这三条不只适用于 LLM 采样,在很多 GPU 代码里同样成立,但都是经验法则,具体取舍仍要以 profile 为准。
9.4 Logit 处理器详解
回到步骤 2-5 的 logit 处理器。每个都是针对特定使用场景的微调。
9.4.1 allowed_token_ids:白名单约束
给定一个允许的 token 集合,其他所有 token 的 logit 置为 -inf。
典型用途:限制输出只能是"yes/no"、"A/B/C/D"、特定词表(金融词、法律术语)。
sampling_params = SamplingParams(
allowed_token_ids=[tokenizer.encode("yes", add_special_tokens=False)[0],
tokenizer.encode("no", add_special_tokens=False)[0]],
temperature=0,
max_tokens=1,
)
# 生成结果只能是这两个 token 之一(假设 "yes"/"no" 各自编码成单个 token;
# 不加 add_special_tokens=False,不少 tokenizer 的 [0] 会是 BOS)
这比后处理"强制匹配"更可靠——后处理需要 LLM 先生成一段文字再 parse,allowed_token_ids 直接从源头锁死。
9.4.2 bad_words 和 stop_token_ids:禁词
"禁词"指定某些词不能被生成(把相应 token 的 logit 置 -inf,细节见 §9.4.6)。用于 content filter、风险词屏蔽。它常和 stop_token_ids 混淆:
sampling_params = SamplingParams(
bad_words=["代词1", "词组 2"], # 字符串级禁词
# 对比:
stop_token_ids=[29898, 13], # token id 级——这是停止条件,不是禁词
)
注意 bad_words 和 stop_token_ids 的区别:
bad_words:禁止采样到,如果采样到会被屏蔽(生成会继续)stop_token_ids:触发停止(生成在这里结束)
9.4.3 logits_bias:细粒度偏置
给指定 token 的 logit 加一个常数偏置:
sampling_params = SamplingParams(
logit_bias={12345: +100.0, 67890: -100.0}, # token id → bias
)
正值鼓励这个 token、负值抑制。常用于:
- Logit-based 白名单(让某些 token 更可能出现而不是强制)
- 风险控制(抑制敏感词但不完全禁用)
- OpenAI API 兼容(openai SDK 就支持这个字段)
| 偏置值 | 效果 |
|---|---|
| +100 或更大 | 强制出现(近似 allowed_token_ids) |
| +1 到 +5 | 明显鼓励 |
| -1 到 -5 | 明显抑制 |
| -100 或更小 | 几乎禁用(近似 bad_words) |
9.4.4 Penalty 家族
vLLM 支持三种 penalty:
Repetition Penalty(重复惩罚):
抑制重复, 鼓励重复。典型值 1.1-1.2(轻微抑制)。注意 时完全不生效。repetition penalty 看的是 prompt 和已生成输出里出现过的 token。
Frequency Penalty(按频率):, 是 token 在已生成输出里出现的次数。出现 5 次的 token 比出现 1 次的惩罚更重。
Presence Penalty(按存在与否):,只要在已生成输出里出现过就惩罚,不管几次。
frequency 和 presence 只统计输出、不看 prompt,这是照 OpenAI API 的定义;三者在 vllm/model_executor/layers/utils.py:29-67 的 apply_penalties() 里按 repetition → frequency → presence 的顺序依次作用,V0 和 V1 共用这个函数。
三者可以组合。生产中 repetition_penalty=1.1 最常用,能有效减少 LLM 的"复读机"现象。
9.4.5 min_tokens Penalty:强制长度下限
用户要求"至少 100 个 token"。怎么实现?
vLLM 的做法:在输出还不足 min_tokens 个 token 时,把该请求的整个 stop token 集合的 logit 都置 -inf——不只是 EOS,还包括 stop_token_ids(vllm/v1/sample/ops/penalties.py:9-22 的 apply_min_token_penalties)。这样模型不可能选到任何终止 token,被迫继续生成;等输出长度过了 min_tokens 就自动解除。
sampling_params = SamplingParams(
min_tokens=100,
max_tokens=500,
)
# 生成不会在前 100 个 token 里结束(即使模型很想)
9.4.6 bad_words 和 logit_bias 的真实实现细节
bad_words 不是把一个词的所有 token 永久屏蔽。看 vllm/v1/sample/ops/bad_words.py,它保存的是 token 序列列表。每步采样前只检查"当前已生成 token 的尾部是否等于 bad word 的前缀";只有前缀匹配时,才把 bad word 最后一个 token 的 logit 置为 -inf。例如禁用 token 序列 [10, 20, 30] 时,只有当前尾部已经是 [10, 20],才会屏蔽 30。这样可以禁止完整词组,又不会把 30 这个 token 在所有上下文里全局禁掉。反过来,如果 bad word 只编码成 1 个 token,前缀为空、每步都匹配,这个 token 就是全局禁用的。字符串到 token 序列的转换在 SamplingParams.update_from_tokenizer() 里完成,每个词会尝试"不带前导空格"和"带前导空格"两种编码(后者只在得到不同首 token 时才追加),以同时覆盖句首和句中的写法。
logit_bias 则更直接:sampler.py 里遍历每个请求的 bias dict,检查 token id 是否越界,然后对对应 logit 做加法。源码维护注释明确说当前实现 "extremely inefficient",未来可以做 PyTorch C++ op 或优化 bias layout。这提醒我们:logit_bias 适合少量 token 的细粒度偏置,不适合把成千上万个 token 当作动态词表约束来用;如果要做大词表白名单,allowed_token_ids 的 mask 语义更清晰。
这两者和 allowed_token_ids 的区别可以总结成:
| 功能 | 粒度 | 语义 | 典型用法 |
|---|---|---|---|
allowed_token_ids |
静态 token 集合 | 只允许这些 token | 分类、选择题、极窄输出空间 |
bad_words |
token 序列 | 禁止生成完整词/词组 | 内容过滤、避免特定短语 |
logit_bias |
单 token 加减分 | 鼓励或抑制,不一定硬约束 | OpenAI 兼容、轻量倾向控制 |
min_tokens |
stop token 集合 | 长度未达标前禁止停 | 摘要、长回答、格式模板 |
9.5 Per-Request RNG:让 seed 真正可复现
OpenAI API 支持 seed 参数——相同的 seed + prompt + params 应该产生相同输出。如何实现?
9.5.1 全局 RNG 的问题
如果整个 batch 用同一个 torch.Generator:
- 请求 A 先到,它消耗 RNG 状态 N 次
- 请求 B 后到,它的"第 1 次采样"实际用的是 RNG 的第 N+1 个状态
- 结果:相同 seed、相同 prompt 的请求 B,结果依赖"它前面有多少请求"——不可复现
9.5.2 Per-Request Generator
vLLM 为每个请求维护独立的 torch.Generator:
# vllm/v1/sample/metadata.py:10
@dataclass
class SamplingMetadata:
generators: dict[int, torch.Generator] # req_index → generator
# ... 整个 dataclass 共 18 个字段(vllm/v1/sample/metadata.py 43 行):
# temperature/all_greedy/all_random/top_p/top_k/min_p/max_num_logprobs/
# no_penalties/prompt_token_ids/三种 penalty/output_token_ids/min_tokens/
# logit_bias/allowed_token_ids_mask/bad_words_token_ids
创建请求时(model runner 建 generator,InputBatch 按行号登记):
# vllm/v1/worker/gpu_model_runner.py:341-345(_update_states,节选)
if sampling_params.sampling_type == SamplingType.RANDOM_SEED:
generator = torch.Generator(device=self.device)
generator.manual_seed(sampling_params.seed)
else:
generator = None
# vllm/v1/worker/gpu_input_batch.py:307-308(add_request,节选)
if request.generator is not None:
self.generators[req_index] = request.generator
注意这个字典是"稀疏"的——只有显式指定了 seed 且不是贪心的请求(SamplingType.RANDOM_SEED)才在里面。没指定 seed 的请求(典型的 chat 场景)根本不进字典——这是下一节性能优化的关键前提。
9.5.3 真实采样算法:为什么不是 torch.multinomial
看到"从 probs 分布采一个 token"直觉上会想 torch.multinomial(probs, 1, generator=...)——这也是大多数博客、教程里展示的写法。但打开 vllm/v1/sample/ops/topk_topp_sampler.py:238 的 random_sample 会发现 vLLM 压根没用 multinomial(V0 的 _multinomial() 名字叫 multinomial,内部也是同一个技巧;V1 的 FlashInfer 路径则交给 FlashInfer 的拒绝采样 kernel):
# 节选,行尾注释为本专栏所加
def random_sample(probs, generators):
"""We use this function instead of torch.multinomial because
torch.multinomial causes CPU-GPU synchronization."""
q = torch.empty_like(probs)
if len(generators) != probs.shape[0]:
q.exponential_() # 没 seed:全局一把填
if generators:
for i, generator in generators.items(): # 有 seed:只改这些位置
q[i].exponential_(generator=generator)
return probs.div_(q).argmax(dim=-1).view(-1)
两个关键优化:
1. 用 Gumbel-max 取代 multinomial。源码 docstring 给的理由是 torch.multinomial 会引发一次 CPU-GPU 同步,Python 主循环被迫等 GPU。替代方案——对每个候选位置加一份 Exponential(1) 噪声 q,然后取 argmax(probs / q)——数学上等价于 categorical sampling(Gumbel-max trick 的一个变体:-log q 恰好服从 Gumbel 分布、argmax(log p - log q) = argmax(p/q)),但全程只有 element-wise ops + argmax,无 CPU-GPU 同步。省下的不是 FLOPs,而是一次会把 Python 主循环卡住的同步点——具体值多少要看 batch、vocab 和设备,源码 docstring 只给了动机("causes CPU-GPU synchronization")而没给数字,本章也不替它编。
2. 稀疏 seed 的混合填充策略(vllm/v1/sample/ops/topk_topp_sampler.py:247-259 的 NOTE(woosuk) + TODO(woosuk))——真实 batch 里"大多数请求没自定义 seed、少数有"是常态:
- 如果
generators字典的键数!=batch 大小,说明至少有一个请求没 seed——那就先q.exponential_()用全局默认 RNG 批量填充整个 q(一次 CUDA kernel 搞定所有位置)。 - 再对
generators里有条目的那几个位置q[i].exponential_(generator=generator)逐个覆盖——只对"有 seed 的少数位置"做串行操作。
这是典型的"乐观批处理 + 少数补偿"模式:不为了少数可复现请求牺牲大多数普通请求的批处理性能。代价是多写一次 q[i](那几个位置被填两次),源码的 TODO(woosuk) 也承认逐请求处理可能慢;只要带 seed 的请求占少数,总体就是净胜。
9.5.4 可复现性的边界
per-request generator 只保证一件事:这个请求拿到的随机数序列与 batch 里别的请求无关。它不保证 logits 本身逐 bit 相同,所以即使有 seed,也不能保证输出可复现:
| 变化 | 是否影响可复现 |
|---|---|
| 同 GPU、同 vLLM 版本、同样的 batch 组成 | ✓ 可复现(前提见下) |
| 不同 batch 组成(同 GPU) | ✗ 不保证:随机数不受影响,但 batch 变化会让 logits 有细微数值差异,积累后可能采出不同 token(官方 FAQ docs/source/getting_started/faq.md) |
| 不同 GPU 型号 | ✗ 可能有 bit 级差异(cuBLAS 算法选择) |
| 不同 vLLM 版本 | ✗ kernel 实现可能变 |
| 不同 dtype(FP16/BF16) | ✗ 数值路径不同 |
官方示例 examples/offline_inference/reproduciblity.py 把前提写得更严:vLLM 默认不保证可复现;离线推理要关掉 V1 多进程(VLLM_ENABLE_V1_MULTIPROCESSING=0)让调度确定、再固定全局 seed,且只在同硬件、同版本下成立;在线服务(vllm serve)因调度几乎不可能确定,不支持可复现。所以 seed 给的是"尽量可复现",不是"跨部署的 bit-exact"。
9.5.5 logprobs=0 也有语义
OpenAI 风格的 logprobs 有一个容易误解的点:logprobs=0 不是"不返回 logprobs",而是"只返回 sampled token 自己的 logprob"。SamplingParams 文档也写明:只要 logprobs 是非 None,响应就包含被选中 token 的 log probability;如果请求 logprobs=k,还会额外给出最多 k 个最可能 token,因此返回元素可能达到 k+1。
V1 的 gather_logprobs() 正是按这个契约写的。它先对原始 raw_logprobs 做 torch.topk(logprobs, num_logprobs);然后用 gather() 取 sampled token 自己的 logprob;最后把 sampled token id/logprob 和 top-k token id/logprob 拼在一起。同时它还计算 token_ranks = (logprobs >= token_logprobs).sum(-1),也就是 sampled token 在全词表里的 rank。这个 rank 对调试很有用:如果一个业务请求采到了 rank 很靠后的 token,通常说明 temperature 偏高,或 penalty、logit_bias 把分布推离了模型原本的排序(top-p、min-p 只会收窄候选,本身不会让低 rank token 更容易被采中)。
这也解释了为什么 §9.1.4 说 logprobs 要从原始 logits 算。如果用户用 logprobs 做 rerank、置信度、拒答判断或审计,他们要看的应该是模型本来的排序,而不是经过 repetition penalty 或 temperature 改写后的采样分布。采样可以有业务偏置,观测值必须尽量保持可解释。
9.6 结构化输出:约束采样
越来越多的场景需要 LLM 输出严格结构化的内容:JSON、XML、SQL。自由生成很容易产生破损的 JSON(少个括号、多个逗号)。
9.6.1 Guided Decoding 原理
核心思想:在每一步采样前,根据"当前 JSON 解析状态"约束可采样的 token 集合。
举例:模型已生成 {"name": "Alice", "age":,下一个 token 必须是:
- 空格
- 或者直接数字
0-9 - 或者字符串
"... - 绝对不能是
{/[/,
实现方式:把"合法 token 集合"之外的 token logit 置 -inf。它和 allowed_token_ids 的思想相同,但合法集合不是用户一次性给定的静态列表,而是由当前生成到哪一个语法状态动态决定。
9.6.2 FSM-based 约束
JSON schema 的约束本质可以看成一个随生成状态变化的自动机。每个状态对应"当前接受哪些字符或 token"。v0.8.5 V1 的结构化输出目录是 vllm/v1/structured_output/,只有 XGrammar 和 Guidance(经 llguidance 库接入)两个后端;V0 走的是 vllm/model_executor/guided_decoding/,以 logits processor 的形式挂到请求上,可选 outlines、lm-format-enforcer、xgrammar、guidance(vllm/config.py 的 GuidedDecodingBackendV0 / GuidedDecodingBackendV1)。本章讲 V1 主线时以 structured_output/ 为准。
V1 的后端是引擎级选择,不能逐请求指定。DecodingConfig.guided_decoding_backend 在 V1 下默认 "auto"(按请求内容先试 XGrammar,schema 用到它不支持的特性时改用 Guidance),V0 下默认 "xgrammar";请求里的 GuidedDecodingParams.backend 若与引擎级设置不一致,vllm/v1/engine/processor.py 的 _validate_structured_output() 会直接报错 "Request-level structured output backend selection is no longer supported"。所以请求里只写约束本身,后端在建引擎时定:
from vllm import LLM, SamplingParams
from vllm.sampling_params import GuidedDecodingParams
llm = LLM(model=..., guided_decoding_backend="xgrammar") # 或 "guidance";命令行为 --guided-decoding-backend
sampling_params = SamplingParams(
guided_decoding=GuidedDecodingParams(json=json_schema),
)
两个 V1 后端在源码里的分工:
| 维度 | XGrammar | Guidance |
|---|---|---|
| 主要文件 | backend_xgrammar.py |
backend_guidance.py |
| 本地行数 | 299 | 215 |
| 角色 | 接入 XGrammar 编译和匹配能力 | 接入 Guidance 的 schema/grammar 匹配能力 |
| 共同抽象 | 通过 backend_types.py 暴露统一后端接口 |
通过 backend_types.py 暴露统一后端接口 |
生产建议不是"永远选某一个后端",而是先用目标 schema、目标模型和目标输出长度压测。结构化输出的成本包含 grammar/schema 编译、每步合法 token 计算、logit mask 应用和失败重试策略;schema 越复杂,约束越细,越不能把自由生成的吞吐直接套过来。
9.6.3 CUDA Graph 的挑战
Guided Decoding 的 logit mask 是动态变化的(每 step 都要重新计算合法 token 集),而 CUDA Graph 需要固定 shape。
V1 的做法是让 mask 完全不进入被捕获的图:EngineCore 侧的 scheduler 为本步的结构化输出请求算好 grammar bitmask,随 SchedulerOutput 发给 worker;model runner 在模型前向(CUDA Graph 捕获的部分)结束后,先 compute_logits()(vllm/v1/worker/gpu_model_runner.py:1110-1114),再用 apply_grammar_bitmask() 把它施加到 logits 上,然后才交给采样器。动态的 mask 只出现在图外的 eager 代码里。工程上要记住:结构化输出提升的是格式可靠性,不是免费功能;如果一条服务同时承载自由问答和复杂 JSON schema 生成,最好在压测和限流上分开看。
9.7 停止条件检测:四类 + 一个微妙的晚一拍
9.7.1 四种停止条件
- EOS token:模型输出了 tokenizer 的
eos_token_id - max_tokens:生成达到用户指定上限
- stop_token_ids:用户指定的特定 token id(比如一些模型用
<|end|>作自定义终止符) - stop 字符串:生成文本里出现了用户指定的字符串
前三种很好判断(数值比较)。第四种——stop 字符串匹配——是真正有挑战的。
9.7.2 stop 字符串跨 token 问题
假设 stop 字符串是 "\n\n"(两个换行)。生成到某 step 产出 "\n"——这时能判停吗?不能。得看下一 step 是不是还产出 "\n"。
但 token 和字符不一一对应。有些 tokenizer 可能把 "\n\n" 编成一个 token,有些编成两个。stop 字符串的匹配必须发生在解码后的字符串上,而不是 token id 上。
# 朴素写法示意:维护累积的解码字符串
decoded_so_far = detokenize(output_token_ids)
# 每产出新 token 后,检查尾部
for stop_str in sampling_params.stop:
if decoded_so_far.endswith(stop_str):
return FINISH_STOP
边界情况:"\n" 是 "\n\n" 的前缀。新产出的这个 "\n" 可能是 stop 字符串的"开始",也可能不是。当前 V1 不是用 KMP 状态机维护每个 stop 串的状态,而是在 BaseIncrementalDetokenizer.__init__() 里算出 stop_buffer_length = max(len(s) for s in stop) - 1(vllm/v1/engine/detokenizer.py:68-73,仅当 include_stop_str_in_output=False 时,否则为 0),流式输出时按这个长度扣住文本尾部;然后复用 V0 的静态方法 StopChecker.check_stop_strings()(vllm/engine/output_processor/stop_checker.py:94-130),它用 output_text.find(stop_str, 1 - new_char_count - stop_string_len) 从"本次新增字符往前退一个 stop 串长度"的位置开始找,避免重扫已搜过的文本。这比 KMP 朴素,但实现简单,且 stop 字符串通常很短,瓶颈不在这里。(顶层 SamplingParams.output_text_buffer_length 是同一个公式,但那条路径服务的是 V0 的 vllm/outputs.py。)
9.7.3 stop 字符串的"晚一拍"
先排除一个误解。第 2 章 §2.8 讲过,v0.8.5 V1 的普通 EngineCore.step() 是同步的:schedule() → execute_model() → update_from_output(),EOS、stop_token_ids、max_tokens 这些 token 级条件就在同一步的 update_from_output() 里由 check_stop() 判掉,不会多算一拍(只有 pipeline parallel 启用 batch queue 时才会有多个 batch 同时在途)。
"晚一拍"出在 stop 字符串上:它要等前端 OutputProcessor 把 token detokenize 成文本才能判断,而 V1 默认把 EngineCore 放在独立进程里跑(第 2 章)。前端处理第 N 步输出的同时,EngineCore 并不等它,已经在调度、执行后续的步:
EngineCore:step N 产出 token(文本里出现 "\n\n")→ 输出发往前端 → 继续跑 step N+1、N+2 …
前端 :收到 step N 输出 → detokenize → 命中 stop string → 标记 FINISHED、加入 reqs_to_abort
EngineCore:收到 abort 才释放该请求;此前多算的 token 到了前端会被直接忽略
这些被浪费的 token 就是"overshoot"——前端文本语义与后端 token 调度解耦付出的代价。源码里 OutputProcessor 如果发现 detokenizer 命中了 stop string,而 EngineCore 还没把请求标成 finished,会把该请求加入 reqs_to_abort,让后端停止后续执行。浪费多少取决于前端处理输出的快慢、每一步的耗时和发现 stop 的位置,不能写成固定比例;但这个设计把 stop 字符串这种前端文本语义和后端 token 调度清楚地拆开了。
9.7.4 V1 把 token stop 和 string stop 分给两层
V1 的停止检测分两段,这一点比 V0 更值得注意。
第一段在 EngineCore/scheduler 侧,vllm/v1/core/sched/utils.py:5-22 的 check_stop() 只处理 token 级条件:达到 max_model_len 或 max_tokens,命中 EOS,命中 stop_token_ids。这些条件都只依赖 token id 和计数,后端可以在不 detokenize 的情况下立即停止。
第二段在前端 OutputProcessor/Detokenizer 侧。vllm/v1/engine/output_processor.py:339-372 先接收 EngineCore 输出,再调用 IncrementalDetokenizer.update() 把 token 增量解成文本并检查 stop string。如果命中 stop string,前端把 finish_reason 改成 STOP,把 stop_reason 改成命中的字符串;如果 EngineCore 还不知道这个请求已经结束,前端会把 request id 放进 reqs_to_abort,下一轮通知后端清理。
这种拆分的好处是:高频、简单、纯 token 的停止条件留在后端热路径;需要 tokenizer 和字符串语义的 stop string 留在前端处理。代价是 stop string 可能晚一点被后端知道,但避免了每个 GPU step 都把完整字符串逻辑塞进调度器。
9.8 六个场景的采样参数 preset
9.8.1 代码生成
SamplingParams(
temperature=0.2,
top_p=0.95,
max_tokens=2048,
repetition_penalty=1.05, # 抑制循环
stop=["\n\n```"], # 常见代码 markdown 结尾
)
9.8.2 创意写作
SamplingParams(
temperature=0.9,
top_p=0.95,
min_p=0.05, # 轻度过滤长尾
repetition_penalty=1.1,
max_tokens=4096,
)
9.8.3 JSON 结构化输出
SamplingParams(
temperature=0, # 贪心
max_tokens=2048,
guided_decoding=GuidedDecodingParams(json=my_schema), # V1 后端在引擎级指定,见 §9.6.2
)
9.8.4 RAG 问答
SamplingParams(
temperature=0.3,
top_p=0.9,
max_tokens=512,
stop=["\n\nQuestion:", "\n\nUser:"], # 防止模型自问自答
presence_penalty=0.3, # 鼓励覆盖新话题
)
9.8.5 多轮对话
SamplingParams(
temperature=0.7,
top_p=0.9,
max_tokens=1024,
frequency_penalty=0.3, # 减少多轮里的重复
stop=["<|user|>", "<|system|>"],
)
9.8.6 翻译
SamplingParams(
temperature=0.3,
top_p=0.95,
max_tokens=2048,
# 翻译无需 penalty,忠实原文
)
9.8.7 preset 只是起点,不是 SLA
上面这些 preset 只能作为初始值。真正上线时,要按以下顺序调,而不是一次性把所有旋钮都打开。
- 先决定是否需要确定性。代码补全、JSON、分类、RAG 答案通常偏低温;创意写作、角色扮演、头脑风暴才需要较高 temperature。只要 temperature 进入贪心路径,
SamplingParams会自动把 top-p、top-k、min-p 关掉,所以不要写"temperature=0 + min_p=0.1"这种看起来精致、实际无效的配置。 - 再决定候选集约束。top-p 更适合通用自然语言,top-k 更像一个硬上限,min-p 更适合高温下防止尾部 token 混入。三者可以组合,但组合越多,越难解释某次输出为什么被过滤。
- 最后再加 penalty。presence/frequency/repetition penalty 都会改变 logits,过高会让模型避开本来应该重复的专有名词、变量名、术语和引用。尤其是 RAG,重复原文关键词往往是好事,不应该为了"看起来不重复"把 penalty 设太高。
- 如果打开 logprobs,要把响应体积和 GPU 到 CPU 的转移成本算进去。
gather_logprobs()需要 top-k、gather、rank,并把结果给上层协议层消费;它对调试和置信度有价值,但不应默认给所有高 QPS 请求打开。 - 如果使用 structured output,最好把它当成单独流量类型压测。它改变的是每一步可采样 token 集合,不是普通自由生成加一个后处理 JSON parse。复杂 schema 的 TTFT、TPOT 和失败率要单独看。
一个实用的线上流程是:先用低风险默认参数跑通业务,再只改变一个旋钮做 A/B,观察输出质量、拒答率、重复率、TTFT/TPOT、平均输出长度和人工标注结果。采样参数不是"模型性格"的玄学开关,而是一组会改变概率分布、响应体积和后端成本的工程参数。
9.9 常见 5 种采样反模式
| 反模式 | 问题 | 正确做法 |
|---|---|---|
| 温度 0 + min-p | 矛盾(0 温度意味着 argmax,min-p 无意义) | temperature=0 时不设其他策略 |
| temperature 很高 + 没 top-k/top-p | 输出崩坏,出乱码 | 高温必须配 top-p 约束 |
| 给创意任务设 T=0.1 | 输出呆板,用户抱怨"太机器" | 按任务调 T,别一刀切 |
| repetition_penalty 设得过高(>1.5) | 影响正常用词,输出变扭曲 | 1.0-1.2 之间微调 |
| stop 字符串太长 | 流式输出要按最长 stop 串长度扣住尾部文本(stop_buffer_length),匹配也更容易被 tokenizer 细节绊住 |
保持简短,用明显分隔符 |
9.9.1 生产排查:从现象反推采样层
采样问题经常被误判成"模型能力不行"。实际上很多线上异常可以先从采样层排一遍:
| 现象 | 优先检查 | 原因 |
|---|---|---|
| 输出过早结束 | min_tokens、stop、stop_token_ids、EOS |
stop string 可能命中模板分隔符,stop token ids 也可能包含模型特殊 token |
| 输出一直不停 | ignore_eos、max_tokens、stop 字符串是否拼错 |
忽略 EOS 或 stop 文本与 tokenizer 输出不一致,会让请求只能靠长度结束 |
| JSON 经常坏 | structured output 是否真的启用;schema 是否过宽 | 只靠 prompt 要求 JSON,不等于约束采样 |
| 重复严重 | repetition_penalty、frequency/presence penalty、temperature |
低温和无 penalty 容易复读,但 penalty 太高也会伤正常术语 |
| 输出发散 | temperature、top_p、min_p、top_k | 高温无截断最容易让尾部 token 混入 |
| 相同 seed 仍不同 | vLLM 版本、硬件、dtype、参数是否完全一致 | seed 只保证同部署内尽量复现,不保证跨版本 bit-exact |
| logprobs 看起来和采样不一致 | 是否理解 raw logits vs adjusted logits | V1 logprobs 来自原始 logits,采样来自调整后 logits(V0 相反,logprobs 来自调整后的分布) |
排查时有两个实用技巧。
第一,把一次失败请求的 SamplingParams 打出来,确认 __post_init__() 后的真实值,而不是只看客户端传入值。最典型的坑是 temperature 进入贪心路径后,top-p、top-k、min-p 已经被重置;另一个坑是 logit_bias 的 JSON key 原本是字符串,OpenAI 服务端经 SamplingParams.from_optional() 构造参数时会转成 int 并 clamp 到 [-100, 100](直接构造 SamplingParams 不会 clamp)。
第二,给采样问题准备最小复现 prompt。不要用完整业务链路复现,因为检索上下文、系统提示、工具调用和 chat template 都会改变 token 分布。一个好的最小复现包含:模型名、prompt、完整 SamplingParams、是否启用 structured output、vLLM 版本、GPU/dtype、输出 token ids 和文本。只要能稳定复现,再去看 sampler.py、topk_topp_sampler.py、detokenizer.py,定位会快很多。
9.9.2 源码面积:vllm/v1/sample/ 和 structured_output/
把本章涉及的两个相关目录按当前本地源码统计:
vllm/v1/sample/ 共 1627 行(8 个有内容的文件,外加 3 个空 __init__.py)——
| 文件 | 行 | 角色 |
|---|---|---|
rejection_sampler.py |
631 | 本目录最大——但不属于本章主题——是第 12 章投机解码的拒绝采样核心,不在本章主线上 |
topk_topp_sampler.py |
315 | §9.2 的 top-k / top-p kernel 主体 |
sampler.py |
270 | Sampler.forward 6 步主流程(§9.1.3)+ apply_allowed_token_ids / apply_logits_bias 等方法 |
vllm/v1/sample/tpu/sampler.py |
154 | TPU 专用 Sampler——本章未提的板块 |
vllm/v1/sample/tpu/metadata.py |
118 | TPU 专用 SamplingMetadata |
vllm/v1/sample/ops/penalties.py |
58 | min_tokens 惩罚;repetition / frequency / presence 转调共用的 model_executor/layers/utils.py |
metadata.py |
43 | SamplingMetadata 类只有 43 行——只是个 dataclass-like 容器、所有逻辑在 sampler.py |
vllm/v1/sample/ops/bad_words.py |
38 | 禁词处理 |
| 合计 | 1627 | — |
vllm/v1/structured_output/ 6 文件 981 行——
| 文件 | 行 | 角色 |
|---|---|---|
backend_xgrammar.py |
299 | XGrammar 后端 |
backend_guidance.py |
215 | Guidance 后端(经 llguidance 接入) |
utils.py |
174 | 工具 |
__init__.py |
113 | 入口 |
backend_types.py |
96 | Backend 抽象 |
request.py |
84 | Per-request 状态 |
vllm/sampling_params.py 598 行(top-level、非 v1)——是 SamplingParams 用户 API 的 dataclass + 验证逻辑——所有用户面对的 temperature / top_p / top_k / penalties / stop / seed 字段都在这一文件。
两条值得记住的物理事实——
- 本章 §9.4 "Logit 处理器" 在源码里不是单一文件——而是分散在 6 个位置(见文末源码导航)——其中 V1 主路径的独立文件只有 ops/penalties.py 58 + ops/bad_words.py 38 = 96 行——剩下的是 sampler.py 里
Sampler自己的方法(apply_allowed_token_ids/apply_logits_bias等)+ V0 遗留 + entrypoints OpenAI 映射;§9.4 抽象成 "处理器" 概念,V1 实际是一组分散的函数和方法,没有统一的处理器接口 structured_output/有两套 V1 后端:xgrammar 299 + guidance 215——backend_guidance.py215 行是接入 Guidance schema/grammar 匹配能力的适配层;vLLM 通过backend_types.py96 行的抽象层让两套后端可插拔——多 backend 同款架构纪律(与第 4 章讲的 attention backends、第 16 章讲的 punica 后端同款)
串联 ch12:rejection_sampler.py 631 行主要服务投机解码,本章只需要知道它也会复用采样约束能力。真正的 V1 普通采样主线在 sampler.py 270 行和 vllm/v1/sample/ops/topk_topp_sampler.py 315 行。
9.10 本章小结
采样是 LLM 推理的"最后一公里",看似简单但工程细节密集:
- 三阶段管线:logit 处理器 → 采样策略 → 停止检测
- 6 步 forward:dtype / allowed / bad_words / bias / penalties / sample
- logprobs 用原始 logits:保证 API 返回值反映模型真实置信度
- 四大策略:temperature(尖锐度)、top-k(硬截断)、top-p(累计阈值)、min-p(相对阈值)
- 混合 batch:贪心+随机共存,
torch.where是关键 - 快速路径:
all_greedy/all_random跳过混合逻辑 - per-request RNG:让
seed参数真正可复现,和请求顺序无关 - penalty 家族:repetition / frequency / presence,控制重复
- 结构化输出:XGrammar / Guidance 动态约束,logit masking
- stop 字符串:尾部缓冲 +
StopChecker.check_stop_strings(),不是 KMP 状态机 - "晚一拍" overshoot:stop string 在前端判定,EngineCore 在另一进程继续跑,发现后由前端 abort 后端请求
一句话记忆:
Logits 是 15 万维的选择题,采样是挑一个答案的算法——温度决定分布胖瘦,top-k/p 决定候选多少,penalty 决定"别重复自己",stop 决定何时收场。
到这里,采样层的主线已经完整:用户给 SamplingParams,V1 把它压成 SamplingMetadata,sampler 在 GPU 张量上完成约束、惩罚、截断和随机选择,OutputProcessor 再把 token 还原成文本并检查停止条件。后续读 ch12 投机解码时,要把 rejection sampler 看成"复用同一套采样语义的多 token 验证器"。
源码导航
- SamplingParams:
vllm/sampling_params.py- V1 Sampler:
vllm/v1/sample/sampler.py- SamplingMetadata:
vllm/v1/sample/metadata.py- Top-k/Top-p kernel:
vllm/v1/sample/ops/topk_topp_sampler.py- Logit 处理器:V1 没有单独的 logit 处理器模块——本章 §9.4 讨论的逻辑分散在:
vllm/v1/sample/sampler.py(Sampler的 apply_allowed_token_ids / apply_logits_bias 等方法)+vllm/v1/sample/ops/penalties.py(58 行 penalty 实现)+vllm/v1/sample/ops/bad_words.py(38 行)+ V0 遗留vllm/logits_process.py(121 行)+vllm/model_executor/layers/logits_processor.py(196 行 model 层 logits 处理)+vllm/entrypoints/openai/logits_processors.py(89 行 OpenAI 参数映射)。- Guided Decoding:
vllm/v1/structured_output/- OpenAI API 参数映射:
vllm/entrypoints/openai/protocol.py论文
- Holtzman et al., "The Curious Case of Neural Text Degeneration" (Top-p sampling), ICLR 2020 (arXiv:1904.09751)
- Nguyen et al., "Turning Up the Heat: Min-p Sampling for Creative and Coherent LLM Outputs", 2024 (arXiv:2407.01082) — 该文早期版本题为 "Min P Sampling: Balancing Creativity and Coherence at High Temperature",arXiv 上的现行标题是前者
- Willard & Louf, "Efficient Guided Generation for Large Language Models" (guided generation), 2023 (arXiv:2307.09702)