模型 forward 一次,输出的不是一个词,而是词表上几万个 token 的一整张概率分布。从这张分布里挑出下一个词,就是「解码(decoding)」。同一个模型、同一段 prompt,换一种解码方式,输出可以从「呆板重复」变成「天马行空」——甚至从「事实准确」变成「一本正经胡说」。今天讲清楚这最后一步的数学:确定性搜索、随机采样的分布重塑、以及两个漂亮的对照机制。
Greedy 就是贪心算法——每一步只挑当前概率最高的 token,像找零钱时每次都拿面值最大的硬币,简单但容易陷入局部最优。Beam search 则是查询优化器的思路:不敢只押一条执行计划,而是维护一个固定容量为 k 的候选队列(bounded priority queue),每步让 k 条路径各自展开、按累积得分剪枝、只留 top-k 继续。它用 k 倍算力买一点全局视野。
生成一整句话,本质是找一个联合概率最大的 token 序列。理论上要枚举所有序列——词表 5 万、句子 20 词就是 5万²⁰ 种,绝无可能。Greedy 是最粗暴的近似:逐词取 argmax。问题在于局部最优 ≠ 全局最优——这一步概率最高的词,可能把后面逼进死胡同。
Beam search 缓解这点:保留 k 条最优前缀,每步把每条前缀 × 词表全部候选算出,再按对数概率之和取全局 top-k。
用 log 相加而非概率相乘,是为了避免几十个小数连乘下溢到 0(和你在后端算长链路概率时取对数是同一招)。beam 宽度 k=1 就退化成 greedy。
核心反直觉(Holtzman et al. 2019 揭示):对开放式生成(写作、对话),追求「最高概率序列」反而产出枯燥且诡异重复的文本。因为人类真实语言里充满了「不那么高概率但更有信息量」的选择;一味最大化概率,模型会收敛到「the the the」式的安全废话。这就是为什么聊天模型几乎从不用纯 beam search——它适合翻译、摘要这类「答案接近唯一」的任务,不适合创造。
from transformers import AutoModelForCausalLM, AutoTokenizer tok = AutoTokenizer.from_pretrained("gpt2") model = AutoModelForCausalLM.from_pretrained("gpt2") ids = tok("The future of AI is", return_tensors="pt").input_ids # 贪心:每步 argmax,完全确定性 greedy = model.generate(ids, max_new_tokens=30, do_sample=False) # 束搜索:保留 5 条路径,选累积 logP 最高的整句 beam = model.generate(ids, max_new_tokens=30, num_beams=5, early_stopping=True) print(tok.decode(beam[0], skip_special_tokens=True))
如果 greedy 是「永远读缓存里最热的那一行」,采样就是「按热度加权随机抽一行」。Temperature 是控制这个随机性的总阀门——像给 softmax 的分布做「锐化 / 模糊」;而 top-k / top-p / min-p 是三种把长尾垃圾候选先砍掉的截断策略,防止小概率的荒唐 token 被抽中。
Temperature(温度 T)在 softmax 里给每个 logit 除以 T:
zᵢ 是第 i 个 token 的原始 logit。直觉:T < 1(如 0.3)放大了 logit 之间的差距 → 分布更尖 → 高概率词更霸道(趋近 greedy);T > 1(如 1.5)压平差距 → 分布更均匀 → 冷门词更有机会;T → 0 就是 argmax,T → ∞ 就是完全均匀乱选。关键:temperature 不改变模型「知道」什么,只重塑它已经算出的那张分布的锐度。
光调温度不够:高温下长尾的荒唐词也被抬起了概率。三种截断先把尾巴砍掉,再在剩下的候选里按概率归一化采样:
# HuggingFace:三种截断可叠加,采样在剩余候选里进行 out = model.generate( ids, max_new_tokens=40, do_sample=True, # 打开随机采样(否则忽略下面所有参数) temperature=0.8, # 分布锐度:<1 保守,>1 发散 top_k=50, # 只在概率最高的 50 个里采 top_p=0.9, # 且累积概率 ≥0.9 的核内采(动态候选数) min_p=0.05, # 且概率 ≥ 0.05×最高概率 的才保留 ) print(tok.decode(out[0], skip_special_tokens=True)) # 通常不会同时用满三种;理解各自的「动态 vs 固定」差异是关键
像做 diff / 控制变量法:拿一个「业余(弱小)模型」当对照组,用「专家(强大)模型」的对数概率减去它。两个模型共有的廉价坏毛病——重复、语法套话、无脑高频词——会在相减中被抵消掉,只留下强模型独有的知识和判断。不是让两个模型投票求和,而是用弱模型做减法。
接续概念 1 的难题:greedy 太呆、纯采样又可能跑偏。对比解码(Li et al. 2022)换个思路——坏文本的共性是什么?答案:那些无论大小模型都爱犯的错(重复、通用废话)。一个 GPT-2 small 和一个 GPT-2 XL 都会给「the」很高概率;但只有大模型能给出真正贴切的罕见词。于是打分函数变成两者之差:
λ 控制减法力度。直觉:一个 token 如果专家和业余都爱(如「the」),相减后优势被抹平;如果专家爱、业余不爱(专家凭知识才选的词),相减后脱颖而出。但纯做减法有个坑:业余模型极度厌恶的合理词会被过度奖励,产出乱码。所以加一条合理性约束(plausibility constraint):只在专家模型概率足够高(≥ α × 最高概率,思路和 min-p 同源)的候选里做对比,先划定「专家认可的安全区」,再在区内用业余模型排座次。
效果:兼得 greedy 的连贯和采样的丰富,且更少重复退化。后续工作(Contrastive Decoding Improves Reasoning,2023)还发现它能提升推理准确率——因为「业余模型的错误直觉」正是要被减掉的东西。
import torch, torch.nn.functional as F expert = AutoModelForCausalLM.from_pretrained("gpt2-large") amateur = AutoModelForCausalLM.from_pretrained("gpt2") # 小=业余 lp_e = F.log_softmax(expert(ids).logits[:, -1], dim=-1) lp_a = F.log_softmax(amateur(ids).logits[:, -1], dim=-1) # 合理性约束:只保留专家概率 ≥ α×最高 的候选,其余置 -inf alpha, lam = 0.1, 1.0 mask = lp_e < (lp_e.max() + torch.log(torch.tensor(alpha))) score = lp_e - lam * lp_a # 核心:专家 − λ×业余 score[mask] = -float("inf") # 砍掉专家都不认可的词 next_id = score.argmax(-1) # 在安全区内取对比最优
这是 CPU 的投机执行(speculative execution)+ 分支预测,或数据库的乐观并发控制(OCC)搬到了解码上:先让一个便宜的小模型乐观地猜一串 token,再让昂贵的大模型一次性并行验证这串猜测。猜对的直接采纳(省下了逐个生成的时间),猜错的从那里回滚重来。关键是——最终输出和「大模型自己逐字生成」在数学上完全一致,是无损加速。
自回归生成的痛点:逐 token 串行。生成 100 个词就要 100 次大模型 forward,每次都得把整个巨型模型从显存过一遍——GPU 算力闲置,瓶颈在访存带宽而非计算。Leviathan et al.(2022)的洞察:很多 token 是「容易的」(「the」「of」「.」这种),小模型也能猜对,何必动用大模型?
流程:草稿模型 q 先自回归生成 γ 个 token;大模型 p 把这 γ 个 token 一次并行 forward(一次算完 γ 个位置的分布,这是关键——验证是并行的,生成才是串行的)。然后逐个用拒绝采样决定接受与否:
直觉:小模型对 x 的信心若不超过大模型(q ≤ p),无条件接受;若小模型过度自信(q > p),则以 p/q 的概率接受。一旦某个 token 被拒,就丢弃它之后的所有草稿,并从修正分布 norm(max(0, p − q)) 里重采一个——数学上可证明,这套接受 / 重采规则让最终分布严格等于大模型 p 的分布。所以它不牺牲任何质量,只把「一串容易 token」的成本从「γ 次大模型」压到「1 次大模型 + γ 次小模型」。
草稿模型越接近大模型,接受率越高、加速越大;论文在 T5-XXL 上报告约 2–3 倍加速且输出逐字不变。DeepMind 同期的 speculative sampling(Chen et al. 2023)给出了等价的独立推导。
# HuggingFace 内置:传一个小的 assistant_model 即启用推测解码 big = AutoModelForCausalLM.from_pretrained("gpt2-xl") # 目标(慢/准) draft = AutoModelForCausalLM.from_pretrained("gpt2") # 草稿(快) out = big.generate( ids, max_new_tokens=60, assistant_model=draft, # ← 小模型猜、大模型验,输出分布不变 do_sample=True, temperature=0.7, ) print(tok.decode(out[0], skip_special_tokens=True)) # 结果与不加 assistant_model 时分布一致,只是更快