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

21 阅读2分钟

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


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

1.1 四要素概括

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

1.2 矩阵操作详解

LM Head 的核心是一次矩阵乘法

logits=hWout(n,d)×(d,V)(n,V)\text{logits} = \mathbf{h} \cdot W_{\text{out}} \quad (n,d) \times (d,V) \to (n,V)

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

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

logits[j]=hWout[:,j]\text{logits}[j] = \mathbf{h} \cdot W_{\text{out}}[:, j]

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

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

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

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

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

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

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

翻译过程 = 拿 h\mathbf{h} 的感觉与每个字的档案逐一算「契合度」(点积)→ 打分表:

logits=[:1.2,  :8.5,  :6.0,  :0.3,  :2.1]\text{logits} = [我:1.2,\; 晴:8.5,\; 好:6.0,\; 吃:0.3,\; 的:2.1]

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

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

1.4 权重绑定(Weight Tying)

很多模型让 WoutW_{\text{out}}输入嵌入矩阵 EEV×dV \times d)共享权重(互为转置):

输入端:token id查 E 的第 id 行向量E:(V,d)\text{输入端}: \quad \text{token id} \to \text{查 } E \text{ 的第 id 行} \to \text{向量} \quad 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)

此时第 kk 个 logit 有优美的几何解释:

logits[k]=hek=hek\text{logits}[k] = \mathbf{h} \cdot \mathbf{e}_k = \mathbf{h}^\top \mathbf{e}_k

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

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

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


2. probs 长度恒为 VV 的推导

2.1 维度溯源链路

probs\text{probs} 的长度为什么恰好等于词表大小 VV?这是一个从设计到实现的因果链:

hRdWoutRd×VlogitsRVsoftmaxprobsRV\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

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

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

第②步:Softmax 不改变长度

Softmax 只是把 VV 个数各自取 exp\exp 再除以总和:

probs[i]=exp(logits[i])j=1Vexp(logits[j])\text{probs}[i] = \frac{\exp(\text{logits}[i])}{\sum_{j=1}^{V} \exp(\text{logits}[j])}

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

第③步:probs 的下标 = token id

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

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

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


3. Softmax 与温度

3.1 标准 Softmax 公式

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

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

3.2 温度 TT 的作用

在 Softmax 前把 logits 除以温度 TT

probs[i]=exp(zi/T)j=1Vexp(zj/T)\text{probs}[i] = \frac{\exp(z_i / T)}{\sum_{j=1}^{V} \exp(z_j / T)}
温度效果适用场景
T0T \to 0分布变尖,趋近贪心(argmax)事实问答、代码生成
T=1T = 1原始分布通用
T>1T > 1分布变平,更随机创意写作、头脑风暴

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

温度 TT 对概率分布信息熵的影响有严格的数学关系。设 pTp_T 为温度 TT 下的分布:

H(pT)=i=1VpT(i)logpT(i)H(p_T) = -\sum_{i=1}^{V} p_T(i) \log p_T(i)

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

TT \to \infty 的极限:分布退化为均匀分布 p(i)=1/Vp(i) = 1/VHlogVH \to \log V(最大熵)。

温度与熵的单调性:可以证明 H(pT)T0\frac{\partial H(p_T)}{\partial T} \geq 0,即温度越高,熵越大(分布越平坦、越不确定)。具体地:

HT=1T2VarpT(z)\frac{\partial H}{\partial T} = \frac{1}{T^2} \text{Var}_{p_T}(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 是 logits 在分布 pTp_T 下的方差。由于方差恒非负,故 HT0\frac{\partial H}{\partial T} \geq 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],词表 {,,,,}\{今, 天, 气, 晴, 朗\}

TTSoftmax 后的 probs最高概率HH
0.5[0.012, 0.002, 0.000, 0.935, 0.051]晴 0.9350.33
1.0[0.085, 0.031, 0.019, 0.632, 0.232]晴 0.6320.93
2.0[0.159, 0.122, 0.101, 0.320, 0.298]晴 0.3201.47

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


4. 采样策略对比

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

4.1 Greedy(贪心解码 / argmax)

next_id=argmaxi  probs[i]\text{next\_id} = \arg\max_i \; \text{probs}[i]

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

4.2 Temperature Sampling(温度采样)

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

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

4.3 Top-kk Sampling

只保留概率最高的 kk 个 token,其余置 -\infty,再在候选集中采样:

