vLLM 推理内核深度解析

第9章 采样与输出处理:从 Logits 到 Token

作者 杨艺韬 · 9,831 字

第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掩码、偏置、惩罚
采样修改后的 logitstoken id温度、截断、随机/贪心
停止检测token id + 历史继续 / FINISHEOS、长度、字符串匹配

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_idsbad_wordslogit_biasmin_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=Nonefrom_optional() 改成 1.0API 层可以传空,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_EPStop_p=1.0top_k=-1min_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 采样里最基础的旋钮:

pi=exp(i/T)jexp(j/T)p_i = \frac{\exp(\ell_i / T)}{\sum_j \exp(\ell_j / T)}
  • T<1T < 1:分布变尖锐。高概率 token 的相对优势被放大,生成更确定但可能呆板
  • T=1T = 1:分布不变(softmax 标准形态)
  • T>1T > 1:分布变平。低概率 token 的相对机会增加,生成更多样但可能胡言乱语
  • T=0T = 0:退化为 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):

保留 i    piminppmax\text{保留 } i \iff p_i \geq \min_p \cdot p_{\max}

其中 pmax=maxipip_{\max} = \max_i p_i

  • 模型确定时(pmax=0.9p_{\max} = 0.9, min_p=0.1)→ 只保留 pi0.09p_i \geq 0.09 的 token(极少的 2-3 个)
  • 模型不确定时(pmax=0.05p_{\max} = 0.05, min_p=0.1)→ 保留 pi0.005p_i \geq 0.005 的 token(几十个候选)

相比 top-p,min-p 在”尾部平坦”场景下更严格(top-p 会让累计 90% 包含一大片差不多概率的 token,min-p 按相对最大值卡,自动排除”相对较差”的那些)。

生产实战里 min-p=0.05 或 0.1 常和较高 temperature 搭配使用,目的是在保留表达多样性的同时过滤相对最大概率太低的尾部 token。vLLM 的 SamplingParamsmin_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_greedyall_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: boolall_random: bool 标志,一次判断就能跳过不需要的半条路径。全贪心直接返回 argmax,全随机不创建 greedy 候选;只有混合 batch 才同时算两条候选再用 torch.where 合并。

9.3.5 GPU 编程的”三条心法”

本节的所有优化技巧可以浓缩为三条心法:

  1. kernel 数比 FLOPs 重要:多做重复计算换一次少 launch 是赚的
  2. 分支是敌人:用 where 替代 if,用 mask 替代 filter
  3. 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_wordsstop_token_ids:禁词

“禁词”指定某些 token 永远不能被采样(logit 置 -inf)。用于 content filter、风险词屏蔽。

sampling_params = SamplingParams(
    bad_words=["代词1", "词组 2"],  # 字符串级
    # or
    stop_token_ids=[29898, 13],     # token id 级
)

注意 bad_wordsstop_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(重复惩罚):

