输出层与反分词(Output Layer & Detokenization)

一句话本质 :输出层(LM Head)将模型内部的 dd d 维语义表示翻译成对词表中每一个 token 的打分,经 Softmax 归一为概率分布后按策略采样选出一个 token id,再经反分词还原为人类可读的文本------这是 Transformer 中唯一把「数学」翻译回「语言」的环节,与输入侧的分词和嵌入形成完美的编码-解码对称。


1. LM Head( Wout W_{\text{out}} Wout):隐藏状态到词表空间的投影

1.1 四要素概括

要素 内容
为什么 LL L 层 Transformer 输出的隐藏向量 (n,d)(n, d) (n,d) 是模型内部表示,维度 dd d 与词表 VV V 无关,无法直接对应到具体的词;需要一个投影把它「翻译」成词表上每个候选 token 的得分
矩阵操作 logits=h⋅Wout \text{logits} = \mathbf{h} \cdot W_{\text{out}} logits=h⋅Wout,其中 h∈Rd\mathbf{h} \in \mathbb{R}^d h∈Rd, Wout∈Rd×V W_{\text{out}} \in \mathbb{R}^{d \times V} Wout∈Rd×V, logits∈RV\text{logits} \in \mathbb{R}^V logits∈RV
作用 dd d 维内部表示映射到 VV V 维词表空间,每个维度对应一个 token 的「匹配得分」(本质是隐藏向量与每个词嵌入的内积相似度打分)
结果 logits 向量 (V,)(V,) (V,),长度恰好等于词表大小------「下一个 token 是词表中哪个」的全部候选打分

1.2 矩阵操作详解

LM Head 的核心是一次矩阵乘法
logits=h⋅Wout(n,d)×(d,V)→(n,V) \text{logits} = \mathbf{h} \cdot W_{\text{out}} \quad (n,d) \times (d,V) \to (n,V) logits=h⋅Wout(n,d)×(d,V)→(n,V)

其中 h\mathbf{h} h 是经过 FinalNorm 后的隐藏状态, Wout W_{\text{out}} Wout 是输出投影矩阵 (也叫 output embedding),形状 (d,V)(d, V) (d,V)。

矩阵乘法的本质:用 h\mathbf{h} h 去和 Wout W_{\text{out}} Wout 的每一列 做点积。 Wout W_{\text{out}} Wout 的第 jj j 列可看作「词表第 jj j 个 token 在输出空间的代表向量」,点积越大越匹配:
logitsj=h⋅Wout:,j\text{logits}j = \mathbf{h} \cdot W_{\text{out}}:, j logitsj=h⋅Wout:,j

语义 logitsj\text{logits}j logitsj = "当前语义 h\mathbf{h} h" 与 "第 jj j 个 token 代表向量" 的相似度。

1.3 通俗理解:「翻译官 / 相亲红娘」类比

用一个生活化例子讲透。输入「今天天气真」,要预测下一个字,词表简化为 {我,晴,好,吃,的}\{我, 晴, 好, 吃, 的\} {我,晴,好,吃,的}。

主角① h\mathbf{h} h = 模型心里「该接什么」的模糊感觉(一团数字):

「接下来大概率是个积极的、形容天气的、读起来顺口的字......」

主角② Wout W_{\text{out}} Wout 里存着每个字的「气质档案」( Wout W_{\text{out}} Wout 的每一列):

档案特征 h\mathbf{h} h 的契合度
主语、人称代词、中性 不搭
积极、天气相关、形容词 完美契合
积极、通用形容词 挺契合
动作、动词、食物相关 完全不搭
结构助词、高频 不太搭

翻译过程 = 拿 h\mathbf{h} h 的感觉与每个字的档案逐一算「契合度」(点积)→ 打分表:
logits=我:1.2,  晴:8.5,  好:6.0,  吃:0.3,  的:2.1\text{logits} = 我:1.2,\\; 晴:8.5,\\; 好:6.0,\\; 吃:0.3,\\; 的:2.1 logits=我:1.2,晴:8.5,好:6.0,吃:0.3,的:2.1

契合的「晴」得最高分 8.5,不搭的「吃」只有 0.3 → Softmax 后「晴」概率最大 → 输出「今天天气真」。

相亲比方 h\mathbf{h} h 是嘉宾心里「理想对象」的模糊画像, Wout W_{\text{out}} Wout 存着所有候选人的档案,翻译 = 逐一比对契合度并打分,分最高的就是「最佳人选」。所谓「翻译」,本质就是把「一团语义感觉」变成「一张覆盖全词表的打分表」

1.4 权重绑定(Weight Tying)

很多模型让 Wout W_{\text{out}} Wout 与输入嵌入矩阵 EE E( V×dV \times d V×d)共享权重(互为转置):
输入端:token id→查 E 的第 id 行→向量E:(V,d) \text{输入端}: \quad \text{token id} \to \text{查 } E \text{ 的第 id 行} \to \text{向量} \quad E: (V, d) 输入端:token id→查 E 的第 id 行→向量E:(V,d)
输出端:向量→×Wout→词表 logitsWout=E⊤:(d,V) \text{输出端}: \quad \text{向量} \to \times W_{\text{out}} \to \text{词表 logits} \quad W_{\text{out}} = E^\top: (d, V) 输出端:向量→×Wout→词表 logitsWout=E⊤:(d,V)

此时第 kk k 个 logit 有优美的几何解释:
logitsk=h⋅ek=h⊤ek\text{logits}k = \mathbf{h} \cdot \mathbf{e}_k = \mathbf{h}^\top \mathbf{e}_k logitsk=h⋅ek=h⊤ek

即:隐藏状态 h\mathbf{h} h 与第 kk k 个词嵌入 ek \mathbf{e}_k ek 的内积 = "预测下一个词"自然变成了"在嵌入空间中找最近邻"。

优势 解释
语义对称性 输入把 id → 向量,输出把向量 → id,互为逆操作
省参数 少一个 V×dV \times d V×d 矩阵;Qwen-2( V=152064,d=4096V=152064, d=4096 V=152064,d=4096)省约 6.23 亿参数
效果提升 实验表明绑定后困惑度(PPL)通常下降(Press & Wolf, 2017)

