Prhub

#46727 [Feat] Support thinking_token_budget in Model Runner V2

原始 PR 作者 chaunceyjiang 合并时间 2026-08-07 11:36 文件变更 10 提交数 11 评论 29 代码增减 +1122 / -43

执行摘要

V2 采样链路支持 thinking_token_budget 强制收尾

PR body 明确动机:“In some quantized models, such as GLM-5.2 or Qwen quantized models, the model may generate long reasoning traces.” 量化模型的长思考链会显著浪费算力与响应延迟,thinking_token_budget 允许用户在推理 token 达到预算后被强制收尾。此前该能力只在 V1 Model Runner 中可用,V2 入口在 input_processor._validate_params 直接抛 VLLMValidationError、config/vllm.py 打印 warning,因此本 PR 的目标就是 “Support thinking_token_budget in Model Runner V2”,并补齐 GPU 侧扫描与强制结束的实现。

值得精读。核心看 vllm/v1/worker/gpu/sample/thinking_budget.py 的两个 Triton kernel:增量扫描缓存 + 冷扫描块向量化的设计、_load_effective_token 对 committed 与 spec-draft token 的统一读取,以及 forced-end 置 1e9 而非全 vocab 覆写的取舍。njhill 最终提交(clamp、UVA 脏标记、prompt_len 去除)也是很好的 MRV2 状态类优化范本。同时应关注讨论中未解决的 parser 隐式结束 gap,它会在 ParserEngine 迁移时再次触碰同一逻辑。

讨论亮点

njhill 是主要评审者,认可方向后重点推动扫描优化:“我们每次都需要扫描整个 context,其实只需要考虑新增 token”,并最终亲自提交了增量扫描、冷扫描 1024 块向量化(64k 历史 worst-case 约 34x 提速)、budget int32 clamp 等修复。rishitdholakia13 提出两个性能建议:128k prefill 正序扫描是 O(128k),应反向扫描以降低 TTFT;forced-end 不必整块 logits 写 -inf,只需把结束 token 提升到 1e9,chaunceyjiang 表示 make sense 并落地。dafeliton 报告了 Gemma 场景强制结束短语泄漏进主响应的 bug,修复后确认 works much better,但又指出 parser 隐式结束(reasoning 直接切到 <|tool_call> 不发结束符)仍未覆盖,chaunceyjiang 承认该 gap 留待 tool parser 迁移到 ParserEngine 后解决。njhill 还建议删除 remove_request 以保持 state 类一致性,以及未启用时不分配 tensor。

实现拆解

  1. 配置契约扩展(vllm/config/reasoning.py):ReasoningConfig 新增 _natural_reasoning_end_token_ids 私有字段与 natural_reasoning_end_token_ids 属性;initialize_token_ids 优先从 reasoning_parser 取 end_token 作为自然结束标记,未配置 parser 时回退到 reasoning_end_str。由此 reasoning_end_token_ids 的语义变为“预算耗尽时强制的结束短语”,可与模型自然结束标记不同(例如包含过渡句子)。
  2. 核心状态模块(新增 vllm/v1/worker/gpu/sample/thinking_budget.py 的 ThinkingBudgetState):init 先将 start / end / natural-end 三类标记 token ids 落成 GPU tensor,enabled 为三者同时存在,未启用直接 return 不分配任何 tensor;per-request budget 存于 UvaBackedTensor,add_request 中 clamp 到 int32 并记录 _reset_reqs,apply_staged_writes 在 reset 时清空增量扫描缓存、仅在 budget 实际变化时 copy_to_uva。
  3. GPU 扫描与强制收尾:_update_committed_marker_cache_kernel 维护 cached_last_start / cached_last_end / cached_scan_pos 缓存,增量 decode 只扫描新增 token,冷启动或冷恢复时以 1024 块从历史末尾反向回扫;_thinking_budget_kernel 处理 expanded 视角(含 spec-decode draft token),借助 _load_effective_token 混合读取 all_token_ids 与 input_ids,预算耗尽时仅将 reasoning_end_token_ids 对应 logit 置 1e9,避免全 vocab 覆写。
  4. Sampler 接线与校验放开(sampler.py、input_processor.py、config/vllm.py、model_runner.py):Sampler 接收 reasoning_config 并创建 thinking_budget_state,add_request / apply_staged_writes 同步透传,apply_sampling_params 在 apply_temperature 之前调用 thinking_budget_state.apply 保证强制 logit 不受 temperature 缩放影响;call 与 rejection_sampler.py 的 _verify 同步补传 idx_mapping;input_processor 删除“V2 不支持”分支,config/vllm.py 删除 warning,model_runner.py 构造 Sampler 时透传 vllm_config.reasoning_config。
  5. 测试与基准配套:新增 tests/v1/worker/test_gpu_thinking_budget.py(CUDA + triton 条件跳过)覆盖预算耗尽强制 end、多 token end 标记续写、独立 natural end 停止强制、prefill 已结束不强制、增量扫描不重扫全史、超大 budget clamp 不崩溃等场景;新增 benchmarks/kernels/benchmark_thinking_budget.py 提供 cached / incremental-decode / cold-prefill / cold-resume-worst-case / forced-end / batched-budgeted / batched-mixed 七种模式并支持 --max-slowdown 断言;tests/entrypoints/openai/chat_completion/test_thinking_token_budget.py 移除 VLLM_USE_V2_MODEL_RUNNER=0,三个 server fixture 默认使用 V2。
