1. 解码器到底在干什么:从模型输出到一句人话
很多人第一次接触文本生成模型时,注意力全放在模型结构、参数量、训练数据上,觉得只要模型训得好,生成效果自然就好。但实际跑过几个生成任务之后就会发现,同一个模型,换一种解码策略,输出质量可能天差地别。解码器就是那个把模型输出的概率分布翻译成最终文字序列的“翻译官”,它决定了每一步到底选哪个词。
模型本身做的事情其实很单纯:给定前面已经生成的词,输出词表里每个词作为下一个词的概率。比如词表有五万个词,模型就吐给你五万个概率值,加起来等于一。但怎么从这五万个概率里挑出一个词来,这就是解码器的工作了。你可以把它想象成点菜:模型给你一份菜单,上面每道菜都标了推荐指数,解码器就是那个帮你决定“就点这道”或者“再考虑考虑”的人。
这个选择过程看似简单,实则影响深远。贪心解码每次只选当前概率最大的那个词,速度快但容易短视;束搜索会同时保留多条候选路径,用更长的视野来选最优解,但计算开销成倍增加;采样解码则引入随机性,让输出更多样化,但可能跑偏。这三种策略各有各的适用场景,没有哪个是万能的。
我在实际项目里踩过不少坑。有一次做商品标题生成,用贪心解码出来的标题千篇一律,全是“高品质”“超值”“热卖”这种高频词堆砌,读起来像机器写的。换成采样解码之后,多样性上来了,但偶尔会冒出完全不相关的词。后来换成束搜索,配合适当的长度惩罚,才找到一个平衡点。这篇文章就把这三种解码器的原理、实现细节、参数调优和实际使用经验完整梳理一遍,不管你是刚入门还是已经跑过几个项目,应该都能找到有用的东西。
2. 三种解码策略的核心原理拆解
2.1 贪心解码:每一步都选概率最大的那个
贪心解码的逻辑最简单:每一步都选当前概率最高的词,选完就固定下来,继续下一步。用数学语言说,就是在每个时间步 t,选择使条件概率 P(w_t | w_1, ..., w_{t-1}) 最大的那个词 w_t。
这个策略的优点是计算量最小,每步只需要一次前向传播,然后取argmax就行。对于实时性要求高的场景,比如流式对话、实时字幕生成,贪心解码几乎是唯一选择。但它的缺点也很明显:局部最优不等于全局最优。当前步概率最高的词,可能导致后续步的概率整体偏低。
举个具体的例子。假设模型在生成“今天天气真”之后,下一步的词表概率分布是这样的:“好”0.4,“不错”0.3,“棒”0.2,其他词加起来0.1。贪心解码会选“好”,生成“今天天气真好”。但如果选“不错”,后续可能生成“今天天气不错,适合出去走走”,整句话的联合概率可能更高。贪心解码看不到这一点,它只盯着眼前这一步。
注意:贪心解码在长文本生成中容易出现重复循环。比如生成“这个产品很好很好很好很好”,因为“很好”后面接“很好”的概率被模型学得偏高,贪心策略就会一直选下去。这是实际使用中最常见的坑之一。
2.2 束搜索:保留多条候选路径,用更宽的视野做决策
束搜索的思路是:每一步不只保留一个最优候选,而是保留 k 个最优候选,k 就是束宽(beam size)。每一步扩展时,从 k 个候选各自生成所有可能的下一个词,然后从 k × V 个组合中选出总概率最高的 k 个,作为下一步的候选。V 是词表大小。
用生活化的类比来解释:贪心解码像是一个人走迷宫,每个路口都选看起来最宽的那条路走;束搜索像是一支 k 个人的小队分头探路,每到一个路口就汇总所有路径的信息,保留最有希望的那 k 条继续探索。显然,小队找到出口的概率更大,但需要的人力也更多。
束搜索的评分函数通常是序列的联合对数概率:
score(w_1, ..., w_T) = Σ log P(w_t | w_1, ..., w_{t-1})
实际实现中还会加入长度惩罚(length penalty),因为对数概率是负数,序列越长总分越低,不加惩罚的话束搜索会倾向于生成短句。长度惩罚的公式一般是:
score_final = score / (length^α)
其中 α 是惩罚系数,通常取 0.6 到 1.0 之间。α 越大,对长序列的惩罚越轻,生成的句子越长。
束宽的选择是个权衡。束宽越大,搜索空间越广,找到更优解的概率越高,但计算量和内存占用也线性增长。实际项目中,束宽取 4 到 10 比较常见。我做过一个翻译任务,束宽从 1 增加到 5 时,BLEU 分数提升了约 2 个点;从 5 增加到 10 时,只提升了 0.3 个点,但推理时间翻了一倍。所以束宽不是越大越好,要结合任务需求和计算预算来定。
2.3 采样解码:引入随机性,让输出更多样
采样解码不再执着于选概率最大的词,而是按照概率分布随机抽取。概率高的词被抽中的机会大,但概率低的词也有机会被选中。这样每次生成的結果都不一样,多样性大大提升。
最基础的采样是直接从原始概率分布中抽取,但这样有个问题:词表里有很多概率极低的词,它们单个概率很小,但数量庞大,加起来的总概率可能不小。一旦抽到这些词,生成质量就会崩掉。所以实际使用中通常会配合截断策略。
常见的截断方法有两种。Top-k 采样是只保留概率最高的 k 个词,重新归一化后再采样。k 通常取 10 到 50。Top-p 采样(也叫核采样)是保留累积概率达到 p 的最小词集合,p 通常取 0.9 到 0.95。Top-p 的好处是自适应:当模型很确定时,候选集很小;当模型不确定时,候选集自动扩大。
温度参数(temperature)是另一个关键调节旋钮。它作用在 softmax 之前,把 logits 除以温度值 T:
P(w) = softmax(logits / T)
T < 1 时,分布变得更尖锐,高概率词更高概率,低概率词更低概率,输出更保守。T > 1 时,分布变得更平坦,各词概率差距缩小,输出更随机。T = 1 时就是原始分布。实际使用中,T 取 0.7 到 1.0 之间比较常见,具体要看任务对多样性和准确性的要求。
提示:采样解码在开放式生成任务(如故事创作、对话生成)中表现更好,但在需要精确输出的任务(如翻译、摘要)中,束搜索通常更可靠。不要盲目追求多样性而牺牲准确性。
3. 三种解码器的实操实现与参数调优
3.1 贪心解码的实现细节与性能优化
贪心解码的实现非常直接。以 PyTorch 为例,核心代码就几行:
def greedy_decode(model, input_ids, max_length): generated = input_ids for _ in range(max_length): outputs = model(generated) logits = outputs.logits[:, -1, :] next_token = torch.argmax(logits, dim=-1, keepdim=True) generated = torch.cat([generated, next_token], dim=-1) if next_token.item() == eos_token_id: break return generated这段代码的逻辑是:每次把已生成的序列喂给模型,取最后一个位置的 logits,argmax 得到下一个词,拼接到序列后面,直到达到最大长度或遇到结束符。
实际使用中有几个优化点。第一,用 KV Cache 避免重复计算。每次前向传播时,前面已经计算过的 key 和 value 可以缓存下来,只计算新位置的注意力。这样可以把每步的计算量从 O(n^2) 降到 O(n)。第二,batch 内不同序列可能在不同步结束,需要记录每个序列是否已经遇到结束符,避免在 padding 位置上继续生成。第三,如果只是做推理,记得用torch.no_grad()包裹,省内存。
贪心解码的推理速度是最快的,在同等硬件条件下,比束搜索快 k 倍(k 是束宽),比采样解码也快一些(采样需要额外的随机数生成和排序操作)。如果你的场景对延迟敏感,比如实时对话系统,贪心解码是首选。
3.2 束搜索的完整实现与长度惩罚计算
束搜索的实现比贪心解码复杂不少。核心是要维护 k 条候选路径,每条路径有自己的得分和已生成序列。每一步扩展时,计算所有候选的下一个词概率,选出总分最高的 k 个组合。
def beam_search(model, input_ids, beam_width, max_length, alpha=0.6): beams = [(input_ids, 0.0)] # (序列, 对数概率和) completed = [] for step in range(max_length): candidates = [] for seq, score in beams: outputs = model(seq) logits = outputs.logits[:, -1, :] log_probs = torch.log_softmax(logits, dim=-1) topk_log_probs, topk_ids = torch.topk(log_probs, beam_width) for i in range(beam_width): new_seq = torch.cat([seq, topk_ids[:, i:i+1]], dim=-1) new_score = score + topk_log_probs[0, i].item() candidates.append((new_seq, new_score)) # 按得分排序,保留前 beam_width 个 candidates.sort(key=lambda x: x[1], reverse=True) beams = candidates[:beam_width] # 检查是否有序列遇到结束符 new_beams = [] for seq, score in beams: if seq[0, -1].item() == eos_token_id: # 应用长度惩罚 length = seq.shape[1] penalized_score = score / (length ** alpha) completed.append((seq, penalized_score)) else: new_beams.append((seq, score)) beams = new_beams if not beams: break # 如果还没有完成的序列,用当前最优的 if not completed: completed = [(seq, score / (seq.shape[1] ** alpha)) for seq, score in beams] completed.sort(key=lambda x: x[1], reverse=True) return completed[0][0]这段代码里有几个关键点需要展开说。第一,log_softmax比先 softmax 再 log 更数值稳定,避免下溢。第二,得分累加用的是对数概率,因为概率相乘容易下溢,取对数后变成相加。第三,长度惩罚在序列完成时才应用,因为生成过程中长度还在变化,提前惩罚没有意义。
长度惩罚系数 α 的选取需要实验。我一般会先在验证集上跑几组不同的 α 值,看 BLEU 或 ROUGE 分数的变化。经验值是:翻译任务 α 取 0.6 到 0.8,摘要任务取 0.8 到 1.0,对话生成取 1.0 左右。如果发现生成的句子明显偏短,就调大 α;如果偏长且啰嗦,就调小 α。
注意:束搜索在 batch 推理时,不同样本的束宽可能不同(有些样本提前结束了),需要做 padding 对齐。另外,束搜索的内存占用是贪心解码的 k 倍,如果显存紧张,要么减小束宽,要么减小 batch size。
3.3 采样解码的温度、Top-k、Top-p 组合调参
采样解码的实现相对灵活,因为可以组合多种截断策略。下面是一个同时支持 temperature、top-k、top-p 的实现:
def sample_decode(model, input_ids, max_length, temperature=1.0, top_k=0, top_p=0.0): generated = input_ids for _ in range(max_length): outputs = model(generated) logits = outputs.logits[:, -1, :] / temperature # Top-k 截断 if top_k > 0: top_k = min(top_k, logits.size(-1)) indices_to_remove = logits < torch.topk(logits, top_k)[0][..., -1, None] logits[indices_to_remove] = -float('Inf') # Top-p 截断 if top_p > 0.0: sorted_logits, sorted_indices = torch.sort(logits, descending=True) cumulative_probs = torch.cumsum( torch.softmax(sorted_logits, dim=-1), dim=-1) sorted_indices_to_remove = cumulative_probs > top_p sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone() sorted_indices_to_remove[..., 0] = 0 indices_to_remove = sorted_indices_to_remove.scatter( 1, sorted_indices, sorted_indices_to_remove) logits[indices_to_remove] = -float('Inf') probs = torch.softmax(logits, dim=-1) next_token = torch.multinomial(probs, num_samples=1) generated = torch.cat([generated, next_token], dim=-1) if next_token.item() == eos_token_id: break return generated温度参数的作用前面已经说过,这里补充一个实操细节:温度调节和 top-k/top-p 的配合。如果温度设得很低(比如 0.5),分布本身已经很尖锐,top-k 和 top-p 的作用就不明显了。如果温度设得高(比如 1.2),分布很平坦,这时候必须配合 top-k 或 top-p 来截断长尾,否则容易生成乱码。
我的调参顺序一般是:先固定 temperature=1.0,调 top-p 到 0.9 左右,看生成质量;如果还是太随机,降低 temperature 到 0.8;如果发现重复严重,稍微提高 temperature 或增大 top-p。top-k 一般作为辅助,设一个较大的值(比如 50)防止极端情况。
提示:采样解码的随机性意味着同一个输入可能得到不同输出。如果业务场景需要可复现的结果,记得在采样前设置随机种子。但要注意,设置种子后每次生成结果固定,多样性优势就没了,需要根据场景权衡。
4. 不同自然语言生成任务中的解码器选型实战
4.1 机器翻译与文本摘要:束搜索的主场
翻译和摘要这类任务有一个共同特点:输出空间相对封闭,正确答案往往只有少数几个。翻译一句话,好的译文可能就那两三种表达;摘要一段文字,核心信息点基本固定。这类任务对准确性要求高,对多样性要求低,束搜索是最合适的选择。
我在一个中英翻译项目里的实测数据:束宽为 1(即贪心解码)时 BLEU 为 32.5,束宽为 5 时 BLEU 为 35.8,束宽为 10 时 BLEU 为 36.2。从 1 到 5 提升了 3.3 个点,从 5 到 10 只提升了 0.4 个点。考虑到推理时间从 1 倍增加到 10 倍,束宽取 5 是性价比最高的选择。
摘要任务类似,但有一个额外注意点:摘要容易生成重复内容。束搜索本身有一定的去重能力(因为重复路径的得分会累积惩罚),但如果模型本身倾向于重复,束搜索也救不了。这时候可以在评分函数里加一个重复惩罚项,对已经出现过的词降低其概率。具体做法是在每一步计算 logits 时,对已生成词表中的词减去一个惩罚值。惩罚力度需要调,太大导致语句不通顺,太小起不到去重效果。
4.2 开放域对话与故事生成:采样解码更出彩
对话和故事生成是开放式任务,同一个问题可以有无数种合理回答。用户问“今天天气怎么样”,回答“挺好的”“不错”“适合出门”都行,没有标准答案。这时候用束搜索反而不好,因为束搜索会倾向于生成“最安全”的回答,导致对话枯燥、缺乏个性。
采样解码在这里的优势就体现出来了。配合适当的 temperature(0.8 到 1.0)和 top-p(0.9 左右),可以生成既合理又有变化的回答。我做过一个对比实验:同样一个对话模型,束搜索生成的回复重复率(连续两次回复相同)达到 35%,采样解码只有 8%。用户主观评分上,采样解码的回复被认为“更自然”“更像真人”的比例高出 20 多个百分点。
但采样解码也有风险。温度太高或 top-p 太大时,可能生成不连贯甚至矛盾的回复。比如前面说“我喜欢猫”,后面又说“我讨厌猫”。这个问题在长对话中尤其明显。缓解方法是:在采样时加入重复惩罚,并且对对话历史做一致性检查。如果发现新生成的回复和前面矛盾,可以重新采样一次。
4.3 代码生成与结构化输出:约束解码的补充思路
代码生成和结构化输出(如 JSON、SQL)对格式要求极其严格。一个括号不匹配、一个字段名拼错,整个输出就废了。这类任务单纯用贪心、束搜索或采样都不够,需要配合约束解码。
约束解码的核心思想是:在每一步解码时,根据当前已生成的内容,动态限制候选词集合。比如生成 JSON 时,如果当前在键的位置,就只能从预定义的键集合里选;如果在值的位置,就根据值的类型限制候选。这样可以把搜索空间从整个词表缩小到几个合法选项,既提高了准确率,又加快了速度。
实现约束解码通常需要一个状态机或语法解析器,跟踪当前生成到了语法的哪个位置。每一步解码前,根据状态机确定合法词集合,把非法词的 logits 设为负无穷,然后再做贪心或采样。这个方案在代码生成任务里效果显著,我试过一个 Python 函数生成任务,不加约束时语法正确率只有 60% 左右,加了约束后提升到 95% 以上。
注意:约束解码需要针对具体语法写解析器,工作量不小。如果任务格式比较简单(比如固定几个字段),可以用正则表达式做轻量级约束。如果格式复杂(比如嵌套 JSON),建议用现成的语法解析库。
5. 解码器常见问题排查与性能优化经验
5.1 生成重复、循环、不完整句子的排查思路
重复生成是解码器最常见的问题,表现是同一句话或同一个短语反复出现。排查思路按优先级排列:
第一,检查模型本身。如果模型在训练数据上就存在大量重复文本,解码器再怎么调也治标不治本。可以先用贪心解码在训练集上跑几条,看是否也重复。如果训练集上不重复但测试集上重复,说明是过拟合或分布偏移。
第二,检查解码参数。贪心解码和低温度采样最容易重复。解决方法是提高温度、增大 top-p、或者改用束搜索。束搜索的重复惩罚机制(对已生成词降低概率)也能有效缓解。
第三,检查重复惩罚参数。很多推理框架支持repetition_penalty参数,大于 1.0 时对已出现词降权。一般设 1.1 到 1.2 比较合适。设太大(比如 1.5)会导致语句不通顺,因为常用词(如“的”“了”)被过度惩罚。
不完整句子通常是因为最大长度设得太小,或者结束符概率被低估。检查方法:看生成序列是否在达到 max_length 时被强制截断。如果是,增大 max_length 或调整长度惩罚。如果模型在应该结束的时候不输出结束符,可能是训练时结束符样本太少,需要在微调时增加结束符权重。
5.2 束搜索宽度与推理速度的平衡策略
束搜索的推理速度与束宽近似成反比。束宽为 5 时,推理时间是贪心解码的 5 倍左右。如果线上服务对延迟有硬性要求(比如 200ms 以内),束宽就不能太大。
平衡策略有几个方向。第一,动态束宽:简单样本用窄束,复杂样本用宽束。判断标准可以是模型输出的熵,熵低说明模型确定,用窄束就行;熵高说明模型犹豫,用宽束。第二,提前终止:如果某条候选路径的得分已经明显低于其他路径,可以提前剪枝,不再扩展。第三,批量推理:把多个请求攒成一个 batch 一起做束搜索,利用 GPU 并行能力摊薄单次开销。
我在一个线上翻译服务里用了动态束宽方案:先跑一遍贪心解码,计算输出序列的平均对数概率。如果平均概率高于阈值(说明模型很确定),就直接用贪心结果;如果低于阈值,再用束宽为 5 的束搜索。这样 70% 的请求走快速路径,30% 走精确路径,整体延迟降低了 40%,质量只下降了 0.5 个 BLEU。
5.3 采样解码中随机种子与可复现性的处理
采样解码的随机性给调试和线上服务带来麻烦。调试时希望复现问题,线上服务希望同一请求返回一致结果(至少在一定时间内)。处理方法是设置随机种子。
在 PyTorch 里,可以通过torch.manual_seed(seed)设置全局种子。但要注意,如果用了多个 GPU 或分布式推理,每个进程的种子需要不同,否则所有进程生成相同结果。通常做法是torch.manual_seed(seed + rank),rank 是进程编号。
线上服务中,可以用请求 ID 的哈希值作为种子,这样同一请求每次返回相同结果,不同请求之间保持多样性。但这样做有个副作用:如果用户重复发送相同请求,会得到完全相同的回复,可能显得机械。折中方案是:种子基于请求 ID 加时间窗口(比如每小时变一次),这样一小时内相同请求结果一致,跨小时则有变化。
提示:设置种子后,采样解码的多样性优势会部分丧失。如果业务场景对多样性要求高(比如创意写作),建议不设种子或只设一次全局种子,让每次生成都有变化。
6. 解码策略组合与进阶技巧
6.1 混合解码:先束搜索后采样的两阶段方案
单一解码策略很难同时满足准确性和多样性。一个实用的折中方案是两阶段解码:第一阶段用束搜索生成 k 个候选,第二阶段从这 k 个候选里用采样选一个。这样既保证了候选质量(束搜索筛选过),又保留了多样性(采样选择)。
具体实现:先用束宽为 5 的束搜索生成 5 个完整候选序列,计算每个序列的得分。然后根据得分做 softmax 得到概率分布,按概率采样选一个作为最终输出。得分高的候选被选中概率大,但得分低的也有机会。温度参数可以控制选择的随机程度。
这个方案在对话生成任务里效果不错。纯束搜索的回复太“安全”,纯采样又容易跑偏。两阶段方案生成的回复既合理又有变化。我做过 A/B 测试,两阶段方案的回复被用户评为“有趣”的比例比纯束搜索高 15%,评为“合理”的比例比纯采样高 25%。
6.2 对比解码:用两个模型做差异放大
对比解码(Contrastive Decoding)是近两年比较受关注的一种策略。核心思想是:同时用一个强模型和一个弱模型(或者同一个模型的不同层)计算 logits,然后做差,放大强模型相对于弱模型的优势。
公式是:logits_final = logits_strong - α * logits_weak
其中 α 是控制放大程度的系数,通常取 0.5 到 1.0。这个操作的直觉是:弱模型也能预测对的部分(比如常见词、语法结构),做差之后就消掉了;强模型独有的优势(比如更准确的语义、更合理的用词)被保留并放大。
实际使用中,对比解码在减少重复、提高连贯性方面效果明显。我试过一个故事生成任务,普通采样解码的重复率是 18%,对比解码降到 7%。但对比解码需要同时跑两个模型,推理成本翻倍,适合对质量要求高、对成本不敏感的场景。
6.3 解码器与提示工程的配合要点
解码策略和提示工程是相互影响的。一个好的提示可以让贪心解码也生成不错的结果,一个差的提示即使束搜索也救不回来。
配合要点有几个。第一,提示里明确输出格式。如果提示说“用 JSON 格式输出”,模型在解码时就会倾向于生成 JSON 相关的 token,解码器的工作量就小了。第二,提示里给例子(few-shot)。例子会让模型更确定,输出分布更尖锐,贪心解码和低温度采样就够用了。第三,提示里控制长度。如果提示说“用一句话回答”,模型会在生成一句话后倾向于输出结束符,避免不完整或过长。
反过来,解码策略也可以弥补提示的不足。如果提示比较模糊,模型输出分布平坦,这时候用束搜索或低温度采样可以“收紧”输出,避免跑偏。如果提示很明确,模型输出分布尖锐,用采样解码增加一点多样性也无妨。
我在实际项目里的做法是:先写一个中等详细的提示,用贪心解码跑几条看效果。如果效果已经不错,就不折腾解码参数了。如果效果差,先改提示;提示改到极限还不行,再调解码策略。这个顺序很重要,因为改提示的成本远低于调解码参数,而且效果往往更好。
7. 个人实操体会与几个容易忽略的细节
踩了这么多坑,有几个细节是文档里很少提但实际很重要的。
第一个是 padding 对解码的影响。batch 推理时,短序列需要 padding 到和长序列一样长。如果 padding token 的 attention mask 没设对,模型会把 padding 当成真实 token 处理,导致输出异常。这个坑我在早期项目里踩过,排查了一整天才发现是 mask 的问题。检查方法很简单:batch size 设为 1 时结果正常,batch size 大于 1 时结果异常,基本就是 mask 问题。
第二个是结束符的处理。有些模型在训练时结束符样本很少,导致推理时该结束不结束。解决方法是在解码时手动提高结束符的 logit,或者设置一个“最小长度”,达到最小长度后才允许输出结束符。最小长度的设置要看任务,翻译任务一般设 5 到 10,摘要任务设 20 到 30。
第三个是温度参数的缩放问题。不同模型的 logits 尺度不一样,有的模型 logits 绝对值很大(比如 20 到 30),有的很小(比如 1 到 2)。温度参数是作用在 logits 上的,如果 logits 本身很大,温度 1.0 可能已经很尖锐了;如果 logits 很小,温度 1.0 可能还很平坦。所以换模型时,温度参数需要重新调,不能直接套用。
第四个是束搜索的 batch 实现细节。不同样本的束搜索可能在不同步结束,需要做对齐。简单做法是:所有样本都跑到最大长度,结束后用 mask 忽略 padding 部分。这样浪费一些计算,但实现简单不易出错。复杂做法是动态移除已完成的样本,但需要处理 batch 维度变化,容易引入 bug。我一般先用简单做法跑通,有性能需求再优化。
最后分享一个调参小技巧:把 temperature、top-p、repetition_penalty 这三个参数做成可配置的,在验证集上做网格搜索。搜索范围不用太大:temperature 取 [0.7, 0.8, 0.9, 1.0],top-p 取 [0.85, 0.9, 0.95],repetition_penalty 取 [1.0, 1.1, 1.2]。一共 36 种组合,跑一遍验证集就能找到不错的配置。这个工作量不大,但效果比拍脑袋设参数好得多。