GPT-2/3/4、LLaMA 系列、Qwen 系列等几乎所有现代 LLM 都使用权重绑定。详见 01 · 词表分词与嵌入


2. probs 长度恒为 VV V 的推导

2.1 维度溯源链路

probs\text{probs} probs 的长度为什么恰好等于词表大小 VV V?这是一个从设计到实现的因果链:
h∈Rd → Wout∈Rd×V logits∈RV →softmax probs∈RV \mathbf{h} \in \mathbb{R}^d \xrightarrow{W_{\text{out}} \in \mathbb{R}^{d \times V}} \text{logits} \in \mathbb{R}^V \xrightarrow{\text{softmax}} \text{probs} \in \mathbb{R}^V h∈RdWout∈Rd×V logits∈RVsoftmax probs∈RV

第①步: Wout(d,V) W_{\text{out}}(d, V) Wout(d,V) 决定了 logits 是 VV V 维

Wout W_{\text{out}} Wout 有 VV V 列,矩阵乘法 h⋅Wout \mathbf{h} \cdot W_{\text{out}} h⋅Wout 输出 VV V 个数------词表有 VV V 个 token,就需要 VV V 个分数,每个分数对应一个候选 token。 VV V 是设计时固定的超参(= 词表大小),写死在 Wout W_{\text{out}} Wout 的形状里。

第②步:Softmax 不改变长度

Softmax 只是把 VV V 个数各自取 exp⁡\exp exp 再除以总和:
probsi= exp⁡(logitsi) ∑j=1V exp⁡(logitsj) \text{probs}i = \frac{\exp(\text{logits}i)}{\sum_{j=1}^{V} \exp(\text{logits}j)} probsi=∑j=1Vexp(logitsj)exp(logitsi)

输入几个数就输出几个数,只改数值、不改个数。

第③步:probs 的下标 = token id

probs\text{probs} probs 的第 ii i 个位置恰好对应词表中 id=i\text{id}=i id=i 的 token:

bash 复制代码
probs = [0.085, 0.031, 0.019, 0.632, 0.232]
下标:     0       1       2       3       4
对应:   "今"    "天"    "气"    "晴"    "朗"     ← 下标就是 token id!

一句话 VV V 由词表大小决定 → 写死在 LM Head 权重 Wout(d×V) W_{\text{out}}(d \times V) Wout(d×V) 的形状里 → logits 是 VV V 维 → Softmax 后的 probs 自然也是 VV V 维。


3. Softmax 与温度

3.1 标准 Softmax 公式

probsi= exp⁡(zi) ∑j=1V exp⁡(zj) ,zi=logitsi\text{probs}i = \frac{\exp(z_i)}{\sum_{j=1}^{V} \exp(z_j)}, \quad z_i = \text{logits}i probsi=∑j=1Vexp(zj)exp(zi),zi=logitsi

Softmax 是满足"和为 1"约束下信息熵最大(最无偏)的分布------它不引入任何额外假设,只是把相对大小关系忠实地转化为概率。

3.2 温度 TT T 的作用

在 Softmax 前把 logits 除以温度 TT T:
probsi= exp⁡(zi/T) ∑j=1V exp⁡(zj/T) \text{probs}i = \frac{\exp(z_i / T)}{\sum_{j=1}^{V} \exp(z_j / T)} probsi=∑j=1Vexp(zj/T)exp(zi/T)

温度 效果 适用场景
T→0T \to 0 T→0 分布变尖,趋近贪心(argmax) 事实问答、代码生成
T=1T = 1 T=1 原始分布 通用
T>1T > 1 T>1 分布变平,更随机 创意写作、头脑风暴

3.3 温度对信息熵的数学推导

温度 TT T 对概率分布信息熵的影响有严格的数学关系。设 pT p_T pT 为温度 TT T 下的分布:
H(pT)=− ∑i=1V pT(i)log⁡pT(i) H(p_T) = -\sum_{i=1}^{V} p_T(i) \log p_T(i) H(pT)=−i=1∑VpT(i)logpT(i)

T→0T \to 0 T→0 的极限 :分布退化为 one-hot(全部概率集中在 argmax 位置), H→0H \to 0 H→0。

T→∞T \to \infty T→∞ 的极限 :分布退化为均匀分布 p(i)=1/Vp(i) = 1/V p(i)=1/V, H→log⁡VH \to \log V H→logV(最大熵)。

温度与熵的单调性 :可以证明 ∂H(pT) ∂T ≥0 \frac{\partial H(p_T)}{\partial T} \geq 0 ∂T∂H(pT)≥0,即温度越高,熵越大(分布越平坦、越不确定)。具体地:
∂H∂T =1T2 VarpT (z) \frac{\partial H}{\partial T} = \frac{1}{T^2} \text{Var}_{p_T}(z) ∂T∂H=T21VarpT(z)

其中 VarpT (z)=∑ipT(i)zi2− (∑ipT(i)zi) 2 \text{Var}_{p_T}(z) = \sum_i p_T(i) z_i^2 - \left(\sum_i p_T(i) z_i\right)^2 VarpT(z)=∑ipT(i)zi2−(∑ipT(i)zi)2 是 logits 在分布 pT p_T pT 下的方差。由于方差恒非负,故 ∂H∂T ≥0 \frac{\partial H}{\partial T} \geq 0 ∂T∂H≥0,熵随温度单调递增。

直觉:温度越高 → 分布越平 → 不确定性越大 → 信息熵越高。温度是用户控制「模型探索空间大小」的旋钮。

3.4 数值示例

logits=2.0,1.0,0.5,4.0,3.0\text{logits} = 2.0, 1.0, 0.5, 4.0, 3.0 logits=2.0,1.0,0.5,4.0,3.0,词表 {今,天,气,晴,朗}\{今, 天, 气, 晴, 朗\} {今,天,气,晴,朗}:

TT T Softmax 后的 probs 最高概率 HH H
0.5 0.012, 0.002, 0.000, **0.935**, 0.051 晴 0.935 0.33
1.0 0.085, 0.031, 0.019, **0.632**, 0.232 晴 0.632 0.93
2.0 0.159, 0.122, 0.101, **0.320**, 0.298 晴 0.320 1.47

