bobofamilyNIU CHUN
34/36

输入查几行,输出算整表:从 Token ID 到下一 Token

2026-10-1217 分钟—阅读

Tokenizer 编码结束,得到的是一串整数。它们还没有给出“接下来写什么”。

接下来的两个动作看起来都与词表有关,方向却不同:输入端按 ID 取出少数几行向量;输出端用上下文向量,对整个词表逐项计算分数。 即使输入里只有 a、b,输出层也会给 c、d、e 打分。再经过选择规则,分数才变成一个具体的下一 Token ID。

这篇沿着同一次计算,把输入、输出和选择规则接起来。三个职责先分清:

环节接收什么产出什么
Tokenizer 编码文本与模型配套的 ID 序列
语言模型当前 ID 序列下一 Token 的候选分数 logits
生成策略分数,必要时加随机数一个下一 Token ID

先把 Tokenizer 接到模型上

GPT-2 的一个实际编码结果是:

Hi 你! → [17250, 220, 19526, 254, 0]

这里共 5 个 Token:“你”的 UTF-8 字节分布在两个 Token 中,前面的空格另外占一个。 具体编解码过程见沿着 GPT-2 源码走完一次编解码。

总览取 GPT-2 的词表大小 V=50,257、向量维度 D=768。这串输入长度为 T=5,所以查出的向量是 5×768; 模型最后一个位置的上下文向量是 1×768,输出分数是 1×50,257。

Tokenizer 与 GPT 的接口:编码把文本交成 ID,模型给出分数,生成策略选出新 ID,解码负责显示

encoder.json、vocab.bpe 是分词器的配置;Embedding、位置表、Transformer 和输出层是语言模型的参数。 Tokenizer 的“符号 ↔ ID”表和模型的“ID → 向量”表不是同一张表,但 ID 的含义必须一致。

图中的新 ID k 只是示意,没有运行预训练 GPT-2 来预测它。下面换成一个可以把数字展开的小模型;两套词表的 ID 不能混用。


小模型的约定:三个位置,五个候选,四维向量

词表为 a→0、b→1、c→2、d→3、e→4,输入是 a | b | a,即 [0,1,0]。 批量大小 B=1,正文省略批量维。模型只有一个头、一个 Pre-Norm Block,无 Dropout,位置表最多容纳 8 个位置。

所有权重都手工固定,没有训练过。它的作用是展示计算;69% 这样的概率不代表它学到了语言规律。

符号 / 对象含义本例
Token ID词表条目的编号,也用于索引输入向量表[0,1,0]
位置编号当前输入中第几个位置,从 0 开始[0,1,2]
T这次输入有多少个 Token3
V整个词表有多少个条目5
D每个位置用多少个特征表示4
E所有词表项的输入向量V×D = 5×4
P所有可用位置的向量8×4
H模型算出的上下文表示T×D = 3×4
W_head、b独立输出层的权重、偏置5×4、5
Z每个位置对整个词表的分数T×V = 3×5

Token ID 是索引,不是连续数值特征。 ID 4 并不比 ID 1 “大三倍”;模型用整数定位一行向量,再计算向量。 这里 T 专指序列长度,温度用 τ;词表大小 V 也要与注意力中的 Value 向量区分。

输入端:同一个 a,查同一行 E,加不同的 P

GPT-2 和这里的教学模型使用可学习的绝对位置向量。每个位置先查两张表,再逐元素相加:

X₀[t] = E[id[t]] + P[t]
位置 tToken / IDE[id[t]]P[t]X₀[t]
0a / 0[1,0,1,0][0.1,0.2,0,0][1.1,0.2,1,0]
1b / 1[0,1,0,1][0,0.1,0.2,0][0,1.1,0.2,1]
2a / 0[1,0,1,0][0,0,0.1,0.2][1,0,1.1,0.2]

两个 a 都读取 E[0],但分别加上 P[0] 和 P[2]。位置编号要先查表,不是把整数 0、1、2 直接加到向量的每一维。 相加后每个位置仍有 4 个数,没有拼接成 8 维。

token_vectors = token_embedding(ids) # (1, 3, 4)
positions = torch.arange(ids.shape[1], device=ids.device)
position_vectors = position_embedding(positions) # (3, 4)
x0 = token_vectors + position_vectors # (1, 3, 4)

