vLLM 推理内核深度解析

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

作者 杨艺韬 · 11,736 字 · 发布于 · 更新于

采样是 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 采样里最基础的旋钮:

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(严格说是 < 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):

保留 i  ⟺  pi≥min⁡p⋅pmax⁡\text{保留 } i \iff p_i \geq \min_p \cdot p_{\max}

其中 pmax⁡=max⁡ipip_{\max} = \max_i p_i。

  • 模型确定时(pmax⁡=0.9p_{\max} = 0.9, min_p=0.1)→ 只保留 pi≥0.09p_i \geq 0.09 的 token(其余 token 概率总和只有 0.1,至多再有 1 个过线,候选最多 2 个)
  • 模型不确定时(pmax⁡=0.05p_{\max} = 0.05, min_p=0.1)→ 保留 pi≥0.005p_i \geq 0.005 的 token(候选可达上百个,上限 1/0.005=2001/0.005 = 200)

相比 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 编程的"三条心法"

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

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

ℓi′={ℓi/ρif i∈prompt∪output and ℓi>0ℓi⋅ρif i∈prompt∪output and ℓi≤0ℓiotherwise\ell'_i = \begin{cases} \ell_i / \rho & \text{if } i \in \text{prompt} \cup \text{output and } \ell_i > 0 \\ \ell_i \cdot \rho & \text{if } i \in \text{prompt} \cup \text{output 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 时完全不生效。repetition penalty 看的是 prompt 和已生成输出里出现过的 token。

Frequency Penalty(按频率):ℓi′=ℓi−f⋅ci\ell'_i = \ell_i - f \cdot c_i,cic_i 是 token ii 在已生成输出里出现的次数。出现 5 次的 token 比出现 1 次的惩罚更重。

Presence Penalty(按存在与否):ℓi′=ℓi−p⋅1[ci>0]\ell'_i = \ell_i - p \cdot \mathbb{1}[c_i > 0],只要在已生成输出里出现过就惩罚,不管几次。

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 四种停止条件

  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 串的状态,而是在 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 只能作为初始值。真正上线时,要按以下顺序调,而不是一次性把所有旋钮都打开。

  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 字符串太长 流式输出要按最长 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 字段都在这一文件。

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

  1. 本章 §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 实际是一组分散的函数和方法,没有统一的处理器接口
  2. structured_output/ 有两套 V1 后端:xgrammar 299 + guidance 215——backend_guidance.py 215 行是接入 Guidance schema/grammar 匹配能力的适配层;vLLM 通过 backend_types.py 96 行的抽象层让两套后端可插拔——多 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)