🏠 总目录📚 本教程 06b · 解码策略
📑 本页目录(点开跳转)

06b · 解码策略:模型是怎么选出下一个词的

46 分钟 | ⭐⭐ 你天天在调的那几个参数,这里第一次讲清楚


🎯 一句话

模型每一步吐出来的是一个概率分布,不是一个词。从这个分布里挑出那一个词的规则,就叫解码策略——temperaturetop_ptop_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

✅ 好处 ❌ 代价
确定性、最快(省掉抽签)——但"可复现"有个坑,见本章最后 ⚠️ 局部最优 ≠ 全局最优:第一个词选歪,整句跟着歪
抽取/分类/工具调用的默认选择 最容易掉进复读循环(下面会讲机制)

机制:不再只留一条路,而是同时留 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)。

下一个词的概率分布(真实词表 10 万个,这里只画概率最高的 8 个) 0.42 0.21 0.12 0.08 0.06 0.05 0.03 0.03 天气 心情 计划 咖啡 头发 钥匙 砖头 蟑螂 ① greedy (等价 T→0) 1 个 ② top_k = 3 (固定数量) 3 个 ③ top_p = 0.9 (T = 1,自适应) 6 个 ④ 先降温 T=0.3 再 top_p = 0.9 2 个
同一个分布,四种解法留下的候选集完全不同。top_k 是固定数量(不看分布形状),top_p 是自适应的。而温度先改分布、截断再切——降温到 T=0.3 后,同样一个 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

三条规则,比上面这张表更值钱

  1. temperaturetop_p 只调一个。 两个一起动,出了问题你分不清是谁干的。常见做法是把 top_p 钉死在 0.9,只调 T。
  2. 先固定随机性,再调提示词。 T=0 时你改一句提示词、输出变了,那就是提示词的功劳;T=0.9 时你根本不知道是提示词起效了还是抽签换了个结果。这条是提示词工程能不能做下去的前提。
  3. ⚠️ 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 章 评测体系 评测前必须先把采样参数钉死,否则你分不清是改动生效了还是抽签换了个结果

📦 深挖的话要学什么

需要的前置:知道模型最后一层输出 logits(第 2 章)+ decode 是逐 token 的(第 6 章)。不需要新数学,本章唯一的公式就是 softmax。


⚖️ 要不要深挖

你的情况 建议
只调 API 做应用 这一章就是给你写的,全读,而且是本导论里当天就能用上的少数几章之一
做结构化输出 / 工具调用 / 代码生成 重点看 penalty 那一节和调参表——这里最容易埋雷
自建推理服务 ✅ 再往下补约束解码和批量采样
只关心成本和吞吐 🟡 看完「四个动作」和最后那三条规则就够

🔑 我的判断:这一章的投入产出比是整本导论里最高的—— 二十分钟,换掉的是"参数瞎试、出了问题不知道该动哪个"的状态。 而且它不需要 GPU、不需要新数学、不需要重新部署,改一个数字就能验证。


✅ 检查点

  1. 一步 decode 里的四个动作按什么顺序发生?为什么说 temperaturetop_p 不是独立的两个旋钮?
  2. Beam Search 适合什么任务?开放式生成为什么不用它(至少说出两条)?
  3. top-p 比 top-k 好在哪?top_p = 1.0 意味着什么?
  4. temperature 在数学上改变了什么、没改变什么?logits 差 2.0 时,T=0.5 和 T=2 的概率比各是多少?
  5. 复读机是怎么形成的?frequency_penaltypresence_penalty 的区别?哪些场景这几个参数必须留 0?
  6. 调参的三条规则是什么?为什么"先固定随机性再调提示词"不只是习惯问题?
  7. T=0 就一定能逐位复现吗?这件事和上一章的哪个优化有关?
👀 答案
  1. ① 吐 logits(不是概率)→ ② 温度缩放 → ③ top_k/top_p 截断 → ④ softmax 归一化后抽签。不独立是因为温度先改分布形状、截断再在改过的分布上切:降温到 0.3 后,同样的 top_p=0.9 从 6 个候选缩到 2 个
  2. 适合有唯一正确输出的任务:机器翻译、语音识别、OCR、结构化摘要。开放式生成不用它的四条:①高概率 ≠ 好文本(人类文本的逐词概率是起伏的,追求"最可能的一句话"得到的是最平庸的一句话)②文本退化(beam 越宽越短越干瘪,这正是核采样被提出来的动机)③多样性塌缩(k 条 beam 只差一两个词)④成本 k 倍 KV Cache
  3. top-k 是固定数量,不看分布形状——分布尖时塞进 49 个荒唐候选,分布平时又砍掉合理选项;top-p 的候选集大小自适应top_p = 1.0 表示完全不截断,长尾里那 0.001% 的怪词有真实机会被抽中——想"别乱来"该设 0.9 不是 1.0。
  4. 改变的是差距没改变排序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 倍
  5. 自我强化回路:模型刚写出的模式进了上下文,"出现过"本身抬高了它再次出现的概率,greedy 和低温尤其容易掉进去。frequency_penalty 按出现次数累加地罚(次数越多越狠);presence_penalty 只罚"出现过"这个事实,作用是推动换话题repetition_penalty 是乘性的,出现 1 次和 100 次罚得一样。代码 / JSON / 工具调用场景一律留 0——那些 token 本来就该重复。更根本的是:复读常常是"没东西可说"的症状,不是解码的病
  6. temperaturetop_p 只调一个(常见做法是把 top_p 钉死在 0.9 只调 T),两个一起动就分不清是谁干的;②先固定随机性再调提示词;③T=0 也不保证逐位复现。第二条不只是习惯问题:T=0.9 时你根本不知道输出变化是提示词起效了还是抽签换了个结果——没有归因,提示词工程就无法迭代。
  7. 不保证。 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乘ktop_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

打卡记录保存在你的浏览器里,首页能看到总进度