位置向量沿批量维广播。E、P 是参数表;查出的向量和 x0 是这次前向计算的值。 这个加法是本篇所用实现的位置处理方式,不能套成所有 Transformer 都使用同一张绝对位置表。

模型内部①:两次查表、两条残差分支、最后 LayerNorm;同一份手工权重贯穿后续所有数值

Transformer 把每一行变成上下文表示

本例按 Pre-Norm 的顺序计算:

X₁ = X₀ + Attention(LN₁(X₀))
X₂ = X₁ + MLP(LN₂(X₁))
H = LN_f(X₂)

LayerNorm 沿每个位置自己的四个特征归一化,再使用可学习的缩放与偏移;不把不同位置混起来统计。 本例用 PyTorch 的默认 eps=1e-5,缩放初值为 1,偏移为 0。

注意力分支从归一化后的输入生成 Q、K、Value,计算 QKᵀ/√4,把未来位置的分数设成 −∞,按行 softmax, 再加权汇总 Value 并投影。实际注意力权重为:

读取位置 0 读取位置 1 读取位置 2
查询位置 0 1.0000 0.0000 0.0000
查询位置 1 0.0190 0.9810 0.0000
查询位置 2 0.4683 0.0101 0.5216

未来位置的权重为 0,每行之和为 1。这次 softmax 的候选是输入位置,最后一行在位置 0、1、2 之间分配读取权重。 末尾输出层的 softmax 才在词表候选之间分配概率。

MLP 是 Linear 4→16 → ReLU → Linear 16→4。同一套 MLP 在每个位置独立计算;位置之间的信息交流由注意力完成。 两次残差相加都保持 3×4 的形状。教学结构沿用 V7 的 ReLU,GPT-2 对应子层使用 GELU。

最后的位置 t=2 已经结合当前 a | b | a,得到:

h₂ = [0.9546803832, −1.0673931837, 1.0419750214, −0.9292621017]

向量的四个数不是四个 Token 的概率。它们是交给下一层使用的四个特征,下一层才把 D 维映射成 V 个分数。


输出端:每个候选都有一行权重

这里的 lm_head 是语言模型的输出预测层,lm 是 Language Model。 它与 attention head 职责不同:一个把上下文表示变成词表分数,另一个在输入位置之间汇集信息。

本例的输出层是 nn.Linear(4, 5)。PyTorch 保存的权重形状是 输出维度×输入维度,所以 W_head 为 5×4:

z[j] = h₂ · W_head[j] + b[j]
z = h₂ @ W_head.T + b
(1×4) @ (4×5) + (1×5) → (1×5)

对所有位置一起算,得到 Z = H @ W_head.T + b,形状为 3×5;偏置广播到每一行。 第 j 个输出与词表 ID j 对齐。 输入只查过 E 的哪些行,不会限制输出层拥有的五行候选权重。

候选 / IDW_head[j]b[j]最后位置的 logitsoftmax 概率
a / 0[1,0,0,0]00.95468022.03%
b / 1[1,0,1,0]0.12.09665569.03%
c / 2[0,1,0,0]0−1.0673932.92%
d / 3[−1,0,0,0]−0.2−1.1546802.67%
e / 4[0,0,0,1]0−0.9292623.35%

以 b / ID 1 为例,把点积真正展开:

z[1] = 0.954680×1 + (−1.067393)×0 + 1.041975×1 + (−0.929262)×0 + 0.1
≈ 2.096655

其余四个候选也做同样的点积与加偏置。矩阵乘法把它们一次算完,不需要挨个写五次循环。

logit 可以为负,它还不是概率。softmax 把整行分数转换为和为 1 的概率:

p[j] = exp(z[j]) / Σₖ exp(z[k])

点积是匹配分数,但不等于余弦相似度

对于非零向量:

h · w = ‖h‖ ‖w‖ cos θ

点积同时受方向和长度影响。这里没有把两者都除以各自的长度,独立输出层还会加偏置。 称它为“打分”,描述的是用途:形成可比较的候选 logits,交给 softmax 和选择规则。

GPT-2 为什么写成 hEᵀ

