系列 《大模型推理系统揭秘:从 vLLM 看 LLM Serving Infra 核心技术》 第 10 / 16 篇 深入
系列总览 — 为什么这样组织、读它需要什么、读完能做什么 · 幻灯片 — 整个系列的精华,一份可分享的 deck
学习地图 — AI-Infra 工程师学习地图 第 9 / 14 个系列
- 为什么 LLM Serving 比传统 DL 推理难?
- 如何衡量一个 LLM Serving 系统?
- 鸟瞰 vLLM:一个请求如何穿过整个推理系统?
- Scheduler:GPU 这一轮到底给谁用?
- KV Cache:LLM Serving 的第一号内存问题
- GPU 执行:如何让每个 Token 算得更快?
- Multi-GPU:一张卡不够时如何扩展?
- PD 分离:从资源混部走向计算解耦
- 请求形态的扩展:multi-LoRA 与多模态
- 输出在变:采样参数与结构化输出
- 模型适配:如何跟上变化极快的模型世界?
- 硬件解耦:如何不让芯片差异污染 Serving 核心?
- Serving Infra 的下一站:从模型执行器到分布式智能操作系统
- 回到源码:一次请求在 vLLM 内部的真实旅程
- vLLM 与 SGLang:同一个请求穿过两套 Serving 系统
- 系列总结与通关自测
NOTE 本文基于 vLLM v0.27.1(tag
6e448d0, 2026-08-11)源码剖析。文中文件路径、类名和函数名均以该版本为准;vLLM 迭代很快,阅读时请以你手上的版本对照。
上一篇讲的是输入在变:同一个 batch 里的请求各带不同的 LoRA、各带几张图。这一篇看另一端——输出在变。
引擎对输出的默认约定很简单:每一步,每个请求在 logits 里占一行,从这一行采出一个 token。真实服务里这一行几乎从来不是”直接 argmax”:一个请求要 temperature=0.7, top_p=0.9 加 repetition_penalty=1.1,另一个要固定 seed 可复现,第三个要求输出必须是符合某个 JSON schema 的对象,第四个是推理模型,要先自由地”思考”、再输出 JSON。这些要求都是请求带进来的输出语义,每个请求不同,而且会随生成过程变化。
它们看起来都只是”采样时改一下 logits”,可在 vLLM 里,一个 JSON schema 会让请求在调度器里多出一个等待状态、让调度器进程每一步多做一份 CPU 工作;一个 repetition_penalty 会让 model runner 每一步把每个请求的全部历史输出搬上 GPU。本篇的核心问题是:
当每个请求都对”输出”提出自己的要求——自己的温度和惩罚、必须是合法的 JSON——vLLM 在哪几层吸收这些变化?1 代价分别落在谁身上?2
投机解码不在本篇:它不改变输出分布,只改变一次 forward 产出几个 token,是性能手段,已经在第六篇第五章讲完。但它会改变 logits 的行数,和本篇的两类变化都有交叉,交叉点在各章里单独说明。
一、总览:输出契约与两类变化
1. 输出契约:从 hidden_states 到 token
先把”最后一步”画清楚。第六篇的 forward 结束在 hidden_states;接下来是:
这条链对上游有三个隐含约定:
- 每个请求一行 logits:
logits的形状是[num_reqs, V],第 i 行属于 batch 里第 i 个请求; - 每个 token 都可能被选中:词表里任何 token 都在候选集合里,Sampler 只决定概率;
- 采样是无状态的:这一步怎么采,只看这一步的 logits,不看请求过去输出过什么。
输出语义的变化,就是对这三条约定的改写。
2. 两类输出变化
| 变化 | 改的是什么 | 打破的约定 | 例子 |
|---|---|---|---|
| 采样语义(每请求的采样参数与 logits processors) | 分布本身:每个请求按自己的参数改写自己那一行 | 第 1 条要被”每一行有自己的规矩”细化;penalties、min_tokens 打破第 3 条(要看历史输出) |
temperature、top_p / top_k / min_p、三种 penalty、logit_bias、bad_words、allowed_token_ids、min_tokens、seed、logprobs、thinking budget、自定义 logits processor |
| 输出约束(结构化输出) | 分布的支撑集:把 grammar 当前状态下不合法的 token 置 -inf |
打破第 2 条;grammar 是一台随输出推进的状态机,也打破第 3 条 | json、json_object、regex、choice、grammar、structural_tag |
3. 变化落在哪几层
如果这两类变化只改 Sampler,本篇一章就够了。问题在于它们各自向上游捅了多深:
| 层 | 采样语义 | 输出约束(结构化输出) |
|---|---|---|
API / InputProcessor |
SamplingParams.verify() 校验参数(logprobs 上限、logit_bias 的 token id 是否在词表内、开投机时拒绝 min_p / logit_bias) |
_validate_structured_outputs() 校验 schema、为请求选后端(auto 时 xgrammar → guidance → outlines) |
| EngineCore / Scheduler | —— |
|
| KV Cache | —— | —— |
ModelRunner / InputBatch |
|
采样前在 GPU 上按行打掩码(apply_grammar_bitmask()),行序要从调度器的顺序重排成 batch 的顺序 |
| Sampler | 主战场:以”是否改变 argmax”组织的流水线 | 不变:掩码在 Sampler 之前 |
| 多卡 | 每个 TP rank 都拿到完整 logits、各自采样;PP 下只有末 stage 采样 | GrammarOutput 随 RPC 广播给所有 worker;external_launcher 下改同步编译 |
| PD 分离 | 采样参数随请求一起交接 | P、D 各编译一次 grammar,两次编译都落在 TTFT 上 |
| 代价 | 几乎为零;值得算的是 penalties(每步两张 [B, V+1] int64 直方图 + 历史输出上传)和 seed |
几乎全在 CPU 与 TTFT:编译、每步填 bitmask、FSM 推进;GPU 只有一个掩码 kernel |
KV Cache 一行是空的,这本身就是一个结论:输出语义的变化不影响状态怎么存——不同的采样参数、不同的 grammar 产生的都是普通 token,KV 与 prefix cache 不需要多一个键(对比上一篇,LoRA 和多模态都要往块哈希里加 extra_keys)。
4. 回到前面的例子:这一步有多贵?
回到前面的例子(Llama-3-70B、8×H100、TP=8、2050 token prompt、生成 300 token)。Llama-3 的词表是 128256,一行 fp32 logits 是 128256 × 4 B ≈ 513 KB;batch=64 时整个 logits 张量约 33 MB,按 3.35 TB/s 算读一遍只要 0.01 ms。和第六篇里”一步 decode 约 10 ms”相比,Sampler 本身的成本是可以忽略的——这正是能在这里塞进那么多花样的原因。
但有三个东西不便宜,本篇会逐个点名:
- 排序:top-p 的朴素实现要对每行 128256 个数排序;
- 计数:penalties 要为每个请求在 GPU 上建一张
[vocab+1]的 int64 直方图,batch=64 就是 66 MB,而且每步重建; - CPU 上的状态机:结构化输出每步要在调度器进程里为每个请求填一行 bitmask、推进一次 FSM,请求进来时还要编译 grammar——几十到几百毫秒,直接加在这个请求的 TTFT 上。
5. 本文的章节安排
| 章 | 主题 | 内容 |
|---|---|---|
| 二 | 采样语义 |
|
| 三 | 输出约束:结构化输出 |
|
| 四 | 多卡与 PD 分离下的适配 |
|
| 五 | 一步之内的顺序 | 结构化输出、投机解码与 penalties 同时开启时一步的时间轴 |
| 六 | 本文小结 | |
| 七 | 自测 | 5 道题 |
二、采样语义:让同一个 batch 里的每一行按自己的规矩走
1. 从请求到张量:参数在哪几层被接住
一个 temperature=0.7, top_p=0.9, frequency_penalty=0.5 的请求,参数要经过三层才变成 GPU 上的一个数:
- API 进程:
InputProcessor._validate_params()调SamplingParams.verify()——校验 logprobs 个数不超过max_logprobs、logit_bias的 token id 在词表内、allowed_token_ids非空,并按引擎配置检查组合是否合法:开了投机解码时,_validate_spec_decode()对带min_p或logit_bias的请求直接报VLLMValidationError(原因见本章第 6 节);要thinking_token_budget却没配 reasoning parser 的请求也在这里被拒。不合法的组合在进入引擎之前就变成一个 400,而不是在 GPU 上出错。 - 持久 batch:请求进入 model runner 时,
InputBatch.add_request()(vllm/v1/worker/gpu_input_batch.py)把参数写进按下标索引的 CPU 数组——temperature_cpu[req_index]、top_p_cpu[req_index]、frequency_penalties_cpu[req_index]……(greedy 请求的温度记成 0),带seed的请求在generators[req_index]里放一个自己的torch.Generator;同时记下集合(frequency_penalties_reqs等),用来判断整个 batch 是否no_penalties、all_greedy。 - 每步的
SamplingMetadata:InputBatch.refresh_metadata()只在 batch 有增删移动时重建SamplingMetadata——_make_sampling_metadata()用copy_slice()把前num_reqs个 CPU 值拷到 GPU 镜像;all_greedy时连温度都不拷,no_penalties时三种 penalty 和 prompt token 都不拷(注释:”syncing these tensors is expensive”)。
所以”每个请求有自己的参数”在 vLLM 里的落点是持久 batch 的一个下标:请求在 batch 里占一行,参数跟着这一行走。后面几节都从这里展开。
2. Sampler 的流水线
Sampler(vllm/v1/sample/sampler.py)的 docstring 把顺序写得很清楚,这里按它的编号照抄骨架:
# vllm/v1/sample/sampler.py(docstring 顺序,代码略去分支)
class Sampler(nn.Module):
"""
1. 若要 logprobs:按 logprobs_mode 先保存一份"原始" logprobs / logits
2. logits 转 float32
3. allowed_token_ids 白名单(不在名单里的置 -inf)
4. bad_words 排除
5. 非 argmax-invariant 的 logits processors(会影响 greedy 结果的)
a) min_tokens b) logit_bias
6. penalties:repetition / frequency / presence
7. sample():
a) 若不是 all_random,先算 greedy(argmax);若 all_greedy 直接返回
b) 温度
c) argmax-invariant 的 logits processors(默认只有 min_p)
d) top_k / top_p
e) 按分布采样
f) 逐行 torch.where(temperature < eps, greedy, random)
8. 取 top-k logprobs 与被采样 token 的 logprob
"""
这个顺序不是随意的。第 5 步与第 7c 步的区分——”会不会改变 argmax”——是整个流水线的组织原则:min_tokens 把 EOS 置 -inf、logit_bias 直接加偏置,这两个会改变最大值在哪里,所以必须在 greedy 分支之前做;而 min_p 只是把低于阈值的 token 砍掉,最大值永远留着,greedy 请求做不做都一样,所以放到温度之后、只对随机采样的行做。这样一个 batch 里 greedy 与 random 的请求可以走同一条流水线,最后一行 torch.where 分流,不需要拆 batch。
把这个”先共用、再分叉、最后按行合流”的结构画出来(整张 [num_reqs, V] 的 logits 从头到尾没有被拆成两个 batch):
%% 图:Sampler 的流水线:先共用会改变 argmax 的处理,再分 greedy 与随机采样两路,最后按行 torch.where 合流
flowchart TB
L["logits #91;num_reqs, V#93; → float32<br/>(若要 logprobs,先留一份原始 logits)"]
PRE["会改变 argmax 的处理<br/>allowed_token_ids → bad_words<br/>→ min_tokens / logit_bias → penalties"]
L --> PRE
PRE --> GR["greedy 分支<br/>argmax(logits),整张一起算"]
PRE --> TEMP["温度:logits / temperature<br/>(greedy 行的温度先换成 1)"]
TEMP --> MINP["argmax-invariant 处理器<br/>默认只有 min_p"]
MINP --> TKP["top_k / top_p<br/>Triton / FlashInfer / 逐行 sort"]
TKP --> RND["随机采样<br/>argmax(probs / Exp(1) 噪声)<br/>带 seed 的行单独覆盖噪声"]
GR --> ALLG["all_greedy?直接返回 greedy"]
GR --> WH["torch.where(temperature < eps, greedy, random)<br/>逐行选择,不拆 batch"]
RND --> WH
WH --> OUT["sampled_token_ids #91;num_reqs, 1#93;<br/>+ top-k logprobs(按 logprobs_mode 取哪份分布)"]
classDef shared fill:#eef3fb,stroke:#3b6ea5;
classDef greedy fill:#e8f5e9,stroke:#2e7d32;
classDef random fill:#fff3e0,stroke:#ef6c00;
class L,PRE,WH,OUT shared;
class GR,ALLG greedy;
class TEMP,MINP,TKP,RND random;
蓝色是全 batch 共用的段,绿色只对 greedy 有意义、橙色只对随机采样有意义,但两条支路都在整张张量上跑——greedy 请求也会被除以 1、被 top-k 过一遍,只是结果最后被 torch.where 丢掉。这是”向量化优先于省算”的取舍:多算几行远比拆 batch、多发 kernel 便宜。
还有一个容易漏看的点:logprobs 默认取的是”原始” logits 的 log-softmax(logprobs_mode="raw_logprobs"),也就是第 1 步那份、还没经过 penalties 和温度的。源码注释明说这与 V0 不同——V0 返回的是采样用的 processed 分布。如果你的评测依赖 logprobs,这个差别要知道。
3. 每请求不同参数:向量化,而不是循环
第 1 节建好的 SamplingMetadata(vllm/v1/sample/metadata.py)里,temperature、top_p、top_k、三种 penalty 都是形状 [num_reqs] 的张量,all_greedy / all_random / no_penalties 是三个用来短路的标量。整条流水线没有”for req in batch”:
- 温度:
apply_temperature()先把 greedy 请求的温度(< 1e-5)用torch.where换成 1,再对整张 logits 做一次div_; - 随机采样:
random_sample()(vllm/v1/sample/ops/topk_topp_sampler.py)不用torch.multinomial——它会触发 CPU-GPU 同步——而是用指数噪声技巧:q ~ Exp(1),argmax(probs / q),等价于按probs采样。带seed的请求单独用自己的torch.Generator覆盖它那一行的噪声(源码注释承认这个逐请求循环”can be slow”); - top-k / top-p:
apply_top_k_top_p()有三条路:CPU 走 Triton 或纯 PyTorch;GPU 上 batch ≥ 8 走 Triton kernel(topk_topp_triton.py),小 batch 走apply_top_k_top_p_pytorch()——后者对每行做一次全词表sort,这就是第一章点名的”排序”成本。CUDA 上如果 FlashInfer 可用(flashinfer_sampler_supported(),由VLLM_USE_FLASHINFER_SAMPLER控制),TopKTopPSampler.forward_cuda()会把 top-k/top-p/采样交给 FlashInfer 的融合 kernel;但只要 batch 里有带 seed 的请求,或logprobs_mode要求 processed 分布,就退回 native 路径。
“每请求不同参数”这件事在 GPU 上是免费的——一个 [num_reqs] 的向量广播到 [num_reqs, V] 上而已。真正的成本在于每一步都要把这些参数张量维护好,这是下一节 LogitsProcessor 接口存在的原因。
4. LogitsProcessor 接口:为什么它长成”跟着 batch 变化”的样子
LogitsProcessor(vllm/v1/sample/logits_processor/interface.py)只有四个方法:
# vllm/v1/sample/logits_processor/interface.py
class LogitsProcessor(ABC):
def __init__(self, vllm_config, device, is_pin_memory): ...
def apply(self, logits: torch.Tensor) -> torch.Tensor: ... # 对整张 [num_reqs, V] 就地改
def is_argmax_invariant(self) -> bool: ... # 决定进第 5 步还是第 7c 步
def update_state(self, batch_update: BatchUpdate | None): ... # 每次 forward 前调用
关键在 update_state() 的参数 BatchUpdate:
@dataclass(frozen=True)
class BatchUpdate:
batch_size: int
removed: Sequence[int] # 被移出 batch 的下标
added: Sequence[tuple[int, SamplingParams, list[int] | None, list[int]]]
# (下标, 参数, prompt_ids, output_ids 的引用)
moved: Sequence[tuple[int, int, MoveDirectionality]] # 请求在 batch 内的位置变动(单向 / 交换)
为什么接口是这个形状?因为 vLLM V1 的 InputBatch(vllm/v1/worker/gpu_input_batch.py)是一个持久 batch:请求不是每步重新排列,而是占着一个下标,完成的请求腾出的坑由后来者填上、或者把队尾的请求挪过来补洞。一个 logits processor 如果维护了任何”按下标索引的状态”(比如 min_p 的 [max_num_reqs] 张量),就必须跟着这些增删移动同步。InputBatch 里的 BatchUpdateBuilder 在一步之内收集所有 removed / added / moved,refresh_metadata() 时打包成一个 BatchUpdate 喂给每个处理器——removed 按降序排好,处理顺序固定为 removed → added → moved。
added 元组里的 output_tok_ids 是对请求输出列表的引用,不是拷贝(接口注释里特意写了 “Key assumption”)。这样 MinTokensLogitsProcessor 在 update_state() 时只要看 len(out_tok_ids) >= min_toks 就知道哪些请求已经过了最小长度,可以从自己的稀疏字典里删掉——不需要任何人通知它”又生成了一个 token”。
内置的三个处理器(vllm/v1/sample/logits_processor/builtin.py)刻意示范了两种状态组织方式:
| 处理器 | 状态形态 | apply() 做什么 |
argmax-invariant |
|---|---|---|---|
MinPLogitsProcessor |
稠密:[max_num_reqs] 的 CPU pinned 张量 + GPU 镜像,min_p_count 计数为 0 时整个跳过 |
softmax → 每行最大概率 × min_p → 低于阈值置 -inf |
是 |
LogitBiasLogitsProcessor |
稀疏:dict[req_idx → dict[token → bias]],变化时重建一组 (req_idx, tok_id, bias) 的索引张量 |
logits[(reqs, toks)] += biases 一次 index_put |
否 |
MinTokensLogitsProcessor |
稀疏:dict[req_idx → (min_toks, output_ids 引用, stop_token_ids)] |
index_put_(-inf) 到所有未达最小长度请求的 stop token 上 |
否 |
稀疏路线的通用逻辑抽成了 process_dict_updates(),AdapterLogitsProcessor 也复用它。
thinking budget 走的是同一套协议,但不是 LogitsProcessor 子类:ThinkingBudgetStateHolder(vllm/v1/sample/thinking_budget_state.py)用同样的 BatchUpdate 做 sync_batch(),但它需要看到本步”已经提交的输出 + 投机 draft”来判断思考段是否超预算,所以 Sampler.apply_logits_processors() 把它放在 penalties 之后单独调用(holder.update_state() → holder.apply_to_logits()),并明确区分 output_token_ids(已提交)与 spec_token_ids(草稿)。
自定义处理器有两条注册路径(vllm/v1/sample/logits_processor/__init__.py):
- 引擎参数
--logits-processors x.y.z:MyProc(ModelConfig.logits_processors),_load_logitsprocs_by_fqcns()按module:Class导入; - Python entry point 组
vllm.logits_processors(LOGITSPROCS_GROUP),_load_logitsprocs_plugins()自动发现。
两者都要求是 LogitsProcessor 子类,并在引擎启动时实例化一次——V1 不再支持请求级传入 callable。如果你有一个老式的 def f(output_ids, logits),用 AdapterLogitsProcessor 包一层:实现 new_req_logits_processor(params) 按请求参数决定要不要给这个请求装一个,apply() 会逐行调用。逐行调用意味着一个 Python 循环,这是兼容的代价。
两条硬限制值得记住:pooling 模型拒绝任何自定义处理器;开启投机解码时拒绝自定义处理器,min_p 和 logit_bias 也不可用——前端拒绝带这两个参数的请求,引擎侧 build_logitsprocs() 只保留 MinTokensLogitsProcessor 并打一条 warning。原因在本章第 6 节。
5. penalties 的真实代价
penalties 不是 LogitsProcessor,而是流水线里一段硬编码的步骤(Sampler.apply_penalties() → apply_all_penalties(),vllm/v1/sample/ops/penalties.py → apply_penalties(),vllm/model_executor/layers/utils.py)。它做的事很直白:
# vllm/model_executor/layers/utils.py(简化)
def apply_penalties(logits, prompt_tokens_tensor, output_tokens_tensor,
presence_penalties, frequency_penalties, repetition_penalties):
num_seqs, vocab_size = logits.shape
_, prompt_mask = get_token_bin_counts_and_mask(prompt_tokens_tensor, vocab_size, num_seqs)
output_bin_counts, output_mask = get_token_bin_counts_and_mask(output_tokens_tensor, vocab_size, num_seqs)
apply_repetition_penalties(logits, prompt_mask, output_mask, repetition_penalties) # 自定义 CUDA op
logits -= frequency_penalties.unsqueeze(1) * output_bin_counts
logits -= presence_penalties.unsqueeze(1) * output_mask
get_token_bin_counts_and_mask() 用 scatter_add_ 建一张 [num_seqs, vocab_size + 1] 的 int64 直方图(多出的一列给 padding 用)。代价算一下:
| 项 | 量 | 说明 |
|---|---|---|
| 直方图 | 64 × 128257 × 8 B ≈ 66 MB,prompt 和 output 各一张 |
每步重新 zeros + scatter_add_ |
output_token_ids 上传 |
_convert_to_tensors() 把 Python 的 list[list[int]] pad 成张量再 H2D |
长度随已生成 token 数增长;300 步时是 64 × 300 个 int64 |
| repetition penalty | apply_repetition_penalties 自定义 op(csrc/libtorch_stable/sampler.cu) |
读两张 mask + 整张 logits |
几十 MB、几百微秒的量级,和 10 ms 的 decode 比仍然是小数——但它是每一步、只要 batch 里有任何一个请求开了 penalty 就要全 batch 付的固定成本,而且随 batch 和词表线性增长。源码里 NOTE(nick) 直言 “The penalties implementation is currently quite inefficient and will be reworked anyhow”。更隐蔽的成本在 CPU 侧:no_penalties 为 False 时 model runner 必须每步维护并上传 output_token_ids,异步调度下这个列表甚至可能含有 -1 占位(apply_all_penalties() 里专门有一行把 -1 换成 vocab_size)。
对比一下不同采样参数的成本形态:
| 参数 | 每步额外工作 | 成本形态 |
|---|---|---|
temperature / top_k(Triton 或 FlashInfer) |
一次逐元素 / 一个 kernel | 忽略 |
top_p(小 batch 纯 PyTorch 路径) |
每行全词表 sort | 与 V log V 成正比,batch < 8 才会走到 |
min_p |
一次 softmax + amax + mask | 忽略 |
logit_bias / min_tokens / allowed_token_ids / bad_words |
稀疏 index_put | 忽略 |
| 任一 penalty | 两张 [B, V+1] int64 直方图 + 历史输出上传 |
O(B·V) 显存流量 + CPU 侧列表维护 |
带 seed |
逐请求 Python 循环覆盖噪声;FlashInfer 路径失效 | 与带 seed 的请求数线性 |
结论:采样参数里真正值得在容量规划时考虑的只有 penalties 和 seed,其余都淹没在 forward 里。
6. 开投机解码时的采样参数
投机解码(第六篇第五章)不改变输出分布,但它要求 target 在每个 draft 位置上的分布 \(q\) 正是这个请求真正的采样分布——否则拒绝采样就不再无损。麻烦在于第一章第 1 节的第 1 条约定被打破了:logits 不再是每请求一行,而是 [num_reqs + Σ drafts, V],一个带 3 个 draft 的请求就占 4 行(3 行 target + 1 行 bonus)。RejectionSampler.forward() 因此把采样语义拆成两份:
- bonus 行(每请求一行)走完整的
Sampler,本章前几节的一切照常生效; - target 行(每个 draft 一行)走一条投机版的处理链:
RejectionSampler.apply_logits_processors()用repeat_indices(arange(num_reqs).repeat_interleave(num_draft_tokens))把[num_reqs]的 penalty 参数展开到[num_drafts]行;- penalties 要的历史输出也不同:第 i 个 draft 位置的”历史”是”已输出 + 前 i-1 个 draft”,由
_combine_outputs_with_spec_tokens()拼出来; MinTokensLogitsProcessor有专门的apply_with_spec_decode();thinking budget 的ThinkingBudgetStateHolder分开看output_token_ids(已提交)与spec_token_ids(草稿);apply_sampling_constraints()用expand_batch_to_tokens()(一个 Tritonexpand_kernel)把温度、top-k、top-p 从每请求展开到每个 draft 行,再调第 3 节那个apply_top_k_top_p()——源码注释NOTE(woosuk)提醒它用排序、大词表下慢。
只有这几个内置步骤做了”行 → 请求”的映射,通用的 LogitsProcessor.apply(logits) 接口拿不到 SpecDecodeMetadata,不知道每一行属于谁。于是 min_p、logit_bias 和自定义处理器只能对 bonus 行生效、对 target 行不生效——那样输出分布就不再是请求要的分布,vLLM 干脆在开投机时禁用它们(第 4 节末尾那两条限制)。这是采样语义与投机解码之间的一处硬冲突:开投机解码的服务,等于对请求声明不支持 min_p / logit_bias / 自定义 logits processor。
三、输出约束:把 grammar 变成每步一张掩码
1. 约束在哪里生效:四个位置
结构化输出(SamplingParams.structured_outputs,支持 json / json_object / regex / choice / grammar / structural_tag,对应 StructuredOutputOptions 枚举)看起来只是”采样时把不合法 token 屏蔽掉”,但它在请求生命周期上分布在四个位置:
第 ② 步的异步是关键设计:JSON schema 编译成 grammar 可能要几十到几百毫秒,甚至更久,如果在调度循环里同步做,整个引擎会为一个请求停摆。所以编译进线程池,请求进一个专门的等待状态(第十四篇状态机里的 WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR 就是它)。调度器每步在 _try_promote_blocked_waiting_request() 里看一眼 structured_output_req.grammar——这个 property(StructuredOutputRequest.grammar,vllm/v1/structured_output/request.py)会以 100 微秒的超时去 poll Future,好了就把请求放回 WAITING,编译抛异常的请求记入 grammar_compile_error_reqs 单独失败。代价是这个请求的 TTFT 多了一段编译时间;收益是其他请求完全不受影响。external_launcher 模式是例外——各 TP rank 各有调度器,异步编译会让状态转换在不同 rank 上时序不一致,于是退回同步(见第四章第 3 节)。
第 ③ 步里有一个时序细节值得看 EngineCore.step()(vllm/v1/engine/core.py):
scheduler_output = self.scheduler.schedule(...)
future = self.model_executor.execute_model(scheduler_output, non_block=True) # 先把 forward 发出去
grammar_output = self.scheduler.get_grammar_bitmask(scheduler_output) # GPU 在算 forward 时,CPU 填 bitmask
model_output = future.result()
if model_output is None:
model_output = self.model_executor.sample_tokens(grammar_output) # 掩码在采样前才送到 worker
bitmask 的生成与 forward 是重叠的。这是 V1 把 execute_model 和 sample_tokens 拆成两个 RPC 的原因之一——掩码不需要在 forward 之前就绪,只需要在采样之前就绪。两条时间线并排看:
- 正常情况:填 bitmask 比 forward 短,被完全遮住,GPU 不多等。
- 异常情况:请求多、K 大、或每步只有几 ms 时,填 bitmask 比 forward 长,GPU 在 idle 段空转等 RPC 2。
fill_bitmask_parallel_threshold就是为此而设。
2. 后端抽象
vllm/v1/structured_output/backend_types.py 定义了两层:
@dataclass
class StructuredOutputBackend(ABC): # 引擎级,一个引擎只有一个
def compile_grammar(self, request_type: StructuredOutputOptions, grammar_spec: str) -> StructuredOutputGrammar
def allocate_token_bitmask(self, max_num_seqs: int) -> torch.Tensor
def destroy(self)
class StructuredOutputGrammar(ABC): # 请求级,一个请求一个
def accept_tokens(self, request_id, tokens: list[int]) -> bool # 推进 FSM
def validate_tokens(self, tokens: list[int]) -> list[int] # 只校验不推进,返回合法前缀
def rollback(self, num_tokens: int) # 回退 FSM
def fill_bitmask(self, bitmask: torch.Tensor, batch_index: int) # 填当前状态的掩码
def is_terminated(self) -> bool
def reset(self)
四个后端:XgrammarBackend(backend_xgrammar.py,xgr.GrammarCompiler 编译、xgr.GrammarMatcher 做 FSM)、GuidanceBackend(backend_guidance.py)、OutlinesBackend(backend_outlines.py)、LMFormatEnforcerBackend(backend_lm_format_enforcer.py)。StructuredOutputManager.grammar_init() 在第一个请求到来时按 sampling_params.structured_outputs._backend 实例化后端,注释明说 “We only support a single backend. We do NOT support different backends on a per-request basis”。StructuredOutputsConfig.backend 默认 "auto",前端的选择逻辑(第 1 节 ①)会为每个请求写入 _backend,但引擎只认第一个请求的那个:之后即使某个请求在 auto 下被判成了另一个后端,也仍交给已实例化的后端编译,编译失败就记入 grammar_compile_error_reqs。
validate_tokens() / rollback() 两个方法不是 grammar 库的常规接口,它们是为投机解码加的(第 4 节)。
3. bitmask 的生成与应用
形状:xgrammar 的 bitmask 是 int32,每 32 个 token 一个 word,128256 / 32 = 4008 个 int32,每行 16 KB。StructuredOutputManager.grammar_bitmask() 第一次调用时一次性分配 max_num_seqs × (1 + num_spec_tokens) 行——每个请求 1 行给普通 token 或 bonus,K 行给 K 个 draft 位置。
生成(CPU,调度器进程):对本步每个使用结构化输出、且不在 prefill chunk 中的请求(get_grammar_bitmask() 过滤 is_prefill_chunk——prefill 阶段不采样,不需要掩码),调用 grammar.fill_bitmask(bitmask, index);不需要约束的行(reasoning 中、grammar 已终止)填全 -1(_full_mask,即全部允许)。请求数超过 128 且没开投机时,按 16 个一组投进 executor_for_fillmask 线程池并行填。返回值转成 np.ndarray——注释说明是为了跨进程序列化效率。
应用(GPU,worker 进程):apply_grammar_bitmask()(vllm/v1/structured_output/utils.py)做两件事:
- 重排。调度器给的 bitmask 行序是
structured_output_request_ids的顺序,worker 的 logits 行序是input_batch.req_ids的顺序,而且投机解码下每个请求占1 + num_drafts行。函数先按input_batch.req_ids算出每个结构化请求的 logits 起始行(累加前面请求的 draft 数),再把 bitmask 搬到一张[logits.shape[0], 4008]的 pinned 张量里对应的行上,其余行全-1; - 打掩码。
xgr.apply_token_bitmask_inplace(logits, bitmask, indices)——一个 kernel,对indices指定的行把 bit 为 0 的 token 置-inf。所有行都要打时indices=None。
重排这一步用一个三请求的 batch 画出来。A 不用结构化输出但带 1 个 draft,B 用 JSON 约束、没有 draft,C 用 JSON 约束、带 2 个 draft;调度器按 structured_output_request_ids = [C, B] 的顺序填,worker 的 logits 却按 req_ids = [A, B, C] 排:
右表的行号就是 logits_indices 的行号:C 的起始行 3 = A 的 2 行 + B 的 1 行,所以一个请求 draft 数的变化会挪动它后面所有结构化请求的掩码位置——这张 [logits.shape[0], 4008] 的 pinned 张量每步都要重新填。
注意它作用在进 Sampler 之前的原始 logits 上。掩码是硬 -inf,放在温度、penalties 之前或之后对”哪些 token 可能被选中”没有影响,但会影响 logprobs:raw_logprobs 模式下返回的 logprobs 是打掩码之后、其他处理之前的分布。
回到前面的例子:batch=64 全部开 JSON 约束、不开投机:每步 CPU 填 64 行、H2D 传 64 × 16 KB = 1 MB、GPU 一个掩码 kernel 扫 33 MB logits。三项都在百微秒量级。
4. 与投机解码同时开启
投机解码本身(proposer、调度器的预算与回滚、RejectionSampler)见第六篇第五章。这里只看它和 grammar 的交叉:投机解码让一步要决定 1+K 个位置,而每个位置的合法 token 集合取决于前面的位置,叠加时需要两层配合:
① draft 先过一遍 grammar。 Scheduler.update_draft_token_ids() 拿到 proposer 的 draft 后,对结构化请求调 grammar.validate_tokens(spec_token_ids)——不推进 FSM,只返回合法的最长前缀。不合法的尾部直接扔掉(同步路径)或补 -1(异步路径 update_draft_token_ids_in_output(),因为 scheduled_spec_decode_tokens 的长度已经定了)。这是为什么 StructuredOutputGrammar 需要 validate_tokens() 这个”只看不动”的方法。
② bitmask 每个位置一行。 第 i 个 draft 位置的合法 token 集合取决于前 i-1 个 draft 都被接受时 FSM 的状态。grammar_bitmask() 的循环因此对每个请求:填第 0 行(当前状态)→ accept_tokens([d₁]) 推进 → 填第 1 行 → accept_tokens([d₂]) → … → 填 bonus 行 → rollback(state_advancements) 全部回退。遇到 -1(无效 draft)就停止推进、后续行不再约束。于是 FSM 每步被推进 K 次再回退 K 次——结构化输出的 CPU 成本随 K 线性增长,这也是并行填充路径要求 max_num_spec_tokens == 0 的原因(推进/回退是有状态的,不好切成独立任务)。
用一个最小的例子把”FSM 状态 ↔ 掩码行”的对应画出来。grammar 是 {"n": <整数>},请求已经输出了 {"n":,draft 是 [4, 2, }](K=3):
若 d2 = -1(无效 draft),FSM 走到 S1 就停,row 2 / row 3 填全 -1,不约束。
四行掩码分别对应四个不同的 FSM 状态,这就是”每个位置一行”的含义;而这一路推进全是假设 draft 会被接受得出的,所以最后必须 rollback 回 S0,等 RejectionSampler 给出真正接受了几个,再由调度器推进一次。
真正采样时,bonus 行走普通 Sampler、target 行走 RejectionSampler,两者的输入 logits 都已被各自的行掩码挖过,所以被接受的 draft 与 recovered token 都一定合法——update_from_output() 里的 accept_tokens() 若返回 False,源码直接记 error 并把请求置为 FINISHED_ERROR,因为这在设计上不应发生。
5. reasoning 模式下的跳过
推理模型(DeepSeek-R1 一类)的输出前半段是 <think>…</think>,用户要的 JSON 在后面。如果从第一个 token 就打掩码,模型连”思考”都得用 JSON 写。StructuredOutputsConfig.reasoning_parser 指定一个 ReasoningParser,StructuredOutputManager 用它做三件事:
should_fill_bitmask(request):reasoning_ended为 False 时返回 False,该请求这一步填全-1(不约束);enable_in_reasoning=True则始终约束;should_advance(request, new_token_ids):用reasoner.is_reasoning_end_streaming()检测本步的新 token 里是否出现了思考结束标记,出现则置reasoning_ended=True并记下边界reasoning_end_token_index;trim_reasoning_for_advance():思考结束的那一步,新 token 可能是”思考尾巴 + 结束标记 + JSON 开头”混在一起,只把结束标记之后的部分喂给accept_tokens()。
和投机解码叠加时更绕:一个 draft 窗口内可能刚好跨过结束标记。grammar_bitmask() 的循环里 detect_reasoning_end 分支会对每个 draft 位置模拟”到这里为止的序列”判断是否结束,结束后把该窗口剩余位置改为约束、但容忍这些 draft 被 grammar 拒绝(它们是在没有掩码时产生的,不保证合法)。
6. 代价
| 项 | 量级 | 谁付 | 何时付 |
|---|---|---|---|
| grammar 编译 | 简单 regex / choice:毫秒级;复杂 JSON schema:几十到几百毫秒,极端情况秒级 | 该请求的 TTFT | 请求进入时,异步 |
| bitmask 分配 | max_num_seqs × (1+K) × 16 KB,256 × 4 × 16 KB = 16 MB |
引擎进程内存 | 一次 |
| 每步填充 | 每请求每位置一次 fill_bitmask(数十微秒量级)+ 投机下 K 次 accept_tokens / 1 次 rollback |
调度器进程 CPU,与 forward 重叠 | 每步 |
| 每步传输 | 请求数 × (1+K) × 16 KB 序列化 + H2D |
IPC 与 PCIe | 每步 |
| 每步掩码 | 一个 GPU kernel,读写整张 logits | GPU,百微秒以下 | 每步 |
| FSM 推进 | accept_tokens(),微秒级 |
调度器进程 | 每步之后 |
结构化输出的”贵”几乎全在 CPU 和延迟上,GPU 侧近乎免费。当调度器进程是瓶颈(大 batch、小模型、每步只有几毫秒)时,每步几毫秒的 CPU 填充就不再能被 forward 遮住,这是 fill_bitmask_parallel_threshold 存在的场景。
四、多卡与 PD 分离下的适配
第七篇(多卡)和第八篇(PD 分离)讲的都是”一个模型、一串 token”怎么拆到多张卡、两个实例上,没有讨论采样语义和 grammar。把本篇的两类变化放回去,要回答的是:logits 在哪张卡上、谁来采样、掩码怎么送到、grammar 在哪里编译。
1. TP:每个 rank 都拿到完整 logits、各自采样
TP 下 lm_head 是按词表切的 VocabParallelEmbedding,每张卡只算出 [num_tokens, V / tp] 的一片 logits。LogitsProcessor._get_logits()(vllm/model_executor/layers/logits_processor.py)在 lm_head.tp_size > 1 时调 _gather_logits():
current_platform.use_all_gather()为真时用tensor_model_parallel_all_gather,每个 rank 都得到完整的[num_tokens, V]。Platform.use_all_gather()的默认实现返回True,CUDA 平台没有覆盖它,所以 8×H100 上走的是这条;- 返回
False的平台走tensor_model_parallel_gather,只有 rank 0 拿到 logits,其他 rank 得到None。
拿到完整 logits 之后,TP 组里每个 rank 都打同一张掩码、跑同一个 Sampler。GrammarOutput 跟 SchedulerOutput 走同一条路:MultiprocExecutor.sample_tokens() 是一次 collective_rpc("sample_tokens", args=(grammar_output,)),消息写进共享内存的 rpc_broadcast_mq 一次、所有 worker 读到同一份,只有 output_rank 回复(unique_reply_rank=self.output_rank)——就是第七篇第八章那张时序图里 execute_model 的路径,sample_tokens 是它的第二个 RPC。
为什么不让一个 rank 采样、再广播结果?因为下一步的输入 token 要进每个 rank 自己的 InputBatch,异步调度下上一步采出的 sampled_token_ids 直接留在 GPU 上作为下一步的输入,各 rank 自己采就省掉了一次广播。前提是 8 个 rank 采出同一个 token:logits 相同、操作序列相同,随机状态由 GPUWorker 初始化时的 set_random_seed(model_config.seed) 对齐,带 seed 的请求各 rank 用同一个种子建自己的 torch.Generator。
回到前面的例子:batch=64、TP=8,bf16 的完整 logits 是 64 × 128256 × 2 B ≈ 16 MB,每卡贡献 2 MB,一次 all-gather 在 NVLink 上是几十微秒量级;bitmask 随 RPC 每步序列化一份,64 行 × 16 KB ≈ 1 MB。两者都远小于 10 ms 的一步。
2. PP:只有末 stage 采样
PP 下 logits 只在最后一个 stage 产生。第七篇讲 PP 里请求怎么流动时说过,非末 stage 算完把激活发给下游就返回;落到 GPUModelRunner 上,这些 rank 的 execute_model() 返回 IntermediateTensors,不保存采样所需的状态,于是随后的 sample_tokens(grammar_output) 在它们那里直接返回——GrammarOutput 照样广播给所有 worker,但只有末 stage 用得上。
下一步的输入要回到第一个 stage。同步调度下,采出的 token 经 ModelRunnerOutput 回到调度器,再随下一步的 SchedulerOutput 发出去。异步调度下调度器已经提前一步把下一步发出去了,等不及这条路,于是:
- 末 stage 在
sample_tokens()里调_pp_broadcast_prev_sampled_token_ids(),在 PP 组上torch.distributed.broadcast本步的sampled_token_ids; - 其他 stage 在
sample_tokens()开头调_pp_receive_prev_sampled_token_ids_to_input_batch()收下,放进input_batch.prev_sampled_token_ids,下一步拼输入时直接用; - 整个 batch 都在 chunked prefill 中时,采出的是会被丢弃的占位 token,两边都跳过广播。
这条路径断言 sampled_token_ids 的形状是 [num_reqs, 1](”PP+async expects …”),即每请求一个 token。external_launcher(torchrun)下另有一套:broadcast_pp_output 为真时,PP 输出在计算 logits 时就已经同步到所有 rank,采样后不再单独广播。
3. external_launcher 与 DP:谁有自己的调度器
结构化输出的状态在调度器进程里(StructuredOutputManager),所以关键是”有几个调度器”:
external_launcher:每个 TP rank 一个调度器。 各 rank 必须在同一步做出同样的调度决定。异步编译让WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR → WAITING的转换在不同 rank 上发生在不同时刻,打破这个前提并导致死锁(StructuredOutputManager.__init__的注释原话),所以_use_async_grammar_compilation在这种模式下为假:grammar_init()就地调用_create_grammar()同步编译,编译时间不再藏在后台线程里。- DP:每个 DP rank 一个 EngineCore、一个调度器。 请求被路由到哪个 rank,grammar 就在哪个 rank 编译、FSM 就在哪个 rank 推进。xgrammar 的
GrammarCompiler开了缓存(cache_enabled=True,上限VLLM_XGRAMMAR_CACHE_MB),同一个 schema 第二次编译几乎免费,但缓存是每个后端实例自己的:同一个 schema 打到 4 个 DP rank 上,会各编译一次。
4. PD 分离:grammar 在两侧各编译一次
第八篇的交接契约里有一行”请求执行可接续:采样和停止参数、输出预算”。采样参数是请求字段,随请求本身到达 D,不需要额外交接;grammar 不一样,它是每个实例自己编译、自己推进的状态。
按第八篇走读的 NIXL toy proxy(tests/v1/kv_connector/nixl_integration/toy_proxy_server.py):send_request_to_service() 把原请求改成 max_tokens=1、stream=False 发给 P,P 返回后再把原请求加上 P 给的 kv_transfer_params 发给 D。proxy 已经为采样参数做了一处适配:发往 P 之前去掉 min_tokens / min_completion_tokens(注释 “These args are not supported for P”——max_tokens=1 与 min_tokens > 1 自相矛盾)。结构化输出参数则没有去掉,两边都带着,于是:
- P 侧也编译 grammar。 请求在 P 上同样先进
WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR,grammar 就绪之前不会被调度,prefill 被推迟;最后一个 prompt chunk 那一步is_prefill_chunk已为假,get_grammar_bitmask()会为它填掩码——P 用掩码采出那 1 个 token,而这个 token 不会返回给客户端。 - D 侧再编译一次,并且排在 KV 拉取之前。 D 的
EngineCore收到请求时grammar_init()提交编译;D 发起 KV 拉取是在Scheduler.schedule()把请求从 waiting 队列取出、调connector.get_num_new_matched_tokens()的时候,而 grammar 未就绪的请求走不到这一步。所以 D 上的编译和 KV 传输是串行的,不是并行的。 - TTFT 多两段编译时间。 对照第八篇的 TTFT 分解 \(T_{\text{front}}+Q_P+T_P+T_{\text{handoff}}+Q_{D,\text{ready}}+T_{D,\text{first}}+T_{\text{return}}\):P 上的编译落在 \(Q_P\) 里(请求在 P 上等着被调度),D 上的编译串在 \(T_{\text{handoff}}\) 前面。首 token 由 D 产生、受 D 的掩码约束,P 上那次编译只是为了让 P 能调度这个请求。
两点缓解:重复的 schema 在各实例的 xgrammar 缓存里命中,第二次之后编译几乎免费;P 那次编译可以由 proxy 省掉:像处理 min_tokens 一样,在发往 P 的请求里去掉结构化输出参数,P 采出的那个 token 反正会被丢弃。
PD 下 penalties 的历史也值得看一眼:toy proxy 丢弃 P 的输出 token,D 从空的输出历史开始生成,output_token_ids 与不分离时一致。若改成 P 流式返回首 token,D 的历史就要从 1 开始,min_tokens、penalties、grammar 的 FSM 都要把这个 token 计入。故障恢复时同理:第八篇说的流式续接要遵守约束解码,换一个实例意味着用已提交的输出把 grammar 重新推进一遍。
五、一步之内的顺序
把前几章的内容按一步(EngineCore.step())的时间轴排开。结构化输出与投机解码同时开启时交叉最多,所以下图画的是结构化输出 + 投机解码 + penalties 同时开启的一步;不开投机时,去掉其中的 draft 预留、RejectionSampler 和 propose_draft_token_ids(),剩下的就是结构化输出 + 普通 Sampler 的顺序。
图里 RejectionSampler.forward() 的两条处理器链展开是:
- bonus 行走完整的 Sampler 链:
allowed_token_ids → bad_words → min_tokens / logit_bias → penalties → thinking budget → 温度 → min_p → top_k/top_p → 采样; - target 行只走投机版处理器(penalties / bad_words / min_tokens / thinking budget),再由
apply_sampling_constraints把温度 / top_k / top_p 按 draft 展开; - 最后
rejection_sample按 greedy / random 两种 kernel 得到[batch, K+1],被拒的位置填-1; - 采样一结束就
propose_draft_token_ids():EAGLE 跑 K 步 draft forward(走 PIECEWISE 图),产出下一步的 draft。
(开投机时 min_p / logit_bias 在前端就被拒,所以 bonus 链里的 logit_bias 与 min_p 实际上是空的。)
三点值得再说一次:
- 掩码在最前(③ 的第一步),所以后面所有处理器和拒绝采样看到的都是已经挖过的分布;
- draft 在采样之后立刻产生(③ 的最后一步),但要等调度器在 ④ 里用
validate_tokens过滤后才成为下一步的输入; - FSM 的真正推进只发生在 ④ 的
accept_tokens();② 里为填掩码做的推进全部rollback。
📂 本章源码导航
采样与 logits processors
| 想看什么 | 从哪开始 |
|---|---|
| 采样流水线 | vllm/v1/sample/sampler.py → Sampler.forward() / sample() / apply_logits_processors() |
| 前端校验 | vllm/v1/engine/input_processor.py → InputProcessor._validate_params();vllm/sampling_params.py → SamplingParams.verify()、_validate_spec_decode() |
| 每请求参数的容器 | vllm/v1/sample/metadata.py → SamplingMetadata;vllm/v1/worker/gpu_input_batch.py → InputBatch.add_request()、_make_sampling_metadata() |
| 投机解码下的采样 | vllm/v1/sample/rejection_sampler.py → RejectionSampler.apply_logits_processors()、apply_sampling_constraints()(投机解码本身见第六篇的源码导航) |
| top-k / top-p / 随机采样 | vllm/v1/sample/ops/topk_topp_sampler.py → TopKTopPSampler、apply_top_k_top_p()、random_sample();Triton 版 topk_topp_triton.py |
| penalties | vllm/v1/sample/ops/penalties.py → apply_all_penalties();vllm/model_executor/layers/utils.py → apply_penalties()、get_token_bin_counts_and_mask() |
| LogitsProcessor 接口与 BatchUpdate | vllm/v1/sample/logits_processor/interface.py |
| 内置处理器 | vllm/v1/sample/logits_processor/builtin.py → MinPLogitsProcessor、LogitBiasLogitsProcessor、MinTokensLogitsProcessor、process_dict_updates() |
| 加载、注册与 Adapter | vllm/v1/sample/logits_processor/__init__.py → build_logitsprocs()、AdapterLogitsProcessor、LOGITSPROCS_GROUP |
| 持久 batch 的增删移动 | vllm/v1/sample/logits_processor/state.py → BatchUpdateBuilder;vllm/v1/worker/gpu_input_batch.py → InputBatch.refresh_metadata() |
| thinking budget | vllm/v1/sample/thinking_budget_state.py → ThinkingBudgetStateHolder |
结构化输出
| 想看什么 | 从哪开始 |
|---|---|
| 前端校验与后端选择 | vllm/sampling_params.py → SamplingParams._validate_structured_outputs();vllm/config/structured_outputs.py → StructuredOutputsConfig |
| 管理器 | vllm/v1/structured_output/__init__.py → StructuredOutputManager.grammar_init() / grammar_bitmask() / should_fill_bitmask() / should_advance() |
| 后端抽象 | vllm/v1/structured_output/backend_types.py → StructuredOutputBackend、StructuredOutputGrammar、StructuredOutputOptions |
| 各后端 | backend_xgrammar.py(XgrammarBackend、XgrammarGrammar)、backend_guidance.py、backend_outlines.py、backend_lm_format_enforcer.py |
| 请求级状态与 Future | vllm/v1/structured_output/request.py → StructuredOutputRequest.grammar |
| 调度器侧 | vllm/v1/core/sched/scheduler.py → get_grammar_bitmask()、_try_promote_blocked_waiting_request()、update_from_output() 中的 accept_tokens |
| 引擎循环的时序 | vllm/v1/engine/core.py → EngineCore.step() / step_with_batch_queue() |
| GPU 上打掩码 | vllm/v1/structured_output/utils.py → apply_grammar_bitmask();调用处 GPUModelRunner.sample_tokens() |
| GrammarOutput | vllm/v1/core/sched/output.py → GrammarOutput、SchedulerOutput.scheduled_spec_decode_tokens / num_invalid_spec_tokens |
多卡与 PD 分离
| 想看什么 | 从哪开始 |
|---|---|
| TP 下 logits 的收集 | vllm/model_executor/layers/logits_processor.py → LogitsProcessor._get_logits() / _gather_logits();vllm/platforms/interface.py → Platform.use_all_gather() |
GrammarOutput 的广播 |
vllm/v1/executor/multiproc_executor.py → MultiprocExecutor.sample_tokens()、collective_rpc(unique_reply_rank=...) |
| PP 下采样结果回传 | vllm/v1/worker/gpu_model_runner.py → sample_tokens()、_pp_broadcast_prev_sampled_token_ids()、_pp_receive_prev_sampled_token_ids_to_input_batch()、broadcast_pp_output |
| 同步 / 异步编译 | vllm/v1/structured_output/__init__.py → StructuredOutputManager.__init__(_use_async_grammar_compilation);xgrammar 编译缓存 backend_xgrammar.py → XgrammarBackend(VLLM_XGRAMMAR_CACHE_MB) |
| PD 下的请求改写 | tests/v1/kv_connector/nixl_integration/toy_proxy_server.py → send_request_to_service() |
六、本文小结
- 引擎对输出的默认约定是”每请求一行 logits、每个 token 都可能被选中、采样无状态”。输出语义的变化有两类:采样语义改分布本身(每请求的温度、top-p、penalties、
logit_bias、seed、自定义 logits processor……),输出约束改分布的支撑集(结构化输出把不合法 token 置-inf)。两者都不影响 KV 怎么存——这是它们和上一篇的输入变化最大的不同。 - 采样参数在三层被接住:前端
SamplingParams.verify()校验并拒绝不兼容的组合;持久InputBatch按下标保存每请求参数;refresh_metadata()只在 batch 变化时重建SamplingMetadata。Sampler的流水线以”是否改变 argmax”为组织原则,让 greedy 与随机请求走同一条向量化路径;LogitsProcessor接口长成update_state(BatchUpdate)的样子,是因为处理器状态必须跟着持久 batch 的增删移动同步。V1 只接受引擎级注册的处理器类。 - 采样参数里真正有成本的是 penalties(每步两张
[B, V+1]int64 直方图 + 历史输出上传)和seed(逐请求循环、FlashInfer 路径失效),其余淹没在 forward 里。开投机解码时 logits 变成每个 draft 一行,只有内置的几步做了”行 → 请求”的展开,所以min_p、logit_bias和自定义处理器被禁用。 - 结构化输出分布在四个位置:前端选后端;
grammar_init()异步编译,请求进WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR;每步调度器进程填 bitmask、与 forward 重叠,worker 按 batch 行序重排后一个 kernel 打-inf;步后accept_tokens()推进 FSM。与投机解码叠加时 draft 先经validate_tokens()过滤,bitmask 每个位置一行,FSM 每步推进 K 次再rollback()。成本几乎全在 CPU 与 TTFT 上。 - 多卡下:CUDA 上 logits 被 all-gather 到每个 TP rank,每个 rank 用同一张掩码、同一个随机种子各自采样,
GrammarOutput随sample_tokensRPC 广播、只有output_rank回复;PP 下只有末 stage 采样,异步调度时用_pp_broadcast_prev_sampled_token_ids()把 token 广播回其他 stage;external_launcher下 grammar 改为同步编译,DP 下每个 rank 各编译各的。 - PD 分离下采样参数随请求到达 D,grammar 却在 P、D 各编译一次:P 的编译推迟 prefill,D 的编译排在 KV 拉取之前,两段都加在 TTFT 上;toy proxy 已经在发往 P 的请求里去掉了
min_tokens,同样的办法可以省掉 P 上的编译。
七、自测
-
LogitsProcessor的接口为什么是update_state(BatchUpdate)而不是简单的__call__(logits)?答案
vLLM V1 的
InputBatch是持久的——请求在 batch 里占一个下标,完成的请求腾出的坑由后来者填上或由队尾请求挪过来补;处理器维护的每请求状态(如min_p的[max_num_reqs]张量、logit_bias的稀疏字典)必须同步这些增删移动,否则状态与行对不上。所以每步先用BatchUpdate(removed → added → moved)更新状态,再对整批向量化处理。 -
penalties(repetition / presence / frequency)为什么是”真正有成本的”采样参数?batch 64 时一步多出多少显存流量?
答案
它要看请求的全部历史:每步用
scatter_add_为 prompt 和 output 各建一张[B, V+1]的 int64 直方图(batch 64、V = 128256 时每张约 66 MB),并且 model runner 每步要把output_token_ids从 Python 列表 pad 成张量上传。而且只要 batch 里有一个请求开了 penalty,整个 batch 都付。temperature、top-p 只是对 logits 做一次向量运算。 -
开投机解码的服务里,一个带
min_p=0.05的请求会怎样?为什么?答案
在前端就被拒:
SamplingParams._validate_spec_decode()报VLLMValidationError。开投机时 logits 是每个 draft 一行,通用的LogitsProcessor.apply(logits)不知道每行属于哪个请求,min_p只能作用在 bonus 行、作用不到 target 行,拒绝采样用的分布就不再是请求要的分布;所以build_logitsprocs()也只保留MinTokensLogitsProcessor。 -
结构化输出的成本落在哪一侧?在 TP = 8 的部署里,掩码在几张卡上打?
答案
几乎全在 CPU 和 TTFT:grammar 编译(异步,请求在专门状态里等待,时间加在该请求的 TTFT 上)、每步调度器进程填 bitmask(与 forward 重叠)、步后推进 FSM;GPU 只有一个打
-inf的 kernel。TP = 8 时 8 张卡都打:CUDA 上 logits 被 all-gather 到每个 rank,GrammarOutput随sample_tokensRPC 广播给所有 worker,每个 rank 各自打掩码、各自采样。 -
按 toy proxy 部署 PD 分离时,一个带 JSON schema 的请求的 grammar 编译几次、分别在哪里拖慢了 TTFT?
答案
两次。P 上请求要等 grammar 就绪才会被调度,编译时间落在 P 的排队里,推迟 prefill;D 上请求同样先等 grammar,KV 拉取要等请求被
schedule()取出时才发起,所以编译串在 KV 交接之前。P 采出的那个受约束的 token 会被丢弃,P 的编译可以由 proxy 去掉结构化输出参数省掉。
-
两类变化,各落在不同的层。采样语义(温度、top-p、penalties、
logit_bias、seed、自定义 logits processor):前端SamplingParams.verify()校验,持久InputBatch按下标存参数并用BatchUpdate同步处理器状态,Sampler以”是否改变 argmax”组织流水线;开投机解码时 logits 变成每个 draft 一行,min_p/logit_bias/ 自定义处理器被禁用(第二章)。输出约束(结构化输出):前端选后端,grammar_init()异步编译,调度器每步在 CPU 上填 bitmask、步后推进 FSM,worker 在采样前打掩码,与投机解码叠加时还要逐位置填掩码并回退(第三章)。多卡下每个 TP rank 各自采样、PP 只有末 stage 采样;PD 下 grammar 在 P、D 各编译一次(第四章)。KV Cache 一层不需要改。 ↩ -
采样语义几乎免费:参数是
[num_reqs]的向量,广播到整张 logits 上;值得算的是 penalties(每步两张 \([B, V+1]\) int64 直方图,batch 64 时每张约 66 MB,外加历史输出上传)和带seed的请求(逐请求循环、FlashInfer 路径失效),付钱的是整个 batch(第二章第 5 节)。结构化输出的钱几乎全花在 CPU 和 TTFT 上:编译几十到几百毫秒加在该请求的 TTFT 上,每步填 bitmask 由调度器进程付、与 forward 重叠,投机下 FSM 每步推进 K 次再回退;GPU 只有一个掩码 kernel(第三章第 6 节)。PD 分离下编译付两次(第四章第 4 节)。 ↩
系列 《大模型推理系统揭秘:从 vLLM 看 LLM Serving Infra 核心技术》 第 10 / 16 篇 深入
系列总览 — 为什么这样组织、读它需要什么、读完能做什么 · 幻灯片 — 整个系列的精华,一份可分享的 deck
学习地图 — AI-Infra 工程师学习地图 第 9 / 14 个系列
- 为什么 LLM Serving 比传统 DL 推理难?
- 如何衡量一个 LLM Serving 系统?
- 鸟瞰 vLLM:一个请求如何穿过整个推理系统?
- Scheduler:GPU 这一轮到底给谁用?
- KV Cache:LLM Serving 的第一号内存问题
- GPU 执行:如何让每个 Token 算得更快?
- Multi-GPU:一张卡不够时如何扩展?
- PD 分离:从资源混部走向计算解耦
- 请求形态的扩展:multi-LoRA 与多模态
- 输出在变:采样参数与结构化输出
- 模型适配:如何跟上变化极快的模型世界?
- 硬件解耦:如何不让芯片差异污染 Serving 核心?
- Serving Infra 的下一站:从模型执行器到分布式智能操作系统
- 回到源码:一次请求在 vLLM 内部的真实旅程
- vLLM 与 SGLang:同一个请求穿过两套 Serving 系统
- 系列总结与通关自测
上一篇:请求形态的扩展:multi-LoRA 与多模态下一篇:模型适配:如何跟上变化极快的模型世界?
下一个系列:RL 后训练基础设施:rollout 与训练如何共享一组 GPU 深入 · 9 篇,约 2.9 小时
本文由 arganzheng 创作,采用 CC BY 4.0 许可协议。在保留原文作者、署名以及完整原文链接(https://arganzheng.life/output-changes-sampling-and-structured-output.html)的前提下,欢迎各种形式的转载、翻译或商业引用。
COMMENTS
评论存放在 GitHub Discussions, 用 GitHub 账号登录即可发表,支持 Markdown。 想针对正文某句话说?选中那段文字,点浮出的「评论」即可划线评论;觉得哪里写错了,发表时勾上「同时提交 Issue」。 有人回复你时 GitHub 会按你的通知设置发邮件,不用守在这里。