课程0基础Agent开发课 / LLM基础 / 采样策略详解-Greedy-BeamSearch-TopK-TopP
— 19 min read

采样策略详解-Greedy-BeamSearch-TopK-TopP

> **[进阶选读]** 本文适合想深入理解 LLM 内部机制的读者。理解贪心搜索、束搜索、Top-K、Top-P 的区别,才能在不同场景下做出正确的参数选择。路径 A 的读者可以跳过。

采样策略详解:Greedy、Beam Search、Top-k 与 Top-p

[进阶选读] 本文适合想深入理解 LLM 内部机制的读者。理解贪心搜索、束搜索、Top-K、Top-P 的区别,才能在不同场景下做出正确的参数选择。路径 A 的读者可以跳过。


语言模型每一步的输出是一个概率分布(logits,即每个候选词的原始分数,经过 softmax 转换后才变成概率),从这个分布中选取下一个 Token 的过程叫解码(Decoding)采样(Sampling)

参数设错了,再好的模型也会产出重复、混乱的文字。


1.1 解码策略解决什么问题:概率分布到序列的转换

1.1.1 为什么这是一个非平凡的问题

LLM 每一步都会输出一个概率分布,表示"下一个 token 是每个候选词的概率"。把这个分布转换成实际文本,有很多不同的方式,每种方式都有根本性的权衡。

四种采样策略对比
图 6.15:四种采样策略对比——Greedy、Beam Search、Top-K 与 Top-P 的候选词选择方式

核心矛盾:语言的质量有两个维度——准确性(生成正确、连贯的内容)和多样性(生成有创意、不重复的内容)。这两个维度天然有张力:最准确的选择往往是最保守的,而最有创意的选择往往最容易出错。

不同的解码策略在这个权衡上做出不同的选择,适用于不同的场景:

  • 代码生成需要确定性和准确性 → 倾向于保守策略
  • 创意写作需要多样性和新颖性 → 倾向于随机策略
  • 翻译需要全局一致性 → 需要考虑整个句子的最优解

1.2 Greedy Decoding:最直接,也最局限

1.2.1 原理

贪心解码(Greedy Decoding)在每一步选择概率最高的 Token:

$$x_t = \arg\max_{v \in V} P(v \mid x_1, \ldots, x_{t-1})$$

python
import torch
import torch.nn.functional as F

def greedy_decode(model, tokenizer, prompt, max_new_tokens=50):
    """
    贪心解码:每步选择概率最高的 token。
    最简单,但容易陷入重复循环。
    """
    input_ids = tokenizer.encode(prompt, return_tensors="pt")

    for _ in range(max_new_tokens):
        with torch.no_grad():
            outputs = model(input_ids)
            logits = outputs.logits[:, -1, :]  # 取最后一个位置的 logits

        # 直接取 argmax,确定性选择
        next_token = torch.argmax(logits, dim=-1, keepdim=True)
        input_ids = torch.cat([input_ids, next_token], dim=1)

        # 遇到结束符停止
        if next_token.item() == tokenizer.eos_token_id:
            break

    return tokenizer.decode(input_ids[0], skip_special_tokens=True)

1.2.2 局限性

贪心解码有两个主要问题:

1. 重复退化(Repetition Degeneration): 模型一旦陷入某个高概率的短语,就会不断重复。常见症状是"……非常非常非常非常……"或段落循环。

2. 局部最优而非全局最优: 每步选最高概率不等于整个序列概率最高。例如:

code
目标:生成"我爱中国"
步骤 1:"我"概率最高,选"我"
步骤 2:给定"我","非常"概率比"爱"高,选"非常"
步骤 3:给定"我非常",陷入不同路径...
贪心结果:"我非常喜欢这个世界"(不是最优序列)

1.3 Beam Search:探索多条路径

1.3.1 为什么贪心搜索不是最优解:局部最优与全局最优的区别

贪心解码的根本问题在于:每步的局部最优不等于整体的全局最优

一个直觉的例子:如果你正在爬山,每次都走局部最陡峭的方向,很可能爬到一个小山头就停了,而真正的最高峰需要先走一段下坡路才能到达。

在语言生成中,有时候选择一个概率稍低的词,会为后续的词创造更好的上下文,从而让整个序列的概率更高。贪心搜索错过了这种可能性。

Beam Search 的思路:与其只走一条路,不如同时走 k 条路,最后选择整体概率最高的那条。

1.3.2 原理

Beam Search 在每一步保留 $k$(beam width)条概率最高的候选序列,最终选择整体概率最高的那条:

