vLLM 推理内核深度解析
第9章 采样与输出处理:从 Logits 到 Token
第9章 采样与输出处理:从 Logits 到 Token
“The beauty of randomness lies not in chaos, but in controlled unpredictability.”
“采样是 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 里无缝混合贪心和随机采样 - 理解
logprobs为什么从修改前的 logits 计算——这是一个容易被忽视但很关键的设计 - 掌握 per-request
torch.Generator如何保证seed参数的可复现性 - 学会结构化输出(JSON Schema / XGrammar)的约束采样原理
- 看懂 stop 词跨 token 匹配如何靠尾部缓冲和
StopChecker处理 - 理解停止检测”晚一拍”(overshoot 一个 token)在异步流水线里的必要代价
- 拿到四个典型场景的采样参数 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。每一行代表一个请求在词表上的”得分”分布(未归一化)。
想象一下:模型把整个词表打分,每个 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/>· multinomial / 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:#8b5cf6,color:#fff,stroke:none
style LP fill:#f59e0b,color:#fff,stroke:none
style S fill:#3b82f6,color:#fff,stroke:none
style T fill:#10b981,color:#fff,stroke:none
style SC fill:#ec4899,color:#fff,stroke:none
这张图是整章的骨架——每个方框在 vLLM 源码里都对应 vllm/v1/sample/ 下的一个模块。
三阶段的职责分工:
| 阶段 | 输入 | 输出 | 关键操作 |
|---|---|---|---|
| Logit 处理器 | logits | 修改后的 logits | 掩码、偏置、惩罚 |
| 采样 | 修改后的 logits | token id | 温度、截断、随机/贪心 |
| 停止检测 | token id + 历史 | 继续 / FINISH | EOS、长度、字符串匹配 |
9.1.3 Sampler.forward 的 6 步
# vllm/v1/sample/sampler.py(概念性简化)
class Sampler(nn.Module):
def forward(self, logits: torch.Tensor, sampling_metadata) -> SamplerOutput:
# 0. 先保存"原始 logprobs"(在任何修改前)
raw_logprobs = None
if sampling_metadata.max_num_logprobs > 0:
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:21-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 的系统)是误导。
vLLM 的正解:logprobs 永远反映模型原始的数值,采样用的是调整后的分布。两条路径独立,接口契约清晰。
这种”保留原始真相”的设计哲学在 vLLM 里多处体现——KV Cache 的引用计数、Scheduler 的 num_computed_tokens,都是”真实状态”优先,衍生信息让位。
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 是字符串 | int(token),bias clamp 到 [-100, 100] | 兼容 JSON 对象 key 只能是字符串的场景 |
temperature < _SAMPLING_EPS | top_p=1.0、top_k=-1、min_p=0.0 | 贪心采样下截断策略无意义 |
校验同样在这一层完成:presence/frequency penalty 必须在 [-2, 2],repetition_penalty 必须大于 0,top_p 必须在 (0, 1],top_k 只能是 -1 或至少 1 的整数,min_p 必须在 [0, 1],min_tokens 不能超过 max_tokens。这让 GPU sampler 可以假设输入已经合法,不在热路径里做业务级参数解释。
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 在 vLLM 里会直接走 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 的概率和超过 0.1%,采到它们就等于”用 0.0001% 概率的 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 是 2023 年提出的相对较新策略(Nguyen et al., arXiv:2407.01082):
其中 。
- 模型确定时(, min_p=0.1)→ 只保留 的 token(极少的 2-3 个)
- 模型不确定时(, 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 可以串联使用。vLLM 的顺序是:temperature → min-p → (top-k + top-p)。
# 在 sampler.sample() 里
logits = apply_temperature(logits, temperature)
logits = apply_min_p(logits, min_p)
logits = topk_topp_sampler(logits, top_k, top_p, generators) # top-k 和 top-p 在同一 kernel 里
为什么 top-k 和 top-p 合在同一个函数里?看 vllm/v1/sample/ops/topk_topp_sampler.py:170-207:如果只启用 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)
sampled = torch.multinomial(probs, num_samples=1, ...) # 一次完成全 batch
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 里逐请求循环。
9.3.3 混合 batch:贪心 + 随机的并存
一个 batch 里有些请求 temperature=0(贪心),另一些 temperature=0.8(随机)。怎么办?
朴素思路 1:分组处理——按温度把 batch 分两组,各自跑。这引入了分组的 CPU 开销,还可能导致内存重排。
朴素思路 2:统一当随机采样处理——对 temperature=0 的请求,把温度改成 1e-10,让它”几乎” argmax。但这会触发真的 multinomial 采样,数值不稳定。
vLLM 的精妙解法:两条路径都跑一遍,torch.where 混合:
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 层重排请求。
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 代码。
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")[0], tokenizer.encode("no")[0]],
temperature=0,
max_tokens=1,
)
# 生成结果只能是 "yes" 或 "no"
这比后处理”强制匹配”更可靠——后处理需要 LLM 先生成一段文字再 parse,allowed_token_ids 直接从源头锁死。
9.4.2 bad_words 和 stop_token_ids:禁词
“禁词”指定某些 token 永远不能被采样(logit 置 -inf)。用于 content filter、风险词屏蔽。
sampling_params = SamplingParams(
bad_words=["代词1", "词组 2"], # 字符串级
# or
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(轻微抑制)。注意 时完全不生效。
Frequency Penalty(按频率):按 past token 出现的次数惩罚。出现 5 次的 token 比出现 1 次的惩罚更重。
Presence Penalty(按存在与否):只要出现过就惩罚,不管几次。
三者可以组合。生产中 repetition_penalty=1.1 最常用,能有效减少 LLM 的”复读机”现象。
9.4.5 min_tokens Penalty:强制长度下限
用户要求”至少 100 个 token”。怎么实现?
vLLM 的做法:在第 0 到 min_tokens 个 token 的采样里,强制把 EOS token 的 logit 置 -inf。这样模型不可能选到 EOS,被迫继续生成。等超过 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 在所有上下文里全局禁掉。
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
@dataclass
class SamplingMetadata:
generators: dict[int, torch.Generator] # req_index → generator
# ... 另外 13 个字段:temperature/top_p/top_k/min_p/penalties 等
创建请求时:
if sampling_params.seed is not None:
generator = torch.Generator(device="cuda")
generator.manual_seed(sampling_params.seed)
sampling_metadata.generators[req_index] = generator
注意这个字典是”稀疏”的——只有显式指定了 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:
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。torch.multinomial 在 GPU 上实际实现走一次同步(它要知道 CDF 的累计和位置,这种”累积扫描再二分”的操作会触发一次 CPU-GPU sync,整个 Python 主循环被迫 wait)。替代方案——对每个候选位置加一份 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 同步。在一个 decode 步里 batch 512 个请求时,这一刀能省几百微秒的 dispatch 等待。
2. 稀疏 seed 的混合填充策略(line 248-253 的 NOTE(woosuk))——真实 batch 里”大多数请求没自定义 seed、少数有”是常态:
- 如果
generators字典的键数!=batch 大小,说明至少有一个请求没 seed——那就先q.exponential_()用全局默认 RNG 批量填充整个 q(一次 CUDA kernel 搞定所有位置)。 - 再对
generators里有条目的那几个位置q[i].exponential_(generator=generator)逐个覆盖——只对”有 seed 的少数位置”做串行操作。
这是典型的”乐观批处理 + 少数补偿”模式:不为了少数可复现请求牺牲大多数普通请求的批处理性能。代价是多写一次 q[i](那几个位置被填两次),但 batch 中 seeded 请求通常 < 5%,总体是净胜。
9.5.4 可复现性的边界
即使有 seed,也不能保证跨硬件、跨版本可复现:
| 变化 | 是否影响可复现 |
|---|---|
| 同 GPU、同 vLLM 版本 | ✓ 可复现 |
| 不同 batch 大小(同 GPU) | ✓ 可复现(per-request RNG 保证) |
| 不同 GPU 型号 | ✗ 可能有 bit 级差异(cuBLAS 算法选择) |
| 不同 vLLM 版本 | ✗ kernel 实现可能变 |
| 不同 dtype(FP16/BF16) | ✗ 数值路径不同 |
所以 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/top-p/min-p 或 penalty 组合让分布变得很散。
这也解释了为什么 §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”。当前本地 vLLM V1 结构化输出目录是 vllm/v1/structured_output/,主要有 XGrammar 和 Microsoft Guidance 两个后端;Outlines 相关逻辑仍能在 vllm/model_executor/guided_decoding/outlines_logits_processors.py 这类旧路径看到,但本章讲 V1 主线时应以 structured_output/ 为准。
XGrammar:
sampling_params = SamplingParams(
guided_decoding=GuidedDecodingParams(
json=json_schema,
backend="xgrammar",
),
)
Guidance:
sampling_params = SamplingParams(
guided_decoding=GuidedDecodingParams(
json=json_schema,
backend="guidance",
),
)
两个 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。
vLLM 的妥协是把 structured output 当作采样层的动态约束来处理,而不是假设每一步都有同一张 mask。工程上要记住:结构化输出提升的是格式可靠性,不是免费功能;如果一条服务同时承载自由问答和复杂 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 串的状态,而是在 SamplingParams.__post_init__() 里设置 output_text_buffer_length = max_stop_len - 1,detokenizer 输出时保留足够长的尾部缓冲;StopChecker.check_stop_strings() 再从”本次新增字符附近往前一点”的位置调用字符串 find()。这比 KMP 朴素,但实现简单,且 stop 字符串通常很短,瓶颈不在这里。
9.7.3 “晚一拍”的异步流水线代价
回顾 ch02:V1 的异步流水线把”调度下一拍”和”GPU 算当前拍”叠加起来跑。这意味着——在发现本拍 stop 之前,下一拍已经开始了。
step N : GPU 算出了 "\n\n"(stop 命中)
step N+1: CPU 已经开始调度,GPU 开始算第 N+1 拍
update_from_output @ step N+1 开头:
检查 step N 的输出 → 发现 stop → 标记 FINISHED
但 step N+1 的计算已经在做,它的结果会被丢弃
这一个被浪费的 token 就是”overshoot”——V1 为了流水线并行付出的代价。源码里 OutputProcessor 如果发现 detokenizer 命中了 stop string,而 EngineCore 还没把请求标成 finished,会把该请求加入 reqs_to_abort,让后端停止后续执行。浪费多少取决于流水线深度、每轮调度 token 数和发现 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,
backend="xgrammar",
),
)
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 字符串太长(100+ 字符) | 匹配开销大,且容易出 bug | 保持 <20 字符,用明显分隔符 |
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 |
排查时有两个实用技巧。
第一,把一次失败请求的 SamplingParams 打出来,确认 __post_init__() 后的真实值,而不是只看客户端传入值。最典型的坑是 temperature 进入贪心路径后,top-p、top-k、min-p 已经被重置;另一个坑是 logit_bias 的 JSON key 原本是字符串,到 engine 层会转成 int 并 clamp 到 [-100, 100]。
第二,给采样问题准备最小复现 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/ 8 文件 1627 行——
| 文件 | 行 | 角色 |
|---|---|---|
rejection_sampler.py | 631 | 本目录最大——但不属于本章主题——是 ch12 投机解码的拒绝采样核心(已在 ch12 §12.8.1 详细分析) |
topk_topp_sampler.py | 315 | §9.2 的 top-k / top-p kernel 主体 |
sampler.py | 270 | Sampler.forward 6 步主流程(§9.1.3)+ apply_allowed / apply_bias 等 inline 处理 |
tpu/sampler.py | 154 | TPU 专用 Sampler——本章未提的板块 |
tpu/metadata.py | 118 | TPU 专用 SamplingMetadata |
ops/penalties.py | 58 | repetition / frequency / presence penalty 实现 |
metadata.py | 43 | SamplingMetadata 类只有 43 行——只是个 dataclass-like 容器、所有逻辑在 sampler.py |
ops/bad_words.py | 38 | 禁词处理 |
| 合计 | 1627 | — |
vllm/v1/structured_output/ 6 文件 981 行——
| 文件 | 行 | 角色 |
|---|---|---|
backend_xgrammar.py | 299 | XGrammar 后端(Outlines 之外的另一种 FSM 实现) |
backend_guidance.py | 215 | 本章未提的板块——Microsoft Guidance 后端 |
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 内联(apply_allowed_token_ids / apply_bias 直接写在 forward 里)+ V0 遗留 + entrypoints OpenAI 映射;§9.4 抽象成 “处理器” 概念但实际是分散的内联函数——和 LangChain
tools/base.py1157 行集中写法形成对比、是”抽象 vs 内联”取舍的另一面 structured_output/有两套 V1 后端:xgrammar 299 + guidance 215——backend_guidance.py215 行是接入 Guidance schema/grammar 匹配能力的适配层;vLLM 通过backend_types.py96 行的抽象层让两套后端可插拔——多 backend 同款架构纪律(与 §1.6.1 attention backends + §16 punica 后端同款)
串联 ch12:rejection_sampler.py 631 行主要服务投机解码,本章只需要知道它也会复用采样约束能力。真正的 V1 普通采样主线在 sampler.py 270 行和 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:异步流水线 trade-off,发现 stop string 后由前端 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 处理器:没有单一的
vllm/v1/sample/logits_processor.py——本章 §9.4 讨论的逻辑分散在:vllm/v1/sample/sampler.py(主流程内联 apply_allowed_token_ids / apply_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., “Min P Sampling: Balancing Creativity and Coherence at High Temperature”, 2024 (arXiv:2407.01082)
- Willard & Louf, “Efficient Guided Generation for Large Language Models” (guided generation), 2023 (arXiv:2307.09702)