一句话本质 :输出层(LM Head)将模型内部的 d 维语义表示翻译成对词表中每一个 token 的打分,经 Softmax 归一为概率分布后按策略采样选出一个 token id,再经反分词还原为人类可读的文本------这是 Transformer 中唯一把「数学」翻译回「语言」的环节,与输入侧的分词和嵌入形成完美的编码-解码对称。
1. LM Head( Wout):隐藏状态到词表空间的投影
1.1 四要素概括
| 要素 | 内容 |
|---|---|
| 为什么 | L 层 Transformer 输出的隐藏向量 (n,d) 是模型内部表示,维度 d 与词表 V 无关,无法直接对应到具体的词;需要一个投影把它「翻译」成词表上每个候选 token 的得分 |
| 矩阵操作 | logits=h⋅Wout,其中 h∈Rd, Wout∈Rd×V, logits∈RV |
| 作用 | 将 d 维内部表示映射到 V 维词表空间,每个维度对应一个 token 的「匹配得分」(本质是隐藏向量与每个词嵌入的内积相似度打分) |
| 结果 | logits 向量 (V,),长度恰好等于词表大小------「下一个 token 是词表中哪个」的全部候选打分 |
1.2 矩阵操作详解
LM Head 的核心是一次矩阵乘法:
logits=h⋅Wout(n,d)×(d,V)→(n,V)
其中 h 是经过 FinalNorm 后的隐藏状态, Wout 是输出投影矩阵 (也叫 output embedding),形状 (d,V)。
矩阵乘法的本质:用 h 去和 Wout 的每一列 做点积。 Wout 的第 j 列可看作「词表第 j 个 token 在输出空间的代表向量」,点积越大越匹配:
logitsj=h⋅Wout:,j
语义 : logitsj = "当前语义 h" 与 "第 j 个 token 代表向量" 的相似度。
1.3 通俗理解:「翻译官 / 相亲红娘」类比
用一个生活化例子讲透。输入「今天天气真」,要预测下一个字,词表简化为 {我,晴,好,吃,的}。
主角① : h = 模型心里「该接什么」的模糊感觉(一团数字):
「接下来大概率是个积极的、形容天气的、读起来顺口的字......」
主角② : Wout 里存着每个字的「气质档案」( Wout 的每一列):
| 字 | 档案特征 | 与 h 的契合度 |
|---|---|---|
| 我 | 主语、人称代词、中性 | 不搭 |
| 晴 | 积极、天气相关、形容词 | 完美契合 |
| 好 | 积极、通用形容词 | 挺契合 |
| 吃 | 动作、动词、食物相关 | 完全不搭 |
| 的 | 结构助词、高频 | 不太搭 |
翻译过程 = 拿 h 的感觉与每个字的档案逐一算「契合度」(点积)→ 打分表:
logits=我:1.2,晴:8.5,好:6.0,吃:0.3,的:2.1
契合的「晴」得最高分 8.5,不搭的「吃」只有 0.3 → Softmax 后「晴」概率最大 → 输出「今天天气真晴」。
相亲比方 : h 是嘉宾心里「理想对象」的模糊画像, Wout 存着所有候选人的档案,翻译 = 逐一比对契合度并打分,分最高的就是「最佳人选」。所谓「翻译」,本质就是把「一团语义感觉」变成「一张覆盖全词表的打分表」。
1.4 权重绑定(Weight Tying)
很多模型让 Wout 与输入嵌入矩阵 E( V×d)共享权重(互为转置):
输入端:token id→查 E 的第 id 行→向量E:(V,d)
输出端:向量→×Wout→词表 logitsWout=E⊤:(d,V)
此时第 k 个 logit 有优美的几何解释:
logitsk=h⋅ek=h⊤ek
即:隐藏状态 h 与第 k 个词嵌入 ek 的内积 = "预测下一个词"自然变成了"在嵌入空间中找最近邻"。
| 优势 | 解释 |
|---|---|
| 语义对称性 | 输入把 id → 向量,输出把向量 → id,互为逆操作 |
| 省参数 | 少一个 V×d 矩阵;Qwen-2( V=152064,d=4096)省约 6.23 亿参数 |
| 效果提升 | 实验表明绑定后困惑度(PPL)通常下降(Press & Wolf, 2017) |
GPT-2/3/4、LLaMA 系列、Qwen 系列等几乎所有现代 LLM 都使用权重绑定。详见 01 · 词表分词与嵌入。
2. probs 长度恒为 V 的推导
2.1 维度溯源链路
probs 的长度为什么恰好等于词表大小 V?这是一个从设计到实现的因果链:
h∈RdWout∈Rd×V logits∈RVsoftmax probs∈RV
第①步: Wout(d,V) 决定了 logits 是 V 维
Wout 有 V 列,矩阵乘法 h⋅Wout 输出 V 个数------词表有 V 个 token,就需要 V 个分数,每个分数对应一个候选 token。 V 是设计时固定的超参(= 词表大小),写死在 Wout 的形状里。
第②步:Softmax 不改变长度
Softmax 只是把 V 个数各自取 exp 再除以总和:
probsi=∑j=1Vexp(logitsj)exp(logitsi)
输入几个数就输出几个数,只改数值、不改个数。
第③步:probs 的下标 = token id
probs 的第 i 个位置恰好对应词表中 id=i 的 token:
bash
probs = [0.085, 0.031, 0.019, 0.632, 0.232]
下标: 0 1 2 3 4
对应: "今" "天" "气" "晴" "朗" ← 下标就是 token id!
一句话 : V 由词表大小决定 → 写死在 LM Head 权重 Wout(d×V) 的形状里 → logits 是 V 维 → Softmax 后的 probs 自然也是 V 维。
3. Softmax 与温度
3.1 标准 Softmax 公式
probsi=∑j=1Vexp(zj)exp(zi),zi=logitsi
Softmax 是满足"和为 1"约束下信息熵最大(最无偏)的分布------它不引入任何额外假设,只是把相对大小关系忠实地转化为概率。
3.2 温度 T 的作用
在 Softmax 前把 logits 除以温度 T:
probsi=∑j=1Vexp(zj/T)exp(zi/T)
| 温度 | 效果 | 适用场景 |
|---|---|---|
| T→0 | 分布变尖,趋近贪心(argmax) | 事实问答、代码生成 |
| T=1 | 原始分布 | 通用 |
| T>1 | 分布变平,更随机 | 创意写作、头脑风暴 |
3.3 温度对信息熵的数学推导
温度 T 对概率分布信息熵的影响有严格的数学关系。设 pT 为温度 T 下的分布:
H(pT)=−i=1∑VpT(i)logpT(i)
T→0 的极限 :分布退化为 one-hot(全部概率集中在 argmax 位置), H→0。
T→∞ 的极限 :分布退化为均匀分布 p(i)=1/V, H→logV(最大熵)。
温度与熵的单调性 :可以证明 ∂T∂H(pT)≥0,即温度越高,熵越大(分布越平坦、越不确定)。具体地:
∂T∂H=T21VarpT(z)
其中 VarpT(z)=∑ipT(i)zi2−(∑ipT(i)zi)2 是 logits 在分布 pT 下的方差。由于方差恒非负,故 ∂T∂H≥0,熵随温度单调递增。
直觉:温度越高 → 分布越平 → 不确定性越大 → 信息熵越高。温度是用户控制「模型探索空间大小」的旋钮。
3.4 数值示例
设 logits=2.0,1.0,0.5,4.0,3.0,词表 {今,天,气,晴,朗}:
| T | Softmax 后的 probs | 最高概率 | 熵 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=argimaxprobsi
永远选概率最高的 token。确定、可复现,但容易产生重复、呆板的输出(「的的的」循环)。
4.2 Temperature Sampling(温度采样)
next_id=sample(softmax(logits/T))
按温度调节后的概率分布随机抽样。 T<1 趋近贪心, T>1 趋近均匀采样。
4.3 Top- k Sampling
只保留概率最高的 k 个 token,其余置 −∞,再在候选集中采样:
logits′i={logitsi−∞if i∈top-kotherwise
probs′=softmax(logits′)
next_id=sample(probs′)
优点 :屏蔽长尾垃圾 token。缺点 : k 是固定的,无法适应分布形状------模型很确定时候选可能只需 5 个,不确定时可能需要 100 个。
4.4 Top- p / Nucleus Sampling(核采样)
Holtzman et al. (2020) 在论文 "The Curious Case of Neural Text Degeneration" 中提出。核心思想:选累积概率达到 p 的最小候选集合,再采样。
严格定义 :设 V(p)⊆V 是满足以下条件的最小集合(按概率降序排列):
i∈V(p)∑pT(i)≥p
将 V(p) 以外的 token 的 logit 置 −∞,在 V(p) 内重新归一化后采样:
probs′i={∑j∈V(p)pT(j)pT(i)0if i∈V(p)otherwise
next_id=sample(probs′)
候选集大小随分布动态变化 :模型确定时候选少(可能只有几个),不确定时候选多(可能上百个),比 Top- k 更自然。
Holtzman 2020 的核心发现
Holtzman 等人在该论文中通过系统实验揭示了神经文本生成的退化现象:
问题诊断(为什么 Greedy / Beam Search 会退化):
- 文本退化(Degeneration) :使用 Greedy 或 Beam Search 生成的文本虽然流畅,但趋于高度重复、缺乏多样性------大量使用高频词和常见句式
- Perplexity 悖论 :退化文本的 Perplexity 反而极低(甚至比人类文本还低),说明低 Perplexity ≠ 高质量。模型在高概率区域的过度集中导致了「安全但无趣」的输出
Nucleus Sampling 的实验结论:
| 指标 | Greedy | Beam Search | Top- k | Top- p |
|---|---|---|---|---|
| Perplexity | 极低(退化) | 极低(退化) | 适中 | 最接近人类文本 |
| 多样性(Distinct-n) | 低 | 低 | 中 | 高 |
| HUMAN-Eval 偏好 | 低 | 低 | 中 | 高 |
| 重复率 | 高 | 高 | 中 | 低 |
关键洞察:Top- 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/αlogitsi×αlogitsiif logitsi>0 and i∈contextif logitsi≤0 and i∈contextif i∈/context
其中 α>1(常用 1.1~1.2)是惩罚系数。
设计巧妙之处 :对正 logit 除以 α(使其变小),对负 logit 乘以 α(使其更负)------两种情况都降低该 token 的 Softmax 概率,且不改变 logit 的符号(正仍正、负仍负),避免过度惩罚。
4.6 Frequency Penalty 与 Presence Penalty
OpenAI API 中常用的两种惩罚机制,与 Repetition Penalty 目标相似但实现不同:
Presence Penalty(存在惩罚):
logits′i=logitsi−βpresence⋅1i∈generated
只要 token i 在已生成文本中出现过至少一次 ,就减去固定值 β。效果:鼓励模型谈论新话题,引入新概念。
Frequency Penalty(频率惩罚):
logits′i=logitsi−βfrequency⋅count(i,generated)
惩罚力度与 token i 的出现次数 成正比。效果:减少复读机式的重复。
| 惩罚类型 | 公式核心 | 效果 | 常用参数 |
|---|---|---|---|
| Repetition Penalty | logit/α 或 logit×α | 降低已出现 token 概率 | α=1.1 |
| Presence Penalty | logit−β | 鼓励新话题 | β=0.5 |
| Frequency Penalty | logit−β⋅count | 减少高频重复 | β=0.5 |
4.7 Classifier-Free Guidance(CFG)在 LLM 中的应用
CFG 最初用于扩散模型(Ho & Salimans, 2022),后被引入 LLM 以增强生成质量与控制力。
核心思想:在生成时同时考虑「有条件」和「无条件」两个 logits,用加权组合放大条件信号:
logitsguided=logitsuncond+w⋅(logitscond−logitsuncond)
其中 w>1 是引导强度。等价于:
logitsguided=(1−w)⋅logitsuncond+w⋅logitscond
- w=1:退化为普通条件生成
- w>1:放大条件信号,生成更贴合 prompt 的内容,但可能牺牲多样性
- w<1:减弱条件信号,增加随机性
在 LLM 中的实践:一些推理框架(如 vLLM)支持 CFG,通过同时运行一个有 prompt 前缀的序列和一个无前缀(或仅有 BOS)的序列,将两组 logits 按上述公式组合。适用于需要严格控制输出格式或风格的场景。
4.8 各策略综合对比表
| 策略 | 参数 | 候选集 | 确定性 | 多样性 | 核心效果 | 适用场景 |
|---|---|---|---|---|---|---|
| Greedy | --- | 全部(取 argmax) | 最高 | 最低 | 最确定、易重复 | 抽取、代码、需可复现 |
| Temperature | T | 全部 | T↓ 增高 | T↑ 增高 | 通用调节旋钮 | 通用 |
| Top- k | k | 固定 k 个 | 中 | 中 | 截断长尾 | 配合温度使用 |
| Top- p | p | 动态(累积到 p) | 中 | 中高 | 自适应候选集 | 对话主流 |
| Rep. Penalty | α | 全部(调权重) | --- | 提升 | 防复读 | 通用辅助 |
| CFG | w | 全部(调 logits) | w↑ 增高 | w↑ 降低 | 增强条件一致性 | 结构化生成 |
实际系统通常组合使用 :如 T=0.7,top_p=0.9,repetition_penalty=1.1。
5. 自回归循环
5.1 四要素概括
| 要素 | 内容 |
|---|---|
| 为什么 | 模型一次前向只能预测一个下一个 token;要生成完整文本,必须把刚生成的 token 接回输入、再跑一次------循环往复 |
| 矩阵操作 | 无新运算,是 §1~§4 的迭代控制流:嵌入 → L 层前向 → LM Head → 取最后位置 → 采样 |
| 作用 | 将「预测一个 token」的单次能力扩展为「生成任意长度文本」的序列能力 |
| 结果 | 完整 token 序列 → 反分词 → 最终文本 |
5.2 完整闭环:生成 → 拼接 → 下一步
自回归生成的本质是概率的链式分解:
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 关键:每一步只用最后一个位置
因果掩码下,第 i 个位置的输出融合了 token 1...i 的全部信息,它天然就是「看完前 i 个 token 后,对第 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),生成速度大幅提升。详见 details/01-kv-cache。
5.6 流式输出
每生成一个 token 就立刻反分词并返回给前端------这就是你在聊天界面看到字「一个个冒出来」的原因:不是特效,是它真的一个个算出来的。详见 §7 工程实践。
6. 反分词(Detokenization):token id 序列到文本
反分词是整个生成流程中最容易被忽视、却在工程中至关重要的环节 。分词把文本压缩成整数序列,反分词则把这串整数还原为人类可读的文字------它是模型输出与用户感知之间的唯一桥梁。
6.1 定义与问题陈述
反分词(Detokenization) 是将 token id 序列还原为原始文本的过程:
Detokenize:id1,id2,...,idm→原始文本字符串
它是分词(Tokenization)的逆操作:
Tokenize:文本→id1,id2,...,idm
Detokenize:id1,id2,...,idm→文本
理想情况下,两者构成完美的互逆关系: 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。解码时需要反向执行这些合并,把子词还原为原始字符序列。
严格算法描述:
设 BPE 的合并规则列表为 R=(r1),(r2),...,(rK),其中 rk=(ak,bk)→ck,按训练时的顺序排列。
编码(分词)过程(自底向上):
- 将输入文本拆分为单个字符序列
- 从左到右扫描,尝试应用合并规则:如果相邻两个符号构成规则 (ak,bk),则合并为 ck
- 重复直到无法继续合并
解码(反分词)过程(自顶向下,逆向执行):
- 输入 token id 序列 id1,id2,...,idm
- 查词表得到每个 token 对应的字符串: str1,str2,...,strm
- 对每个字符串,按与合并相反的顺序 执行拆分:从最后一条合并规则开始,如果字符串中包含 ck,则替换为 (ak,bk)
- 重复直到所有符号都是单字符
- 将结果字符序列拼接为原始文本
关键 :合并规则的应用是有顺序的(贪心、从左到右),解码时必须严格逆序执行,否则结果可能不正确。
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 字符}
这个映射的目的是:让所有字节值都能表示为「可见字符」,避免控制字符(如 \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.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,将 d 维隐藏状态翻译为 V 维词表打分 |
| 为什么 probs 长度是 V | Wout 有 V 列 → logits 是 V 维 → Softmax 不改长度 → probs 也是 V 维 |
| 温度的作用 | T↓ 分布变尖(趋近贪心), T↑ 分布变平(趋近均匀);熵随 T 单调递增 |
| Top- p 的优势 | 候选集大小随分布自适应,生成文本 Perplexity 最接近人类文本(Holtzman 2020) |
| 自回归的本质 | 链式法则 P(x1:n)=∏P(xi∥x<i),将指数级联合分布分解为 n 个单步预测 |
| 反分词的核心挑战 | Byte-level BPE 的多字节缓冲、SentencePiece 的 byte-fallback 和空格处理、增量解码的 UTF-8 完整性 |
| 工程链路 | GPU 采样 → Output Processor 反分词 → 停止判断 → SSE 推送 → 客户端逐字打印 |
一句话 :Transformer 把输入切成 token、嵌入成向量,经 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- 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