logits[i]={logits[i]if itop-kotherwise\text{logits}'[i] = \begin{cases} \text{logits}[i] & \text{if } i \in \text{top-k} \\ -\infty & \text{otherwise} \end{cases}
probs=softmax(logits)\text{probs}' = \text{softmax}(\text{logits}')
next_id=sample(probs)\text{next\_id} = \text{sample}(\text{probs}')

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

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

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

严格定义:设 V(p)VV^{(p)} \subseteq V 是满足以下条件的最小集合(按概率降序排列):

iV(p)pT(i)p\sum_{i \in V^{(p)}} p_T(i) \geq p

V(p)V^{(p)} 以外的 token 的 logit 置 -\infty,在 V(p)V^{(p)} 内重新归一化后采样:

probs[i]={pT(i)jV(p)pT(j)if iV(p)0otherwise\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}
next_id=sample(probs)\text{next\_id} = \text{sample}(\text{probs}')

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

Holtzman 2020 的核心发现

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

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

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

Nucleus Sampling 的实验结论

指标GreedyBeam SearchTop-kkTop-pp
Perplexity极低(退化)极低(退化)适中最接近人类文本
多样性(Distinct-n)
HUMAN-Eval 偏好
重复率

关键洞察:Top-pp 采样生成的文本 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]={logits[i]/αif logits[i]>0 and icontextlogits[i]×αif logits[i]0 and icontextlogits[i]if icontext\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}

其中 α>1\alpha > 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]=logits[i]βpresence1[igenerated]\text{logits}'[i] = \text{logits}[i] - \beta_{\text{presence}} \cdot \mathbb{1}[i \in \text{generated}]

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

Frequency Penalty(频率惩罚):

logits[i]=logits[i]βfrequencycount(i,generated)\text{logits}'[i] = \text{logits}[i] - \beta_{\text{frequency}} \cdot \text{count}(i, \text{generated})

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

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

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

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

核心思想:在生成时同时考虑「有条件」和「无条件」两个 logits,用加权组合放大条件信号:

logitsguided=logitsuncond+w(logitscondlogitsuncond)\text{logits}_{\text{guided}} = \text{logits}_{\text{uncond}} + w \cdot (\text{logits}_{\text{cond}} - \text{logits}_{\text{uncond}})

其中 w>1w > 1 是引导强度。等价于:

logitsguided=(1w)logitsuncond+wlogitscond\text{logits}_{\text{guided}} = (1 - w) \cdot \text{logits}_{\text{uncond}} + w \cdot \text{logits}_{\text{cond}}
  • w=1w = 1:退化为普通条件生成
  • w>1w > 1:放大条件信号,生成更贴合 prompt 的内容,但可能牺牲多样性
  • w<1w < 1:减弱条件信号,增加随机性

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

4.8 各策略综合对比表

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

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


5. 自回归循环

5.1 四要素概括

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

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

自回归生成的本质是概率的链式分解

P(x1,x2,,xn)=i=1nP(xix1,,xi1)P(x_1, x_2, \ldots, x_n) = \prod_{i=1}^{n} P(x_i \mid x_1, \ldots, x_{i-1})

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

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

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

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

5.5 KV Cache 加速

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

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

每步计算量从 O(n2)O(n^2) 降为 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{原始文本字符串}

它是分词(Tokenization)的逆操作:

Tokenize:文本[id1,id2,,idm]\text{Tokenize}: \text{文本} \to [id_1, id_2, \ldots, id_m]
Detokenize:[id1,id2,,idm]文本\text{Detokenize}: [id_1, id_2, \ldots, id_m] \to \text{文本}

理想情况下,两者构成完美的互逆关系:Detokenize(Tokenize(text))=text\text{Detokenize}(\text{Tokenize}(\text{text})) = \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。解码时需要反向执行这些合并,把子词还原为原始字符序列。

严格算法描述

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

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

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

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

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

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

6.3.2 实际实现中的简化

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

对每个 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 字符}\}

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

6.4.2 解码过程

输入 token ids: [15339, 198, 5765]

第 1 步:查词表得到每个 token 对应的「Unicode 编码字符串」
  15339"Hello"5Unicode 字符)
  198"Ċ"1Unicode 字符,实际代表 \n)
  5765"ä¸ĭ"3Unicode 字符,实际代表中文"今"的 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,而是将该字符回退到字节级表示

假设字符 "ð" (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 的解码

输入 token ids: [231, 176]  (来自 byte-fallback)

第 1 步:查词表
  231"<0xC3>"  → 字节值 0xC3
  176"<0xB0>"  → 字节值 0xB02 步:收集连续字节序列
  [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):

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

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

["▁Hello", "▁world"] → "Hello world"    ← 每个 ▁ 变为一个空格

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

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

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

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

6.6.1 问题描述

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

场景: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 解决方案:缓冲 + 延迟输出

算法:增量反分词

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

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

具体示例

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)

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

Byte-level BPE(GPT-2/4)

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

6.7.3 多语言混合文本

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

"Hello你好world"

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

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

6.8 反分词在 Output Processor 中的位置

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

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/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]

客户端处理

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 的位置

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.01ms/token),真正的瓶颈在 GPU 计算和显存带宽。


8. 总结

8.1 全流程一图总览

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

一句话:Transformer 把输入切成 token、嵌入成向量,经 LL 层「自注意力 + 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) 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