实现输出公式输入 / 输出权重
V7 练习、本文五 Token 模型z = h W_headᵀ + bE 与 W_head 独立,带输出偏置
GPT-2、所对照的 nanoGPTz = h Eᵀ复用输入 E,无输出偏置

GPT-2 的 model.py 用 tf.gather(wte, X) 查输入行,输出用 tf.matmul(h_flat, wte, transpose_b=True)。 nanoGPT 显式把 transformer.wte.weight 与 lm_head.weight 绑定。

共享权重就是同一张参数表承担两种用途:输入时查行,输出时让所有行参与点积。 只计算输入 / 输出权重矩阵,共享时是 VD 个参数,独立时是 2VD;偏置、位置表和其余网络参数不在这笔账里。

模型内部②:展开所有候选的线性打分,区分训练与生成,再对照独立权重和共享权重

训练用各位置,生成用最后位置

教学文本是 a | b | a | b。取前面三项作为输入,后移一项作为目标:

输入:[0, 1, 0] → a, b, a
目标:[1, 0, 1] → b, a, b
位置可用上下文下一 Token 目标实际交叉熵
0ab / 10.409955
1a | ba / 03.320039
2a | b | ab / 10.370652

每个位置都提出一道“预测下一项”的题。因果 mask 保证位置 0 不能偷看位置 1 的答案;训练时三道题可以并行计算。

最后一处目标为 ID 1,损失是 −ln(p[1]) = −ln(0.690284…) ≈ 0.370652。 三处取平均:

L = (0.4099546 + 3.3200393 + 0.3706515) / 3
≈ 1.36688185
loss = F.cross_entropy(
logits.reshape(-1, 5), # 3 行,每行 5 个原始 logits
targets.reshape(-1), # 三个正确 ID:[1,0,1]
)
loss.backward()

cross_entropy 接收原始 logits,内部完成稳定的 log-softmax 与负对数似然;这里不用先采样,也不用先手动 softmax。 配套实验检查了 Embedding、位置表、Q/K/Value、MLP、lm_head 的权重梯度,均非零;只算了梯度,没有做优化器更新。

生成时,已有完整上下文 [0,1,0],要补的是它之后的一项,因此读取最后位置的分布。 V7 先算全部位置 logits,再取 logits[:, -1, :];nanoGPT 推理分支直接只对最后一个上下文向量运行输出层。


概率怎样变成一个 ID

贪心:取当前第一名

取最大的 logit,等价于取最大的 softmax 概率。本例得到 b / ID 1。

这只保证当前一步最高。假设第一步 A 为 0.6、B 为 0.4;A 后面最可能的下一项只有 0.5,B 后面某项却有 0.99。 两步路径的概率分别为 0.6×0.5=0.30 和 0.4×0.99=0.396。第一步选 A,并没有得到最高概率的整条路径。

概率采样:用一个随机数定位区间

为了手算,这一节明确使用四舍五入后的概率 [0.2203,0.6903,0.0292,0.0267,0.0335],它们的和恰好为 1。 把 [0,1) 按对应长度切成五段:

Token / ID区间长度
a / 0[0, 0.2203)0.2203
b / 1[0.2203, 0.9106)0.6903
c / 2[0.9106, 0.9398)0.0292
d / 3[0.9398, 0.9665)0.0267
e / 4[0.9665, 1)0.0335

均匀抽取 u∈[0,1),落在哪段,就返回哪个 ID。u=0.50 选 b,u=0.05 选 a,u=0.93 选 c,u=0.95 选 d。 均匀随机数落入某段的概率等于区间长度,因此这个过程实现了按概率抽取。

from bisect import bisect_right
from itertools import accumulate
boundaries = list(accumulate(probabilities))
boundaries[-1] = 1.0
token_id = bisect_right(boundaries, u)

bisect_right 找第一个严格大于 u 的累计边界。若 u 恰好等于 0.2203,会进入 b 的区间,符合左闭右开的约定。 长度为 0 的区间也不会被选中。

torch.multinomial(probs, num_samples=1) 按权重返回一列的索引;列与词表 ID 对齐时,这个索引就是下一 Token ID。 区间法解释抽样原理,不声称 PyTorch 内部逐行执行这段 Python。

固定这张分布,用 Python Random(12) 抽取 100,000 次,得到:

Tokenabcde
次数21,98069,0972,9962,5973,330
频率21.980%69.097%2.996%2.597%3.330%

