为什么需要thinking-budget

你是否也曾见过一个LLM在深度思考中逐渐陷入了自我怀疑的无限循环中呢?

虽然CoT一定程度上提升模型回答的准确率,reasoning-mode也逐渐成为各大开源模型中的必需品。但新的问题也随之而来:失控的思考。LLM常常会表现出overthinking的问题,大量冗余猜测,和自我否定,无一不增加token预算,这些问题在小参数量的、采用蒸馏获得的reasoning模型中尤为常见。

那么一个很自然的想法:为模型的思考设定一个预算,对于简单问题可以选择比较少的预算,而难题可以使用较大的预算。

主流模型厂商所提供的推理控制

会发现,在主流的模型提供商的API中和应用服务中,普遍有对于推理长度的控制手段

OpenAI: 提供了reasoning.effort等参数来暗示模型的思考深度(针对o1,o3系列模型)

Anthropic: 允许通过budget_tokens或特定提示词(如 “think” < “think hard” < “think harder” < “ultrathink.")来触发不同级别的思考

Gemini & Qwen: 直接在API中提供了明确的thinking_budget参数设置

开源实现:Budget Forcing

那么对于开发者而言,特别是本地化部署的LLM要如何实现thinking-budget?

最直观的方法便是使用prompt,但效果并不好,特别当模型在训练过程中没有特别针对性训练,明文化要求模型“用更少的token进行思考” 一般并不起什么作用。相比之下Qwen3系列的混合thinking模型中,在训练阶段加入了/think and /no_think的标记,于是在推理过程中在system-prompt或者user-prompt中加入/no_think能够从think模式切换。

 (Section 4.3)“To better integrate the two modes and enable users to dynamically switch the model’s thinking process, we design chat templates for Qwen3, as shown in Table 9. Specifically, for samples in thinking mode and non-thinking mode, we introduce /think and /no think flags in the user query or system message, respectively. This allows the model to follow the user’s input and select the appropriate thinking mode accordingly.”
…

(Section 4.4): “Format Following: In addition to explicit instructions, we expect the model to adhere to specific formatting conventions. For instance, it should respond appropriately to the /think and /no think flags by switching between thinking and non-thinking modes…”
…
 (Section 4.7): “ThinkFollow: Involves multi-turn dialogues with randomly inserted /think and /no thinkflags to test whether the model can correctly switch thinking modes based on user queries.”

不过在阅读Qwen3的technical-report和相关文档时,发现Qwen官方为开源社区提供的thinking-budget的解决思路。

其提供了一种两步走的实现思路:

Step-1: generate,这一步和不同的generate没有差别,只是设定max_new_tokens为thinking_budget


# first generation until thinking budget
generated_ids = model.generate(
    **model_inputs,
    max_new_tokens=thinking_budget
)
output_ids = generated_ids[0][input_length:].tolist()

Step-2: 检查  

即检查 对应的Token ID(151668)是否存在于输出中

  • 如果存在:说明模型在预算内已经完成了思考,无需干预。

  • 如果不存在:说明思考过程被预算中断了, 将early_stopping_text注入,和上一轮输出拼接,作为新的一轮输入,进行第二次model generate

# check if the thinking process has finished (151668 is </think>)

if 151668 not in output_ids:

    print("thinking budget is reached")

    # 准备提前终止的提示

    early_stopping_text = "\n\nConsidering the limited time by the user, I have to give the solution based on the thinking directly now.\n</think>\n\n"

    early_stopping_ids = tokenizer([early_stopping_text], ...).input_ids.to(model.device)

    

    # 将提示拼接到第一次生成的后面

    input_ids = torch.cat([generated_ids, early_stopping_ids], dim=-1)

    

    # 进行第二次生成

    generated_ids = model.generate(

        input_ids=input_ids,

        ...

    )

不过这个思路需要进行两轮的输出,那有没有更优雅的实现方式?

答案是肯定的。即利用LogitsProcessor。

想法来自于这篇blog和github-issue讨论

其核心原理是:创建一个自定义的LogitsProcessor,在模型生成每个token时进行检查:

  • 判断当前是否处于<think>和</think>之间

  • 计算自<think>开始后已生成的token数量

  • 当数量达到thinking_budget时,强行修改下一token的概率分布(logits),将</think>的概率设为1.0,其余所有token的概率设为负无穷

以受限编码的思路实现thinking-budget。

from typing import List, Optional
import torch

class ThinkLogitsProcessor:
    """A logits processor that limit the number of thinking tokens."""

    def __init__(self, think_start_token, think_end_token, num_think_tokens: int = 100):
        """
        Initialize the think logits processor.

        Args:
            tokenizer: The tokenizer used for the model
            num_think_tokens: Maximum number of tokens allowed in thinking section
        """
        self.num_think_tokens = num_think_tokens
        self.think_start_token = think_start_token
        self.think_end_token = think_end_token

    def __call__(
        self,
        input_ids: List[int],
        logits: torch.Tensor,
    ) -> torch.Tensor:
        """
        Process the logits to enforce </think> token when needed.

        Args:
            input_ids: List of input token IDs.
            logits: Tensor of logits for the next token.

        Returns:
            Processed logits tensor.
        """
        # Check if we're in a thinking section
        if self.think_start_token in input_ids and self.think_end_token not in input_ids:
            # Find the position of the last <think> token
            think_start_pos = len(input_ids) - 1 - input_ids[::-1].index(self.think_start_token)

            # Calculate number of tokens since <think>
            tokens_since_think = len(input_ids) - think_start_pos - 1

            # If we've reached the maximum thinking length, force </think>
            if tokens_since_think >= self.num_think_tokens:
                # Set all other logits to -inf except for </think>
                logits = torch.full_like(logits, float('-inf'))
                logits[self.think_end_token] = 1.0

        return logits

以vllm使用为例:

(⚠️注: 不过在vllm升级到V1版本之后,采用python编写的LogitsProcessor 被放弃,以下代码需要采用V1版本之前的vllm,如0.7.3)

from transformers import AutoTokenizer
from vllm import LLM, SamplingParams

tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen3-4B")
think_start_token = tokenizer.convert_tokens_to_ids("<think>") 
think_end_token = tokenizer.convert_tokens_to_ids("</think>")

sampling_params = SamplingParams(
            temperature=temperature,
            max_tokens=max_tokens,
            logits_processors=[ThinkLogitsProcessor(think_start_token, think_end_token, num_think_tokens)]
        )


llm = LLM(model="Qwen/Qwen3-4B")
conversations = ["your-conversations"]
outputs = llm.chat(messages=conversations,
                          sampling_params=sampling_params,
                          use_tqdm=False)

以上方法均采用了硬截止的方案,也就是强行加入</think>,从结果上看会显得比较生硬,同时模型一般会按照“惯性”,在回答中,继续一段思考的过程。

基于这套方案的改进策略,比如软性截止(在越接近budget时,线性或多项式增加</think> 的输出概率)一定程度会有所改善。

此外,special-token的插入或者直接插入自然语言提示,也是可行的。

基于难度截断

不过新的问题出现:“一刀切”的预算并不完美。简单问题预算太长,复杂问题预算又太短。手动设置预算在合成数据和rollout过程中并不现实。最直观的想法,是采用一个分类模型,预测一个最佳的budget。

在TALE这篇工作中,利用一个zero-shot prompt,要求模型分析问题并预估生成完整准确回答所需的最小Token数,而后将预算值嵌入到prompt中,如:

Task: Analyze the given question and estimate the
minimum number of tokens required to generate a
complete and accurate response. Please Give the
response by strictly following this format: [[budget]],
for example, Budget: [[12]].

在实际实验中,观察模型输出结果时发现,模型很容易出现猜测,不确定,自我怀疑的表述,如:

或者,可能XXX?
…
可能答案是XX?
…
可能我的计算有误?
…
可能我哪里弄错了?
…

特别是特别长的COT后期,模型基本在自我怀疑循环论证的死循环中不能自拔,或许通过捕获这些不确定词汇,在这些词汇频繁出现时,增加 的概率,以起到停止思考的作用,以实现early-stop。不过这套方案只适合小型模型,尤其是蒸馏过的推理模型。这些小型模型出现的模式化的‘自我怀疑’、‘不确定’,大概来自于teacher-model,但小型模型本身并不具备(或者很难具备)真正的自我纠正能力,于是大量的“wait…", “But wait…“, “Alternatively…“, 本身对于推理而言或许并没有太多帮助(需要实验佐证)。

Code
import torch
from typing import List, Dict, Tuple, Set, Optional
from collections import defaultdict

class UncertaintyThinkLogitsProcessor:
    """
    A logits processor that limits thinking based on detected uncertainty patterns.
    Optimized for efficiency and memory usage.
    """

    def __init__(
        self,
        tokenizer,
        think_start_token: int,
        think_end_token: int,
        uncertainty_phrases: Dict[str, float],
        uncertainty_threshold: float = 5.0,
        growth_strategy: str = "linear",
        growth_factor: float = 1.0,
        max_think_tokens: int = 1000,
        debug: bool = False
    ):
        """Initialize the uncertainty-based think logits processor."""
        self.tokenizer = tokenizer
        self.think_start_token = think_start_token
        self.think_end_token = think_end_token
        self.uncertainty_threshold = uncertainty_threshold
        self.growth_strategy = growth_strategy
        self.growth_factor = growth_factor
        self.max_think_tokens = max_think_tokens
        self.debug = debug

        # Process uncertainty phrases and build efficient lookup structures
        self.uncertainty_phrases = {}
        self.phrase_by_length = defaultdict(list)  # Group phrases by length for efficient lookup
        
        for phrase, score in uncertainty_phrases.items():
            token_ids = tuple(tokenizer.encode(phrase, add_special_tokens=False))
            self.uncertainty_phrases[token_ids] = score
            self.phrase_by_length[len(token_ids)].append(token_ids)

        # Sort by length for more efficient searching (shorter phrases first)
        self.sorted_lengths = sorted(self.phrase_by_length.keys())
        self.max_phrase_length = max(self.sorted_lengths) if self.sorted_lengths else 0

        # State tracking - optimized data structures
        self.accumulated_uncertainty = 0.0
        self.last_processed_position = 0  # Track last processed position instead of full input length
        self.detected_phrase_positions = set()  # Use set for O(1) lookup, store positions only
        
        # Pre-allocate tensor for logits modification to avoid repeated allocation
        self._force_think_end_logits = None

        if self.debug:
            print(f"Initialized with {len(uncertainty_phrases)} uncertainty phrases")
            print(f"Max phrase length: {self.max_phrase_length}")
            print(f"Uncertainty threshold: {uncertainty_threshold}")

    def _find_uncertainty_phrases(self, tokens: List[int], start_scan_pos: int = 0) -> List[Tuple[int, int, float]]:
        """
        Efficiently find uncertainty phrases in tokens using optimized algorithm.
        Returns list of (start_pos, end_pos, uncertainty_score) tuples.
        """
        found_phrases = []
        tokens_len = len(tokens)
        
        # Only scan from the specified start position
        for pos in range(start_scan_pos, tokens_len):
            # Check phrases of each length, starting from shortest
            for phrase_len in self.sorted_lengths:
                if pos + phrase_len > tokens_len:
                    break  # Can't fit this phrase length
                
                # Extract candidate phrase
                candidate = tuple(tokens[pos:pos + phrase_len])
                
                # Check if this phrase exists in our lookup for this length
                if candidate in self.phrase_by_length[phrase_len]:
                    # Verify it's actually in our main dictionary (should always be true)
                    if candidate in self.uncertainty_phrases:
                        found_phrases.append((pos, pos + phrase_len - 1, self.uncertainty_phrases[candidate]))
        
        return found_phrases

    def __call__(
        self,
        input_ids: List[int],
        logits: torch.Tensor
    ) -> torch.Tensor:
        """Process logits based on uncertainty in thinking."""
        # Convert input_ids to list if it's a tensor (avoid repeated conversion)
        if isinstance(input_ids, torch.Tensor):
            input_ids = input_ids.tolist()

        # Early exit if no uncertainty phrases configured
        if not self.uncertainty_phrases:
            return logits

        # Check if we're in a thinking section
        try:
            think_start_pos = len(input_ids) - 1 - input_ids[::-1].index(self.think_start_token)
            in_thinking = self.think_end_token not in input_ids[think_start_pos:]
        except ValueError:
            # No think_start_token found
            return logits

        if in_thinking:
            thinking_tokens = input_ids[think_start_pos + 1:]
            tokens_since_think = len(thinking_tokens)
            
            # Only process new tokens since last call
            new_tokens_start = max(0, self.last_processed_position)
            
            if tokens_since_think > new_tokens_start:
                # Calculate scan start position to catch phrases spanning the boundary
                scan_start = max(0, new_tokens_start - self.max_phrase_length + 1)
                
                # Find new uncertainty phrases
                new_phrases = self._find_uncertainty_phrases(thinking_tokens, scan_start)
                
                # Process only truly new phrases (not already detected)
                for start_pos, end_pos, base_score in new_phrases:
                    phrase_id = (start_pos, end_pos)
                    
                    if phrase_id not in self.detected_phrase_positions:
                        # Apply growth strategy
                        if self.growth_strategy == "poly":
                            uncertainty_increase = base_score * (tokens_since_think ** self.growth_factor)
                        else:  # linear or default
                            uncertainty_increase = base_score
                        
                        self.accumulated_uncertainty += uncertainty_increase
                        self.detected_phrase_positions.add(phrase_id)
                        
                        if self.debug:
                            phrase_tokens = thinking_tokens[start_pos:end_pos + 1]
                            phrase_text = self.tokenizer.decode(phrase_tokens)
                            print(f"Detected: '{phrase_text}' pos:{start_pos}-{end_pos} "
                                  f"score:+{uncertainty_increase:.2f} total:{self.accumulated_uncertainty:.2f}")
                
                # Update last processed position
                self.last_processed_position = tokens_since_think

            # Check if we should force </think>
            should_force_end = (
                self.accumulated_uncertainty >= self.uncertainty_threshold or
                tokens_since_think >= self.max_think_tokens
            )
            
            if should_force_end:
                if self.debug:
                    reason = ("uncertainty threshold" if self.accumulated_uncertainty >= self.uncertainty_threshold 
                             else "max tokens")
                    print(f"Forcing </think> due to {reason}")
                
                # Efficiently create force-end logits (reuse tensor if possible)
                if self._force_think_end_logits is None or self._force_think_end_logits.shape != logits.shape:
                    self._force_think_end_logits = torch.full_like(logits, float('-inf'))
                else:
                    self._force_think_end_logits.fill_(float('-inf'))
                
                self._force_think_end_logits[self.think_end_token] = 1.0
                return self._force_think_end_logits

        else:
            # Reset state when not in thinking mode or when thinking has ended
            self._reset_state()

        return logits

    def _reset_state(self):
        """Reset the processor state efficiently."""
        self.accumulated_uncertainty = 0.0
        self.last_processed_position = 0
        self.detected_phrase_positions.clear()  # More efficient than creating new set

    def reset(self):
        """Reset the processor state."""
        self._reset_state()
        # Also clear the cached tensor
        self._force_think_end_logits = None

使用

from vllm import LLM, SamplingParams
from transformers import AutoTokenizer

# Define uncertainty phrases
uncertainty_phrases = {
    # Chinese uncertainty phrases
    "可能我哪里算错了": 2.0,
    "我不确定": 1.5,
    "让我想想": 1.0,
    "可能": 0.5,
    "也许": 0.5,
    "或许": 0.5,
    "等等": 0.8,
    "我需要重新思考": 1.2,
    "有点困惑": 1.0,
    "我搞错了": 1.5,
    "我可能理解错了": 1.8,
    "这个有点棘手": 1.0,
    "需要再想想": 1.0,
    "可能我的分析有误?": 2.0,
    "可能我的理解有误?":2.0,
    "?":1.0,
    "正确?":1.0,
    "或者": 1.0,
    "这似乎不符":0.8,

    # English uncertainty phrases
    "I'm not sure": 1.5,
    "I need to reconsider": 1.2,
    "Let me think": 1.0,
    "maybe": 0.5,
    "perhaps": 0.5,
    "wait": 0.8,
    "hmm": 0.7,
    "let me check again": 1.2,
    "I might be wrong": 1.5,
    "I'm confused": 1.2,
    "I'm not certain": 1.5,
    "?":1.0,
    "That can't be right":1.0,
    "Wait":0.7,
    "right?":0.5
}

# Setup

llm = LLM(model="Qwen/Qwen3-4B")
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen3-4B")

# Get token IDs
think_start_token = tokenizer.convert_tokens_to_ids("<think>")
think_end_token = tokenizer.convert_tokens_to_ids("</think>")

uncertainty_processor = UncertaintyThinkLogitsProcessor(
    tokenizer=tokenizer,
    think_start_token=think_start_token,
    think_end_token=think_end_token,
    uncertainty_phrases=uncertainty_phrases,
    uncertainty_threshold=5.0,
    growth_strategy="linear",
    max_think_tokens=1000,
    debug=False
)

sampling_params = SamplingParams(
    n=5,
    temperature=0.7,
    max_tokens=5000,
    # top_p = 0.95,
    # top_k = 20,
    logits_processors=[uncertainty_processor]
)

Budget Guidance

在Steering LLM Thinking with Budget Guidance 这篇文章中,提出了Budget Guidance的概念。

借鉴了Diffusion Models中的Classifier Guidance思想,非强制模型输出特定内容或格式,而是“软性地”引导生成过程,使其自然地趋向于一个目标长度,通过 一个轻量级的预测器模块(Auxiliary Predictor),在LLM生成每个token时,预测剩余思考过程的长度分布

基于贝叶斯定理,将有预算约束的生成问题 p(下一个token | 上下文, 剩余长度 <= 预算) 转化为 p(下一个token | 上下文) * P(剩余长度 <= 预算 | 上下文, 下一个token) 的形式

$$ p(Y_t|X,Y_{<t}, L_t\leq \bar{l}-t) \to p(Y_t|X,Y_{<t})\cdot Pr(L_t \leq \bar{l}-t|X,Y_{<t},Y_t) $$

其中$p(Y_t|X,Y_{<t})$ 为原始LLM的输出概率,而$Pr(L_t \leq \bar{l}-t|X,Y_{<t},Y_t)$ 就是由Auxiliary Predictor提供的引导分数。

Pasted 2025-08-03-20-33-30.jpeg

在这篇文章中, Auxiliary Predictor 采用BERT-base model,不只是给出一个单一的长度预测值,而是将剩余长度的对数建模为一个伽马分布,BERT 以LLM在生成每个token时,最后一层的所有Hidden States作为输入,输出两个参数,伽马分布的形状参数λ和速率参数α,利用伽马分布的CDF,根据当前剩余的预算(目标总预算 - 已生成长度)计算出一个概率值,也就是引导分数。

Predictor训练:在OpenR1-Math-220k上,将一个完整的思考过程在不同位置截断,让预测器学习预测从该截断点到结束还需要的长度,训练目标是最大化预测的伽马分布对数似然函数,使预测分布尽可能接近真实剩余长度

Thinking长度真的越长越好吗

在Lost at the Beginning of Reasoning这篇工作中,发现模型的推理过程对第一步的推理(initial reasoning step)有极强的依赖性。一旦第一步出错,模型很难在后续步骤中纠正这个错误,导致最终答案大概率也是错误的 (Lost at the Beginning)

基于上述发现,作者提出了一种名为Early Pruning的采样策略。该策略的核心思想是:先生成多个简短的第一步推理候选项,然后利用一个奖励模型(Reward Model)快速评估这些候选项的质量,只保留质量最高的几个,并仅对这些有希望的路径继续生成完整的推理过程

并创建了一个名为 LaBoR (Lost at the Beginning of Reasoning) 的新基准,一个专门用于衡量长思维链模型在“开局不利”情况下自我修正能力的基准,每个样本都包含一个问题和一个故意设计的、有缺陷的第一步推理。

写在最后

本文围绕如何有效控制大模型的Thinking Budget展开,不过需要指出的是,本文的讨论主要聚焦于推理侧的解决方案。这些方法的核心优势在于其灵活性和“即插即用”的特性,无需对模型本身进行改动,非常适合本地化部署和快速验证。

然而,这也构成了本文的局限性。推理侧的控制更像是一种“外部约束”,有时难免显得生硬或不够鲁棒。一个更全面、更根本的Thinking-Budget解决方案,必然需要推理与训练的协同作用。特别是在Post-training中,加入不同推理强度的标记和特别训练,而后在推理侧,即可通过指令实现更高效的thinking。

参考

Claude Code: Best practices for agentic coding

Gemini-thinking-budget

Qwen3: Limit thinking length

Qwen3: Thinking budget

Limiting Qwen 3’s Thinking

Limit thinking tokens #15418

Token-Budget-Aware LLM Reasoning

Steering LLM Thinking with Budget Guidance

Lost at the Beginning of Reasoning