Skip to content

约 10 分钟阅读

10. 输出层与解码:从 logits 到文字 ​

上一篇补上了词序,到这里“年假怎么申请?”已经走完了 32 层,每个位置都得到一个 4096 维的向量。这一篇看最后一步:最后一个位置的向量怎样变成词表里 128256 个分数,分数怎样变成概率,再怎样挑出一个 token“根据”。这一步一半由模型结构和权重决定,另一半由解码参数决定,两者经常被混在一起讨论。读完你应该能手算一组 logits 在不同温度、top-k、top-p、min-p 下的结果,说清输出层占多少参数和计算,并能判断“回答停不下来”“总在重复”这类问题该去查模型还是查解码配置。

最后一层之后:一次 RMSNorm 和一次矩阵乘法 ​

资料助手的这次请求,套上 Llama 3.1 Instruct 的对话模板后一共 53 个 token。Prefill 结束时,第 32 层输出的形状是 [53, 4096]。要预测的是第 54 个 token,只有最后一个位置的向量有用,它看过了前面全部 53 个 token。

text
最后位置的隐藏向量   [1, 4096]
→ 最终 RMSNorm      [1, 4096]          归一化,每维乘一个可学习的缩放,参数 4096 个
→ lm_head           [1, 4096] × [4096, 128256] → [1, 128256]

输出层(Transformers 里叫 lm_head)就是一个没有 bias 的线性层,把 4096 维投影到词表大小。它算出来的 128256 个数叫 logits,每个 token 一个分数,分数越高表示模型越倾向于它。

这一层的账:

text
参数:4096 × 128256 = 525,336,576           占 8B 总参数 8,030,261,248 的 6.5%
权重:525,336,576 × 2 字节 = 1,050,673,152 字节 ≈ 1.05 GB(0.98 GiB),BF16
计算:2 × 4096 × 128256 ≈ 1.05 GFLOPs / token  占每 token 约 16 GFLOPs 前向的 6.5%
输出:128256 个 FP32 分数 = 513,024 字节 ≈ 501 KiB / 位置

Llama-3.1-8B 的 tie_word_embeddings 是 false,输出层和第 04 篇的词嵌入表形状相同、各有一份。小模型常把两者共享,Qwen2.5-0.5B 共享后省下的 1.36 亿参数占它总参数的四分之一以上。

训练和推理在这里有一个差别。训练时每个位置都要预测下一个 token、都要算损失,所以 53 个位置全部过一遍输出层;推理时只需要最后一个位置。Transformers 的 LlamaForCausalLM.forward 有一个 logits_to_keep 参数,生成时只对最后位置算 logits。如果 53 个位置全算,光 FP32 的 logits 就有约 25.9 MiB,长输入、大批量时这会是一笔可观的显存。

从第 32 层最后位置的 4096 维向量经过最终 RMSNorm 和 4096×128256 的输出层得到 logits,这一段由架构决定;再经过改分数、砍候选、softmax 与抽样选出 token 110747“根据”,这一段由解码参数决定,选出的 token 追加到输入末尾进入下一步

图里蓝框内的部分对同一个输入每次结果都一样,因为它只是固定权重的矩阵乘法。橙框内的部分由 generation_config.json 或请求参数控制,改这些参数不需要动模型。最后选出的 token 追加到输入末尾,下一步只为这 1 个新 token 跑一遍 32 层,这就是推理系列讲的 Decode 循环。

logits 变成概率:softmax ​

为了能手算,假设这一步词表里只有 6 个候选(真实情况下其余十几万个 token 的分数都很低,对结果影响很小)。教学用的 logits 如下:

候选根据您申请首先请好
logit5.04.03.02.51.50.0

softmax 把一组任意实数变成和为 1 的概率:

text
pᵢ = exp(zᵢ) / Σⱼ exp(zⱼ)        zᵢ 是第 i 个候选的 logit

exp:148.413  54.598  20.086  12.182  4.482  1.000     合计 240.761
p:  0.616    0.227   0.083   0.051   0.019  0.004

“根据”的分数只比“您”高 1,概率却是它的 e ≈ 2.7 倍:softmax 把分数差变成概率的倍数关系。另外,所有 logit 同时加减一个常数,概率不变,所以 logit 的绝对大小没有意义,只有差值有意义。

用 Llama 3 的分词器检查,“根据”是单个 token,编号 110747;“年假”则被切成“年”和“假”两个 token。所以模型这一步选“根据”,下一步才接着生成“公司”或“《员工手册》”。

温度:只改差距,不改排序 ​

温度(temperature)T 是在 softmax 之前把所有 logit 除以 T:

text
pᵢ = exp(zᵢ / T) / Σⱼ exp(zⱼ / T)