有限次抽样不要求频率与目标概率完全相等。这也不是生成了十万个连续 Token:真实自回归生成每追加一个 ID,就换了上下文,下一步会重新计算分布。

★ 温度:从概率之比看它改了什么

softmax 之前,把 logits 除以同一个正数 τ:

pᵢ(τ) = exp(zᵢ/τ) / Σⱼ exp(zⱼ/τ),τ > 0

比较 b 和 a,共同分母消掉:

p_b(τ) / p_a(τ)
= exp(z_b/τ) / exp(z_a/τ)
= exp((z_b − z_a)/τ)

本例分数差约为 2.096655 − 0.954680 = 1.141975:

τb 相对 a 的概率倍数abcde
0.59.829.20%90.29%0.16%0.14%0.21%
13.1322.03%69.03%2.92%2.67%3.35%
21.7725.83%45.71%9.40%9.00%10.07%

较低温度放大领先者的相对优势;较高温度缩小差距。表中标签经过舍入,计算使用完整精度。

正温度不改变排序,所以本例贪心始终选 b。 变的是抽取时的概率,不是模型权重。 当最高分唯一,τ→0⁺ 时分布集中到它;若最高分并列,极限会在并列最高项之间分配。 代码不能直接除以 0,需要贪心就显式用 argmax。

拖动温度:五个 logits 固定,只改变采样分布新窗口打开 ↗

稳定计算时先减去最大 logit:exp((zᵢ−max(z))/τ)。共同因子会在归一化中约掉,数学结果不变,又能避免指数上溢。

top-k:限定数量,再归一化

温度固定为 1,按概率从高到低排列为 b、a、e、c、d。k=2 保留 b、a,其余三项在本次抽取中的概率变为 0。

保留下来的原概率之和约为 0.6903+0.2203=0.9106。这还不是一个和为 1 的新分布,需要再除以这部分总质量:

p′_b = p_b / (p_b + p_a) ≈ 0.758042
p′_a = p_a / (p_b + p_a) ≈ 0.241958
p′_c = p′_d = p′_e = 0

k=2 是两个候选参加抽取,不是一次输出两个,也不是各有一半机会。 归一化后两者的概率比保持不变。 k=1 时只有最高项,在本例中与贪心一致。

nanoGPT 的实现先取第 k 大的 logit,再把比它小的项设成 −∞,随后 softmax:exp(−∞)=0,保留项自动重新归一化。 它使用“严格小于阈值”的比较,边界若有并列分数,可能保留多于 k 项。本例无并列;下方教学代码按排名取恰好 k 项,并列按 ID 排序。

top-p:包含跨过阈值的那一项

top-p 按概率降序累加,取累计概率至少达到阈值 p 的最短前缀,再对保留项归一化。 这里采用 Nucleus Sampling 论文 §3.1 的式 (2)、(3):累计条件是 ≥ p。

排名Token / 原 ID原概率累计概率
1b / 169.03%69.03%
2a / 022.03%91.06%
3e / 43.35%94.41%
4c / 22.92%97.33%
5d / 32.67%100.00%

阈值 0.90:只有 b 不够,加 a 后到 91.06%,保留 [1,0]。 阈值 0.92:b+a 不够,再加 e,保留 [1,0,4]。 阈值 0.95:前三项只到 94.41%,还要加 c,保留 [1,0,4,2]。 排序改变展示顺序,不会改变 Token 原本的 ID。

top-k 固定数量,top-p 的数量跟着分布变化。p=0.90 时,若第一名已有 96%,只需保留它一个;若五项各有 20%,则要保留全部五项。

分别调整 top-k 与 top-p:看候选集合和归一化后的概率新窗口打开 ↗

这两个筛选只决定当前这一步哪些 ID 参加抽取,不会删除 Tokenizer 的词表条目。 下一步随上下文重新计算,被筛掉的 ID 仍可能回来;这与 Unigram 训练时缩小持久词表的删词操作不同。

源码边界:旧版 GPT-2 的同名函数少留了一项

所对照的 GPT-2 src/sample.py(提交 9b63575)先计算降序累计概率,再用下面的索引选阈值:

阈值下标 = max(满足 cumulative_probs <= p 的项数 − 1, 0)