文件 模块 状态 重要度
vllm/v1/worker/gpu/sample/thinking_budget.py 采样器 added 9.08
vllm/config/reasoning.py 配置 modified 7.09
vllm/v1/worker/gpu/sample/sampler.py 采样器 modified 6.17
benchmarks/kernels/benchmark_thinking_budget.py 基准脚本 added 8.5
tests/v1/worker/test_gpu_thinking_budget.py 单元测试 added 7.76
vllm/v1/engine/input_processor.py 输入校验 modified 6.02
vllm/v1/worker/gpu/model_runner.py 模型执行器 modified 4.96
vllm/v1/worker/gpu/spec_decode/rejection_sampler.py 投机解码 modified 4.18

关键符号

ThinkingBudgetState.__init__ ThinkingBudgetState.add_request ThinkingBudgetState.apply_staged_writes ThinkingBudgetState.apply _update_committed_marker_cache_kernel _thinking_budget_kernel _load_effective_token ReasoningConfig.initialize_token_ids Sampler.apply_sampling_params Sampler.__call__

关键源码片段

vllm/config/reasoning.py core-logic

扩展 ReasoningConfig 配置契约,新增 natural_reasoning_end_token_ids,区分模型自然结束标记与预算耗尽时的强制结束短语,是 thinking budget 语义正确性的基础。

# vllm/config/reasoning.py
# ReasoningConfig 原先只有一个 ending 概念;本 PR 拆成两套:
# - natural_reasoning_end_token_ids:parser 定义的自然结束标记(模型自己会输出)
# - reasoning_end_token_ids:预算耗尽时强制插入的结束短语(可含过渡句子)
# 这样强制短语不会在模型自然结束时被重复插入,也不会因预算截断而缺失。def initialize_token_ids(self, model_config):
    if (self._reasoning_start_token_ids is not None
            and self._reasoning_end_token_ids is not None
            and self._natural_reasoning_end_token_ids is not None):
        self._enabled = True
        return # 已经初始化过则跳过
​
    tokenizer = cached_tokenizer_from_config(model_config=model_config)
    reasoning_start_str = self.reasoning_start_str
    reasoning_end_str = self.reasoning_end_str
    natural_reasoning_end_str = ""
    if self.reasoning_parser:
        parser_cls = ReasoningParserManager.get_reasoning_parser(self.reasoning_parser)
        reasoning_parser = parser_cls(tokenizer)
        start_token = reasoning_parser.reasoning_start_str
        if start_token and not reasoning_start_str:
            reasoning_start_str = start_token
        end_token = reasoning_parser.reasoning_end_str
        if end_token and not reasoning_end_str:
            reasoning_end_str = end_token
        # parser 的 end 字符串即“自然结束”标记,与强制结束短语区分开
        natural_reasoning_end_str = end_token or ""
