采样策略详解-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})$$
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. 局部最优而非全局最优: 每步选最高概率不等于整个序列概率最高。例如:
目标:生成"我爱中国"
步骤 1:"我"概率最高,选"我"
步骤 2:给定"我","非常"概率比"爱"高,选"非常"
步骤 3:给定"我非常",陷入不同路径...
贪心结果:"我非常喜欢这个世界"(不是最优序列)
1.3 Beam Search:探索多条路径
1.3.1 为什么贪心搜索不是最优解:局部最优与全局最优的区别
贪心解码的根本问题在于:每步的局部最优不等于整体的全局最优。
一个直觉的例子:如果你正在爬山,每次都走局部最陡峭的方向,很可能爬到一个小山头就停了,而真正的最高峰需要先走一段下坡路才能到达。
在语言生成中,有时候选择一个概率稍低的词,会为后续的词创造更好的上下文,从而让整个序列的概率更高。贪心搜索错过了这种可能性。
Beam Search 的思路:与其只走一条路,不如同时走 k 条路,最后选择整体概率最高的那条。
1.3.2 原理
Beam Search 在每一步保留 $k$(beam width)条概率最高的候选序列,最终选择整体概率最高的那条:
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)$$
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 中采样:
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$ 无法适应变化的概率分布:
场景 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 集合中采样。
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 重复惩罚与频率惩罚
贪心解码和低温采样都容易产生重复。两种惩罚机制可以缓解这个问题:
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.9 用 transformers 库演示不同采样策略
transformers:HuggingFace开发的开源库,提供数千个预训练模型的统一加载和推理接口,是AI社区最广泛使用的NLP工具库。
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 采样策略决策树
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)方向调整。