解码与采样全解析:从Token概率到生成质量调优)
在LLM应用开发这条路上model.generate()应该是我见过的出场率最高的调用接口没有之一。不管你是刚把一个开源模型跑起来还是已经在做复杂的智能体流程最终让模型“开口说话”的几乎都是这一行。很多人第一次跑 generate 的时候结果往往很尴尬要么车轱辘话来回重复要么答非所问要么生成到一半戛然而止。这些问题里真正属于模型“智商不够”的其实很少绝大多数问题都出在输出方式——也就是 generate 内部那套解码与采样逻辑上。这篇文章打算把这层逻辑彻底拆开。我会先从 token 概率是怎么产生的讲起再把解码策略、采样参数、流式输出、批量生成这些高频知识点逐个过一遍最后用实际代码演示怎么调参、怎么做问题排查。适合刚接触 LLM 开发、想系统建立生成逻辑认知的开发者也适合那些已经被生成质量问题折磨到想换模型的人——因为很多时候问题根本不在于模型。1. generate 到底在生成什么先搞清楚原理1.1 大模型输出的是“下一个 token 的概率”很多人以为模型是在“思考一句话”然后像人一样把这句话说出来。但实际上现代大模型的工作方式没那么高级它是典型的自回归结构拿到你给的文本把它拆成一串 token然后不断预测“下一个 token 是什么”预测一个就拼一个拼完再预测下一个循环往复。关键点在于模型在每一步输出的不是一个确定的 token而是整个词表上的一个概率分布。举个例子你输入“中国的首都是”模型不会直接告诉你“北京”这两个字它会在词表里的每一个 token 上打一个分数。可能是“北”这个 token 拿到 0.6 的高分“上”拿到 0.2“东”拿到 0.05剩下几万个 token 瓜分最后那点概率。generate 要做的就是基于这个分布决定到底选哪个 token 出去。这里有个基础概念必须提一下token 不等于汉字。Token 是模型处理文本的最小单位在中文模型里一个 token 可能是一个字、一个词甚至半个字。同一个文本在不同分词器下切出来的 token 数量完全不同。这会直接影响你设置的 max_new_tokens 够不够用也会影响你看到的“输出到一半断掉”的观感。1.2 从 logits 到概率再到采样三个环节一条链模型内部前向计算之后会拿出一个叫 logits 的向量长度和词表大小一致。这个向量里的数值是“原始分数”范围没有限制可以是正数也可以是负数数值本身没有直观意义。为了让这些分数变成“概率”需要经过一个 softmax 操作把所有分数归一化变成总和为 1 的概率分布。这中间温度参数 temperature 也插了一脚它在 softmax 之前对 logits 做缩放具体形式是P softmax(logits / T)。当 T 等于 1 时保持原分布T 小于 1 时分布会被拉尖锐高概率的 token 概率更高T 大于 1 时分布被抹平低概率的 token 也有机会被选中。理解这条链路可以打个比方logits 是每个选手的原始得分softmax 是把得分换算成夺冠概率而温度 T 是组委会临时改的计分规则——T 越小比赛越倾向“让冠军拿走一切”T 越大越给冷门选手爆冷的机会。采样策略则插在最后一步拿到概率分布之后用什么规则从中挑 token。挑法不同就有了贪心解码、束搜索、温度采样、top-k、top-p 这些不同的输出方式。generate 的全部秘密基本都集中在这条链路上。2. generate 的关键参数逐个拆解2.1 长度控制max_new_tokens 和 max_length 千万别混长度相关参数是新手掉坑重灾区尤其是max_new_tokens和max_length这一对。简单区分参数含义使用建议max_new_tokens新生成的 token 数量上限目的明确推荐日常使用max_length输入文本长度 新生成 token 的总长度上限需要额外计算容易踩坑min_new_tokens生成结果的最低长度防止模型“一句话结束”需要强制长度时使用我见过最典型的翻车场景有人把max_length设成 512但输入 prompt 光 tokenize 出来就已经超过 512 了结果 generate 结果直接是空。原因很简单模型发现输入已经超出长度阈值干脆一个 token 都不生成。更隐蔽的是输入 prompt 有 300 tokenmax_length512看起来没问题但模型实际可生成的只有 212 token如果业务需要更长输出就会被悄悄截断。所以我的建议很直接能用max_new_tokens就不要用max_length。它语义清晰不受输入长度干扰也不容易在长上下文场景下产生意外行为。2.2 temperature控制随机性的大旋钮temperature是出场率最高的采样参数它的作用是改变概率分布的“尖锐程度”。取值越大输出越随机、越有发散性取值越小输出越保守、越可预测。实际项目里我一般这么划分0.0 到 0.3代码生成、翻译、信息抽取、结构化输出。这些场景追求准确几乎不允许随机发挥。0.6 到 0.9对话助手、文案改写、故事梗概。保留一点随机性让表达更自然但不会离谱。0.9 以上创意写作、头脑风暴、营销文案。允许模型大胆发散哪怕产生一些“意外惊喜”。有一个实操细节需要特别提醒temperature只有在采样模式do_sampleTrue下才会生效。如果你用的是贪心解码或束搜索temperature 压根不参与工作你调了半天发现没变化不是参数坏了是策略不对。另外某些实现里temperature0会有特殊行为。有的 API 会把它当作贪心解码有的会直接报错。稳妥做法是想完全确定就显式开贪心不要用 temperature0 去“模拟”确定性。2.3 top_k只从概率最高的 K 个 token 里选top_k的思路很粗暴把所有 token 按概率从高到低排序只保留前 K 个作为候选然后在这 K 个里按概率采样。top_k1的时候就是贪心解码top_k50是很多库里的经典默认值。这个参数的好处是简单直白坏处是不够灵活。想象一个分布极其集中的场景第一名概率 0.9第二名 0.05剩下几十万个 token 分 0.05。这时候 top_k50 会把一堆概率只有百万分之一的噪声词也拉进候选池虽然单个概率低但数量多了被选中的概率加起来并不小。反过来如果概率分布很均匀前 50 个 token 又可能覆盖不了足够丰富的表达空间。所以现在的主流做法是在 top_k 基础上再加一个更自适应 top_p或者干脆只用 top_p。2.4 top_p核采样让候选集合自己伸缩top_p又叫核采样它的逻辑比 top_k 聪明把 token 按概率从高到低排序然后从最高概率那个开始逐个累加直到累计概率超过设定的 p 值所有参与累加的 token 进入候选池。举个例子top_p0.9那模型只会考虑“概率贡献了 90%”的那一小撮 token。如果分布非常集中候选可能只有两三个如果分布很平均候选可能有几十上百个。这种动态伸缩的特性让 top_p 在不同语境下都能保持合理的候选范围这也是它成为现代对话模型默认采样方式的原因。实操里我一般把top_p设在 0.85 到 0.95 之间。太低容易显得机械太高又会让低概率噪声混进来。小技巧是先用 top_p 做主采样top_k 可以不设集中精力调 temperature参数越少越容易控制。2.5 惩罚参数处理重复但别过度依赖生成质量里最烦人的问题之一就是重复这时候就要上repetition_penalty和no_repeat_ngram_size。repetition_penalty是通用惩罚模型在生成时对已经出现过的 token 的分数打折扣出现次数越多折扣越狠。典型取值在 1.0 到 1.3 之间。1.0 就是不惩罚1.1 已经能明显感受到重复变少1.3 以上会让表达变得支离破碎因为一些必要的功能词也会被误伤。no_repeat_ngram_size则是对连续重复片段做硬限制设定no_repeat_ngram_size3模型就不会生成任何长度为 3 的连续片段让它出现第二次。这对抑制“我今天我今天我今天”这类局部重复非常有效代价是模型能用的表达空间被压缩了一些设太小甚至可能卡住输出。这两种惩罚之间有关联。通常我会先通过调整采样参数缓解一部分重复问题再上惩罚项做微调。如果一上来就把惩罚拉满产出是“看起来不重复了但读起来不像人话”。3. 解码策略怎么选从贪心搜索到束搜索3.1 贪心解码确定性强天花板低贪心解码是最简单的输出方式每一步都选概率最高的 token。它的逻辑和名字一样直白——既然模型觉得某 token 概率最高那就选它。好处是速度快、结果可复现。同一个 prompt 跑十次结果一模一样这在测试阶段非常方便。但它的天花板也很明显贪心是局部最优不是全局最优。每一步选最好的不代表整句话最好。更致命的是它特别容易陷入高频循环。想象模型在前几步选了一个概率很高的词这个组合在训练数据里频繁出现后续每一步它都觉得“接着重复这个词组合”是最高概率选择生成就变成了“哈哈哈”或者“好的好的好的”这种循环。所以贪心解码适合的场景很有限代码注释、意图分类、结构化抽取这些几乎没有创造力需求的场合。3.2 束搜索多条候选路径并行尝试束搜索beam search比贪心高级一些它同时维护num_beams条候选序列每一步从所有候选序列的扩展里挑出全局概率最高的num_beams条保留。这样最终生成时不是只盯一条路走到黑而是让“种子选手”都跑一跑最后选总分数最高的那条。这特别适合翻译、摘要、语音识别这类对整体流畅度和忠实度要求高的任务。我调过几个翻译模型用num_beams4或num_beams5配合no_repeat_ngram_size3输出质量比贪心高出一截。但束搜索的缺点也要说清楚。第一个是显存和速度开销大束数越大并行计算占用的显存线性上涨。第二个是它天然倾向于生成“高概率但平庸”的短句子因为短句的概率乘积往往更大。解决方式是配length_penalty。length_penalty 0时模型更愿意生成较长句子 0时则更偏好短句。在摘要任务里我一般用 0.6 到 1.0 之间的值既能控制篇幅又让句子更完整。3.3 采样模式让模型说点“新话”把do_sampleTrue打开generate 才真正进入”采样”的世界。配合 temperature、top_p、top_k模型每次生成都会带一点随机性这就让同一个 prompt 可以产出多种表达。对话系统几乎都用这个模式因为没人喜欢每次问同样问题都得到一字不差的回答。采样模式的参数组合我有一份自己常用的底表应用场景temperaturetop_p备注对话助手0.70.9最稳妥的通用配置创意写作0.8~1.00.95允许一定发散代码生成0.1~0.20.8低温度高确定性结构化数据抽取greedy不启用完全确定性采样模式的问题是结果不可复现。调试时如果发现某次输出特别差你得先区分是参数问题还是随机抖动。我的做法是固定随机种子同一个 prompt 跑 5 次看整体分布而不是揪着单次输出评判。3.4 根据任务选策略场景对照选解码策略有个基本顺序先定策略再调参数。策略错了再精细的参数也救不回来。任务类型推荐策略参考配置机器翻译 / 摘要beam searchnum_beams4~6no_repeat_ngram_size3代码生成 / 信息抽取greedy 或低温度采样temperature≤0.2top_p0.8多轮对话采样temperature0.7top_p0.9创意文案 / 故事采样temperature≥0.8top_p0.95指令遵循类任务低温度采样temperature0.1~0.3很多人在这一步就开始搞混这很正常。策略和参数是两层东西策略是“怎么从概率里挑 token”的规则框架参数是规则内部的微调旋钮。这两件事拆开理解调参思路就清晰了。4. 实操写一个完整的 generate 调用流程4.1 最小可运行示例纸上谈兵半天不如直接看代码。以下是我在实战中经常用的最小生成模板假设你已经加载好了一个开源模型和对应的 tokenizerfrom transformers import AutoTokenizer, AutoModelForCausalLM model_name some-org/chinese-llm-7b tokenizer AutoTokenizer.from_pretrained(model_name) model AutoModelForCausalLM.from_pretrained(model_name, device_mapauto) messages [ {role: user, content: 用一句话解释一下什么是梯度下降} ] # 关键一步把消息列表转换成模型训练时的输入格式 text tokenizer.apply_chat_template(messages, tokenizeFalse, add_generation_promptTrue) inputs tokenizer(text, return_tensorspt).to(model.device) outputs model.generate( **inputs, max_new_tokens256, # 只控制生成长度不碰输入长度 do_sampleTrue, # 开启采样 temperature0.7, top_p0.9, repetition_penalty1.05, ) # 去掉输入部分只解码新生成的 token response tokenizer.decode( outputs[0][inputs[input_ids].shape[1]:], skip_special_tokensTrue ) print(response)这段代码里有几个点值得展开讲讲。第一apply_chat_template不是可选项它会把消息列表渲染成模型在训练时见过的格式。跳过这一步模型拿到的输入格式不对输出质量会断崖式下降。第二decode 时用inputs[input_ids].shape[1]切片是为了去掉 prompt 部分只保留生成内容。第三skip_special_tokensTrue可以过滤掉s、/s这类特殊 token不然输出会带着一堆尖括号符号。4.2 流式输出怎么做普通生成方式要等模型把整段内容完全生成完才一次性返回结果。这在长文本生成时体验很差用户点了发送看着光标转五六秒然后突然蹦出一大段文字。流式输出的思路是模型每生成一个 token就立刻把它传到前端用户能像看打字机一样看到内容一点点出现体感延迟大大降低。在 transformers 生态里用TextIteratorStreamer就能实现import threading from transformers import TextIteratorStreamer streamer TextIteratorStreamer(tokenizer, skip_special_tokensTrue) kwargs dict( **inputs, max_new_tokens256, do_sampleTrue, temperature0.7, top_p0.9, streamerstreamer, ) # 注意必须放到后台线程里跑否则 streamer 拿不到数据 thread threading.Thread(targetmodel.generate, kwargskwargs) thread.start() for chunk in streamer: print(chunk, end, flushTrue)TextIteratorStreamer内部维护了一个队列generate 每产出一个 token 就会往队列里塞主线程持续读取并输出。这里最容易踩的坑是忘记用线程。如果直接在生成循环里迭代 streamer程序会卡死在 wait 状态因为 generate 还没跑完队列里没有数据。4.3 输入太长与输出被截断三个坑一次说清第一个坑是max_length小于输入长度的“零输出”问题前面已经提过解决办法是用max_new_tokens。第二个坑是pad_token缺失。很多开源模型的 tokenizer 没有定义pad_token一旦你批量生成就需要给短序列做 padding此时模型会报错或者疯狂打警告。一行代码就能解决if tokenizer.pad_token is None: tokenizer.pad_token tokenizer.eos_token第三个坑更隐蔽批量生成时忘记传attention_mask。模型虽然有 padding但不知道哪些 token 是真实文本、哪些是 pad生成时就会把 padding 部分也当成上下文结果非常诡异。所以批量生成时一定要把attention_mask一起给进去。4.4 批量生成与输出切分批量生成能极大提升吞吐量尤其在服务端场景下一次处理多个请求比逐个调用划算得多。但批量生成的解码需要多一步处理因为 padding 的关系每个样本的真实长度不一样不能直接outputs[0]当完整回答。inputs tokenizer(list_of_texts, return_tensorspt, paddingTrue) inputs inputs.to(model.device) outputs model.generate( **inputs, max_new_tokens128, do_sampleTrue, temperature0.7, ) prompt_length inputs[input_ids].shape[1] responses [] for out, mask in zip(outputs, inputs[attention_mask]): # 先过滤掉 padding token再切掉 prompt 部分 actual_tokens out[mask.bool()] response_tokens actual_tokens[prompt_length:] responses.append(tokenizer.decode(response_tokens, skip_special_tokensTrue))这里有个细节生成任务通常要求左 padding也就是 padding 放在句子左边而不是右边。因为模型是在右侧接续生成如果右侧有 padding生成内容就会和 padding token 混在一起。这也是为什么很多开源模型默认padding_sideleft。5. 生成质量问题排查实录5.1 输出无限重复先别怪模型现象模型生成“好的好的好的好的”或者整段车轱辘话。问题排查顺序我已经整理成一个固定流程确认当前是否在采样模式。如果还是 greedy第一件事就是do_sampleTrue配temperature0.7、top_p0.9大多数循环立刻缓解。如果仍然重复加no_repeat_ngram_size3它会把连续重复的 n-gram 直接掐死。还不行再上repetition_penalty1.1~1.2做全局重复惩罚。注意每次只动一个参数改完看效果再动下一个。很多人一次性把所有参数全加上最后出了问题根本不知道是哪一步引入的。这个调试习惯非常重要。5.2 答非所问、输出内容与问题无关如果模型生成的内容连贯、流畅但完全不是用户问的东西那大概率不是采样参数的问题而是输入格式出了问题。我排查的第一个地方永远是 chat template。很多开源模型的基座版本和对话版本训练方式完全不同基座模型根本不认识[INST]或|user|这类对话标签。你非要用对话格式往里塞它输出的自然就是“自由的续写”。用apply_chat_template做格式化就是为此服务的。如果确认格式没问题再看温度。信息密度高、事实性要求强的任务温度超过 0.7 就很容易跑偏。我的做法是这类任务直接降到 0.2 以下甚至切贪心。5.3 输出到一半突然断掉生成内容戛然而止可能是几个原因叠加。最常见的是模型提前生成了eos_token_id这个结束符导致生成阶段自动终止。这在短回答任务里表现不明显但在长文本生成里很恼人。对策是提高max_new_tokens没有用要看是不是模型本身默认的结束条件太激进。另一个原因是 decode 时没过滤特殊 token。某些模型会在正常文本里插入特殊标记比如|endoftext|它代表着句子结束但如果你没做skip_special_tokensTrue会看到输出里有奇怪的符号一部分文本被隐藏掉了。5.4 生成速度太慢瓶颈在哪LLM 生成慢慢在自回归的串行解码。模型每生成一个 token都要把之前的全部 token 重新过一遍模型。这个过程的计算量虽然大但更关键的是它没法在序列维度上并行——你生成第 100 个 token 之前必须先生成第 99 个。在原生 transformers 接口里能做的主要优化是这几点流式输出让用户看着内容一点点出来体感延迟大幅下降。批量生成一次处理多个请求充分利用 GPU 并行能力整体吞吐会提升数倍。减小生成长度max_new_tokens没必要设太大很多场景下 512 和 1024 的最终效果差别不大但耗时翻倍。模型量化int8、int4 量化后显存占用和推理速度都有明显改善质量损失在很多业务场景里可以接受。如果这些还不够就需要上专门的推理引擎了。现在社区里成熟的高性能推理方案基本都实现了 PagedAttention、Continuous Batching 这类技术生成吞吐能比原生 transformers 高出一个量级。生产环境部署这基本是必选项。5.5 常见问题速查表现象可能原因解决思路输出全空max_length小于输入长度改用max_new_tokens内容无限重复贪心/低温度陷入高频循环开采样、加惩罚项答非所问没用 chat template格式错误检查apply_chat_template输出带尖括号符号解码没过滤特殊 tokenskip_special_tokensTrue生成到一半停住触发 EOS、max_new_tokens 过小检查停止条件调大生成上限批量生成结果错乱缺少 attention_mask、pad_token补齐 mask设置 pad_token生成太慢自回归串行解码流式、批量、量化、换推理引擎关于 generate 的输出方式我个人在实际操作中最大的体会是调参顺序比参数本身重要。先理解概率分布是怎么产生的再选择解码策略最后才去碰 temperature、top_p 这些旋钮。很多人一上来就把所有参数都堆上模型行为像抽风然后开始怀疑模型能力——其实大部分时候是策略和参数组合出了问题。另外有个很容易被忽略的事实generate 不只是“调用一次拿结果”那么简单tokenizer 侧的配置和模型侧的逻辑同样关键。pad_token、attention_mask、chat_template这三样东西没处理好generate 调一万遍也白搭。我现在的习惯是每加载一个新模型先跑一条最小生成用例确认这三个基础项没问题再进入业务逻辑开发。最后分享一个我自己调试时的小技巧对比不同参数的效果别只看单次输出。固定随机种子同一个 prompt 连续跑 5 次观察输出分布。你会发现有些参数组合下结果时好时坏有些组合虽然单次不是最惊艳但整体稳定可靠。生成质量这件事稳定性往往比上限更重要。记住参数之间是联动的不是孤立存在的每次只动一个变量你才能真正搞清楚每个参数在你的任务里到底贡献了什么。