LLM 解码策略

LLM 解码策略

大模型每生成一个词之前,都会在整个词表上算一遍概率分布,候选词动辄上万。所谓解码策略,就是决定从这堆概率里挑哪个词真正写进句子的规则。同一份模型权重,解码规则不同,输出可能天差地别。聊天机器人翻来覆去说同一句话,写故事写到一半开始胡言乱语,翻译腔忽重忽轻,背后多半是解码策略没调对。这一步看着不起眼,却直接决定用户拿到手的文本顺不顺、像不像人话。

这一节我们把最常用的几类解码策略挨个拆开讲:贪心搜索、束搜索、随机采样,再落到 HuggingFace 里这几类方法对应的参数组合。每种方法都会配一个能跟着算一遍的小例子,看完大家就能根据自己的任务决定该开哪个开关。读之前要先知道自回归语言模型是怎么一个词接一个词往下猜的,也就是每一步都基于已经生成的内容去预测下一个词的概率。如果这个基本循环还不熟,建议先补一下再回来。

1. 贪心搜索

我们从最简单的讲起。贪心搜索的规则只有一条:每一步都从概率分布里挑概率最高的那个词,挑完接在句子后面,再基于新句子算下一步。它不用保存任何中间结果,算得快,结果也确定,同一个输入永远生成同一句话。

我们拿个小例子走一遍。假设模型刚读完 Hello,下一个词的分布是这样的:

候选词概率
world0.6
there0.3
everyone0.1

贪心搜索直接取概率 0.6 的 world,句子变成 Hello world。问题在于,每一步只看眼前最高,可能错过全局更优的整句。我们接着往下算一步就能看出来。假设现在手上有两个半截句子,模型给出的下一个词分布分别是这样的:

已生成的半截句子概率最高的下一个词该词概率
Hello world!0.4
Hello thereyou0.9

我们把两步概率乘起来比一比。贪心那条路 Hello world 接 !,联合概率是 0.6×0.4=0.24。而被贪心第一步就淘汰的 there 那条路,Hello there 接 you,联合概率是 0.3×0.9=0.27,反而更高。也就是说,第一步看着不是最优的选择,往后走却通向了一条概率更大的句子。贪心只认眼前,这种全局最优就在第一步被它丢了。所以它适合要求不高、要快的场景,很少单独用在讲究质量的生成任务里。

2. 束搜索

既然贪心一次只盯一条路会丢全局最优,那自然的改进就是同时保留几条路一起走。束搜索(Beam Search)把每一步保留的候选句子条数记成 k,这个 k 叫束宽。每一步不是只留当前最优的一条,而是把所有候选句扩展一步后的结果放在一起排序,只留下累积概率最高的 k 条,其余的直接丢弃。走到最大长度或遇到结束标记后,再从这 k 条里挑累积概率最高的那条当最终输出。它比贪心多花 k 倍的计算,换来的是更连贯、更像正经句子的结果。

我们拿一个英法翻译的小例子完整走一遍,输入是 hello,束宽 k=2。第一步模型在空句子上给出的前几个候选词是 bonjour 0.5、salut 0.3、coucou 0.2,束搜索保留前两个,也就是 bonjour 和 salut,coucou 直接淘汰。

第二步分别扩展这两条。bonjour 后面模型给的候选是 tout 0.4、le 0.3、la 0.2、mon 0.1,乘上前一步概率得到 bonjour tout 是 0.20、bonjour le 是 0.15。salut 后面给的是 tout 0.5、mon 0.3、la 0.2,得到 salut tout 是 0.15、salut mon 是 0.09。把这四条放在一起排序,保留前两条 bonjour tout 0.20 和 salut tout 0.15,bonjour le 和 salut mon 淘汰。

第三步继续扩展这两条。bonjour tout 后面候选是 est 0.6、la 0.3、ce 0.1,得到 bonjour tout est 0.12、bonjour tout la 0.06。salut tout 后面是 est 0.7、la 0.2、ce 0.1,得到 salut tout est 0.105、salut tout la 0.03。再排序,保留前两条。整个过程每一步留下了哪些束,我们整理成下表:

轮次保留下来的束(累积概率)这一轮被淘汰的束
初始bonjour 0.5、salut 0.3coucou 0.2
第 1 轮后bonjour tout 0.20、salut tout 0.15bonjour le 0.15、salut mon 0.09
第 2 轮后bonjour tout est 0.120、salut tout est 0.105bonjour tout la 0.060、salut tout la 0.030

走到结束,在两条候选里取累积概率最高的,最终输出 bonjour tout est。回头看这个过程,束宽 k 是一个实打实的权衡旋钮。k 取 1 就退化成贪心,k 越大搜索越细,越不容易错过好句子,但每一步都要算 k 个分支,显存和时间都跟着涨。实际项目里 k 取 5 到 10 比较常见,算力紧张就往小调,追求质量再往上加。

束搜索也有自己的毛病。它一路挑概率高的走,生成的句子往往四平八稳,很容易翻来覆去用同样的句式,读起来有点机械。另外,逐词相乘的概率天然偏向短句子,长句在连乘下会吃亏,实际用的时候一般要配长度归一化。这些毛病让它在翻译、摘要这类要确定性结果的任务上还是好用,但在聊天、写故事这类要点新鲜感的场景,就得换个思路。

3. 随机采样

贪心和束搜索都是确定性的,规则定死,输出就定死。它俩共同的毛病是容易陷进高频词的套路,翻来覆去一个腔调。要让文本有变化,就得在选词时引入随机性,这就是采样。做法是不再硬挑概率最高的词,而是把概率分布当成一个转盘,照着概率大小去转,概率大的词更容易被转到,但小概率词也有机会被选中。

