📑 本页目录(点开跳转)
06b · 解码策略:模型是怎么选出下一个词的
⏱ 46 分钟 | ⭐⭐ 你天天在调的那几个参数,这里第一次讲清楚
🎯 一句话
模型每一步吐出来的是一个概率分布,不是一个词。从这个分布里挑出那一个词的规则,就叫解码策略——temperature、top_p、top_k、那几个 penalty,全都活在这一步。
🧭 先接住上一章
第 6 章推理优化的三大武器(量化 / 批处理 / 投机解码)解决的是「这一步跑得多快」。 这一章解决完全不同的另一件事:「这一步选哪个词」。
两件事是正交的,但经常被混在一起。分开的办法很简单,看现象:
| 现象 | 去哪一章 |
|---|---|
| 慢、贵、显存不够、吞吐上不去 | 第 6 章 |
| 输出飘 / 复读 / 每次结果不一样 / JSON 间歇性打不开 | ⭐ 这一章 |
它是每个调 API 的人天天在动、却最少被讲清楚的东西。 唯一的前置是知道 decode 是一个一个吐 token 的——第 6 章「两个阶段」讲过。
🎲 一步 decode 里到底发生了什么
① 模型吐出 logits —— 一个和词表一样长的实数向量
词表 10 万 → 10 万个分数,可以是任意实数(3.0、-8.2、0.35…)
⚠️ 它【不是概率】,还没归一化
② 温度缩放:logits ÷ T ← temperature 在这里生效
③ 截断候选集:top_k / top_p ← 把长尾砍掉
④ softmax 归一化 → 抽签 ← 真正「选」出那一个 token
⭐ 注意顺序:温度【先】改变分布形状,截断【再】在改过之后的分布上切。
所以调大 T 会让 top_p 放进来更多词 —— 这两个旋钮不是独立的。
⚠️ 主流实现(HuggingFace transformers、vLLM)的默认顺序是 温度 → top_k → top_p, 但不同框架不保证一致。拿不准就只动一个参数,别指望跨框架搬参数还是同一个行为。
🥇 Greedy:永远选最大的那个
每一步取 argmax,不抽签。等价于 T→0。
| ✅ 好处 | ❌ 代价 |
|---|---|
| 确定性、最快(省掉抽签)——但"可复现"有个坑,见本章最后 ⚠️ | 局部最优 ≠ 全局最优:第一个词选歪,整句跟着歪 |
| 抽取/分类/工具调用的默认选择 | 最容易掉进复读循环(下面会讲机制) |
🌲 Beam Search:以及为什么开放式生成不用它
机制:不再只留一条路,而是同时留 k 条部分序列(beam)。每一步把每条 beam 的所有可能扩展都算出来,按累计对数概率排序,全局取前 k 条继续。
k = 3,第 t 步:
3 条 beam × 每条取若干候选 → 一堆候选序列
→ 按 Σ log p 排序 → 只留前 3 条 → 进入第 t+1 步
⚠️ 长度偏好:每多一个词就多乘一个 <1 的概率,总分只会更低
→ beam search 天然偏爱【短句】
→ 所以要配长度惩罚 length_penalty,把总分除以 长度^α
它在哪些任务上是对的:机器翻译、语音识别、OCR、结构化摘要——这些任务有一个(接近)唯一的正确输出,"找到概率最高的那条序列"就是任务目标本身。
⭐ 但开放式生成(聊天、写作、创意)不用它,原因有四条,第一条最根本:
| 原因 | 说明 |
|---|---|
| 高概率 ≠ 好文本 ⭐ | 人写的句子并不走最高概率路径——真实人类文本的逐词概率是起伏的(时而出人意料),而 beam 挑出来的序列概率曲线平得不自然。追求"最可能的一句话",得到的是最平庸的一句话 |
| 文本退化 | beam 开得越宽,输出越短、越干瘪、越容易复读。这个观察正是核采样(top-p)被提出来的直接动机 |
| 多样性塌缩 | k 条 beam 常常只差一两个词。想要「给我 5 个不同的开头」,beam 给你的是 5 个几乎一样的开头 |
| 成本 k 倍 | k 条 beam = k 份 KV Cache,显存和带宽都乘 k。而 decode 本来就卡在带宽上(第 6 章的第一条铁律:decode 卡在显存带宽,不是算力) |
🔗 最后那条成本其实有救:vLLM 的块共享让各条 beam 共用公共前缀的 KV 块, 只有分叉之后的部分才各存一份 —— 展开见 AI基础设施 17。
⚠️ 一个常见的搬运事故:把翻译服务里调好的 num_beams=5 原样搬到"生成营销文案"上,
结果每次生成的十条文案开头几乎一模一样。参数没错,任务变了——从"有唯一正确答案"变成了"要多样性"。
✂️ top-k:固定砍到 k 个
只保留概率最高的 k 个候选,重新归一化再抽签。
它的毛病只有一条:k 是固定数量,但分布形状是变的。
分布很尖("中华人民共和" 后面几乎必然是 "国")
→ k=50 会把 49 个荒唐候选硬塞进候选集
分布很平(一个故事的开头,几百个词都合理)
→ k=50 又把大量合理选项砍掉了
🎯 top-p 核采样(nucleus sampling):候选集自适应
按概率从大到小累加,累计一超过 p 就停手,只在这个"核"里抽签。
⭐ 关键优点:候选集大小自己会变。 分布尖 → 核可能只有 1–2 个;分布平 → 核可以有几百个。这正好补上了 top-k 的毛病,所以它是现在的默认选择(典型值 0.9 / 0.95)。
top_p=0.9 只剩 2 个候选(因为「天气」的概率被拉到了 0.89)。⚠️ top_p = 1.0 不是"安全值"——它表示完全不截断,长尾里那 0.001% 的怪词有真实机会被抽中。想要"别乱来"应该设 0.9,不是 1.0。
🌡️ temperature 到底在做什么(数学)
$$p_i = \frac{e^{z_i / T}}{\sum_j e^{z_j / T}}$$
🔗 这就是 第 15 章数学地基里
softmax(x/T)那一小节的下游用法——那里讲了式子,这里讲它落到 API 参数上是什么后果。
⭐ 最值得记住的一条:温度不改变排序,只改变差距。
任意两个 token 的概率比是
$$\frac{p_i}{p_j} = e^{(z_i - z_j)/T}$$
argmax 永远是同一个词,变的是"第二名有多大机会翻盘":
| logits 差 2.0 时 | 概率比 | 直觉 |
|---|---|---|
| T = 0.5 | e⁴ ≈ 54.6 倍 | 第二名基本没戏 → 输出收敛、保守 |
| T = 1(模型原始分布) | e² ≈ 7.4 倍 | 训练时学到的那个分布 |
| T = 2 | e ≈ 2.7 倍 | 长尾被抬起来 → 输出发散、容易崩 |
🔗 这件事你在别处见过:
temperature就是探索与利用的生成版—— T=0 是纯利用(每次都选当前最优),T 大是探索。 强化学习 07 讲的是同一个权衡在决策问题上的样子。
🔁 复读机问题与三个 penalty
复读是怎么来的:自回归有一个自我强化回路——模型刚写出"非常重要",这个模式就进了上下文;而"上下文里出现过"这件事本身抬高了它下一次出现的概率;于是越读越读。greedy 和低温最容易掉进去(因为它们永远选那个已经被抬高的词)。
三个参数长得像,算法完全不同,面试最爱在这里追问:
| 参数 | 怎么算 | 特点 |
|---|---|---|
repetition_penalty |
出现过的 token,正 logit 除以 λ,负 logit 乘以 λ | 乘性,只看"出没出现过"——出现 1 次和 100 次罚得一样重 |
frequency_penalty |
logit 减去 α × 已出现次数 | 加性,次数越多罚得越狠 |
presence_penalty |
logit 减去 β × [出现过 = 1] | 加性,只罚"出现过"这个事实 → 推动模型换新话题 |
no_repeat_ngram_size = n |
已出现过的 n-gram 直接硬禁 | 最粗暴,也最容易误伤 |
⚠️ 它们最大的问题:不区分「该重复」和「不该重复」。
代码生成:for / i / = / ( / ) / return 本来就该反复出现
→ 开 frequency_penalty = 模型被逼着换写法 = 语法错误
结构化输出:JSON 的 { } " : 和字段名必须重复
→ 惩罚会把 JSON 打坏,而且是【间歇性】打坏,最难查
中文:的、是、了、在 天生就是高频字
→ 惩罚一大,句子开始变得别扭
⭐ 结论:代码 / JSON / 工具调用场景,这三个参数一律留 0。
🔑 更根本的一条:复读常常是"没东西可说"的症状,不是解码的病。 上下文里信息不够时,模型会退回到最安全的模式反复念。 这时候加惩罚只是把症状换个样子——该做的是查提示词和检索(第 8 章)。
🎛️ 几个旋钮怎么配合调
| 场景 | temperature | top_p | penalty |
|---|---|---|---|
| 抽取 / 分类 / 工具调用 / JSON | 0 | — | 全 0 ⭐ |
| 代码生成 | 0 ~ 0.2 | 0.95 | 全 0 ⭐ 绝不要开 |
| RAG 问答 / 客服 | 0 ~ 0.3 | 0.9 | 0 |
| 通用对话 | 0.7 | 0.9 | presence 0 ~ 0.3 |
| 创意写作 / 头脑风暴 | 0.9 ~ 1.1 | 0.95 | presence 0.3 ~ 0.6 |
| 要 n 个不同的候选 | 0.8 ~ 1.0,采样 n 次 | 0.95 | ❌ 别用 beam search |
⭐ 三条规则,比上面这张表更值钱:
temperature和top_p只调一个。 两个一起动,出了问题你分不清是谁干的。常见做法是把top_p钉死在 0.9,只调 T。- 先固定随机性,再调提示词。 T=0 时你改一句提示词、输出变了,那就是提示词的功劳;T=0.9 时你根本不知道是提示词起效了还是抽签换了个结果。这条是提示词工程能不能做下去的前提。
- ⚠️ T=0 也不保证 100% 可复现。 GPU 上浮点加法不满足结合律,而每一步的归约顺序会随 batch 的组成变化——同样的输入,和不同的请求拼在一个 batch 里,可能算出不同的 logits,恰好卡在两个候选分数极接近时就会输出不同。 🔗 而 batch 组成是动态的,正是第 6 章的武器二 · 连续批处理造成的:吞吐优化和逐位复现是一对天然矛盾。要严格复现只能牺牲吞吐(固定 batch、关掉连续批处理)。
🔨 手写一遍:温度 + top-p 采样
import numpy as np
def sample_next(logits, temperature=0.8, top_p=0.9, rng=None):
"""从一步 logits 里采一个 token id。顺序:温度 → top-p → 归一化 → 抽签"""
rng = rng or np.random.default_rng(0)
z = np.asarray(logits, dtype=np.float64)
if temperature <= 0: # ⭐ T=0 就是 greedy,不抽签
return int(np.argmax(z))
z = z / temperature # ⭐ ① 温度先改分布形状
z = z - z.max() # 数值稳定:防 exp 溢出
p = np.exp(z); p /= p.sum()
order = np.argsort(-p) # 概率从大到小
cum = np.cumsum(p[order])
keep = order[:np.searchsorted(cum, top_p) + 1] # ⭐ ② 累计刚过 p 就停手
q = p[keep] / p[keep].sum() # ⭐ ③ 只在核里重新归一化
return int(rng.choice(keep, p=q))
logits = [3.0, 2.3, 1.75, 1.35, 1.05, 0.85, 0.35, 0.35] # 就是上图那 8 个
print(sample_next(logits, temperature=1.0, top_p=0.9)) # 核里 6 个候选
print(sample_next(logits, temperature=0.3, top_p=0.9)) # 核里只剩 2 个 ⭐
💡 十行代码把上面那张图跑了一遍:同一组 logits,只把 T 从 1.0 降到 0.3,核就从 6 个缩到 2 个——温度和截断是串起来的,不是两个独立开关。
🔗 和你已知的关系
左列有的来自日常用 LLM 的经验,有的在站内别的板块讲过——碰上过哪几条就从哪几条接进来,一条没碰过也不影响读这一章。
| 你可能已经知道的 | 这一章的「为什么」 |
|---|---|
调 temperature 让输出更稳 / 更野 |
softmax(z/T) 把 logits 的差距指数级放大或压缩 |
| 「T=0 才好调提示词」这条经验 | 不是玄学:先固定随机性,改动才有归因 |
| 结构化输出偶尔崩、JSON 打不开 | 多半是 penalty 开着——那些 token 本来就该重复 |
| 流式输出一个字一个字 | 每一个字都走了一遍本章这四个动作 |
| 提示词写得越具体输出越稳 | 分布本来就尖的时候,采样参数几乎不起作用 ⭐ |
想接着深挖,去这几处:
| 去哪 | 为什么 |
|---|---|
| 第 6 章 推理优化 | ⭐ 回到"跑得多快"那一半:量化、连续批处理、投机解码,以及为什么投机解码能保证输出分布严格等价(它动的正是本章这一步) |
| 第 15 章 数学地基 | softmax(x/T) 的式子本身在那里,本章是它落到 API 参数上的后果 |
| 强化学习 07 | ⭐ temperature 就是探索与利用的生成版;那一章讲同一个权衡在决策问题上长什么样(UCB、Thompson Sampling) |
| AI基础设施 17 | beam search 的 k 条候选靠块共享才不至于把显存乘 k;也解释了动态 batch 为什么会打断逐位复现 |
| 第 8 章 RAG 深水区 | ⭐ 复读多半是"没东西可说"的症状——真该查的是检索有没有把信息捞进来 |
| 第 11 章 评测体系 | 评测前必须先把采样参数钉死,否则你分不清是改动生效了还是抽签换了个结果 |
📦 深挖的话要学什么
- 约束解码:JSON 模式 / 正则约束是怎么用 logits mask 实现的、它和 Function Calling 的关系、约束会不会伤害质量
- 更多采样方法:
min_p、typical sampling、对比解码(contrastive decoding)、镜像采样 - beam search 的细节:长度惩罚 α 怎么定、覆盖惩罚、beam 内去重
- 批量采样:一次生成 n 个候选时的 KV 块共享,以及 Self-Consistency 为什么必须配采样而不能用 greedy
- 和评测的关系:pass@k 为什么必须配温度、温度选错会让评测结论整个反过来
需要的前置:知道模型最后一层输出 logits(第 2 章)+ decode 是逐 token 的(第 6 章)。不需要新数学,本章唯一的公式就是 softmax。
⚖️ 要不要深挖
| 你的情况 | 建议 |
|---|---|
| 只调 API 做应用 | ✅ 这一章就是给你写的,全读,而且是本导论里当天就能用上的少数几章之一 |
| 做结构化输出 / 工具调用 / 代码生成 | ✅ 重点看 penalty 那一节和调参表——这里最容易埋雷 |
| 自建推理服务 | ✅ 再往下补约束解码和批量采样 |
| 只关心成本和吞吐 | 🟡 看完「四个动作」和最后那三条规则就够 |
🔑 我的判断:这一章的投入产出比是整本导论里最高的—— 二十分钟,换掉的是"参数瞎试、出了问题不知道该动哪个"的状态。 而且它不需要 GPU、不需要新数学、不需要重新部署,改一个数字就能验证。
✅ 检查点
- 一步 decode 里的四个动作按什么顺序发生?为什么说
temperature和top_p不是独立的两个旋钮? - Beam Search 适合什么任务?开放式生成为什么不用它(至少说出两条)?
- top-p 比 top-k 好在哪?
top_p = 1.0意味着什么? temperature在数学上改变了什么、没改变什么?logits 差 2.0 时,T=0.5 和 T=2 的概率比各是多少?- 复读机是怎么形成的?
frequency_penalty和presence_penalty的区别?哪些场景这几个参数必须留 0? - 调参的三条规则是什么?为什么"先固定随机性再调提示词"不只是习惯问题?
- T=0 就一定能逐位复现吗?这件事和上一章的哪个优化有关?
👀 答案
- ① 吐 logits(不是概率)→ ② 温度缩放 → ③ top_k/top_p 截断 → ④ softmax 归一化后抽签。不独立是因为温度先改分布形状、截断再在改过的分布上切:降温到 0.3 后,同样的
top_p=0.9从 6 个候选缩到 2 个。 - 适合有唯一正确输出的任务:机器翻译、语音识别、OCR、结构化摘要。开放式生成不用它的四条:①高概率 ≠ 好文本(人类文本的逐词概率是起伏的,追求"最可能的一句话"得到的是最平庸的一句话)②文本退化(beam 越宽越短越干瘪,这正是核采样被提出来的动机)③多样性塌缩(k 条 beam 只差一两个词)④成本 k 倍 KV Cache。
- top-k 是固定数量,不看分布形状——分布尖时塞进 49 个荒唐候选,分布平时又砍掉合理选项;top-p 的候选集大小自适应。
top_p = 1.0表示完全不截断,长尾里那 0.001% 的怪词有真实机会被抽中——想"别乱来"该设 0.9 不是 1.0。 - 改变的是差距,没改变排序(
argmax永远是同一个词)。概率比 $p_i/p_j = e^{(z_i-z_j)/T}$:logits 差 2.0 时,T=0.5 → e⁴ ≈ 54.6 倍,T=1 → e² ≈ 7.4 倍,T=2 → e ≈ 2.7 倍。 - 自我强化回路:模型刚写出的模式进了上下文,"出现过"本身抬高了它再次出现的概率,greedy 和低温尤其容易掉进去。
frequency_penalty按出现次数累加地罚(次数越多越狠);presence_penalty只罚"出现过"这个事实,作用是推动换话题;repetition_penalty是乘性的,出现 1 次和 100 次罚得一样。代码 / JSON / 工具调用场景一律留 0——那些 token 本来就该重复。更根本的是:复读常常是"没东西可说"的症状,不是解码的病。 - ①
temperature和top_p只调一个(常见做法是把 top_p 钉死在 0.9 只调 T),两个一起动就分不清是谁干的;②先固定随机性再调提示词;③T=0 也不保证逐位复现。第二条不只是习惯问题:T=0.9 时你根本不知道输出变化是提示词起效了还是抽签换了个结果——没有归因,提示词工程就无法迭代。 - 不保证。 GPU 浮点加法不满足结合律,归约顺序会随 batch 组成变化,同样输入拼进不同 batch 可能算出不同 logits。而 batch 组成动态变化正是第 6 章武器二连续批处理造成的——吞吐优化和逐位复现是一对天然矛盾,要严格复现只能牺牲吞吐。
🛑 可以停在这里
⚡ 走神救援
这一章管的是「选哪个词」(第 6 章管「跑多快」,两件事正交:慢和贵去查第 6 章,输出飘/复读/JSON 间歇打不开来查这里)。一步decode四个动作 = 吐logits(不是概率,是任意实数)→温度缩放→top_k/top_p截断→softmax抽签;⭐温度先改分布、截断再切,所以两个旋钮不独立——同一组logits,T从1.0降到0.3,
top_p=0.9的候选就从6个缩到2个(主流实现顺序是温度→top_k→top_p,但跨框架不保证一致)。greedy=argmax=T→0,确定最快但最易复读。beam search留k条序列按累计对数概率排(还要配长度惩罚,否则天然偏爱短句),只适合翻译/ASR/OCR这类有唯一正确答案的任务;⭐开放式生成不用它的四条:高概率≠好文本(人类文本的逐词概率是起伏的,追求"最可能"得到的是"最平庸")、文本退化(越宽越干瘪,这正是核采样被提出来的动机)、多样性塌缩(k条只差一两个词)、KV Cache乘k。top_k是固定数量不看分布形状(分布尖时塞进荒唐候选,分布平时又砍掉合理选项),top_p自适应;⚠️top_p=1.0不是安全值,是完全不截断,长尾那0.001%的怪词有真实机会被抽中。温度只改差距不改排序(argmax永远是同一个词):logits差2.0时T=0.5→54.6倍、T=1→7.4倍、T=2→2.7倍;它就是探索与利用的生成版。复读=自我强化回路("出现过"本身抬高再次出现的概率);三个penalty:frequency按次数累加罚、presence只罚"出现过"(推动换话题)、repetition是乘性的(1次和100次一样)——⚠️代码/JSON/工具调用一律留0,那些token本来就该重复,惩罚会间歇性打坏JSON,最难查;⭐复读多半是"没东西可说"的症状,不是解码的病,该去查提示词和检索。三条规则:T和top_p只调一个、先固定随机性再调提示词(否则改动无法归因,提示词工程就没法迭代)、⚠️T=0也不保证逐位复现——GPU浮点加法不满足结合律,batch组成一变归约顺序就变,而动态batch正是第6章连续批处理造成的,吞吐优化和逐位复现天然矛盾。
下一节 👉 07-本地部署与开源生态.md