那句"生日快乐",Transformer 的 24 层走了 11 遍

0 阅读44分钟

朋友明天过生日,你让助手写一句祝福。从你按下回车,到屏幕上出现那行字,中间发生了什么?

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 段内容五花八门,默认理解是这一轮要我干什么。

三处实际差别:

systemuser
位置固定在整串最前面夹在任务描述里,离生成点更近
训练分布全局设定为主本轮任务为主
生效范围跨轮延续只这一轮

两者冲突时没有硬性优先级。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预训练语料的文档分隔符
assistant77091角色名,普通 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 到 130RoPE 的角度依据

前两个由 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,位置号接着排。
prefilldecode 第一步
input_ids(1, 131)(1, 1)
attention_mask(1, 131)(1, 132)
position_ids0…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
隐藏维度 C896
注意力头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 会被推到极端。

这里有两个名字都叫归一化的操作,管的东西完全不同:

作用对象在哪
RMSNorm896 维的表示向量一层里出现两次:进注意力前、进 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解码logitsoftmax 概率
1105511生日18.190.6628
2100549祝16.150.0864
399614朋友14.840.0232
4110834亲爱的14.840.0232
5100644今天14.690.0199

前五名全指向祝福语的开头,说明这个位置已经锁定在祝福这件事上。第一名 生日 承接背景里明天过生日,第二名 祝 是另一条常见开头。

打分有了,下一个 token 怎么定,这一步不归模型管。

7. 采样

输入:(1, 151936) 的分数。 输出:1 个 token id。

分数进来不是直接抽。transformers 里中间要过一条 LogitsProcessor 链,顺序有讲究:

顺序处理本模型设置做什么
1重复惩罚1.1已出现过的 token 分数往下压,防复读
2温度0.7整体除以 0.7,差距被放大,分布变尖
3top-k20只留分数最高的 20 个候选
4top-p0.9按概率累加到 0.9,其余清零
5重新归一 + 抽签不适用剩下的重新归一成和为 1,再随机抽一个

温度是唯一平滑改变分布形状的参数。除以小于 1 的数(这里是 0.7),分数之间的差距被拉开,高的更高、低的更低,softmax 之后概率往头部集中,分布变尖。除以大于 1 的数,差距被抹平,分布变平。

top-k 和 top-p 也在改熵:硬截断之后重新归一,留下来的候选概率都被抬高,熵一定下降。区别在于它们只做留或删,不改变留下来的候选之间的相对比例;温度是连续地把整条曲线拉高或压平。一个是剪切,一个是缩放。

这次实跑在第一个位置的数据:

候选原始概率除以 0.7 后top-k 20top-p 0.9
生日0.6628约 0.81保留保留
祝0.08640.054保留保留
朋友0.02320.0084保留保留
亲爱的0.02320.0084保留保留
今天0.01990.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 MB1.54 MB
141(第一轮 10 个 token 写完)3.30 MB1.65 MB
100023.44 MB11.72 MB
409696 MB48 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文本
1105511生日
2104053快乐
33837,
499513愿
556568你

生日快乐 四个字拆成两步两个 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
归一RMSNorm896 维同形状,尺度统一
投影q/k/v_proj896 维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 head896 维(1, 131, 151936)
取行只留最后一个位置131 行(1, 151936)
约束logits 处理器151936 个分数非法候选置负无穷
投机草稿 + 并行验证当前序列1 到 γ+1 个 token
采样温度 + top-k + top-p151936 个分数1 个 id(生日)
循环前向 + KV Cache1 个新 token下一个 token,缓存 131 → 141
判停停止逻辑新 token第 11 步 EOS
工具解析 + 执行 + 回填一段结构化文本新历史,重开 prefill
异常中止与回收取消信号释放缓存块,结束原因
还原detokenizer11 个 id一句祝福
多轮会话管理历史文本第二轮 161 个 token