​
    if not natural_reasoning_end_str:
        # 未配置 parser 时退化为 equal reasoning_end_str,保持旧行为
        natural_reasoning_end_str = reasoning_end_str
​
    if not reasoning_start_str or not reasoning_end_str:
        return
    self._reasoning_start_token_ids = tokenizer.encode(
        reasoning_start_str, add_special_tokens=False)
    self._reasoning_end_token_ids = tokenizer.encode(
        reasoning_end_str, add_special_tokens=False)
    self._natural_reasoning_end_token_ids = tokenizer.encode(
        natural_reasoning_end_str, add_special_tokens=False)
    # 三个字段任一为空都视为配置失败,抛出 ValueError
vllm/v1/worker/gpu/sample/sampler.py dependency-wiring

Sampler 是 ThinkingBudgetState 的接入点:构造、add_request、apply_staged_writes 生命周期透传,并在 apply_sampling_params 中 temperature 之前调用 budget 强制逻辑,决定强制 logit 的生效顺序。

# vllm/v1/worker/gpu/sample/sampler.py
# 接线点:thinking budget 处理必须放在 temperature 之前,
# 这样 1e9 的强制 logit 不会被 temperature 缩放抵消,
# 保证预算耗尽后必然采样到 reasoning 结束标记。def apply_sampling_params(
    self, logits, expanded_idx_mapping, idx_mapping,
    idx_mapping_np, pos, input_ids, expanded_local_pos, return_logprobs,
):
    # ... 先应用 penalties / logit_bias / bad_words 等 state ...
​
    # Force the reasoning end marker once a request's thinking budget is
    # reached; applied before temperature so the forced token is always kept.
    self.thinking_budget_state.apply(
        logits,
        expanded_idx_mapping,
        idx_mapping,
        idx_mapping_np,
        input_ids,
        expanded_local_pos,
    )
    # Apply temperature in place.
    self.sampling_states.apply_temperature(
        logits, expanded_idx_mapping, idx_mapping_np)

评论区精华

decode 步每次全量重扫 context 的优化 性能

njhill 指出当前实现每次扫描整个已提交 context,实际只需要考虑新增 token,并推送了 draft 优化提交。

结论:演进为 cached_scan_pos 增量扫描 + 冷扫描 1024 块向量化;njhill 最终提交记录显示 64k 历史下 cold resume worst-case 约 34x 提速。 · 已解决

prefill 长上下文应反向扫描标记 性能

rishitdholakia13:128k prefill 正序扫描找 START_THINKING 是 O(128k),而模板通常把标记放在提示末尾,反向扫描更早命中,可降低 TTFT。

结论:冷扫描改为从 history 末尾按 1024 块回退扫描;增量 decode 只扫新 token,prefill 结束后 cached_scan_pos 更新到当前长度。 · 已解决

避免全 vocab logits 覆写 性能

rishitdholakia13:不必把整个 logits 写为 -inf,只需把结束 token logit 提升到 1e9,省掉 O(vocab_size / logits_block_size) 的循环,draft token 多时收益更明显。

结论:chaunceyjiang 采纳(make sense),提交 Optimize thinking budget forcing by avoiding full-vocab logits overwrite。 · 已解决

remove_request 与 state 类一致性 设计

njhill:Sampler 新增的 remove_request 与其它 state 类不一致,建议删除;model_runner 里的调用也一并还原。

结论:按建议移除,ThinkingBudgetState 不单独管理移除逻辑。 · 已解决

未启用时不应分配 tensor 设计

njhill 建议把 enabled 判断提前,未启用时不创建 UvaBackedTensor 与缓存数组,避免默认路径的内存与初始化开销。

结论:最终 __init__ 先计算 enabled 再分配,未启用直接 return。 · 已解决

强制结束短语泄漏进主响应 正确性

