全程拆解A|一次输入怎样变成三个输出Token
这不是把“Tokenizer→Transformer→输出”再画一遍。我们同时追踪四条线:请求对象怎样变、张量怎样变、缓存里有什么、程序下一步根据什么继续。最后把计算对应到包内能运行的代码。
实验边界:以下微型Decoder只有两层、宽度4、单头、词表8,使用固定合成权重和绝对位置项,未训练,不具备语言理解能力;它用于验证前向、位置、因果mask和缓存。产品消息模板、真实Tokenizer、GPU内核不在这个实验中实现。概念依据见模型、缓存与工具接口来源。[TR][CHAT][CACHE][TOOLS]
本章目录
沿着对象与状态理解本章
一次预测完成只表示刚选出一个新Token。该Token还没经过自身的各层计算;继续生成时它才作为新输入。下图给出固定小模型的真实中间结果,并明确标出EOS如何终止循环,与最大长度截断分开。
下方图解的固定输入在“输入与适用条件”中声明。它用于解释本章机制,不与本章其他独立例子的数值暗中接续。
一次输入如何连着预测,直到结束
预测到的Token什么时候显示,什么时候成为下一轮输入?
生成像接一条未完的序列,但不是把整句想好再逐字揭幕。每一轮使用已有前缀产生下一项分布,选择一个Token,把它加入已生成序列,再进行下一轮。使用KV缓存时,程序只把尚未处理的新Token送入模型;历史并没有被丢掉,而是通过每层缓存参与计算。
输入与适用条件:独立、可复算的微型模型:7项教学词表、4维表示、单层单头因果Attention、残差与tanh前馈;省略归一化和位置编码。输出头只用三条指定前缀拟合,目的是演示“学习→!→EOS”,不具有通用语言能力。完整参数与中间值随包提供。
1 / 10 · 输入是“我爱”,还没有输出
窄屏可左右滑动图形,或点击“放大图形”。文字解释紧随图后。
本例教学分词得到[1,2]。这是一台极小的因果模型;下面的候选分数由程序计算,不是商业模型实测。
输入=[1,2];缓存长度=0默认静止。用“下一步”看完解释再继续;播放到最后一步自动停止。键盘聚焦播放器后,可用左右键逐步查看、空格播放/暂停。图中颜色辅助区分,含义同时用文字注明。
为什么需要
先区分已处理、已选出、待处理三种状态,才能正确实现生成与缓存。
怎样实现
循环中先前向、再选Token、判停止;未停止时只追加新Token作为下一次输入。
可能的代价与误解
把刚选出的Token算进缓存长度,会造成位置偏移、重复追加或漏算;更改模型参数还会让缓存失效。
如何判断是否做对
本图实际算出三轮选择[3,5,6],缓存长度[2,3,4];每轮与从头计算整个前缀的结果比较。
A.1|用户界面的一句话,不是模型收到的全部输入
假设用户说“解释函数foo”。前端还持有选中的函数文本;宿主加载仓库规则和可用工具;它们构成消息对象。此时对象可能是字符串、结构化内容块、图片引用或工具定义,不是神经网络张量。
用户输入:“解释函数foo”
前端附件:选中源码、文件位置
宿主配置:系统规则、仓库指引、工具列表
会话状态:此前消息、当前任务进展
↓ 上下文选择与序列化
模型可接收的消息与工具结构
↓ 模型模板与Tokenizer
Token ID序列、位置、注意力约束
谁负责拿源码?可能是前端附件、宿主自动检索,也可能是模型先调用工具。不能从用户最终看到的回答倒推“它一定读了整个仓库”。观察实际请求清单与工具轨迹才能确认。
模板负责把角色与内容转成模型训练时能处理的形式。字符串里写“system:”并不天然等于真正的系统角色。真实产品还可能用专门的控制Token或结构化通道。本实验从已编码的三个ID[1,2,3]开始,不虚构它们与某个中文短语的真实对应。
A.2|拿到ID之后,第一份浮点对象是什么
词嵌入矩阵E形状为[8,4],三个ID取出三行,得到[3,4]。在一般batch记法中是[1,3,4],程序省略了恒为1的batch轴。
本实验给每个位置加一个固定位置项:第p个位置第j维加0.1·sin((p+1)/(j+1))。这只是便于检查位置偏移的教学选择,不是RoPE,也不是在声称现代LLM都把正弦直接加到embedding。
因此第一个张量H含两类信息:Token身份和位置。ID本身不是语义浮点向量;embedding参数决定ID怎样投影成可计算表示。推理时这里是查表,不是临时调用另一个Embedding检索服务。
此时有三个必须分开的数:词表8说明有8种Token;序列3说明本次有3个位置;宽度4说明每个位置有4个特征。把序列长度误当隐藏维度,会直接写错矩阵乘法。
A.3|进入第0层:先归一化,再生成Q/K/V
本实验使用pre-LN。对每个位置单独计算均值、方差并归一化:
均值 μ = (x₁+x₂+x₃+x₄)/4
方差 σ² = Σ(xⱼ−μ)²/4
LN(x)ⱼ = (xⱼ−μ)/sqrt(σ²+ε)
这里只为简洁省略可学习缩放和偏移,不能据此推断完整LayerNorm没有这些参数。归一化跨的是特征维,不是三个Token一起归一化;否则新增Token就可能改变旧位置数值,破坏后面缓存证明的前提。
归一化后的每行分别乘Wq,Wk,Wv,三者在本实验都是[4,4]。得到Q、K、V各[3,4]。它们由同一输入生成,但使用不同权重,因此不是同一份向量换个名字。
Q回答“当前查询要匹配什么方向”,K提供被匹配方向,V提供聚合内容。这是功能解释,不意味着维度天然对应人类可读概念。训练会共同调整这些投影,使最终预测目标更好。
A.4|怎样确保第1个位置看不到第3个位置
三个位置的分数矩阵是QKᵀ/√4,形状[3,3]。第i行是第i个查询对所有Key的分数。因果mask是:
位置0:[允许,禁止,禁止]
位置1:[允许,允许,禁止]
位置2:[允许,允许,允许]
被禁止位置在softmax前视为负无穷,概率为零;不是先对全部位置softmax再随便清零,否则剩余概率未必归一化,且如果其他路径使用了未来信息仍会泄漏。
训练中可以同时计算三个位置,因为所有输入已给定,mask负责限制依赖。生成中没有未来Token,所以要逐步获取它们。并行计算位置与允许访问未来,是两个不同问题。
mask之外还可能有padding限制。一个左填充batch中,逻辑位置和数组下标不一定相同;不能只用数组长度当位置。实验没有padding,从而把因果和缓存问题单独隔离出来。
A.5|一行注意力到底做了什么:完整手算
为了看清数字,暂用更小的二维局部例子,和四维Decoder区分开:
当前Q = [1,1]
历史K = [[1,0], [0,1], [1,1]]
历史V = [[1,2], [3,4], [4,6]]
点积是1、1、2,除以√2后约0.707107、0.707107、1.414214。为了稳定计算,统一减去最大值,指数变成exp(−0.707107),exp(−0.707107),1。归一化得到:
a = [0.24825508, 0.24825508, 0.50348984]
输出第一维为0.24825508×1+0.24825508×3+0.50348984×4≈3.00697968;第二维为0.24825508×2+0.24825508×4+0.50348984×6≈4.51046952。包内数值程序给出完整浮点结果。
这里输出的是特征向量,不是Token,也不是“选中了第三个词”。虽然第三个权重最大,前两个仍有贡献;向量随后还会经过输出投影与后续网络。这个例子解释了为什么单看注意力热度不能完整解释最终答案。
A.6|注意力之后还发生什么,为什么不能直接输出文字
回到四维Decoder:每个位置的聚合向量乘Wo,得到宽度4,再与该层原输入做残差相加。随后第二次归一化,经过4→8→4的前馈网络,本实验用ReLU,最后再加残差。实际模型可能用SwiGLU等其他结构。
H0
↓ LN → QKV → attention → Wo
+ H0
= R0
↓ LN → Wup → ReLU → Wdown
+ R0
= H1
第1层接收H1,执行同样的结构,但使用另一组权重。第1层K/V不是第0层K/V的复制品;它们来自更深的上下文化表示,所以每层缓存都必须分别保存。
FFN逐位置作用,不在此处直接混合不同Token,但它会变换已经聚合的特征。把“注意力负责理解、FFN只是无关附属”当成答案是不准确的。最终能力来自整个网络共同训练。
A.7|第一个输出Token从哪里来
两层结束后,最终归一化表示乘语言模型输出矩阵[4,8],得到每个位置的8个logits。三个输入位置得到[3,8],其中最后一行用于预测紧接输入的第一个输出Token。
包内实际运行的最后一行约为:
[-0.052135, -0.102202, -0.101966, -0.051542,
0.024250, 0.088107, 0.108598, 0.075637]
选择贪心解码时,最大值位于ID6,所以输出第一个Token是6。这里不需要先进行一次“空decode”才能拿到第一个Token:Prefill已产生最后位置的预测分布。
采用采样时还要按温度、top-k/top-p等规则处理分布。温度改变分布,不改变已经算出的历史K/V。若Token对应文本字节片段,显示层还要正确处理多字节字符和控制Token,不能把每个Token都当作一个完整汉字。
此刻缓存长度仍是3,不是4。 网络刚处理的是输入[1,2,3];ID6是由输出分布选择的结果,还没有经过各层计算,所以尚无它自己的K/V。
A.8|第二个Token:只输入刚才的ID6
下一步把ID6送回模型,逻辑位置为3。查embedding并加位置项后,得到[1,4]。第0层只对这个位置计算新Q/K/V,把新K/V追加到已有3行后,缓存变成[4,4]。
新Q形状[1,4],乘历史加当前的K转置[4,4],分数是[1,4],不是再次生成整个[4,4]查询分数矩阵。随后softmax、V聚合、残差、FFN,进入第1层。第1层用自己的历史缓存重复这个过程。
实际新logits约为:
[0.232867, -0.009785, -0.247622, -0.363579,
-0.300585, -0.089644, 0.165419, 0.339064]
贪心会选择ID7作为第二个输出。此刻缓存长度4,覆盖[1,2,3,6],不包括刚选出的7。要预测第三个输出,再送入ID7,位置4,各层缓存增长到5。实验可以直接继续这一步;这里强调时间线,而不是把这些无语言意义的ID包装成真实答案。
A.9|完整时间线:序列、缓存与输出错一位会怎样
| 时点 | 本轮实际送入 | 本轮结束缓存覆盖 | 本轮选出的输出 |
|---|---|---|---|
| Prefill | 1,2,3 | 1,2,3 | 6 |
| Decode 1 | 6 | 1,2,3,6 | 7 |
| Decode 2 | 7 | 1,2,3,6,7 | 下一输出ID |
若调用者把整段[1,2,3,6]和旧缓存一起送入一个只接受新Token的接口,就可能重复追加前缀;若把位置重置为0,位置编码错误;若生成EOS后还盲目执行工具,则把语言停止和执行状态混淆。
框架有时会自动裁剪已缓存输入,但这属于接口约定。自己实现时要明确接受的是“全输入”还是“增量后缀”,不要依赖猜测。缓存对象还应绑定模型/adapter身份,否则换权重后沿用旧状态会得到不一致结果。
A.10|一次处理两个新Token时,mask为什么不是普通小三角
已有P=3个缓存,新增C=2个Token。Q是[2,4],K是[5,4],分数[2,5]。正确mask:
新位置3:[1,1,1,1,0]
新位置4:[1,1,1,1,1]
直接生成tril(ones(2,5))却得到[1,0,0,0,0]与[1,1,0,0,0],新查询看不到大部分历史和自身。这个Bug不会总是报维度错误,而是悄悄改变输出,非常适合面试追问“代码能跑为何不对”。
正确条件是key_position ≤ past_length + query_index,再合并padding或滑动窗口约束。包内测试同时比较全量前向与单Token/双Token增量前向,并故意用错误位置产生差异,证明测试能发现特定错误,而不只证明程序不崩溃。
A.11|如何证明缓存实现正确,而不是“生成的句子看起来正常”
固定权重、关闭随机dropout、使用相同Token序列,分别运行全量前向与分段缓存前向,比较对应位置logits,设置合理浮点容差。再覆盖不同分段长度、位置偏移和前缀编辑。
若不相等,按顺序检查:输入是否重复、位置是否偏移、mask是否对齐、每层缓存是否正确、K是否在正确阶段应用位置变换、batch/padding是否混淆。不要先调采样温度,因为错误在分布生成之前。
本包测试还使用有限差分检查注意力对Q的解析梯度。这验证一个局部反向公式,不代表完整训练正确。把验证范围写清楚很重要:前向一致性、梯度检查、训练收敛、任务泛化是四种不同证据。
A.12|把这个例子迁移到真实模型时,必须补上什么
真实系统需要更大的参数、多个头或GQA/MLA、真实Tokenizer、训练过的权重、模型对应的归一化和激活、正确位置方案、批处理、精度与设备内核。服务还要管理请求取消、KV分页、前缀共享和资源限制。
但诊断方法不变:先明确接口契约和每一步对象,再做可对照的小规模验证,最后测性能。不能因为某技术叫FlashAttention就省略数值检查,也不能因为KV公式算得对就断言显存足够容纳所有并发。
本例的回答标准是:能够不看图,把从消息到ID、从ID到各层张量、从最后logits到采样、从新Token到增量缓存逐步讲出来,并说明每一处“已计算”和“尚未计算”的边界。