python
import torch
import heapq

def beam_search(model, tokenizer, prompt, num_beams=4, max_new_tokens=50):
    """
    Beam Search:同时维护 num_beams 条候选路径,取最优完整序列。
    适合翻译、摘要等需要全局一致性的任务。
    """
    input_ids = tokenizer.encode(prompt, return_tensors="pt")
    vocab_size = model.config.vocab_size

    # 初始化:每条 beam 是 (累积log概率, token序列)
    beams = [(0.0, input_ids)]
    completed = []

    for step in range(max_new_tokens):
        candidates = []

        for score, ids in beams:
            if ids[0, -1].item() == tokenizer.eos_token_id:
                completed.append((score, ids))
                continue

            with torch.no_grad():
                logits = model(ids).logits[:, -1, :]
                # 取对数概率
                log_probs = F.log_softmax(logits, dim=-1)

            # 取 top-k 扩展候选
            top_log_probs, top_tokens = torch.topk(log_probs, k=num_beams)

            for log_prob, token in zip(top_log_probs[0], top_tokens[0]):
                new_score = score + log_prob.item()
                new_ids = torch.cat([ids, token.unsqueeze(0).unsqueeze(0)], dim=1)
                candidates.append((new_score, new_ids))

        # 保留分数最高的 num_beams 条路径
        candidates.sort(key=lambda x: x[0], reverse=True)
        beams = candidates[:num_beams]

    # 返回分数最高的完整序列
    all_sequences = completed + beams
    best = max(all_sequences, key=lambda x: x[0])
    return tokenizer.decode(best[1][0], skip_special_tokens=True)

1.3.3 Beam Search 的局限

Beam Search 在翻译、文档摘要中效果良好,但在开放式生成场景下表现差:

  • 文本平淡:多条 beam 往往收敛到相似的高频路径,缺乏多样性
  • 长文重复:在较大 beam width 下也会出现短语重复
  • 不适合创意写作:最高概率的序列往往是"安全"但无趣的文本

1.4 Temperature:控制分布的"温度"

Temperature 是最重要的采样参数,它在采样之前对 logits 进行缩放:

$$P_T(x) = \text{softmax}\left(\frac{\text{logits}}{T}\right)$$

python
import torch
import torch.nn.functional as F
import matplotlib.pyplot as plt
import numpy as np

def apply_temperature(logits, temperature):
    """
    Temperature 的本质:对 logits 除以 T,然后重新计算 softmax。
    T → 0:概率集中于最高分 token(趋近贪心)
    T = 1:原始概率分布
    T → ∞:所有 token 概率趋于均匀分布
    """
    if temperature == 0:
        # 退化为贪心解码
        return torch.zeros_like(logits).scatter_(
            -1, logits.argmax(dim=-1, keepdim=True), 1.0
        )
    return F.softmax(logits / temperature, dim=-1)

# 可视化 temperature 对概率分布的影响
logits = torch.tensor([3.0, 1.5, 0.5, -0.5, -1.0])  # 示例 logits

for T in [0.3, 0.7, 1.0, 1.5, 2.0]:
    probs = apply_temperature(logits.unsqueeze(0), T).squeeze()
    print(f"T={T:.1f}: {probs.numpy().round(3)}")
# T=0.3: [0.975 0.023 0.001 0.000 0.000]  # 非常集中
# T=0.7: [0.786 0.175 0.032 0.006 0.002]  # 较集中
# T=1.0: [0.601 0.245 0.090 0.033 0.020]  # 原始分布
# T=1.5: [0.444 0.275 0.170 0.071 0.041]  # 更分散
# T=2.0: [0.367 0.270 0.199 0.103 0.062]  # 接近均匀

实践准则:

  • T = 0.0~0.3:确定性任务(代码生成、数学推理、事实问答)
  • T = 0.7~1.0:通用对话
  • T = 1.0~1.5:创意写作、头脑风暴

1.5 Top-k 采样:限制候选数量

Top-k 采样在每步只从概率最高的 $k$ 个 Token 中采样:

python
def top_k_sampling(logits, k=50):
    """
    Top-k 采样:保留概率最高的 k 个 token,其余置为 -inf。
    问题:k 是固定的,无法适应概率分布的形态。
    - 分布尖锐时(少数词概率很高),k=50 引入太多低概率噪音
    - 分布平坦时(很多词概率相近),k=50 可能不够
    """
    # 将排名低于 k 的 token 的 logits 设为负无穷
    top_k_logits, top_k_indices = torch.topk(logits, k, dim=-1)
    filtered_logits = torch.full_like(logits, float('-inf'))
    filtered_logits.scatter_(-1, top_k_indices, top_k_logits)

    probs = F.softmax(filtered_logits, dim=-1)
    next_token = torch.multinomial(probs, num_samples=1)
    return next_token