i={i/ρif ipast tokens and i>0iρif ipast tokens and i0iotherwise\ell'_i = \begin{cases} \ell_i / \rho & \text{if } i \in \text{past tokens and } \ell_i > 0 \\ \ell_i \cdot \rho & \text{if } i \in \text{past tokens and } \ell_i \leq 0 \\ \ell_i & \text{otherwise} \end{cases}

ρ>1\rho > 1 抑制重复,ρ<1\rho < 1 鼓励重复。典型值 1.1-1.2(轻微抑制)。注意 ρ=1.0\rho = 1.0 时完全不生效。

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_wordslogit_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_wordstoken 序列禁止生成完整词/词组内容过滤、避免特定短语
logit_bias单 token 加减分鼓励或抑制,不一定硬约束OpenAI 兼容、轻量倾向控制
min_tokensstop 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:238random_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 取代 multinomialtorch.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_logprobstorch.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 后端在源码里的分工:

维度XGrammarGuidance
主要文件backend_xgrammar.pybackend_guidance.py
本地行数299215
角色接入 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 四种停止条件

  1. EOS token:模型输出了 tokenizer 的 eos_token_id
  2. max_tokens:生成达到用户指定上限
  3. stop_token_ids:用户指定的特定 token id(比如一些模型用 <|end|> 作自定义终止符)
  4. 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-22check_stop() 只处理 token 级条件:达到 max_model_lenmax_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 只能作为初始值。真正上线时,要按以下顺序调,而不是一次性把所有旋钮都打开。

  1. 先决定是否需要确定性。代码补全、JSON、分类、RAG 答案通常偏低温;创意写作、角色扮演、头脑风暴才需要较高 temperature。只要 temperature 进入贪心路径,SamplingParams 会自动把 top-p、top-k、min-p 关掉,所以不要写”temperature=0 + min_p=0.1”这种看起来精致、实际无效的配置。
  2. 再决定候选集约束。top-p 更适合通用自然语言,top-k 更像一个硬上限,min-p 更适合高温下防止尾部 token 混入。三者可以组合,但组合越多,越难解释某次输出为什么被过滤。
  3. 最后再加 penalty。presence/frequency/repetition penalty 都会改变 logits,过高会让模型避开本来应该重复的专有名词、变量名、术语和引用。尤其是 RAG,重复原文关键词往往是好事,不应该为了”看起来不重复”把 penalty 设太高。
  4. 如果打开 logprobs,要把响应体积和 GPU 到 CPU 的转移成本算进去。gather_logprobs() 需要 top-k、gather、rank,并把结果给上层协议层消费;它对调试和置信度有价值,但不应默认给所有高 QPS 请求打开。
  5. 如果使用 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_tokensstopstop_token_ids、EOSstop string 可能命中模板分隔符,stop token ids 也可能包含模型特殊 token
输出一直不停ignore_eosmax_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 logitsV1 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.pytopk_topp_sampler.pydetokenizer.py,定位会快很多。

9.9.2 源码面积:vllm/v1/sample/structured_output/

把本章涉及的两个相关目录按当前本地源码统计:

vllm/v1/sample/ 8 文件 1627 行——

文件角色
rejection_sampler.py631本目录最大——但不属于本章主题——是 ch12 投机解码的拒绝采样核心(已在 ch12 §12.8.1 详细分析)
topk_topp_sampler.py315§9.2 的 top-k / top-p kernel 主体
sampler.py270Sampler.forward 6 步主流程(§9.1.3)+ apply_allowed / apply_bias 等 inline 处理
tpu/sampler.py154TPU 专用 Sampler——本章未提的板块
tpu/metadata.py118TPU 专用 SamplingMetadata
ops/penalties.py58repetition / frequency / presence penalty 实现
metadata.py43SamplingMetadata 类只有 43 行——只是个 dataclass-like 容器、所有逻辑在 sampler.py
ops/bad_words.py38禁词处理
合计1627

vllm/v1/structured_output/ 6 文件 981 行——

文件角色
backend_xgrammar.py299XGrammar 后端(Outlines 之外的另一种 FSM 实现)
backend_guidance.py215本章未提的板块——Microsoft Guidance 后端
utils.py174工具
__init__.py113入口
backend_types.py96Backend 抽象
request.py84Per-request 状态

vllm/sampling_params.py 598 行(top-level、非 v1)——是 SamplingParams 用户 API 的 dataclass + 验证逻辑——所有用户面对的 temperature / top_p / top_k / penalties / stop / seed 字段都在这一文件。

两条值得记住的物理事实——

  1. 本章 §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.py 1157 行集中写法形成对比、是”抽象 vs 内联”取舍的另一面
  2. structured_output/ 有两套 V1 后端:xgrammar 299 + guidance 215——backend_guidance.py 215 行是接入 Guidance schema/grammar 匹配能力的适配层;vLLM 通过 backend_types.py 96 行的抽象层让两套后端可插拔——多 backend 同款架构纪律(与 §1.6.1 attention backends + §16 punica 后端同款)

串联 ch12rejection_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)