直接按原始分布采样有个问题:长尾里那些概率极低的怪词偶尔会被抽中,句子当场就崩了。实际用的时候一般先把候选范围收窄,再在收窄后的分布里抽样。两个最常用的收窄手段是 Top-K 和 Top-P。采样之前还有一个旋钮叫温度,作用是在算概率之前先把模型输出的分数整体缩放一遍。温度小于 1 会把分布削尖,高概率词更高,越接近贪心。温度大于 1 会把分布抹平,词与词之间的差距变小,采样出来的结果更跳脱。

3.1 Top-K 采样

Top-K 最简单粗暴:每一步只保留概率最高的 K 个词,把它们的概率重新归一化成一个小转盘,其余词直接判出局,然后在这个小转盘里随机抽一个。K 取多少完全由我们定,跟实际概率大小无关。

3.2 Top-P 核采样

Top-P 换了个思路,不数个数,改数累积概率。把词按概率从高到低排好,一个个累加,一旦累加和超过阈值 P 就停,只保留这一批词,剩下的淘汰,再在这一批里重新归一化抽样。这样它是自适应的:模型很确定的时候候选可能只有两三个词,模型很犹豫的时候候选池自动放大。

我们拿同一张概率表把两种方法对一遍,模型在某一步给出的候选是这样的:

候选词概率
world0.5
there0.3
everyone0.1
guys0.05
folks0.05

取 K=2 做 Top-K,只留概率最高的 world 和 there,在这两个里面按归一化后的比例抽。取 P=0.8 做 Top-P,从 world 开始累加,0.5 加上 there 的 0.3 等于 0.8,刚好到阈值,候选也是 world 和 there,后面的 everyone、guys、folks 全部出局。这个例子里两者结果恰好一样,但换个分布就会看出差别:如果第一名概率就占了 0.95,Top-K 仍会硬塞进 K 个词,把不靠谱的也留下,而 Top-P 只会留第一名,这正是 Top-P 更灵活的地方。

4. 参数组合

讲到这里,三种基本策略就清楚了。真正用起来时,不管是 HuggingFace 的 transformers 还是别的推理框架,我们几乎不会单独说一句用贪心,而是靠几个开关把这些策略拼出来。理解了前面的机制,这几个开关一眼就能对上号。

最核心的两个开关是 do_sample 和 num_beams,再配合 top_k、top_p、temperature、num_beam_groups 这些细节旋钮,就能组合出工程里见到的几种标准解码方式。它们不是随便凑的参数,而是 GenerationConfig 里明确列出的预设分支,就写在 transformers/generation/configuration_utils.py 附近。常用组合我们整理成下表:

解码方式num_beamsdo_sample其他关键参数特点
贪心解码1False无每步取最高概率,结果确定,容易重复
多项式采样1Truetop_p、temperature单路随机,多样性强,可能跑题
束搜索解码大于 1False无多路并行,句子流畅但偏套路
束搜索多项式采样大于 1Truetop_p、temperature多路里再带随机,兼顾质量和变化
对比搜索大于 1Falsepenalty_alpha 大于 0、top_k 大于 1用惩罚抑制重复,长文不易绕回
多样化束搜索大于 1Falsenum_beam_groups 大于 1束分组独立生成,一次出多条差异明显的结果

这张表怎么读呢。do_sample 关掉就是确定性搜索,打开就是随机采样。num_beams 是 1 表示单路搜索,大于 1 表示束搜索。两个维度一交叉,贪心、单路采样、束搜索、束搜索采样这四种就定下来了。剩下两种是后来加的增强版。对比搜索靠 penalty_alpha 给历史里重复出现的词扣分,专门治大模型写着写着绕回同一句话的毛病。多样化束搜索把 k 条束分成几组,组与组之间互相约束不许太像,一次输出几条差异明显的候选,适合摘要、翻译这种要给多个版本挑的场景。

落到代码上,这些组合就是 model.generate 里传几个参数的事。我们拿 GPT-2 做例子,三种最常见的调用长这样:

from transformers import AutoModelForCausalLM, AutoTokenizer

model = AutoModelForCausalLM.from_pretrained("gpt2")
tokenizer = AutoTokenizer.from_pretrained("gpt2")
inputs = tokenizer("今天天气真", return_tensors="pt")

# 贪心:do_sample 关掉,num_beams 取 1
model.generate(**inputs, max_new_tokens=20, do_sample=False, num_beams=1)

# 束搜索:do_sample 关掉,num_beams 取 5
model.generate(**inputs, max_new_tokens=20, num_beams=5, do_sample=False)

# 核采样:打开采样,用 top_p 和 temperature 控制随机性
model.generate(**inputs, max_new_tokens=20, do_sample=True, top_p=0.9, temperature=0.8)

5. 方法对比

几种方法摆在面前,实际任务该挑哪个?我们把它们的性格放在一张表里对照,选起来就有依据了。

方法多样性流畅度计算开销适合的任务
贪心搜索最低一般最小短句补全、要求结果确定的场景
束搜索低高中等翻译、摘要这类要稳定输出
Top-K / Top-P 采样高看温度小聊天、写故事、创意生成
束搜索采样中高较高较大既要质量又要变化
多样化束搜索高较高大一次出多条候选供挑选

这里给一个实用的判断方式:任务对错分明、答案要稳定,就往束搜索靠,比如翻译、摘要。任务鼓励发散、怕它死板,就往采样靠,比如闲聊、写脑洞。卡在中间,就用束搜索加采样,或者用对比搜索压一压重复。这些参数都是旋钮,先拿一小批真实输入跑几版看看手感再固定下来,比盯着论文调参靠谱得多。