1.5.1 Top-k 的核心问题

固定 $k$ 无法适应变化的概率分布:

code
场景 1:下一词极其明确
词表概率:["巴黎"=0.95, "里昂"=0.03, "马赛"=0.01, 其他总计0.01]
k=50 会强制引入 50 个候选,大量垃圾 token 参与采样

场景 2:下一词有很多合理选项
词表概率:["开心"=0.05, "快乐"=0.05, "高兴"=0.04, "愉悦"=0.04, ...(共100个相近词)]
k=50 可能只覆盖了 40% 的概率质量,遗漏了很多合理选项

1.6 Top-p(Nucleus)采样:按概率质量动态截断

1.6.1 为什么 Top-p 比 Top-k 更灵活

Top-k 的根本问题是:不同的概率分布"形态"不同。有时候概率高度集中(下一个词几乎确定),有时候概率很分散(下一个词有很多合理选项)。固定数量 k 无法适应这种变化。

Top-p 的洞察是:与其固定候选数量,不如固定概率质量——从"概率之和超过 p"的最小集合中采样。这样:

  • 概率集中时("北京是中国的"后面,"首都"概率 90%),候选集自动缩小到 1-2 个词
  • 概率分散时("今天的心情"后面,有几十个合理词),候选集自动扩大到几十个词

这种自适应性使 Top-p 成为实践中最广泛使用的采样策略。

Top-p 采样(Nucleus Sampling)解决了 Top-k 的固定数量问题:从覆盖累积概率超过 $p$ 的最小 Token 集合中采样

python
def top_p_sampling(logits, p=0.9, temperature=1.0):
    """
    Top-p (Nucleus) 采样:动态确定候选集大小。
    p=0.9 意味着:从概率之和恰好超过 90% 的最小 token 集合中采样。
    - 分布尖锐时:可能只需要 5 个 token 就覆盖 90% 概率
    - 分布平坦时:可能需要 100+ token 才能覆盖 90% 概率
    自动适应分布形态,比 top-k 更鲁棒。
    """
    # 先应用 temperature
    logits = logits / temperature

    # 按概率降序排列
    sorted_logits, sorted_indices = torch.sort(logits, descending=True, dim=-1)
    sorted_probs = F.softmax(sorted_logits, dim=-1)

    # 计算累积概率
    cumulative_probs = torch.cumsum(sorted_probs, dim=-1)

    # 找到累积概率超过 p 的位置,将该位置之后的 token 置为 -inf
    # 注意:保留刚好超过 p 的那个 token(shift by 1)
    sorted_indices_to_remove = cumulative_probs - sorted_probs > p
    sorted_logits[sorted_indices_to_remove] = float('-inf')

    # 还原原始顺序
    filtered_logits = torch.scatter(
        logits, -1, sorted_indices,
        torch.where(sorted_indices_to_remove, torch.tensor(float('-inf')), sorted_logits)
    )

    probs = F.softmax(filtered_logits, dim=-1)
    next_token = torch.multinomial(probs, num_samples=1)
    return next_token

1.7 重复惩罚与频率惩罚

贪心解码和低温采样都容易产生重复。两种惩罚机制可以缓解这个问题:

python
def apply_repetition_penalty(logits, input_ids, repetition_penalty=1.3):
    """
    重复惩罚(Repetition Penalty):降低已出现 token 的概率。
    penalty > 1.0:惩罚重复(推荐 1.1~1.5)
    penalty < 1.0:鼓励重复(罕见场景)
    """
    for token_id in set(input_ids[0].tolist()):
        if logits[0, token_id] > 0:
            logits[0, token_id] /= repetition_penalty  # 正值除以惩罚系数,降低概率
        else:
            logits[0, token_id] *= repetition_penalty  # 负值乘以惩罚系数,更负
    return logits

def apply_frequency_penalty(logits, input_ids, frequency_penalty=0.5):
    """
    频率惩罚(Frequency Penalty):根据 token 出现次数累积惩罚。
    与 repetition_penalty 的区别:
    - repetition_penalty:只要出现过就惩罚(布尔型)
    - frequency_penalty:出现越多惩罚越重(线性累积)
    OpenAI API 使用频率惩罚,transformers 库使用重复惩罚。
    """
    from collections import Counter
    token_counts = Counter(input_ids[0].tolist())

    for token_id, count in token_counts.items():
        logits[0, token_id] -= frequency_penalty * count
    return logits