T 小于 1,分数差被放大,高分的更高;T 大于 1,分数差被压小,低分的候选分到更多概率。

同一组 logits 在温度 0.5、1、1.5 下的概率:“根据”分别为 0.861、0.616、0.477,“好”分别约为 0、0.004、0.017,排序始终不变

温度从 0.5 升到 1.5,“根据”的概率从 0.861 降到 0.477,排在最后的“好”从几乎为 0 升到 0.017。不论温度多少,6 个候选的排序都不变。T 趋近 0 时分布越来越集中,极限就是只选最高分的那个,叫贪心解码(greedy)。多数推理服务把 temperature=0 当作贪心处理,而不是真的去除以 0。

Llama-3.1-8B-Instruct 的 generation_config.json 推荐温度 0.6。用这组 logits 算,T = 0.6 时“根据”0.804、“您”0.152,后面四个合计不到 0.05。

砍掉长尾:top-k、top-p、min-p ​

温度只改形状,不会把任何候选的概率变成 0。真实词表有十几万个 token,长尾里每个都很小,加起来却不少,偶尔抽中一个就会冒出不相干的字。下面三种方法都是在抽样前先砍掉一部分候选,再把剩下的重新归一化。

top-k 只保留分数最高的 k 个。k = 3 时留下根据、您、申请,重新归一化后是 0.616/0.926 = 0.665、0.245、0.090。

top-p(核采样)按概率从高到低累加,累计概率达到 p 时停止。p = 0.9 时:0.616 → 0.843 → 0.927,到第 3 个才超过 0.9,同样留下 3 个。如果先按推荐温度 0.6 算概率再做 top-p 0.9,累计 0.804 → 0.956,只留 2 个。

min-p 设一个相对门槛:概率低于“最高概率 × min_p”的候选全部去掉。min_p = 0.1 时门槛是 0.616 × 0.1 = 0.0616,“首先”的 0.051 不够,也是留 3 个。

在这一步上三种方法结果相同,差别要换个分布才看得出来:

三种分布下 top-k=3、top-p=0.9、min-p=0.1 各留下多少候选:资料助手这一步都留 3 个;6 个候选几乎一样好的平坦分布里 top-k 只留 3 个,另两种留 6 个;一个 0.5 加 50 个 0.01 的长尾分布里 top-p 留 41 个,min-p 只留 1 个

第二行是平坦分布,6 个候选的概率在 0.135 到 0.202 之间,都说得通(比如一句话可以用好几种方式开头)。top-k 固定留 3 个,硬砍掉一半合理的选择;top-p 和 min-p 都会全部保留。第三行是一个 0.5 的主候选加 50 个各 0.01 的长尾。top-p 0.9 必须累计 0.5 + 40 × 0.01 才达到 0.9,于是留下 41 个,其中 40 个都是边缘候选;min-p 的门槛是 0.05,只留下主候选。这正是 min-p 被提出来的理由:门槛随最高概率浮动,模型有把握时砍得狠,没把握时放得宽,在高温度下尤其明显。

三种方法可以叠加。Transformers 的 generate 按“温度 → top-k → top-p → min-p”的顺序依次处理,前一步砍过的候选不再参与后一步;vLLM 等引擎的顺序和实现细节可能不同,同一组参数换个引擎结果未必一致。Llama-3.1-8B-Instruct 推荐 temperature 0.6、top_p 0.9;Qwen2.5-0.5B-Instruct 推荐 temperature 0.7、top_p 0.8、top_k 20,再加 repetition_penalty 1.1。这些推荐值是模型发布方调出来的经验设置,是解码策略的一部分,不是模型结构的一部分。采样参数在服务链路里怎样传递、在哪一步生效,见推理系列第 07 篇。

重复惩罚与其他改分数的参数 ​

还有一类参数在温度之前直接修改 logits。重复惩罚(repetition penalty)降低已经出现过的 token 的分数。Transformers 的实现是:出现过的 token,分数为正时除以 penalty,为负时乘以 penalty。资料助手的提示里已经有“申请”,penalty = 1.1 时它的分数从 3.0 变成 2.727,概率从 0.083 降到 0.065。

这里有一个容易踩的坑:Transformers 对 decoder-only 模型统计的“出现过”包括整段提示。资料助手的回答里本来就该反复出现“年假”“申请”“审批”这些词,惩罚设得太重,模型会刻意绕开它们,写出“休息日假期的提交流程”这种别扭的说法,甚至换成不准确的近义词。症状是回答措辞越来越奇怪而事实没变;排查时把 penalty 调回 1.0 对比,就能分辨是模型问题还是解码问题。

反过来,贪心解码或温度很低时,模型容易陷入循环,同一句话反复输出。这通常不是模型“坏了”,而是每一步都选最高分、一旦进入一个高概率的循环就出不来。适度的温度、重复惩罚或 no_repeat_ngram_size 可以缓解。

