朋友明天过生日,你让助手写一句祝福。从你按下回车,到屏幕上出现那行字,中间发生了什么?
0. Transformer 执行链路
六个环节各管一段,前后不能换顺序:
| 环节 | 谁在做 | 干什么 |
|---|---|---|
| 渲染 | chat template | 把结构化消息拼成一条带标记的串 |
| 分词 | tokenizer | 把串切成 131 个 token,换成 id |
| 备料 | 张量准备 | 配齐 input_ids / attention_mask / position_ids |
| 前向 | 模型本体 | 24 层解码器,把 id 加工成下一个词的打分 |
| 采样 | 采样器 | 从 151936 个分数里挑出一个 id |
| 收尾 | 判停 + 解码 | 判断停不停,把 id 还原成文字 |
前向只走一次,采样要反复走。真正循环的只有四步:前向取分数、采样出 token、拼到序列末尾、判断要不要停。
这六步是按一条请求的视角排的。线上服务里还有一层不在这条链上、却决定吞吐的角色:调度器。它决定这一刻哪几条请求一起进模型,第 8 节讲循环时再说它。
例子用一条消息起步,走两轮对话:
user:① 角色 你是一位中文写作助手。
② 背景 好朋友明天过生日,他爱打篮球,平时爱开玩笑。
③ 任务 帮我写一句生日祝福,我要发在微信上。
④ 约束 不超过 30 字;口语一点,别太正式。
⑤ 示例 输入:同事升职 → 输出:恭喜高升,改天请你吃饭!
⑥ 格式 一句话。
assistant:生日快乐,愿你篮球巨星梦圆!
user:再加一句祝他投篮越来越准。
assistant:生日快乐,愿你篮球天赋日新月异!
这次实跑的环境和参数:
| 项 | 值 |
|---|---|
| 模型 | Qwen2.5-0.5B-Instruct |
| 权重精度 | float32 |
| 批大小 | 1 条请求 |
| 输入长度 | 131 个 token |
| 采样 | temperature 0.7、top_k 20、top_p 0.9、repetition_penalty 1.1 |
| 输出上限 | 64 个新 token |
| 运行方式 | transformers,单进程,不走批处理 |
后面出现的数字,包括 token 数、范数、概率、缓存大小,都来自这一次运行。换模型、换精度、换采样参数,数字会变,流程不变。
1. 渲染
输入:两条结构化消息。 输出:一条纯文本串,还没变成数字。
对话模板(chat template)负责这一步。规则很直白:每条消息用 <|im_start|>角色 开头,用 <|im_end|> 收尾,中间隔一个换行放内容,整条消息后面再补一个换行。最后额外加 <|im_start|>assistant\n,停在末尾,给模型留出该它说话的位置。
<|im_start|>system
你是一个乐于助人的中文助手。<|im_end|>
<|im_start|>user
① 角色 你是一位中文写作助手。
② 背景 好朋友明天过生日,他爱打篮球,平时爱开玩笑。
③ 任务 帮我写一句生日祝福,我要发在微信上。
④ 约束 不超过 30 字;口语一点,别太正式。
⑤ 示例 输入:同事升职 → 输出:恭喜高升,改天请你吃饭!
⑥ 格式 一句话。<|im_end|>
<|im_start|>assistant
渲染和分词是同一个 tokenizer 对象上的两个方法。apply_chat_template() 只拼串,全程不查表;tokenizer() 才查表。顺序不能倒:模板吐出的串,就是分词的输入。
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
msgs = [
{"role": "system", "content": "你是一个乐于助人的中文助手。"},
{"role": "user", "content": "① 角色 你是一位中文写作助手。……"},
]
text = tok.apply_chat_template(
msgs, tokenize=False, add_generation_prompt=True
)
print(repr(text))
1.1 system 和 user 差在哪
模型眼里没有 system 字段这个概念。渲染完之后,两段就是同一条 id 序列里前后相连的两截文字。
它能区分两者,靠的是训练分布:system 段几乎都是「你是……助手」这类全局设定,模型见得多,就把它当整场对话的基调;user 段内容五花八门,默认理解是这一轮要我干什么。
三处实际差别:
| system | user | |
|---|---|---|
| 位置 | 固定在整串最前面 | 夹在任务描述里,离生成点更近 |
| 训练分布 | 全局设定为主 | 本轮任务为主 |
| 生效范围 | 跨轮延续 | 只这一轮 |
两者冲突时没有硬性优先级。user 离生成位置近、指令感强,明确要求覆盖时通常生效;system 里写身份底线、安全边界这类内容,训练中反复强化过,一般不会被单轮顶掉。
落到实操:全局的角色、语气、边界放 system,这一轮的任务、约束、示例放 user。
2. 分词
输入:那条串。 输出:131 个整数。
分词做两件事:先切,把整串切成一段段子词;再查,每段去词表换一个编号。
2.1 中文按字节切,不按字切
直觉上中文按词或按字切。这台模型用的是字节对编码(BPE,Byte Pair Encoding)的字节级版本:先把整串按 UTF-8 转成字节,再按训练学到的合并规则,把常挨在一起的字节并成一个 token。合并规则一共 151387 条。
所以一个 token 对应几个字没有定数。下面是 user 消息开头 13 个 token 的真实切分:
| 序 | 词表写法 | 解码出来 | id |
|---|---|---|---|
| 1 | âij | 半个 ① | 48312 |
| 2 | ł | 半个 ① | 254 |
| 3 | Ġè§ | 空格 + 半个「角」 | 54955 |
| 4 | Ĵ | 半个「角」 | 240 |
| 5 | èī² | 色 | 38035 |
| 6 | Ġ | 一个空格 | 220 |
| 7 | Ġ | 一个空格 | 220 |
| 8 | ä½ł | 你 | 56568 |
| 9 | æĺ¯ä¸Ģä½į | 是一位 | 109182 |
| 10 | ä¸Ńæĸĩ | 中文 | 104811 |
| 11 | åĨĻä½ľ | 写作 | 105293 |
| 12 | åĬ©æīĭ | 助手 | 110498 |
| 13 | ãĢĤĊ | 。+ 换行 | 8997 |
13 个 token 装下 18 个字符,规律有四处:
- 「①」占 2 个 token。它是符号,UTF-8 三个字节,被切成前两字节一段、最后一字节一段。
- 「角」跨 2 个 token,而且第 3 行把前面的空格也捎上了。合并按哪几个字节常挨着定,不按字符边界定。
- 「是一位」三个字只占 1 个 token。高频词组被整体合并,比按字切更省。
- 「。」跟换行合成 1 个 token(8997)。单独出现时是另一个 id(1773)。
自己打印一遍,看到的会是同一组数字:
ids = tok(text, return_tensors="pt")["input_ids"][0].tolist()
print(len(ids)) # 131
for i, tid in enumerate(ids[:13], 1):
raw = tok.convert_ids_to_tokens(tid)
print(i, tid, repr(raw), repr(tok.decode([tid])))
2.2 词表与特殊标记
词表规模有三个数字,容易对不上:
| 数字 | 含义 |
|---|---|
| 151643 | 分词器基础条目数 |
| 151665 | 加上 22 个特殊标记后的可用 id 总数 |
| 151936 | 嵌入表与 LM head 实际分配的行数 |
第三个比第二个大,多出来的是对齐硬件留的余量,分词器用不到。
几个关键标记:
| 标记 | id | 作用 | ||
|---|---|---|---|---|
| `< | im_start | >` | 151644 | 消息开头,后面紧跟角色名 |
| `< | im_end | >` | 151645 | 消息结尾,也是这一轮的停止信号 |
| `< | endoftext | >` | 151643 | 预训练语料的文档分隔符 |
| assistant | 77091 | 角色名,普通 token 不是特殊标记 |
最后一行要注意:assistant 只是普通词,因为训练时总出现在那个位置,模型才把它当成该我说话了的信号。
2.3 131 的构成
131 由四段拼出来:
| 段 | token 数 | 内容 |
|---|---|---|
| system 消息 | 14 | 起手标记 + system + 内容 + 结尾 + 换行 |
| user 开头 | 3 | 起手标记 + user + 换行 |
| user 正文 | 109 | ① 到 ⑥ 六段,含换行和空格 |
| 收尾 | 5 | 结尾标记 + 换行 + 起手标记 + assistant + 换行 |
| 合计 | 131 |
末尾四个 id 是 198、151644、77091、198,对应换行、起手标记、assistant、换行。模型从这四个之后接着往下写。
3. 备料:三张张量
输入:131 个 id。 输出:三个形状都是 (1, 131) 的张量。
只有 id,模型开不了工。注意力算分要知道哪些位置是真实内容、哪些是补齐;旋转位置编码(RoPE,Rotary Position Embedding)算角度要知道每个位置排第几。这两件事都不在 id 里。
| 名字 | 形状 | 这次的值 | 干什么 |
|---|---|---|---|
| input_ids | (1, 131) 整数 | 131 个 id | 原料 |
| attention_mask | (1, 131) | 全 1,和为 131 | 标出真实位置 |
| position_ids | (1, 131) | 0 到 130 | RoPE 的角度依据 |
前两个由 tokenizer 直接返回,第三个不返回。单条、没有补齐时,位置号就是 0 到 130,传不传结果一样;批量推理时不能省。
enc = tokenizer(text, return_tensors="pt")
enc["input_ids"] # (1, 131)
enc["attention_mask"] # (1, 131),全 1
# position_ids 不在里面,要自己算
attention_mask 这次看着没用,131 个位置全是真实内容,所以全 1。它是为批处理准备的:一批里长短不齐,短的补到等长,补出来的位置填 0。
两条文本:["人工智能", "深度学习很有趣"],左补齐到 4 个位置
input_ids = [[151643, 151643, 151643, 104455],
[102217, 100134, 99165, 103027]]
attention_mask = [[0, 0, 0, 1],
[1, 1, 1, 1]]
第一句只有 1 个真 token,后面 3 个 151643 是补齐。补了位置却不给掩码,注意力会去关注那三个空位,把真实信息的权重稀释掉。每补一个位置,就要在掩码里记一个 0。这张掩码最后会加到注意力分数表上,第 5 节能看到它落在哪一格。
位置号同理,错得更隐蔽。左补齐时最前面几列是 pad。直接把列号 0、1、2、3 当位置号,第一条样本里那个唯一的真 token「人工智能」就会被当成位置 3。RoPE 按位置号算旋转角,位置错了角度就错,模型读到的就是另一句话。
正确的算法是从掩码累加:每个位置前面有多少个真 token,它就是第几位。
import torch
ids = torch.tensor([[151643, 151643, 151643, 104455],
[102217, 100134, 99165, 103027]])
mask = torch.tensor([[0, 0, 0, 1],
[1, 1, 1, 1]])
by_column = torch.arange(ids.shape[1]).unsqueeze(0).expand_as(ids)
by_mask = (mask.cumsum(-1) - 1).clamp(min=0)
print(by_column.tolist()) # [[0, 1, 2, 3], [0, 1, 2, 3]]
print(by_mask.tolist()) # [[0, 0, 0, 0], [0, 1, 2, 3]]
这是左补齐的两条样本。按列号算,第一条里那个真 token 的位置号是 3;按掩码算,它回到 0。pad 位填什么无所谓,模型不会读它,惯例填 1。
这台模型的实现里,不传 position_ids 时用的是 arange(seq_len) + past_len,也就是按列号。所以批量推理要自己算好传进去,默认值只对没有补齐的单条输入成立。
3.1 prefill 与 decode
同一套结构,两种节奏:
- prefill(预填充):整条 131 个位置一次性喂进去,一次前向算完。这是第一轮。
- decode(逐 token 解码):之后每次只喂新采出来的那 1 个 token,位置号接着排。
| prefill | decode 第一步 | |
|---|---|---|
| input_ids | (1, 131) | (1, 1) |
| attention_mask | (1, 131) | (1, 132) |
| position_ids | 0…130 | 单值 131 |
| 输出 logits | (1, 131, 151936) | (1, 1, 151936) |
| 第 0 层 KV | (1, 2, 131, 64) | (1, 2, 132, 64) |
prefill 的 logits 有 131 行,只有最后一行用得上,前面每个位置要预测的词早就已知了。
3.2 TTFT 与 TPOT
这两种节奏对应两个延迟指标,混在一起看会误判。
| 指标 | 从哪到哪 | 由什么决定 | 瓶颈 |
|---|---|---|---|
| TTFT | 请求发出 → 第一个字出现 | 排队时间 + prefill | 算力 |
| TPOT | 第一个字之后,相邻两个 token 的间隔 | decode 每步的前向 | 显存带宽 |
TTFT(Time To First Token,首 token 延迟)里,131 个位置要全部过一遍 24 层,前面还可能排着队。TPOT(Time Per Output Token,每 token 输出时间)里,一次前向只算一个位置,却要把全部权重和缓存读一遍。
prefill 是算力密集,能靠并行和更大的 batch 摊薄;decode 是访存密集,卡在把几百 MB 缓存从显存搬到计算单元这条路上。
用户说半天不出字,是 TTFT 问题;说一个字一个字蹦,是 TPOT 问题,处理方向完全不同。
4. 24 层解码器的骨架
前 3 节把文字变成了三张张量,这一节看模型本身。
4.1 三种裁法与硬参数
Transformer 有三种常见裁法:编码器加解码器(翻译)、只留编码器(BERT,双向、不生成)、只留解码器(GPT 系,Qwen2.5 属于这一路)。
Qwen2.5-0.5B 的硬参数:
| 项 | 值 |
|---|---|
| 层数 | 24 |
| 隐藏维度 C | 896 |
| 注意力头 | 14 个 Q 头,2 个 KV 头 |
| 每头维度 | 64 |
| FFN 中间维 | 4864 |
| 词表 | 151936 |
| 总参数 | 约 4.94 亿 |
它没有编码器,没有交叉注意力,输入直接进解码器栈。提示词和回答走同一条栈,区别只在提示词一次整条进(prefill),回答一个 token 一个 token 进(decode)。保证生成时不偷看后面的,是每层注意力里的因果掩码。
记住上文靠的是每层算出的 K、V 缓存,第 8 节细讲。
4.2 嵌入:id 换成向量
输入:(1, 131) 的 id。 输出:(1, 131, 896) 的向量。
做法是拿 id 去一张 151936 行 × 896 列的表里取行。这一步没有计算,只有搬运。
第一个 token 的嵌入向量范数是 0.3089。这个数字本身不重要,重要的是它的位置:它是后面 24 层反复改写的起点。语义不在这里定,是 24 层加工出来的。
还有一处省钱的设计:输出头(LM head,Language Model head)与嵌入表共用同一份权重,叫权重绑定(tied embeddings)。出口要把 896 维投到 151936 维,本来需要一份 1.36 亿参数的矩阵,共享之后省下整整一份。输入输出本来就是同一张词表,共用既省钱通常也更稳。
id 变成向量了,但这串向量对顺序一无所知。同一批向量换个顺序,模型看到的完全一样。
4.3 RoPE:位置旋进 Q、K
输入:每个头 64 维的 Q、K,以及每个位置的位置号。 输出:旋过角的 Q、K,形状不变。
补位置有几种做法:把位置编码加到嵌入上、学一张相对位置偏置表、或者像这台模型一样,把位置直接旋进 Q 和 K。
131 个位置各有编号 0 到 130,每个编号配一组旋转角,角的形状是 (1, 131, 64),正好等于单个注意力头的维度。Q、K 投出来之后,每一对维度按这个角旋转一次,然后才去算分数。
θ_k = pos / base^(2k/d) k = 0 … d/2-1,d = 64,base = 1000000
x' = x·cosθ - y·sinθ
y' = x·sinθ + y·cosθ
d 是头维度 64,k 取 0 到 31,θ 从 1 一路降到 1000000^(-62/64)。base 越大,相邻位置的角差越小,长上下文里位置分得越细。
好处体现在分数上:位置 i 和 j 的匹配度取决于两者旋转角之差,也就是位置差。位置差相同的一对 token,不管出现在句子哪个位置,相对相位都一样。
RoPE 没有可学参数,角度是现算的,不存在某个位置在训练语料里少见就学不好的问题。这一点在长上下文里很关键。
作用对象要说清:它不改那 896 维的隐藏向量,隐藏向量原样往下一层走。它旋的是注意力内部的 Q 和 K,在算分数之前那一步。位置信息不是加到表示上,是进打分公式时带上。
5. 一层解码器:十二道工序
输入:(1, 131, 896)。 输出:(1, 131, 896)。形状不变,内容全换了一遍。
这一层是个 Block,24 层各有自己的一份参数,结构完全一样。下面按顺序过一遍,编号和图上的 ① 到 ⑫ 对应。
5.1 入口的 RMSNorm
输入:(1, 131, 896)。 输出:同形状,每个位置的范数被拉到统一尺度。
把每个 token 的 896 维向量按自身均方根缩放到统一尺度,再乘一组可学的缩放系数。公式是:
y = x / sqrt(mean(x^2) + eps) * g eps = 1e-6,g 是可学的 896 维向量
手工按这个公式复算一遍,与模型输出最大差 2.38e-07,纯浮点误差。
import torch
def rms_norm(x, g, eps=1e-6):
rms = x.pow(2).mean(-1, keepdim=True).add(eps).sqrt()
return x / rms * g
为什么必须做:后面要算点积,向量一大分数就爆,softmax 会被推到极端。
这里有两个名字都叫归一化的操作,管的东西完全不同:
| 作用对象 | 在哪 | |
|---|---|---|
| RMSNorm | 896 维的表示向量 | 一层里出现两次:进注意力前、进 FFN 前 |
| softmax | 注意力算出的分数表 | 注意力内部,打分之后 |
也有把归一化放在子层之后的布局,叫 Post-Norm。这台模型放在子层之前,叫 Pre-Norm。为什么这么挪,看范数就明白:第一个位置的向量进层时 0.3089,出层 10.07,一层之内就是三十倍的量级。24 层堆下去,不在子层入口放一道归一化必然失控。
5.2 Q/K/V 投影与 GQA
输入:归一后的 (1, 131, 896)。 输出:Q (1, 131, 896),K (1, 131, 128),V (1, 131, 128)。
归一之后分三路投影:
- q_proj 投出 (1, 131, 896),拆成 14 个头,每头 64 维
- k_proj、v_proj 各投出 (1, 131, 128),只有 2 个头,每头 64 维
128 是重点:14 个 Q 头配 2 个 K/V 头,这叫分组查询注意力(GQA,Grouped Query Attention)。它省的是缓存和带宽,K、V 只需存 2 个头而不是 14 个,少 7 倍;计算时这 2 组 K/V 复制给 14 个 Q 头共用。
表达力有损失,实测性价比很高,现在的开源模型普遍这么配。
投出来的 Q、K 还要先过 RoPE,旋完角才算分数。所以 RoPE 不在主链上,它嵌在注意力内部。
5.3 注意力五步
输入:Q (1, 14, 131, 64),K (1, 2, 131, 64),V (1, 2, 131, 64)。 输出:每个头 (131, 64) 的新表示,14 个头共 (1, 14, 131, 64)。
一个头的完整流程,顺序不能乱。
① 打分。 Q 和 K 做点积,Q (131, 64) 乘 K 转置 (64, 131),得到 131 × 131 的分数表。第 i 行第 j 列表示位置 i 该给位置 j 多少注意力,每一格是 64 次乘加。
② 缩放。 除以 √64,也就是 8。维度越大点积越大,不压一下 softmax 会被推到几乎只有一个 1。
③ 加掩码。 到这一步手里是一张 131 × 131 的分数表,往上加两张加性掩码,目标都是负无穷。
一张是因果掩码,管行:把每行里列号大于行号的格子置成负无穷。效果在权重表里直接可见:第一个位置对自己的注意力权重是 1.0000。位置 0 的未来全被挡住,softmax 之后只能把全部权重留给自己。序列第一个 token 谁也看不见,这就是只能从左往右看的实现方式。
另一张是补齐掩码,管列,也就是备料那一步交出来的 attention_mask 在链上的落点。哪一列是补出来的空位,这一列就在所有行上置负无穷。不管哪个 token 来算注意力,权重都分不到假内容上。单条、没有补齐时这张掩码全 1,加进去等于没加。批处理里不能省,尤其是左补齐:补位在真内容前面,因果掩码挡不住它们,只能靠这张掩码。
两张掩码叠在同一张分数表上,先后无所谓,都是把不该看的格子压到负无穷,再一起过 softmax。
④ softmax。 把每行分数变成相加为 1 的权重。
⑤ 加权 V。 拿权重对各位置的 V 加权求和,得到 (131, 64)。注意力真正干活的地方在这里:它不改单个位置的维度,只把别的位置的信息按权重搬过来。位置 i 的新表示里混进了它认为相关的内容。
14 个头各跑一遍,每个头得到 (1, 14, 131, 64) 里的一片。用代码写出来是这几行:
q = q.view(1, 131, 14, 64).transpose(1, 2) # (1, 14, 131, 64)
k = k.view(1, 131, 2, 64).transpose(1, 2) # (1, 2, 131, 64)
v = v.view(1, 131, 2, 64).transpose(1, 2)
k = k.repeat_interleave(7, dim=1) # 2 组 K 复制给 14 个头
v = v.repeat_interleave(7, dim=1)
scores = q @ k.transpose(-1, -2) / 8 # (1, 14, 131, 131)
scores = scores + causal_mask + pad_mask # 加性掩码,被挡的位置是 -inf
w = scores.softmax(-1)
out = w @ v # (1, 14, 131, 64)
5.4 拼头、o_proj、残差①
输入:(1, 14, 131, 64)。 输出:(1, 131, 896)。
14 个头拼回 896 维,再过一道 o_proj。输出 (1, 131, 896),和进来时一样。
注意力的输出只是附加信息,还没决定用多少。决定这件事的是残差。
第一次残差:把这一层的输入原样加上注意力的输出。
这条旁路让梯度有一条不衰减的通路,同时保证就算注意力什么有用的都没搬来,也保住原来那份表示。残差不是装饰,它让每个子层只负责改动量,不负责全量重建。
5.5 FFN 前再归一、SwiGLU、残差②
进前馈网络(FFN,Feed-Forward Network)前再归一一次,和入口那道是同一件事。
为什么要两道,而不是一道管到底:残差主干上的范数是累加的,走到这里已经比入口大了几十倍,而 FFN 那两个矩阵的输入尺度是在固定范围里训出来的。每进一个子层就重新拉一次,两个子层才不会互相牵制。
SwiGLU 是 FFN 的具体形式,三个矩阵分工明确:
- gate_proj 和 up_proj 各把 896 维投到 4864 维,得到两份 (1, 131, 4864)
- silu 作用在 gate 那份上,再与 up 那份逐元素相乘,一路当内容、一路当闸门
- down_proj 把 4864 维投回 896 维
gate = silu(x @ W_gate) # (1, 131, 4864)
up = x @ W_up # (1, 131, 4864)
h = gate * up
out = h @ W_down # (1, 131, 896)
silu(x) = x * sigmoid(x)
第二次残差:FFN 输出再加回主干,这一层结束。
一层整块的范数从入口 5.50 变成出口 65.93,位置 0 那个向量从 0.3089 变成 10.07。一层干的活,在数值上直接看得见。
两个子层的分工可以说得很短:注意力在位置之间搬信息,FFN 在单个位置内部加工内容。前者决定看谁,后者决定把看到的东西变成什么。
还有两处容易漏的细节:q、k、v 三路投影带 bias,o_proj 和 FFN 的三个投影都不带;每层算出的 K、V 会同时写进 KV 缓存,供后面的 decode 复用。
5.6 24 层堆叠
输入:(1, 131, 896)。 输出:(1, 131, 896)。
第 1 层的输出进第 2 层,重复 24 次。结构一样,参数各自独立,这是模型靠堆层数涨能力的来源。
挑第一个位置看它在各层出口的向量范数:
| 位置 | 范数 |
|---|---|
| 嵌入后 | 0.3089 |
| 第 0 层出口 | 10.07 |
| 第 5 层 | 1724.4 |
| 第 11 层 | 1728.5 |
| 第 17 层 | 1732.8 |
| 第 23 层 | 82.5 |
中段那些上千的数字不是错误。残差逐层累加会把尺度撑起来,到后几层又被压回去。
24 层合计约 3.58 亿参数,平均每层 1490 万,其中 FFN 占 1307 万(896 × 4864 × 3)。注意力那四路投影加起来约 183 万。
6. 出口:final RMSNorm 与 LM head
输入:最后一层的 (1, 131, 896)。 输出:只取最后一个位置后的 (1, 151936)。
24 层之后还有一道 final RMSNorm,公式和层内那两道一样。
它不是把向量压小。实测第 23 层出口范数 82.5 的那个向量,过完这道反而涨到 260.4。它管的是拉齐:残差累加 24 层下来,各位置范数早就参差不齐,同一层出口位置 0 是 82.5,位置 130 只有 56.3。直接拿去投影,不同位置打出来的分量尺度不一致。过一道 RMSNorm,每个向量按自身均方根重新定标,出去的范数挤进 260 上下的一条窄带。
然后交给 LM head:一个 896 到 151936 的投影,把最后一层向量换成对全词表每个 token 的打分。输出 (1, 131, 151936)。
131 个位置每个都有一份完整打分,但只有最后一个位置用得上,前面那些位置要预测的词早就已知了。外面先从 (1, 131, 151936) 里取最后一行,形状变成 (1, 151936),前面 130 行直接丢掉。
logits(未归一化的分数)不是概率,可正可负、没有上界,大小只在同一行内部有比较意义。要变概率得再来一次 softmax。
最后一个位置的分数前五名:
| 排名 | id | 解码 | logit | softmax 概率 |
|---|---|---|---|---|
| 1 | 105511 | 生日 | 18.19 | 0.6628 |
| 2 | 100549 | 祝 | 16.15 | 0.0864 |
| 3 | 99614 | 朋友 | 14.84 | 0.0232 |
| 4 | 110834 | 亲爱的 | 14.84 | 0.0232 |
| 5 | 100644 | 今天 | 14.69 | 0.0199 |
前五名全指向祝福语的开头,说明这个位置已经锁定在祝福这件事上。第一名 生日 承接背景里明天过生日,第二名 祝 是另一条常见开头。
打分有了,下一个 token 怎么定,这一步不归模型管。
7. 采样
输入:(1, 151936) 的分数。 输出:1 个 token id。
分数进来不是直接抽。transformers 里中间要过一条 LogitsProcessor 链,顺序有讲究:
| 顺序 | 处理 | 本模型设置 | 做什么 |
|---|---|---|---|
| 1 | 重复惩罚 | 1.1 | 已出现过的 token 分数往下压,防复读 |
| 2 | 温度 | 0.7 | 整体除以 0.7,差距被放大,分布变尖 |
| 3 | top-k | 20 | 只留分数最高的 20 个候选 |
| 4 | top-p | 0.9 | 按概率累加到 0.9,其余清零 |
| 5 | 重新归一 + 抽签 | 不适用 | 剩下的重新归一成和为 1,再随机抽一个 |
温度是唯一平滑改变分布形状的参数。除以小于 1 的数(这里是 0.7),分数之间的差距被拉开,高的更高、低的更低,softmax 之后概率往头部集中,分布变尖。除以大于 1 的数,差距被抹平,分布变平。
top-k 和 top-p 也在改熵:硬截断之后重新归一,留下来的候选概率都被抬高,熵一定下降。区别在于它们只做留或删,不改变留下来的候选之间的相对比例;温度是连续地把整条曲线拉高或压平。一个是剪切,一个是缩放。
这次实跑在第一个位置的数据:
| 候选 | 原始概率 | 除以 0.7 后 | top-k 20 | top-p 0.9 |
|---|---|---|---|---|
| 生日 | 0.6628 | 约 0.81 | 保留 | 保留 |
| 祝 | 0.0864 | 0.054 | 保留 | 保留 |
| 朋友 | 0.0232 | 0.0084 | 保留 | 保留 |
| 亲爱的 | 0.0232 | 0.0084 | 保留 | 保留 |
| 今天 | 0.0199 | 0.0067 | 保留 | 保留 |
| 其余 151931 个 | 0.1845 | ≤ 0.16 | 只留 15 个 | 清零 |
原始 softmax 里 生日 占 0.6628,第二名 祝 只有 0.0864。除以 0.7 之后差距被进一步放大,生日 升到约 0.81,祝 降到 0.054,长尾整段被压到 0.16 以下。top-k=20 先把 20 名之后清零,top-p 再从高往低累计,累计到 0.9 时留下大约十个候选,其余清零。
所以这一步不是只剩一个候选,生日 也没有变成 1.0。它占八成,抽中它是大概率事件,但不是必然。这次实跑抽到的就是 生日。
把温度降到 0.3 以下、或者 top-p 设成 0.5,才会真正退化成贪心。参数不同,同一个位置的输出可以完全不同。
分布越尖的位置,top-p 越容易一次切完;分布越平的位置,才真的抽得起来。所以同样设了温度 0.7,在不同位置的实际行为可以差很远。
7.1 采样之前还有一道闸:约束层
上面那条链只管分数怎么变成概率。真实服务里,这条链上还挂着几个处理器,专门改分数,目的是让输出符合格式或长度要求。它们排在采样之前,作用对象是同一份 (1, 151936)。
不允许停。min_new_tokens 的实现很直接:前 N 步把 EOS 的分数改成负无穷。模型这段时间无论多想结束都停不了。它防的是模型一开口就吐个句号,代价是模型真有话说时也会被逼着多写 N 个 token。
不允许说某些词。bad_words_ids、suppress_tokens 把指定 token 的分数压掉,常用于屏蔽敏感词,或者不让模型输出内部标记。
必须符合语法。 结构化输出(JSON、函数参数、SQL)靠的不是模型自觉,是每一步按已生成的内容查一次语法状态机,把所有下一步会破坏语法的 token 分数置成负无穷。模型只能在合法集合里挑,生成出来的东西天然是合法 JSON。工具调用的参数能被稳定解析出来,原因就在这里。
这一层和采样器是两件事:采样器决定在合法候选里怎么挑,约束层决定哪些候选合法。
7.2 参数也能把停止信号挡在门外
反过来,采样参数配错了,模型会想停也停不下来。
看第一步的实测数据:EOS(结束符)的 logit 是 -3.04,在 151936 个候选里排到八万名开外,top-k=20 的候选集合里根本没有它。这在开头是好事。但如果在模型已经该收尾的位置,EOS 恰好排在 21 名,top-k=20 就会把它切掉,模型只能继续编。
top-p 同理。累计概率到 0.9 时如果 EOS 还没轮到,它就被清零了。温度也有份:温度调高,分布被拉平,EOS 的概率被稀释,收尾明显变晚。
表现很好认:finish_reason 是 length,回答末尾多出一段废话,或者干脆把下一轮的 <|im_start|>user 也替你写了。遇到模型不会停,先去看采样参数,不要先怀疑模型。
8. 生成循环
输入:刚采出的 1 个 token。 输出:下一个 token,以及更新后的缓存。
生成是个循环:前向取分数、采样出 token、拼到序列末尾、判断要不要停。没停就回到第一环。
渲染和分词只在每轮开头做一次。循环里反复走的只有后面四步。
8.1 为什么每步只喂一个 token
第一个 token 出来后,序列长度变成 132,但循环里喂给模型的只有刚采出来的那一个,长度维是 1。
能这样做,是因为生成第 132 个 token 时,前 131 个位置的 K、V 早就算过了。这些值只取决于各自位置左边的输入,后面多一个词不会改变它们。既然不变,就不必重算。
这个性质来自因果掩码:注意力对每个位置的输出只依赖它自己和它左边,右边的内容进不来。后面所有缓存优化都建立在这一点上。
位置号这条线也要接上。decode 第一步喂进去的是位置 131 的 token,position_ids 是单值 131,不是从头再数一遍。
它投出的 Q、K 按位置 131 对应的角度旋转,这个 Q 再去和缓存里的 131 个 K 做点积。那些 K 在各自那一步就已经按自己的位置号旋过,旋转角相减,剩下的正好是位置差:新 token 看第 3 个 token,距离是 128。
位置号接错,整串相对相位跟着错位。
掩码在 decode 这一步也跟着变形:分数表从 131 × 131 变成 1 × 132,只有一行,列数等于缓存里的 131 加新来的 1。这一行没有未来可挡,因果掩码全放行;补齐掩码照旧按列压掉补位,一个都不能漏。
8.2 缓存长什么样,多大
模型每层都把自己的 K、V 存下来。prefill 结束时第 0 层的形状是 (1, 2, 131, 64):1 条样本、2 个 KV 头、131 个位置、每头 64 维。24 层各存一份,K 和 V 各一份。
按 float32 算,每多一个 token 占用的显存:
24 层 × 2(K 和 V)× 2 个头 × 64 维 × 4 字节 = 24,576 字节 ≈ 24 KB
实测值:
| 序列长度 | KV Cache(fp32) | KV Cache(bf16) |
|---|---|---|
| 131(prefill 结束) | 3.07 MB | 1.54 MB |
| 141(第一轮 10 个 token 写完) | 3.30 MB | 1.65 MB |
| 1000 | 23.44 MB | 11.72 MB |
| 4096 | 96 MB | 48 MB |
这个数字跟着精度走。权重和缓存放 bf16(现在部署的默认选择),每个元素 2 字节,每 token 就是 12 KB,整张表减半。
看到别人给的缓存数字比自己算的小一半,多半是精度口径不同,不是谁算错了。
这台模型小,数量级不吓人。换成 72B(80 层、8 个 KV 头、128 维),bf16 每 token 就是 320 KB,4096 的上下文光缓存 1.3 GB;按 fp32 算翻倍成 640 KB 和 2.6 GB。
上下文越长越吃显存,原因在这里。
8.3 缓存不在模型里
这是最容易理解错的一点:缓存由外面的代码保管。
decode 那一步,外面除了喂新 token,还得把上一轮攒下的 K、V(框架里叫 past_key_values)一起带进去。模型算出新位置的 K、V,追加到缓存上,再把更新后的缓存交回来。
喂进去、追加、交回来,这个往返是循环能省算力的原因。缓存不放在模型内部,每一步都要显式传。
没有它会怎样:每生成一个词,都要把前面 131 个位置全部重算一遍。24 层、每层四次大矩阵乘,代价是几十倍起。缓存就是把这部分重复计算一次买断。
逐 token 吐出来的前几步(本次实跑):
| 步 | id | 文本 |
|---|---|---|
| 1 | 105511 | 生日 |
| 2 | 104053 | 快乐 |
| 3 | 3837 | , |
| 4 | 99513 | 愿 |
| 5 | 56568 | 你 |
生日快乐 四个字拆成两步两个 token。第 6 步的 篮球(105732)一个 token 装下两个字。切分粒度按词频和字节定,和字数没有一对一关系。
8.4 一条请求并不独占这个循环
输入:队列里的若干条请求。 输出:每个 step 里,每条活跃请求各一个新 token。
前面一直用一条请求的一个循环在讲,这是理解机制的正确视角,但不是线上跑的样子。线上一个模型实例同时服务几十上百条请求。
调度器在每个 step 开始时做一次决定:从队列里挑若干条没说完的请求凑成一个 batch,一起送进模型。每条请求在这个 step 里各出 1 个 token,然后各自判断停不停。
说完的下一个 step 立刻移出 batch,空出来的位置给排队的请求。这套做法叫连续批处理(continuous batching)。
于是第 11 步停下的请求,在线上对应的是它参加了 11 个 step。它不独占模型,这 11 个 step 里模型还顺带算了别人的 token。
延迟会随同批请求数上升,吞吐也跟着上升,这是同一条曲线的两端。
新请求也不必等当前 batch 全部结束才进来。它的 prefill 可以切成几块,分散到几个 step 里做(chunked prefill),这样一条长 prompt 不会把正在 decode 的请求全卡住。代价是这条请求的 TTFT 变长,换来的是其他人的 TPOT 更稳。
缓存也归调度器管。每条请求的 KV Cache 按固定大小的块分配,逻辑上连续、物理上可以不连续,用完立刻回收。这样不必按最大长度预留显存。
以 vLLM 为例,两个常见上限:同时容纳多少条请求(max_num_seqs),一个 step 最多处理多少 token(max_num_batched_tokens)。前者卡并发,后者卡单步的计算量。
8.5 投机解码:一次前向出多个 token
输入:当前序列。 输出:1 到 γ+1 个 token,以及截断到实际接受长度的缓存。
decode 的瓶颈是访存:一次前向只产出 1 个 token,却要把全部权重和缓存读一遍。投机解码想让这一次前向多产出几个。
做法是配一个小模型当草稿,也可以挂在主干上加几个预测头。草稿先连着猜 γ 个 token,成本很低;大模型把这 γ 个位置连同当前位置一次并行验证,开销和原来一次前向差不多。
然后从前往后比:猜对几个接受几个,第一个不对的位置按大模型的分布重新采一个。
产出长度取决于草稿的命中率。设单步接受率为 α,期望接受长度是 (1 - α^(γ+1)) / (1 - α)。α = 0.8、γ = 4 时是 3.36 个 token 一步;按验证环节多花 8% 的开销估算,实际加速大约 2.5 倍。
这个 8% 是估算值,真实开销取决于草稿模型大小和 batch 形状,落地前要实测。
γ 不是越大越好。α 低的时候,后面几个草稿几乎必然被拒,算了也没用;草稿越长,验证那一步的 batch 也越大。工程上 γ 常取 3 到 6,按任务调。
有一处容易漏:验证时 γ+1 个位置的 K、V 都算出来了,被拒的那些必须从缓存里截掉,只保留接受的前缀。
截断做错,缓存里就留下一串模型从没真正采纳过的位置,后面每一步都在错误的历史上继续。
流程形状没变,前向之后还是采样。变的是「一次前向出一个」成了「一次前向出一段」。
9. 判停
输入:刚采出来的 token。 输出:继续,或者结束。
判停由外面的代码负责,不在模型里。这一轮在第 11 步停下:模型吐出了 151645,也就是 <|im_end|>。
这台模型的配置里把 <|endoftext|>(151643)也登记成结束信号,两个一起监听,谁先吐出都算说完。本例停的是前者。
9.1 EOS 的分数是爬上去的
这是整条链路上最容易被忽略、也最能说明问题的一条曲线。
刚开写的时候,EOS 在 151936 个候选里排到八万名开外,logit 是 -3.04。模型此刻几乎不可能结束,它连开头都还没写。
写过一半,EOS 慢慢往上爬。第 10 步吐出「!」之后,EOS 的 logit 冲到 20.09,排名第 1,下一步就被选中了。
也就是说,模型决定说完了不是某个开关被触发,而是一个候选的分数一路涨到最高。判停代码要做的,只是在新 token 落地的那一刻看一眼它的 id。
9.2 停止条件与 finish_reason
| 条件 | 作用 | 本例 |
|---|---|---|
| EOS | 首选。模型自己决定说完了 | 第 11 步触发 |
| 长度上限 | 安全网。模型迟迟不吐 EOS 时强行截断 | 未触发 |
| 停止串 | 最灵活,但可能横跨几个 token,要先解码再找 | 未使用 |
一个常见错误:判据是新采出来的这个 token 是不是 EOS,不是生成的文本里有没有 EOS。
有些模型在回答结束后会继续编下一轮对话,EOS 会出现在中间,必须截在第一个。
停止原因要往上层传,接口里通常叫 finish_reason,取值三种:stop、length、content_filter。
stop 是模型自己吐了 EOS 或命中停止串;length 是撞了长度上限;content_filter 是安全策略在中途改写或截断了输出。
用户看到回答到一半没了,多数是 length,不是模型不会说。
9.3 工具调用:停在标记上,再开一次 prefill
输入:模型吐出的一段结构化文本。 输出:一条新的历史,和一次重新开始的 prefill。
工具调用不是另一种模型,是同一条流水线多开一个岔口。
做法是先把工具定义(名字、参数 schema)渲染进 prompt,和 system、user 一起进 prefill。模型照常一个 token 一个 token 地写,写到约定的标记为止,比如 </tool_call>。
外面把这段文本解析成函数名和参数,真正去执行,拿到结果,再以 tool 角色拼成一条消息接在历史后面,最后重新做一次 prefill。
这一步必须重来:工具返回的内容模型从没见过,不在任何缓存里,只能当成新的输入重新算一遍。这也是除多轮对话之外,唯一会重开 prefill 的路径。
参数写错、工具报错,也要原样拼回去。模型看到错误信息,下一轮会改参数再调一次。所以一次工具调用经常是生成、执行、报错、重新生成好几圈,延迟就是这么上去的。
判停在这里的含义变了:不是说完,是这一段该交给外面了。停下来的 token 不是 EOS,是一个约定的边界标记。
9.4 异常与取消:没说完也得收场
输入:中止信号、超时、显存告急。 输出:释放掉的缓存块,以及一个明确的结束原因。
前面讲的都是正常收场:模型吐 EOS,或者撞到长度上限。线上还有一种收场不在这两类里,外面把它掐断。
用户关页面、点停止、请求超时,都会让一条请求中途作废。作废时必须做一件事:把它占的 KV Cache 还回去。
缓存是按块借的,不还,显存只涨不降,跑几个小时之后新请求全部 OOM。这是线上最常见的一类显存泄漏,代码上看不出错,因为每一步都正常返回了。
显存真不够时,调度器还有一招:挑一条优先级低的请求驱逐出去(preemption),把它的缓存换到内存里,或者干脆丢掉、等轮到它时重算。被驱逐的请求延迟会明显变长,但不会失败。
所以判停有四种结局:模型说完了、长度到顶了、被安全策略拦了、被外面中止了。前三种都会带着 finish_reason 正常返回,第四种通常连响应都没有,只剩一个断开连接。
10. 解码
输入:11 个 id。 输出:一句话。
得到的是:
生日快乐,愿你篮球巨星梦圆!
两个细节要处理。
一是特殊标记要过滤。<|im_end|> 是结构符号,不该出现在回答里,解码时用 skip_special_tokens=True 去掉。
拿输入串末尾那 4 个 id(198 / 151644 / 77091 / 198)试:不过滤解出来是 '\n<|im_start|>assistant\n',过滤之后是 '\nassistant\n'。
二是不能一个 token 一个字地解。一个汉字可能被拆成两个 token,只把当前 token 拿去解码,遇到半个字会得到替换符。
实测:单独解 48312 得到替换符,254 也是,54955 是空格加替换符;把这 5 个(48312 / 254 / 54955 / 240 / 38035)一起解,得到完整的 ① 角色。
这一点在流式输出里是硬约束。解码器不能拿到一个 token 就吐一个字,否则用户屏幕上会先闪出乱码再被修正。
| 做法 | 代价 |
|---|---|
| 每步把整个已生成序列重解一遍,只输出新增部分 | 实现简单,每步重解 |
| 维护字节缓冲区,能拼出完整 UTF-8 字符时才 flush | 省算力,实现要小心 |
10.1 流式的第一个字什么时候能发
前面这条约束,直接决定首字延迟。
一个汉字可能跨两三个 token。第一个 token 到手时,它可能只是某个字的前两个字节,解出来是替换符,这时候不能发。
要等到手里的字节能拼出一个完整字符,才有东西可发。所以流式接口真正 flush 的时机是攒够一个完整字符,不是拿到一个 token。
首字出现的时刻,可能比第一个 token 晚一两步。
这也解释了一个现象:有些服务首 token 很快,但第一个字出来后停顿一下。TTFT 的报表数字很好看,用户感知到的却是第一个完整字符到达的那个时刻。
11. 第二轮:历史要重新拼回去
用户追加了一句「再加一句祝他投篮越来越准。」
这里最关键的事实:模型是无状态的。上一轮它算过的所有东西,除了外面代码自己留着的文本,什么都没保留。
它不知道刚才写过哪一句,除非把那段历史重新塞给它。
所以第二轮的输入是四条消息一起渲染:system、第一条 prompt、第一轮的回答、新的追问,末尾再加起手标记。
渲染出来 161 个 token,比第一轮多 30 个。前 131 个与第一轮完全相同,逐 id 比对是 True。
这个事实直接换来一条优化:前缀缓存。前面那 131 个位置的 K、V 不必重算,直接复用第一轮的缓存,只算新增的 30 个。
对话轮数越多、历史越长,复用比例越高。
第二个回答 12 个内容 token:
生日快乐,愿你篮球天赋日新月异!
它没有另起一句,而是在上一轮「愿你篮球」后面接着续,把「投篮越来越准」的意思并了进去。
这就是把历史拼回输入的收益:模型的记忆全部来自被重拼的这段文本。
多轮有两条实践规则:
- 历史渲染必须稳定。每轮渲染出的前缀有细微差别(拼了时间戳、拼接方式不一致),前缀缓存就匹配不上,优化全部落空。
- 历史会越攒越长,到一定程度要截断或摘要,剪掉的往往是最前面那些不再影响当前回答的内容。
- 历史会越攒越长,到一定程度要截断或摘要,剪掉的往往是最前面那些不再影响当前回答的内容。
11.1 上下文超限的三种处理
历史不可能无限拼下去。超过模型能处理的最大长度,只有三条路。
截断最常用,也最容易出事。从最前面砍,第一个被砍掉的就是 system,角色设定、输出格式、安全边界全在里面。
表现出来是聊到十几轮之后,模型突然不按格式回答了,或者忘了自己是谁。稳妥的做法是保留 system、保留最近若干轮,砍中间那些已经不相关的内容。
滑窗只保留最近 N 个 token,更省事。但位置号不能跟着重置,否则同一个词在新一轮里回到了位置 0,RoPE 的相位和它前面的上下文完全对不上。位置要继续累加,或者换用能外推的方案。
摘要是把早期对话压成一段话再拼回去。它不丢信息,但要多花一次模型调用,而且摘要有延迟、可能漏掉细节。
常见做法是两者结合:近期保留原文,远期压成摘要。
三种做法都在改输入,也就都在改输出。哪一轮开始效果变差,先去看历史是从哪里开始被砍的。
11.2 前缀缓存真正赚的是跨请求
多轮复用前缀,省的是同一会话里前面那几百个 token。更大的收益在跨请求。
一套固定的 system prompt,比如客服机器人的角色设定和规则,会在成千上万条请求里一字不差地重复。命中之后,这部分 prefill 一次都不用重算。
命中条件很硬:从第一个 token 开始逐个相同,中间差一个字符就全部失效。
所以模板里不要塞时间戳、随机 id、当前用户名这类每次都变的东西,尤其不要放在最前面。把可变内容挪到后面,前缀才稳。
历史渲染必须稳定这条规则不只是多轮对话的注意事项,它直接决定缓存能不能命中,命中率又直接决定 TTFT。
12. 全流程表
| 环节 | 组件 | 进去什么 | 出来什么 |
|---|---|---|---|
| 渲染 | chat template | 两条消息 | 带 start/end 标记的串 |
| 分词 | tokenizer | 一条串 | 131 个 id |
| 备料 | 张量准备 | 131 个 id | 三张 (1, 131) |
| 调度 | 连续批处理 | 队列里的若干请求 | 每个 step 每条各 1 个 token |
| 嵌入 | embedding | (1, 131) | (1, 131, 896) |
| 位置 | RoPE | 位置 0 到 130 | 旋过角的 Q、K |
| 归一 | RMSNorm | 896 维 | 同形状,尺度统一 |
| 投影 | q/k/v_proj | 896 维 | q (1,131,896),k/v (1,131,128) |
| 打分 | 缩放点积 | Q、K | (1, 14, 131, 131) |
| 掩码 | 因果掩码 + 补齐掩码 | 分数表、attention_mask | 未来位置与补位都置负无穷 |
| 加权 | softmax + V | 分数、V | 每位置新表示 |
| 输出 | o_proj + 残差 | 14 个头 | (1, 131, 896) |
| 加工 | SwiGLU FFN | (1, 131, 896) | 同形状,中间 4864 维 |
| 堆叠 | 24 层 | 上一层输出 | 逐层改写,范数 0.31 → 1728 → 82.5 |
| 收尾 | final RMSNorm | 最后一层输出 | 尺度拉齐到 260 上下 |
| 打分 | LM head | 896 维 | (1, 131, 151936) |
| 取行 | 只留最后一个位置 | 131 行 | (1, 151936) |
| 约束 | logits 处理器 | 151936 个分数 | 非法候选置负无穷 |
| 投机 | 草稿 + 并行验证 | 当前序列 | 1 到 γ+1 个 token |
| 采样 | 温度 + top-k + top-p | 151936 个分数 | 1 个 id(生日) |
| 循环 | 前向 + KV Cache | 1 个新 token | 下一个 token,缓存 131 → 141 |
| 判停 | 停止逻辑 | 新 token | 第 11 步 EOS |
| 工具 | 解析 + 执行 + 回填 | 一段结构化文本 | 新历史,重开 prefill |
| 异常 | 中止与回收 | 取消信号 | 释放缓存块,结束原因 |
| 还原 | detokenizer | 11 个 id | 一句祝福 |
| 多轮 | 会话管理 | 历史文本 | 第二轮 161 个 token |