本例 p=0.90 时,只有第一项的累计值不超过 0.90,项数为 1,阈值下标为 0。 于是按第一项 b 的分数作为门槛,只保留 b;论文规则会把让累计值跨过 0.90 的 a 一起留下。

本文代码和交互采用论文规则。另用 Python 独立复现了旧源码的阈值索引,确认上述差别;没有运行它的 TensorFlow 版本。 判断行为要看边界条件,不能只看函数叫不叫 top_p_logits。


一段能复算的筛选代码

下面的最小版本假定输入是非空、有限 logits;top-k 与 top-p 分别应用。 完整实验还包含区间抽样、参数校验、重复抽取,以及旧 GPT-2 的边界对照。

import math
def softmax(logits, temperature=1.0):
if not math.isfinite(temperature) or temperature <= 0:
raise ValueError("temperature must be finite and positive")
maximum = max(logits)
weights = [math.exp((z - maximum) / temperature) for z in logits]
total = sum(weights)
return [w / total for w in weights]
def ranked_ids(probs):
return sorted(range(len(probs)), key=lambda i: (-probs[i], i))
def top_p_ids(probs, threshold):
if not 0 < threshold <= 1:
raise ValueError("top-p must be in (0, 1]")
selected, mass = [], 0.0
for token_id in ranked_ids(probs):
selected.append(token_id)
mass += probs[token_id]
if mass >= threshold:
break
return selected
def renormalize(probs, selected):
allowed = set(selected)
mass = sum(probs[i] for i in allowed)
return [p / mass if i in allowed else 0.0
for i, p in enumerate(probs)]
logits = [0.9546803832054138, 2.0966553688049316,
-1.067393183708191, -1.1546803712844849,
-0.929262101650238]
probs = softmax(logits)
kept_k = ranked_ids(probs)[:2] # [1, 0]
kept_p = top_p_ids(probs, 0.92) # [1, 0, 4]
after_k = renormalize(probs, kept_k)
after_p = renormalize(probs, kept_p)
assert math.isclose(sum(after_k), 1)
assert math.isclose(sum(after_p), 1)

输出概率仍按原 ID 顺序放置,没入选的项填 0。这样后续抽出来的下标可以直接当 ID 使用。 如果把候选压缩成一个新数组,就必须再通过保留的 ID 列表映射回原词表。

从完整模型开始复现

下载五 Token 模型完整代码和采样实验完整代码,放在同一目录。 第一个文件包含所有手工权重,前向、梯度检查和数值记录;第二个读取它输出的 JSON,只依赖 Python 标准库。

Terminal window
python gpt_model_interface_demo.py
python gpt_sampling_demo.py

第一个实验需要 PyTorch;本次复核使用 Python 3.9.6、PyTorch 2.2.2、CPU float32。 运行后在脚本旁生成两份同名 JSON。图表显示值经过舍入,实验与两个演示使用未舍入的 logits;只有区间手算和十万次固定分布抽取采用明确列出的四位概率。

本次核对包括:GPT-2 的五个 ID 与文本往返、输入查表相加、因果 mask、注意力按行归一化、输出矩阵乘法、交叉熵和梯度、 温度后的概率和与排序、top-k / top-p 的候选集合,以及固定种子的十万次抽取。源码对照为 GPT-2 9b63575 与 nanoGPT 3adf61e。

最后把循环接回去

已有 ID 序列
→ 模型:最后位置的 logits
→ 贪心;或温度 → 筛选 → 归一化 → 抽取一个 ID
→ 追加到上下文
→ 检查结束标记或长度上限;继续时重新预测

工程实现可以组合温度、top-k、top-p,但执行顺序属于算法的一部分:后一步读取前一步处理过的分布。 例如先改变温度,top-p 所需的候选数量也可能改变。这里把两种筛选分别演示,便于看清每条规则。

需要显示文字时交给 Tokenizer 解码。单个 Token 未必包含完整字符,因此流式显示也要正确处理未凑齐的字节。 生成循环的结束由结束标记、长度上限等规则控制,与 BPE 编码时“没有可合并对”是两个层次。

输入查表回答“这些 ID 对应哪些向量”;模型计算回答“在这个上下文下,各候选得多少分”;生成策略再把这张分数表变成这一步实际追加的一个 ID。