结构化输出(要求只输出合法 JSON 或从固定选项里选)也属于这一类:在每一步把不符合语法的 token 分数设为负无穷,模型权重不变,只是候选被约束了。

停止 token 与对话模板 ​

模型不会自己“知道”回答完了,它只是在某一步选中了一个特殊 token。Llama 3.1 Instruct 的对话模板把资料助手的请求拼成这样:

text
<|begin_of_text|><|start_header_id|>system<|end_header_id|>

Cutting Knowledge Date: December 2023
Today Date: 26 Jul 2024

你是公司资料助手,只根据提供的制度回答。<|eot_id|><|start_header_id|>user<|end_header_id|>

年假怎么申请?<|eot_id|><|start_header_id|>assistant<|end_header_id|>

用 Transformers 5.21.0 的 apply_chat_template 渲染,这段一共 53 个 token;同一组消息用 Qwen2.5-0.5B-Instruct 的模板(<|im_start|> / <|im_end|>)是 29 个。末尾的 assistant 标记告诉模型轮到它回答了,回答结束时它应当输出 <|eot_id|>(编号 128009)。Llama-3.1-8B-Instruct 的 generation_config.json 把 eos_token_id 写成 [128001, 128008, 128009],分别是 <|end_of_text|>、工具调用用的 <|eom_id|> 和 <|eot_id|>,采到其中任何一个就停。

模板和停止 token 都不在网络结构里,但都由后训练决定:模型在指令微调时见过成千上万次“回答 + <|eot_id|>”,学会了在说完时给这个 token 很高的分数。推理端用错模板或漏了停止 token,模型本身没有任何变化,表现却会明显变差。一个典型症状是回答说完后接着写出一行“user”,再自问自答下去:生成配置只在等 128001,而模型已经输出了 128009。对话模板写错、结束符不一致在训练阶段的后果见训练系列第 09 篇。

为什么一次只能出一个 token ​

输出层每一步只输出“下一个位置”的分布。第 55 个 token 的分布取决于第 54 个 token 是什么:选了“根据”,接下来大概率是“公司”或“《”;选了“您”,接下来可能是“可以”。在第 54 个 token 确定之前,第 55 个位置的分布无从计算。语言模型把一整句话的概率拆成逐个 token 的条件概率相乘:

text
P(根据, 公司, 制度, …) = P(根据 | 提示) × P(公司 | 提示, 根据) × P(制度 | 提示, 根据, 公司) × …

训练时整段文本已知,因果掩码让所有位置一次并行算完;推理时后面的 token 还不存在,只能一步一步来。这是自回归结构本身决定的。

有些方法看起来一步出了多个 token,但没有改变这条规则。DeepSeek-V3 的多 token 预测(MTP)在训练时额外加了预测更远位置的模块,推理时可以拿它来猜后面几个 token(第 17 篇);投机解码用一个快的草稿模型先猜几个,再让目标模型一次验证,猜错的部分丢掉重来,最终每个 token 仍然服从目标模型的分布(推理系列第 15 篇)。它们省的是“每步都要读一遍全部权重”的时间,不是跳过了逐 token 的条件依赖。

架构决定的与解码决定的 ​

现象由什么决定改动方式
词表大小、logits 的形状架构(vocab_size、lm_head)换模型
同一输入下每个候选的分数权重训练或微调
是否共享词嵌入和输出层架构(tie_word_embeddings)换模型
每次只能确定一个 token自回归结构投机解码、MTP 只能加速,不改规则
回答的随机程度、多样性解码参数(温度、top-k、top-p、min-p)改请求参数
是否重复、措辞是否回避某些词解码参数(重复惩罚等)为主,权重也有影响先调参数对比
何时停止、输入格式后训练学到的习惯 + 推理端模板与 eos_token_id 配置核对模板与生成配置

排查回答质量时可以按这张表分层:先把温度设为 0、关掉各种惩罚,确认模板和停止 token 正确,看贪心结果是否合理;贪心结果本身就错,问题在模型或提示;贪心正确而采样后出错,问题在解码参数。

输出层是一次 4096 × 128256 的矩阵乘法,占 8B 模型约 6.5% 的参数和计算,推理时只对最后一个位置算。softmax 把分数差变成概率倍数,温度改变差距但不改排序,top-k、top-p、min-p 分别按个数、累计概率和相对门槛砍掉长尾;重复惩罚、停止 token 和对话模板都在模型结构之外,却直接决定用户看到什么。到这里,一个 decoder-only 模型从分词到输出的每个部件都已拆完。接下来的实践篇在笔记本上加载一个真实的小模型,用代码把这些部件逐层打开,验证前面手算的参数、形状和各层的作用。

别急,先让缓存热一下。