温度越低,概率越集中于「晴」;温度越高,各候选越均等。


4. 采样策略对比

采样策略决定了「如何从概率分布中选出下一个 token」,直接影响输出的确定性 vs 创造性

4.1 Greedy(贪心解码 / argmax)

next_id=arg⁡ max⁡i   probsi \text{next\_id} = \arg\max_i \; \text{probs}i next_id=argimaxprobsi

永远选概率最高的 token。确定、可复现,但容易产生重复、呆板的输出(「的的的」循环)。

4.2 Temperature Sampling(温度采样)

next_id=sample(softmax(logits/T))\text{next\_id} = \text{sample}(\text{softmax}(\text{logits} / T)) next_id=sample(softmax(logits/T))

按温度调节后的概率分布随机抽样。 T<1T < 1 T<1 趋近贪心, T>1T > 1 T>1 趋近均匀采样。

4.3 Top- kk k Sampling

只保留概率最高的 kk k 个 token,其余置 −∞-\infty −∞,再在候选集中采样:
logits′i= { logitsi if i∈top-k −∞ otherwise \text{logits}'i = \begin{cases} \text{logits}i & \text{if } i \in \text{top-k} \\ -\infty & \text{otherwise} \end{cases} logits′i={logitsi−∞if i∈top-kotherwise
probs′=softmax(logits′)\text{probs}' = \text{softmax}(\text{logits}') probs′=softmax(logits′)
next_id=sample(probs′)\text{next\_id} = \text{sample}(\text{probs}') next_id=sample(probs′)

优点 :屏蔽长尾垃圾 token。缺点 kk k 是固定的,无法适应分布形状------模型很确定时候选可能只需 5 个,不确定时可能需要 100 个。

4.4 Top- pp p / Nucleus Sampling(核采样)

Holtzman et al. (2020) 在论文 "The Curious Case of Neural Text Degeneration" 中提出。核心思想:选累积概率达到 pp p 的最小候选集合,再采样。

严格定义 :设 V(p)⊆VV^{(p)} \subseteq V V(p)⊆V 是满足以下条件的最小集合(按概率降序排列):
∑i∈V(p) pT(i)≥p \sum_{i \in V^{(p)}} p_T(i) \geq p i∈V(p)∑pT(i)≥p

V(p)V^{(p)} V(p) 以外的 token 的 logit 置 −∞-\infty −∞,在 V(p)V^{(p)} V(p) 内重新归一化后采样:
probs′i= { pT(i) ∑j∈V(p) pT(j) if i∈V(p) 0 otherwise \text{probs}'i = \begin{cases} \frac{p_T(i)}{\sum_{j \in V^{(p)}} p_T(j)} & \text{if } i \in V^{(p)} \\ 0 & \text{otherwise} \end{cases} probs′i={∑j∈V(p)pT(j)pT(i)0if i∈V(p)otherwise
next_id=sample(probs′)\text{next\_id} = \text{sample}(\text{probs}') next_id=sample(probs′)

候选集大小随分布动态变化 :模型确定时候选少(可能只有几个),不确定时候选多(可能上百个),比 Top- kk k 更自然。

Holtzman 2020 的核心发现

Holtzman 等人在该论文中通过系统实验揭示了神经文本生成的退化现象:

问题诊断(为什么 Greedy / Beam Search 会退化)

  • 文本退化(Degeneration) :使用 Greedy 或 Beam Search 生成的文本虽然流畅,但趋于高度重复、缺乏多样性------大量使用高频词和常见句式
  • Perplexity 悖论 :退化文本的 Perplexity 反而极低(甚至比人类文本还低),说明低 Perplexity ≠ 高质量。模型在高概率区域的过度集中导致了「安全但无趣」的输出

Nucleus Sampling 的实验结论

指标 Greedy Beam Search Top- kk k Top- pp p
Perplexity 极低(退化) 极低(退化) 适中 最接近人类文本
多样性(Distinct-n)
HUMAN-Eval 偏好
重复率

关键洞察:Top- pp p 采样生成的文本 Perplexity 与人类文本最接近------这并非巧合,而是因为 Nucleus Sampling 保持了模型概率分布的「形状」(熵),既不人为压缩也不过度扩展,是对模型输出分布最忠实的采样。

4.5 Repetition Penalty(重复惩罚)

Keskar et al. (2019) 在 "Ctrl: A Conditional Transformer Language Model for Controllable Generation" 中提出。对已出现在上下文中的 token 施加惩罚,降低其被再次选中的概率。

数学公式:对 logits 做如下修改后,再送入 Softmax:
logits′i= { logitsi/α if logitsi>0 and i∈context logitsi×α if logitsi≤0 and i∈context logitsi if i∉context \text{logits}'i = \begin{cases} \text{logits}i / \alpha & \text{if } \text{logits}i > 0 \text{ and } i \in \text{context} \\ \text{logits}i \times \alpha & \text{if } \text{logits}i \leq 0 \text{ and } i \in \text{context} \\ \text{logits}i & \text{if } i \notin \text{context} \end{cases} logits′i=⎩ ⎨ ⎧logitsi/αlogitsi×αlogitsiif logitsi>0 and i∈contextif logitsi≤0 and i∈contextif i∈/context

其中 α>1\alpha > 1 α>1(常用 1.1~1.2)是惩罚系数。

设计巧妙之处 :对正 logit 除以 α\alpha α(使其变小),对负 logit 乘以 α\alpha α(使其更负)------两种情况都降低该 token 的 Softmax 概率,且不改变 logit 的符号(正仍正、负仍负),避免过度惩罚。

4.6 Frequency Penalty 与 Presence Penalty

OpenAI API 中常用的两种惩罚机制,与 Repetition Penalty 目标相似但实现不同:

Presence Penalty(存在惩罚):
logits′i=logitsi−βpresence⋅1i∈generated\text{logits}'i = \text{logits}i - \beta_{\text{presence}} \cdot \mathbb{1}i \\in \\text{generated} logits′i=logitsi−βpresence⋅1i∈generated

只要 token ii i 在已生成文本中出现过至少一次 ,就减去固定值 β\beta β。效果:鼓励模型谈论新话题,引入新概念。

Frequency Penalty(频率惩罚):
logits′i=logitsi−βfrequency⋅count(i,generated)\text{logits}'i = \text{logits}i - \beta_{\text{frequency}} \cdot \text{count}(i, \text{generated}) logits′i=logitsi−βfrequency⋅count(i,generated)

惩罚力度与 token ii i 的出现次数 成正比。效果:减少复读机式的重复。

惩罚类型 公式核心 效果 常用参数
Repetition Penalty logit/α\text{logit} / \alpha logit/α 或 logit×α\text{logit} \times \alpha logit×α 降低已出现 token 概率 α=1.1\alpha = 1.1 α=1.1
Presence Penalty logit−β\text{logit} - \beta logit−β 鼓励新话题 β=0.5\beta = 0.5 β=0.5
Frequency Penalty logit−β⋅count\text{logit} - \beta \cdot \text{count} logit−β⋅count 减少高频重复 β=0.5\beta = 0.5 β=0.5

4.7 Classifier-Free Guidance(CFG)在 LLM 中的应用

CFG 最初用于扩散模型(Ho & Salimans, 2022),后被引入 LLM 以增强生成质量与控制力。

核心思想:在生成时同时考虑「有条件」和「无条件」两个 logits,用加权组合放大条件信号:
logitsguided=logitsuncond+w⋅(logitscond−logitsuncond) \text{logits}{\text{guided}} = \text{logits}{\text{uncond}} + w \cdot (\text{logits}{\text{cond}} - \text{logits}{\text{uncond}}) logitsguided=logitsuncond+w⋅(logitscond−logitsuncond)

其中 w>1w > 1 w>1 是引导强度。等价于:
logitsguided=(1−w)⋅logitsuncond+w⋅logitscond \text{logits}{\text{guided}} = (1 - w) \cdot \text{logits}{\text{uncond}} + w \cdot \text{logits}_{\text{cond}} logitsguided=(1−w)⋅logitsuncond+w⋅logitscond

  • w=1w = 1 w=1:退化为普通条件生成
  • w>1w > 1 w>1:放大条件信号,生成更贴合 prompt 的内容,但可能牺牲多样性
  • w<1w < 1 w<1:减弱条件信号,增加随机性

在 LLM 中的实践:一些推理框架(如 vLLM)支持 CFG,通过同时运行一个有 prompt 前缀的序列和一个无前缀(或仅有 BOS)的序列,将两组 logits 按上述公式组合。适用于需要严格控制输出格式或风格的场景。

4.8 各策略综合对比表

策略 参数 候选集 确定性 多样性 核心效果 适用场景
Greedy --- 全部(取 argmax) 最高 最低 最确定、易重复 抽取、代码、需可复现
Temperature TT T 全部 T↓T \downarrow T↓ 增高 T↑T \uparrow T↑ 增高 通用调节旋钮 通用
Top- kk k kk k 固定 kk k 个 截断长尾 配合温度使用
Top- pp p pp p 动态(累积到 pp p) 中高 自适应候选集 对话主流
Rep. Penalty α\alpha α 全部(调权重) --- 提升 防复读 通用辅助
CFG ww w 全部(调 logits) w↑w \uparrow w↑ 增高 w↑w \uparrow w↑ 降低 增强条件一致性 结构化生成

实际系统通常组合使用 :如 T=0.7,top_p=0.9,repetition_penalty=1.1T=0.7, \text{top\_p}=0.9, \text{repetition\_penalty}=1.1 T=0.7,top_p=0.9,repetition_penalty=1.1。


5. 自回归循环

5.1 四要素概括

要素 内容
为什么 模型一次前向只能预测一个下一个 token;要生成完整文本,必须把刚生成的 token 接回输入、再跑一次------循环往复
矩阵操作 无新运算,是 §1~§4 的迭代控制流:嵌入 → LL L 层前向 → LM Head → 取最后位置 → 采样
作用 将「预测一个 token」的单次能力扩展为「生成任意长度文本」的序列能力
结果 完整 token 序列 → 反分词 → 最终文本

5.2 完整闭环:生成 → 拼接 → 下一步

自回归生成的本质是概率的链式分解
P(x1,x2,...,xn)= ∏i=1n P(xi∣x1,..., xi−1 ) P(x_1, x_2, \ldots, x_n) = \prod_{i=1}^{n} P(x_i \mid x_1, \ldots, x_{i-1}) P(x1,x2,...,xn)=i=1∏nP(xi∣x1,...,xi−1)

每步只做一件事:给定前面所有 token,预测下一个 token 的概率分布

以「今天」为输入,生成「今天天气晴朗」为例:

erlang 复制代码
第 1 步: ["今","天"]
   → Embedding + L 层 Transformer + FinalNorm + LM Head
   → 取最后位置 logits → Softmax → 采样 → "天"
   → 序列变为 ["今","天","天"]

第 2 步: ["今","天","天"]
   → 前向(配合 KV Cache 只算新 token)→ 采样 → "气"
   → 序列变为 ["今","天","天","气"]

第 3 步: → "晴"    第 4 步: → "朗"    ...

最终输出: "今天天气晴朗"

5.3 停止条件

生成循环在以下任一条件满足时终止:

| 停止条件 | 说明 |
|------------------|-------------------------------------------------|--------|--------|
| EOS token | 采样到特殊的结束符 <eos>(end-of-sequence),模型主动表示"我说完了" |
| max_tokens | 达到用户设定的最大生成 token 数上限 |
| stop strings | 命中用户设置的停止字符串(如 "\n\n"、`"< | im_end | >"`) |

5.4 关键:每一步只用最后一个位置

因果掩码下,第 ii i 个位置的输出融合了 token 1...i1 \ldots i 1...i 的全部信息,它天然就是「看完前 ii i 个 token 后,对第 i+1i+1 i+1 个 token 的预测」。生成时我们已经有了前面所有 token,只缺「下一个」,所以只需要最后一个位置的预测

arduino 复制代码
位置 0 → 预测"今之后"     (已知,丢弃)
位置 1 → 预测"今天之后"   (已知,丢弃)
位置 2 → 预测"今天天之后" (已知,丢弃)
位置 3 → 预测"今天天气之后" ★ 用这个 ★

5.5 KV Cache 加速

朴素做法每步都把整个序列 重新跑一遍前向,浪费巨大。因果掩码保证历史 token 的 K/V 一旦算出就不再改变,可以缓存

ini 复制代码
步骤 1: 算 [今,天]         的 K,V     ← 填入 Cache
步骤 2: 只算 [天]          的 K,V     ← 追加到 Cache,复用 [今,天] 的 K,V
步骤 3: 只算 [气]          的 K,V     ← 追加到 Cache,复用历史

每步计算量从 O(n2)O(n^2) O(n2) 降为 O(n)O(n) O(n),生成速度大幅提升。详见 details/01-kv-cache

5.6 流式输出

每生成一个 token 就立刻反分词并返回给前端------这就是你在聊天界面看到字「一个个冒出来」的原因:不是特效,是它真的一个个算出来的。详见 §7 工程实践。


6. 反分词(Detokenization):token id 序列到文本

反分词是整个生成流程中最容易被忽视、却在工程中至关重要的环节 。分词把文本压缩成整数序列,反分词则把这串整数还原为人类可读的文字------它是模型输出与用户感知之间的唯一桥梁

6.1 定义与问题陈述

反分词(Detokenization) 是将 token id 序列还原为原始文本的过程:
Detokenize:id1,id2,...,idm→原始文本字符串 \text{Detokenize}: id_1, id_2, \\ldots, id_m \to \text{原始文本字符串} Detokenize:id1,id2,...,idm→原始文本字符串

它是分词(Tokenization)的逆操作:
Tokenize:文本→id1,id2,...,idm\text{Tokenize}: \text{文本} \to id_1, id_2, \\ldots, id_m Tokenize:文本→id1,id2,...,idm
Detokenize:id1,id2,...,idm→文本 \text{Detokenize}: id_1, id_2, \\ldots, id_m \to \text{文本} Detokenize:id1,id2,...,idm→文本

理想情况下,两者构成完美的互逆关系: Detokenize(Tokenize(text))=text\text{Detokenize}(\text{Tokenize}(\text{text})) = \text{text} Detokenize(Tokenize(text))=text。

在自回归生成中的位置:每生成一个 token id,需要立刻反分词得到对应文本片段,通过 SSE 推送给前端,实现流式逐字输出。

6.2 反分词并非简单的「查表」

一个常见的误解是:反分词就是 vocab[id]------从词表中查出对应字符串即可。对于简单分词器(如词级分词),这基本正确。但对于现代子词分词器(BPE、SentencePiece),情况复杂得多:

分词器类型 反分词复杂度 原因
词级分词 低(直接查表拼接) 每个 id 对应完整单词
WordPiece 中(需处理 ## 前缀) 非词首子词需去掉前缀再拼接
BPE(字符级) 中(需处理子词拼接) 一个词被拆成多个子词
Byte-level BPE (需字节解码) token 对应的不是字符而是字节序列的片段
SentencePiece (需处理 空格、byte-fallback) 空格编码特殊,可能有字节回退

6.3 BPE 解码算法

6.3.1 基于 merge 规则的逆向操作

BPE 分词器在训练时记录了一系列合并规则 (a,b)→ab(a, b) \to ab (a,b)→ab。解码时需要反向执行这些合并,把子词还原为原始字符序列。

严格算法描述

设 BPE 的合并规则列表为 R=(r1),(r2),...,(rK) R = (r_1), (r_2), \\ldots, (r_K) R=(r1),(r2),...,(rK),其中 rk=(ak,bk)→ck r_k = (a_k, b_k) \to c_k rk=(ak,bk)→ck,按训练时的顺序排列。

编码(分词)过程(自底向上):

  1. 将输入文本拆分为单个字符序列
  2. 从左到右扫描,尝试应用合并规则:如果相邻两个符号构成规则 (ak,bk) (a_k, b_k) (ak,bk),则合并为 ck c_k ck
  3. 重复直到无法继续合并

解码(反分词)过程(自顶向下,逆向执行):

  1. 输入 token id 序列 id1,id2,...,idm id_1, id_2, \\ldots, id_m id1,id2,...,idm
  2. 查词表得到每个 token 对应的字符串: str1,str2,...,strm \\text{str}_1, \\text{str}_2, \\ldots, \\text{str}_m str1,str2,...,strm
  3. 对每个字符串,按与合并相反的顺序 执行拆分:从最后一条合并规则开始,如果字符串中包含 ck c_k ck,则替换为 (ak,bk) (a_k, b_k) (ak,bk)
  4. 重复直到所有符号都是单字符
  5. 将结果字符序列拼接为原始文本

关键 :合并规则的应用是有顺序的(贪心、从左到右),解码时必须严格逆序执行,否则结果可能不正确。

6.3.2 实际实现中的简化

实践中,大多数 BPE 实现(如 tiktoken、HuggingFace tokenizers不存储完整的逆向规则 ,而是直接在词表中记录每个 token 对应的原始字符序列(或字节序列)。解码时只需:

markdown 复制代码
对每个 token id:
  1. 查词表得到对应的字符串(或字节序列)
  2. 直接拼接所有结果
  3. 处理分词时引入的特殊标记(如 </w> 词边界)

6.4 Byte-level BPE 解码(GPT-2 方案)

GPT-2/3/4 使用的 Byte-level BPE 有一个独特的特性:基础单元不是 Unicode 字符,而是 UTF-8 字节 。这意味着一个 token 对应的不是一段可读文本,而是一段字节序列的片段

6.4.1 GPT-2 的字符映射表

GPT-2 定义了一个从 256 个字节值到 256 个可打印 Unicode 字符的双射映射(byte-to-unicode mapping):
byte_to_unicode:{0,1,...,255}→{可打印 Unicode 字符}\text{byte\_to\_unicode}: \{0, 1, \ldots, 255\} \to \{\text{可打印 Unicode 字符}\} byte_to_unicode:{0,1,...,255}→{可打印 Unicode 字符}

这个映射的目的是:让所有字节值都能表示为「可见字符」,避免控制字符(如 \x00\n)在文本处理中造成问题。

6.4.2 解码过程

vbnet 复制代码
输入 token ids: [15339, 198, 5765]

第 1 步:查词表得到每个 token 对应的「Unicode 编码字符串」
  15339 → "Hello"     (5 个 Unicode 字符)
  198   → "Ċ"         (1 个 Unicode 字符,实际代表 \n)
  5765  → "ä¸ĭ"       (3 个 Unicode 字符,实际代表中文"今"的 UTF-8 字节)

第 2 步:将所有 Unicode 字符串拼接
  "HelloĊä¸ĭ"

第 3 步:通过 unicode_to_byte 逆映射,将每个 Unicode 字符还原为字节值
  "H" → 0x48, "e" → 0x65, "l" → 0x6C, "l" → 0x6C, "o" → 0x6F
  "Ċ" → 0x0A (换行符)
  "ä" → 0xE4, "¸" → 0xB8, "ĭ" → 0xAD

第 4 步:将字节序列按 UTF-8 解码为 Unicode 文本
  [0x48, 0x65, 0x6C, 0x6C, 0x6F, 0x0A, 0xE4, 0xB8, 0xAD]
  → "Hello\n今"     (注:0xE4 0xB8 0xAD 是"今"的 UTF-8 编码... 
                        实际可能是"中"或其他汉字,取决于具体字节)

关键洞察 :一个中文汉字(如「今」)的 UTF-8 编码是 3 个字节,在 Byte-level BPE 中可能被合并为 1 个 token(如果语料中足够频繁),也可能被拆成 2~3 个 token。因此反分词时必须等所有相关字节都到齐,才能正确解码为完整字符

6.5 SentencePiece 的 byte-fallback 解码机制

LLaMA、Qwen 等模型使用的 SentencePiece 分词器引入了 byte-fallback 机制,以处理词表中不存在的字符。

6.5.1 byte-fallback 的原理

当 SentencePiece 在分词时遇到一个词表中没有对应 token 的字符 时,不会报错或产生 OOV,而是将该字符回退到字节级表示

bash 复制代码
假设字符 "ð" (U+00F0) 不在词表中:

1. 将 "ð" 编码为 UTF-8 字节:[0xC3, 0xB0]
2. 每个字节映射为特殊 token:<0xC3> 和 <0xB0>
   这些 token 在词表中有专门的 id(如 231, 176)
3. 输出两个 token id: [231, 176]

SentencePiece 的词表中,<0x00><0xFF> 共 256 个字节级 token 作为「保底」,确保任何 Unicode 字符都能被表示。

6.5.2 byte-fallback 的解码

csharp 复制代码
输入 token ids: [231, 176]  (来自 byte-fallback)

第 1 步:查词表
  231 → "<0xC3>"  → 字节值 0xC3
  176 → "<0xB0>"  → 字节值 0xB0

第 2 步:收集连续字节序列
  [0xC3, 0xB0]

第 3 步:按 UTF-8 解码
  [0xC3, 0xB0] → "ð" (U+00F0)

注意 :解码时必须连续收集 byte-fallback token,直到遇到非 byte-fallback token 或序列结束,再一次性做 UTF-8 解码。中途截断会导致非法 UTF-8 序列。

6.5.3 SentencePiece 的空格处理

SentencePiece 将空格编码为特殊字符 (U+2581,Lower One Eighth Block):

arduino 复制代码
"Hello world" → ["▁Hello", "▁world"]
"Hello  world" → ["▁Hello", "▁", "world"]    ← 两个空格:一个在 ▁Hello 里,一个是单独的 ▁

解码时需要将 还原为空格:

arduino 复制代码
["▁Hello", "▁world"] → "Hello world"    ← 每个 ▁ 变为一个空格

中文特殊处理 :中文文本通常没有空格,SentencePiece 对中文的处理是在第一个字符前加 ,后续字符不加:

arduino 复制代码
"今天天气" → ["▁今", "天", "天", "气"]
解码时:["▁今", "天", "天", "气"] → "今天天气"  ← 开头的 ▁ 变为空格(如果是文本开头则去掉)

6.6 增量反分词(流式输出):多字节 token 的中间状态处理

这是反分词在工程实践中最具挑战性的问题。

6.6.1 问题描述

在流式输出场景中,模型每生成一个 token 就需要立刻反分词并推送给前端。但某些 token 在反分词后可能产生不完整的 UTF-8 字节序列

ini 复制代码
场景:Byte-level BPE,模型连续生成了 3 个 token

Token 1 (id=40123): 反分词后字节 = [0xE4]           ← UTF-8 多字节字符的第 1 个字节
Token 2 (id=38291): 反分词后字节 = [0xB8]           ← 第 2 个字节
Token 3 (id=50211): 反分词后字节 = [0xAD]           ← 第 3 个字节

只有当 3 个字节 [0xE4, 0xB8, 0xAD] 全部到齐后,才能解码为 "中"

如果每收到一个 token 就尝试解码,前两个 token 会产生非法 UTF-8 序列,无法显示。

6.6.2 解决方案:缓冲 + 延迟输出

python 复制代码
算法:增量反分词

维护一个字节缓冲区 byte_buffer = []

每收到一个新 token id:
  1. 查词表得到对应的字节序列 bytes
  2. 将 bytes 追加到 byte_buffer
  3. 尝试从 byte_buffer 开头解码尽可能多的完整 UTF-8 字符
  4. 将已解码的字符推送给前端
  5. 将未解码的剩余字节保留在 byte_buffer 中,等待后续 token

具体示例

ini 复制代码
Token 1 到达 → buffer = [0xE4]
  尝试解码:0xE4 是 UTF-8 三字节序列的首字节,还需 2 个后续字节
  → 缓冲区保留 [0xE4],推送 ""(空)

Token 2 到达 → buffer = [0xE4, 0xB8]
  尝试解码:0xE4 0xB8 还缺 1 个字节
  → 缓冲区保留 [0xE4, 0xB8],推送 ""

Token 3 到达 → buffer = [0xE4, 0xB8, 0xAD]
  尝试解码:[0xE4, 0xB8, 0xAD] = "中" ✓
  → 缓冲区清空,推送 "中"

6.6.3 不同分词器的缓冲策略差异

分词器 缓冲单位 特殊情况
Byte-level BPE (GPT-2) 字节 多字节 UTF-8 字符可能被拆到多个 token 中
SentencePiece (LLaMA) 字符(通常) byte-fallback 时需缓冲字节
WordPiece (BERT) 子词 ## 前缀需缓冲到下一个非 ## token

6.6.4 边界情况处理

  • EOS 时清空缓冲区 :当生成 <eos> 或达到 max_tokens 时,必须将缓冲区中剩余的字节强制解码(即使是不完整的 UTF-8 序列,也应以 replacement character `` 输出)
  • Surrogate pairs(代理对):在 UTF-16 环境中(如 JavaScript),某些 Unicode 字符(如 emoji)需要两个 16 位代码单元,也需要缓冲处理
  • BPE 的 merge 边界:某些 BPE 实现中,单个 token 可能跨越子词边界,需确保解码时正确处理

6.7 子词边界与空格处理

6.7.1 不同分词器的空格编码方式

分词器 空格表示 编码示例 解码操作
GPT-2 (Byte-level BPE) 空格就是普通空格字符(映射为 Ġ,U+0120) "Hello world"["Hello", "Ġworld"] Ġ (空格)
SentencePiece 空格 → (U+2581) "Hello world"["▁Hello", "▁world"]
WordPiece 子词前缀 ## 标记非词首 "playing"["play", "##ing"] 去掉 ## 后拼接

6.7.2 中文的特殊处理

中文文本没有天然的空格分隔,不同分词器对中文的处理各有特点:

SentencePiece(LLaMA / Qwen)

arduino 复制代码
"今天天气真好"
→ 分词: ["▁今", "天", "天", "气", "真", "好"]
→ 解码: "▁" → 空格(文本开头去掉)→ "今天天气真好"

Byte-level BPE(GPT-2/4)

arduino 复制代码
"今天天气真好"
→ UTF-8 编码: 18 个字节(每个汉字 3 字节)
→ 分词: 可能被合并为 6 个 token(每汉字 1 个),也可能部分汉字被拆为 2~3 个 token
→ 解码: 字节序列 → UTF-8 解码 → "今天天气真好"

6.7.3 多语言混合文本

当文本混合多种语言时,反分词需要特别注意:

arduino 复制代码
"Hello你好world"

GPT-2 BPE:
  → ["Hello", "ä½", "ij", "å¥", "½", "world"]
  → 解码: 英文部分直接,中文部分需等字节序列完整后 UTF-8 解码
  → "Hello你好world"

关键挑战:英文和中文的 token 边界不同,缓冲区管理需要同时处理
ASCII(单字节)和多字节 UTF-8 的混合序列

6.8 反分词在 Output Processor 中的位置

在推理引擎(如 vLLM)中,反分词是 Output Processor 组件的核心职责之一:

vbnet 复制代码
GPU 采样出 next_token_id
    │
    ▼
Output Processor:
    │
    ├─ ① 反分词: tokenizer.decode(next_token_id) → 文本片段
    │     └── 处理 byte 缓冲、空格、子词边界
    │
    ├─ ② 停止判断: next_token_id == EOS? 达到 max_tokens? 命中 stop string?
    │
    ├─ ③ 流式组装: 将文本片段打包为 SSE event
    │     └── data: {"choices":[{"delta":{"content":"晴"}}]}
    │
    └─ ④ 推送给客户端

stop string 检测的特殊处理 :用户可能设置 stop=["\n\n"] 作为停止条件。但 \n\n 可能跨越两个 token(第一个 token 解码出 \n,第二个也解码出 \n)。Output Processor 需要维护一个文本缓冲区,拼接已解码的文本,再检查是否命中 stop string。


7. 工程实践

7.1 SSE 流式响应

当 HTTP 请求中 stream=true 时,推理服务不关闭连接,而是通过 Server-Sent Events(SSE)协议每产出一个 token 就推送一条事件:

http 复制代码
HTTP/1.1 200 OK
Content-Type: text/event-stream
Transfer-Encoding: chunked

data: {"id":"chatcmpl-abc","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}

data: {"id":"chatcmpl-abc","choices":[{"index":0,"delta":{"content":"你"},"finish_reason":null}]}

data: {"id":"chatcmpl-abc","choices":[{"index":0,"delta":{"content":"好"},"finish_reason":null}]}

data: {"id":"chatcmpl-abc","choices":[{"index":0,"delta":{"content":"!"},"finish_reason":null}]}

data: {"id":"chatcmpl-abc","choices":[{"delta":{},"finish_reason":"stop"}]}

data: [DONE]

客户端处理

python 复制代码
response = requests.post(url, json=payload, stream=True)
for line in response.iter_lines():
    if line.startswith(b"data: "):
        data = json.loads(line[6:])
        if data == "[DONE]":
            break
        token = data["choices"][0]["delta"].get("content", "")
        print(token, end="", flush=True)   # 逐字打印

非流式stream=false):服务端等全部生成完毕后,一次性返回完整 JSON。用户等待时间长,但实现简单。

7.2 Output Processor 组件职责

Output Processor 是推理引擎(如 vLLM)中连接 GPU 计算与客户端输出的桥梁:

职责 详细说明
反分词 将 GPU 产出的 token id 经分词器解码为文本片段,处理 byte 缓冲和子词边界
停止判断 检查 EOS token、max_tokens 上限、用户自定义 stop strings
流式组装 将文本片段打包为 SSE 格式的 JSON event
推送 通过 HTTP chunked transfer 将 event 推送给客户端
资源释放 生成完成后,释放该请求占用的 KV Cache 块

引擎主循环中 Output Processor 的位置

python 复制代码
while has_unfinished_requests():
    # 1. 调度:决定本步参与计算的请求集合
    batch = scheduler.schedule()

    # 2. 执行:GPU 前向 + 采样
    outputs = model_runner.execute(batch)

    # 3. 处理输出:反分词、停止判断、流式推送
    for req, next_token in zip(batch.requests, outputs.tokens):
        req.append_token(next_token)
        
        # Output Processor 的核心逻辑
        text_chunk = tokenizer.decode(next_token)       # 反分词
        if next_token == EOS or req.len >= req.max_tokens:
            req.finish()
            block_manager.free(req)                     # 释放 KV 块
            emit_final_result(req)                      # 返回最终结果
        else:
            emit_stream_token(req, text_chunk)          # SSE 推送

7.3 性能指标

指标 含义 影响因素
TTFT(Time To First Token) 首字延迟 prompt 长度、Prefill 算力、排队时间
TPOT(Time Per Output Token) 每 token 延迟 模型大小、显存带宽、batch size
Throughput(tokens/s) 吞吐 并发 batch 大小、GPU 数量

用户体感 :TTFT 决定「多久开始看到字」,TPOT 决定「字蹦出来的速度」。反分词本身的耗时通常可忽略( <0.01< 0.01 <0.01ms/token),真正的瓶颈在 GPU 计算和显存带宽。


8. 总结

8.1 全流程一图总览

scss 复制代码
"今天天气"
    │ ① 分词
    ▼
[20831, 1920, 1920, 3021]                              (n,)
    │ ② 嵌入 + 位置编码
    ▼
X (n, d)                                                语义向量
    │
    │ ③ ×L 层 Transformer Block
    │   (GQA 注意力 + SwiGLU FFN + 残差 + RMSNorm)
    ▼
H (n, d)                                                融合全局上下文的表示
    │ ④ 最终 RMSNorm
    ▼
    │ ⑤ LM Head: H · W_out (d, V)
    ▼
logits (n, V)                                           词表打分
    │ ⑥ 取 logits[-1]
    ▼
next_logits (V,)                                        "今天天气之后接什么"
    │ ⑦ 温度 /T + top-p 截断
    │ ⑧ Softmax → probs → argmax/sample
    ▼
next_id = 3                                             选中的 token id
    │ ⑨ 反分词: vocab[3]
    ▼
"晴"                                                    输出文本!
    │ ⑩⑪⑫ 停止判断 → 追加 token → 更新 KV Cache → 回到③
    ▼
继续生成: "晴" → "朗" → ... → <eos>

8.2 核心结论速查

问题 结论
LM Head 是什么 输出投影矩阵 Wout∈Rd×V W_{\text{out}} \in \mathbb{R}^{d \times V} Wout∈Rd×V,将 dd d 维隐藏状态翻译为 VV V 维词表打分
为什么 probs 长度是 VV V Wout W_{\text{out}} Wout 有 VV V 列 → logits 是 VV V 维 → Softmax 不改长度 → probs 也是 VV V 维
温度的作用 T↓T \downarrow T↓ 分布变尖(趋近贪心), T↑T \uparrow T↑ 分布变平(趋近均匀);熵随 TT T 单调递增
Top- pp p 的优势 候选集大小随分布自适应,生成文本 Perplexity 最接近人类文本(Holtzman 2020)
自回归的本质 链式法则 P( x1:n )=∏P(xi∥ x<i ) P(x_{1:n}) = \prod P(x_i \| x_{<i}) P(x1:n)=∏P(xi∥x<i),将指数级联合分布分解为 nn n 个单步预测
反分词的核心挑战 Byte-level BPE 的多字节缓冲、SentencePiece 的 byte-fallback 和空格处理、增量解码的 UTF-8 完整性
工程链路 GPU 采样 → Output Processor 反分词 → 停止判断 → SSE 推送 → 客户端逐字打印

一句话 :Transformer 把输入切成 token、嵌入成向量,经 LL L 层「自注意力 + FFN」反复提炼,在词表上预测下一个 token 的概率,采样选字后经反分词还原为人类可读的文本------LM Head 是「翻译官」,Softmax 是「归一化器」,采样策略是「决策者」,反分词是「还原者」,四者协作完成了从数学世界到语言世界的最后一跃。


参考文献

  • Press, O. & Wolf, L. (2017). Using the Output Embedding to Improve Language Models. EACL.
  • Holtzman, A. et al. (2020). The Curious Case of Neural Text Degeneration . ICLR. --- Nucleus (Top- pp p) Sampling
  • Keskar, N. et al. (2019). CTRL: A Conditional Transformer Language Model for Controllable Generation. --- Repetition Penalty
  • Ho, J. & Salimans, T. (2022). Classifier-Free Diffusion Guidance. NeurIPS Workshop. --- CFG
  • Radford, A. et al. (2019). Language Models are Unsupervised Multitask Learners. --- Byte-level BPE (GPT-2)
  • Kudo, T. & Richardson, J. (2018). SentencePiece: A simple and language independent subword tokenizer. --- SentencePiece & byte-fallback
  • Sennrich, R. et al. (2015). Neural Machine Translation of Rare Words with Subword Units. --- BPE 引入 NLP
  • Kwon, W. et al. (2023). Efficient Memory Management for Large Language Model Serving with PagedAttention. SOSP. --- vLLM & Output Processor
相关推荐
Nturmoils1 小时前
三伏天正是减肥天:我在 EdgeOne Makers 上从 0 到 1 上线了一个 AI 健康教练
人工智能
猿的天空1 小时前
机器人双手迎来全栈训练系统:灵初智能EgoSteer让灵巧手无所不能
网络·人工智能·计算机·ai·程序员·机器人·编程
刘海东刘海东1 小时前
人工智能图论(提纲)
人工智能
陕西企来客1 小时前
2026年7月技术好GEO优化方案:算法适配与内容策略
人工智能·算法·机器学习·技术好geo优化
想会飞的蒲公英1 小时前
用 Streamlit 给文本分类模型做一个演示页面
人工智能·python·机器学习
老徐聊GEO1 小时前
亲测有效的AI品牌检测公司案例分享
大数据·人工智能·python
IT智慧客07311 小时前
Vibe Coding 时代:Vue 消失了还是 React 太强?
人工智能
韭菜学长2 小时前
科技型中小企业如何申请政府补贴?申报指南
大数据·人工智能
触底反弹2 小时前
🔥 AI 写代码总翻车?这套实战方法论救了我
人工智能·面试·程序员