1.8 为什么推理模型用低 Temperature

推理模型(o1、DeepSeek-R1)在推理链生成阶段通常使用接近 0 的 Temperature:

  1. 确定性需要一致性:数学推导的每一步需要精确,引入随机性会导致推理链前后矛盾
  2. 验证需要可重复性:推理模型的训练依赖于对回答正确性的判断,低温度使得评估稳定
  3. 推理链已提供多样性:探索性思维通过长推理链实现,最终答案应从推理结论中确定性地导出

1.9 用 transformers 库演示不同采样策略

transformers:HuggingFace开发的开源库,提供数千个预训练模型的统一加载和推理接口,是AI社区最广泛使用的NLP工具库。

python
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch

model_name = "Qwen/Qwen2.5-1.5B-Instruct"  # 使用小模型演示,可替换
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(model_name, torch_dtype=torch.float16)

prompt = "写一首关于秋天的短诗:"
inputs = tokenizer(prompt, return_tensors="pt")

# 策略 1:贪心解码(确定性,适合代码/数学)
greedy_output = model.generate(
    **inputs,
    max_new_tokens=50,
    do_sample=False       # 关闭采样 = 贪心
)

# 策略 2:Beam Search(适合翻译/摘要)
beam_output = model.generate(
    **inputs,
    max_new_tokens=50,
    num_beams=4,          # 保留 4 条路径
    do_sample=False,
    early_stopping=True
)

# 策略 3:高 Temperature 采样(创意写作)
creative_output = model.generate(
    **inputs,
    max_new_tokens=50,
    do_sample=True,
    temperature=1.2,      # 高温:更随机、更有创意
    top_p=0.95
)

# 策略 4:低 Temperature + Top-p(平衡质量与多样性)
balanced_output = model.generate(
    **inputs,
    max_new_tokens=50,
    do_sample=True,
    temperature=0.7,      # 中低温:质量与多样性平衡
    top_p=0.9,
    repetition_penalty=1.1
)

# 策略 5:Top-k 采样
topk_output = model.generate(
    **inputs,
    max_new_tokens=50,
    do_sample=True,
    temperature=1.0,
    top_k=50              # 仅从 top-50 token 中采样
)

for name, output in [
    ("贪心", greedy_output),
    ("Beam Search", beam_output),
    ("高温创意", creative_output),
    ("低温平衡", balanced_output),
    ("Top-k", topk_output)
]:
    text = tokenizer.decode(output[0], skip_special_tokens=True)
    print(f"\n【{name}】\n{text[len(prompt):]}")

1.10 采样策略决策树

需要生成文本

任务类型?

确定性任务
代码、数学、事实问答

创意任务
写作、头脑风暴

结构化输出
翻译、摘要、分类

Temperature ≤ 0.3
do_sample=False
或贪心解码

需要高多样性?

Temperature 1.0-1.5
Top-p 0.95
repetition_penalty 1.1

Temperature 0.7-1.0
Top-p 0.9
平衡多样性与质量

序列长度?

较短输出
Beam Search
num_beams=4-8

较长输出
Temperature 0.7
Top-p 0.9

推荐配置确定

出现重复?

添加 repetition_penalty
1.1-1.3

配置完成


1.11 对比表格

策略 随机性 多样性 一致性 速度 适用场景
贪心解码 极低 最高 最快 代码补全、简单事实
Beam Search 慢(k 倍) 翻译、摘要
Temperature(低) 推理链、精确指令
Temperature(高) 创意写作、多样性需求
Top-k 通用对话
Top-p(Nucleus) 可调 通用首选(最鲁棒)
Top-p + 低温 低-中 生产环境默认配置

1.12 小结

解码策略的选择本质上是在确定性与多样性之间进行权衡:

  • 贪心/Beam Search:确定性最强,适合有明确正确答案的任务
  • Temperature:调控分布的"锐度",是最基础的多样性控制手段
  • Top-k:固定候选数,简单但不够灵活
  • Top-p:按概率质量动态截断,是实践中最常用的策略
  • 重复惩罚:所有策略的辅助手段,防止文本退化

在实际应用中,temperature=0.7, top_p=0.9, repetition_penalty=1.1 是一个适用于大多数通用场景的起点配置,然后根据具体任务的特点向确定性(降温、关闭采样)或多样性(升温、扩大 p)方向调整。

本页目录