dafeliton:Gemma 31B 配 reasoning-config(end 串为过渡句子加 <channel|>)且 budget 1500 时,该短语仍出现在主回答里;V1 曾有 workaround。

结论:作者修复后 dafeliton 确认 works much better;问题源于强制短语与自然结束标记的判断边界,修复后仍遗留隐式结束 gap。 · 已解决

隐式 parser 结束(tool_call 直接截断推理)未覆盖 正确性

dafeliton:Gemma 可从 reasoning 直接转 <|tool_call> 而不输出结束符;ParserEngine 会发 REASONING_END,但 V2 budget 只扫描 natural_reasoning_end_token_ids,长直接 tool call 仍会被当推理,强制短语可能插入 tool call 参数。

结论:chaunceyjiang 承认问题,计划等多数 tool parser 迁移到 ParserEngine 后再统一处理;本 PR 未修复。 · 待处理

thinking budget 性能回归测试 测试

rishitdholakia13 询问是否加性能回归测试防止长生成退化,njhill 反问是否愿意贡献;chaunceyjiang 用 Codex 生成了 kernel 级 benchmark 并给出 128 / 16384 / 32768 历史的实测数据。

结论:新增 benchmarks/kernels/benchmark_thinking_budget.py,支持 --max-slowdown 让 CI 可断言缓存与增量 decode 路径的延迟不随 history 长度退化。 · 已解决

风险与影响

  1. 隐式结束 gap(未解决):V2 budget 状态只扫描 natural_reasoning_end_token_ids,Gemma 等模型从 reasoning 直接切到 <|tool_call> 时不输出结束符,长 tool call 仍可能被当作推理阶段,强制短语会被插入 tool call 或其参数;dafeliton 与 chaunceyjiang 在讨论中确认,待 tool parser 统一迁移到 ParserEngine 后处理。
  2. spec-decode 交互复杂:_thinking_budget_kernel 依赖 expanded_idx_mapping / expanded_local_pos 定位 draft token,_load_effective_token 中 cur_req_first_pos + pos - total_len + 1 的偏移计算较为微妙;rejection_sampler.py 仅一行改动,结合 PR#50939 的 -1 占位 token 场景,任何 expanded 索引语义变化都可能回归。
  3. 冷扫描仍随历史长度增长:prefill 冷启动与 resume 需扫描 committed 历史,虽然 1024 块向量化带来约 34x 提速,但 benchmark 特意将 cold-resume-worst-case 排除在 --max-slowdown 判定之外,说明 worst-case 依然可能随 history 增长而退化。
  4. 测试平台覆盖有限:test_gpu_thinking_budget.py 在非 CUDA 平台整体 skip,ROCm / CPU 下的 Triton kernel 行为缺少回归保障。
  5. 配置契约变更:ReasoningConfig 新增字段并改变 reasoning_end_token_ids 语义(由自然结束改为强制短语),依赖旧语义的外部配置或序列化结果需要重新走 initialize_token_ids。

用户侧:V2 用户可直接使用 thinking_token_budget 控制推理长度,无需退回 V1 或设置 VLLM_USE_V2_MODEL_RUNNER=0,对 Qwen3.5-27B-FP8、GLM-5.2 等长思考链量化模型收益明显;e2e 测试已默认覆盖 V2 路径。系统侧:采样阶段新增 GPU kernel,仅当 batch 中存在带 budget 请求时才执行,普通请求路径通过 use_thinking_budget 掩码快速返回;强制 logit 置 1e9 发生在 temperature 之前,语义稳定。团队侧:MRV2 sampler 的 state 类接线模式继续扩展(与 penalties / logit_bias / bad_words 对齐),后续 ParserEngine 迁移时需回访隐式结束问题。

核心采样路径新增 GPU Kernel spec-decode 交互耦合 隐式结束标记未覆盖 冷扫描仍需全量扫历史 新增配置契约字段

关联 Issue

未识别关联 Issue

当前没有检测到明确关联的 Issue 链接,后续同步到相关引用后会出现在这里。

完整报告

参与讨论