大模型为什么一个字一个字地回复

按下回车之后,一条回复是怎么一个 token 一个 token 写出来的。

目录
  1. 发出去的是整段对话
  2. 模型看到的是一篇文档
  3. 切成 token
  4. 一次前向:从编号到打分
  5. 掷骰子
  6. 写一个,喂回去
  7. 读得快,写得慢
  8. 一块显卡,很多人
  9. 思考和工具:同一个循环多转几圈
  10. 回到屏幕
  11. 资料

聊天框里的回复一块一块往外冒,看着像打字机特效。它不是特效:第二块出现之前,模型真的还没把它算出来。

(有的产品会把收到的字再匀一匀,画得更顺,但节奏的源头在服务器那边。)

很多现象都能从这一点讲起:第一个字为什么要等,后面为什么匀速;为什么按 token 收费;为什么同一个问题问两遍,答案会变;为什么对话一长,就又贵又慢。

拿一段普通的对话,从按下回车开始,一路跟到最后一个字落在屏幕上:

用户:我家刚领养了一只橘猫,特别能吃。
助手:恭喜!橘猫胃口好是出了名的,记得控制一下零食。
用户:给它起个名字吧,两个字。

助手的第二句回复,下面拆开慢放:

慢放;候选和概率是示意

模型每被调用一次,只交出一张打分表。采样从表里挑一个 token,接到末尾,再调用下一次。这句回复,就是这个循环转了 27 圈。

慢放里有两处不太对劲。第 5 圈,“子”的分数最高,挑中的却是“座”;第 9 圈,模型明明交出了一个 token,屏幕上一个字也没多。这两件事,后面都会讲到。

发出去的是整段对话

按下回车,前端发出一个 HTTP 请求。各家接口大同小异,按最常见的 OpenAI 格式写出来是这样:

POST /v1/chat/completions HTTP/1.1
Authorization: Bearer sk-...
Content-Type: application/json
{
"model": "some-chat-model",
"stream": true,
"messages": [
{ "role": "system", "content": "你是一个乐于助人的助手。" },
{ "role": "user", "content": "我家刚领养了一只橘猫,特别能吃。" },
{ "role": "assistant", "content": "恭喜!橘猫胃口好是出了名的,记得控制一下零食。" },
{ "role": "user", "content": "给它起个名字吧,两个字。" }
]
}

注意 messages:上一轮的一问一答,原样又发了一遍。

模型本身不记事。它没有“会话”这个概念,每个请求进来,都是一段从头读起的新文本,答完什么也不留下。它知道这只猫是橘色的,只是因为那句话又被塞进了这次的输入。把第一轮删掉,再问“给它起个名字吧”,它连“它”是什么都不知道。

历史可以由前端带着,也可以由服务端按会话 ID 取回来再拼上,结果都一样:喂给模型的,是到目前为止的整段对话。所以“新建对话”会让它失忆;改掉前面某句话再发,等于从那里分出一段新的历史。

这也意味着,对话越长,每一轮要重读的东西越多。这笔账留到后面算。

它还有上限:模型一次能读的 token 数是固定的,也就是常说的上下文窗口。聊得太久,产品只能截掉或者压缩前面的内容。这时它“忘了”你最早说的话,多半是因为那几句压根没被发过去。

请求先到网关:校验密钥,查额度和限流,有的还要过一道内容审核,然后被转发到某台跑着这个模型的机器,排进它的队列。到这里,还和普通的 Web 服务差不多。

模型看到的是一篇文档

messages 是给程序看的结构。模型只吃一串 token,没有“数组”“字段”这些概念,所以推理服务要先用这个模型专属的聊天模板,把对话摊平成一段文本。拖动那条竖线,看看界面底下是什么:

Qwen 系列使用的 ChatML 格式

摊平以后,整段对话就是一篇文档。每条消息夹在 <|im_start|> 和 <|im_end|> 之间,开头写着角色。最后一行停在 assistant 后面,没有下文。模型要做的,就是把这篇文档接着写下去。

所以聊天其实是一种排版。模型在预训练阶段学的,是给任意文本续写;后训练阶段,它又读了大量这种格式的对话,才学会:读到 assistant 那一行,就用助手的口吻往下写,写完补一个 <|im_end|>。

<|im_start|> 和 <|im_end|> 在词表里各占一个位置,是专门的控制标记。用户消息里要是出现一模一样的字符串,服务端得把它当普通文字切开,否则谁都能伪造一段 system。

可是除了这几个标记,系统提示、你的消息、搜索结果、工具返回值,最后都是同一串 token。模型分辨“这句话是谁说的”,只能靠这些标记和训练出来的习惯。提示词注入能得逞,根子就在这里:网页里藏一句“忽略之前的所有指令”,到了模型那里,和别的字没什么两样。

什么时候停,也是模型自己写出来的。它写出 <|im_end|>,服务端看到这个 token,就把这一轮收住。要是忘了设这个停止条件,它会顺着文档的格式往下写:换行,<|im_start|>user,替你把下一句也说了。早年自己搭聊天程序的人,不少都见过这种自问自答。

切成 token

模型读的不是字,是整数。tokenizer 把文本切成词表里的片段,每个片段对应一个编号。词表是训练之前就定好的:Qwen3 有 151,643 个普通 token,另有 26 个控制标记;Llama 3 一共 128,256 个。

现在主流的做法叫字节级 BPE。先把文本按 UTF-8 拆成字节,每个字节算一个 token,起步只有 256 个;然后在语料里反复找出现次数最多的相邻一对,合并成一个新 token,记下这次合并。合并十几万次,词表就成了。以后给新文本分词,按同样的顺序重放这些合并。

下面这个分词器是在页面里现场训练的。语料是一千多字编出来的养猫琐事,规模小得可怜,合并几百次就学到头了,但每一步都和真的一样。输入框里的字可以随便改:

在页面里现场训练的字节级 BPE;语料约 1,200 字

先看最右边训练完的样子。“橘猫”“能吃”“特别”在语料里反复出现,各自成了一个 token;“领”只出现过一次,它那几个字节凑不成常见的对子,到最后还是三个孤零零的字节。真实的词表也一样:生僻字、冷门语言、少见的表情,常常被拆成好几块。

再往左拖。合并得越少,切出来越碎;拖到最左边,每个汉字都拆成 3 个字节(常用汉字在 UTF-8 里就占 3 个字节),一句话的 token 比字还多。中间某些位置,还能看到只合并了一半的字,甚至一个 token 横跨两个字的边界——BPE 只管哪两块常挨在一起,不管字在哪里断开。

在输入框里打一句英文试试。语料里几乎没有英文,单词会被切得七零八落。放到真实世界也一样:分词器训练时少见的语言,同样的意思要多花 token,更贵,也更占上下文。

词表越大,同样的文本切出来越短,一次能装下的内容越多;代价是 embedding 表和输出层跟着变大。十几万是现在常见的折中。

这件事的后果很实在。计费和上下文长度都按 token 算,同一段话换个模型,token 数可能差出一截。模型也“看不见”字母:问它 strawberry 里有几个 r,它看到的是几个编号,字母藏在编号背后。数字怎么切也各有规矩:Qwen 把每一位数字单独切开,Llama 3 最多三位一组,这会影响它做算术的方式。

一次前向:从编号到打分

编号进了模型,第一步是查表。embedding 矩阵每行对应一个 token,Qwen3-8B 的这张表有 151,936 行、4,096 列。编号是几,就取第几行,一个 token 就变成了 4,096 个数。编号本身没有大小意义,它只是个行号。

接着,这些向量依次穿过几十层(Qwen3-8B 是 36 层)。每一层做两件事,都是在原来的基础上加一点:

x←x+Attention⁡(Norm⁡(x)),x←x+MLP⁡(Norm⁡(x)).\begin{aligned} x&\leftarrow x+\operatorname{Attention}(\operatorname{Norm}(x)),\\ x&\leftarrow x+\operatorname{MLP}(\operatorname{Norm}(x)). \end{aligned}

注意力让每个位置去读前面的位置。第二轮里的“它”,就是在这里和上一轮的“橘猫”连上的;具体怎么读,我在《Transformer》里拆过,这里不重复。MLP 对每个位置单独做一次非线性变换,模型的大部分参数在这儿。

两步都只往 xx 上加。xx 像一条从第一层贯穿到最后一层的主干,每层往上面写一点东西,所以常被叫作残差流。

走完最后一层,取最后一个位置的向量,再归一化一次,然后和词表里每个 token 的输出向量各做一次点积,得到 151,936 个分数,叫 logits。分数越高,越可能是下一个 token。

挑词于是成了一道几何题:哪个 token 的方向和这个向量对得最准,哪个分数就最高。用二维画出来是这样:

二维示意:只画了 7 个候选,轨迹是编的;分数就是点积,概率是对分数做 softmax

橙色箭头是最后一个位置的向量。它在每条方向上的投影有多长,那个候选的分数就是多少;虚线标出的是得分最高的那一条。

把层数往回拖,能看到它一路怎么走过来的。前几层推得没什么章法,各个候选的分数都差不多;第 12 层,注意力读到了“橘猫”,向量被一把拽向“橘”;后面几层越推越长,分数的差距拉开,分布越来越尖。点“删掉第一轮”,那一把就没了,箭头停在“小”和“咪”中间。

真模型里的向量有几千维,候选有十几万个,过程是同一个:每一层推一把,最后看它指向哪里。把中间层的向量直接拿去算分数,这招有个名字叫 logit lens,在真模型上也能看到答案一层层成形。

这一趟下来,模型的参数一个也没变。你聊的内容不会写进模型,下一个请求进来,它还是原来那个它。另外,提示词里的每个位置都要走完全部层,因为后面还要用到它们的中间结果;但只有最后一个位置的分数,用来挑下一个 token。

掷骰子

分数变成概率,靠 softmax:

pi=ezi/T∑jezj/Tp_i=\frac{e^{z_i/T}}{\sum_j e^{z_j/T}}

TT 是温度。T=1T=1 是模型原本的分布;T<1T<1 把差距拉大,分布变尖;T>1T>1 把差距抹平,冷门的 token 也有了机会;T→0T\to0 时只剩分数最高的那个,叫贪心解码。

之后常常还有两道筛子:top-k 只留分数最高的 kk 个;top-p 从高到低累加概率,凑够 pp 就截断,后面的全扔掉,剩下的重新归一。Qwen3 给非思考模式的建议是 T=0.7T=0.7,top-p 取 0.8,top-k 取 20。

开头起名的那一刻,可以画成下面这样。格子的宽度就是概率:第一个字先分走一段,第二个字在它下面再按条件概率分。一个名字的概率,就是一路乘下来:

P(橘座)=P(橘)×P(座∣橘)=0.38×0.22≈0.084P(\text{橘座})=P(\text{橘})\times P(\text{座}\mid\text{橘})=0.38\times0.22\approx0.084
概率是示意;只展开了名字的前两个字

“抽一次”相当于往底边随手扎一针,针落在哪一格,就是哪个名字。格子越宽,越容易被扎中。开头第 5 圈挑中 22% 的“座”,就是这一针恰好扎在了那里。概率是我按常识编的,规则是真的。

温度往右拖,灰色的长尾越来越宽,“龘Q”这类怪名字冒出来的机会跟着变大;打开 top-p,凑够 90% 之后的长尾直接被剪掉。温度拖到 0,整张图只剩一条路,每次都是“橘子”。

所以“重新生成”换了答案,不是模型改了主意,是骰子重掷了一次。神经网络是个确定的函数,随机性全在采样这一步。

写一个,喂回去

挑出来的 token 接到序列末尾,整个序列再送进模型,算下一个。这叫自回归:

P(y1,…,ym∣c)=∏t=1mP(yt∣c,y<t)P(y_1,\dots,y_m\mid c)=\prod_{t=1}^{m}P(y_t\mid c,y_{<t})

每个新 token 都以前面的全部内容为条件,包括模型自己刚写下的那些。这也决定了它没法回头改:写下去的,就成了后文的前提。第 5 圈挑中了“座”,后面就只能顺着“橘座”往下圆。

照字面执行,每写一个 token,都要把整段序列从头算一遍。但注意力有条规矩:每个位置只能看自己和前面。新 token 进来,前面那些位置能看到的内容一点没变,算出来的东西当然也不变。

新 token 真正需要的,是前面每个位置在每一层的 K 和 V:K 用来和它的 Q 匹配,V 是匹配上之后读走的内容。把它们存下来,新 token 只算自己那一份,再去读存好的就行。这就是 KV cache:

示意:提示词 10 个 token,生成 8 个

左边每一行都要从第一个位置算起,越往下越长,总量按平方涨;右边除了第一行,每次只算一个新位置,其余的全靠读缓存。生成 8 个 token,差距是 108 比 17;生成几千个,差距就是上千倍。

缓存不是免费的。每个位置、每一层都要存一份 K、一份 V。以 Llama-3.1-8B 为例:32 层,每层 8 组 KV 头,每头 128 维,BF16 下每个数 2 字节。一个 token 就是

2×32×8×128×2 B=128 KiB.2\times32\times8\times128\times2\ \text{B}=128\ \text{KiB}.

8,192 个 token 的对话,光缓存就正好 1 GiB,而且只属于这一个请求。这还是省过的:Llama 3 的 32 个查询头共用 8 组 K、V(这种做法叫 GQA),要是每个头各存一份,还得再乘 4。

循环在这几种情况下停:挑中结束标记 <|im_end|>;写满了 max_tokens;碰到调用方设的停止字符串;或者你点了“停止生成”,前端断开连接,服务端停下这个请求。

读得快,写得慢

现在可以回答开头那个问题了:第一个字为什么要等,后面为什么匀速?

第一次调用,要把整段对话读一遍。这几百上千个 token 都是已知的,可以并排塞进同一次前向,变成大矩阵乘大矩阵,这正是 GPU 最擅长的活,算力能跑满。这一段叫 prefill。首字要等多久,主要看它,再加上排队。

之后每次调用只有一个新 token。可模型每一层的权重,还是得从显存搬进计算单元过一遍。一个 8B 的模型,BF16 下是 16 GB;为了这一个 token,16 GB 要全部读一遍,读完只做了很少的乘加。这一段叫 decode,它卡在显存带宽上,算力大半闲着。

把两段放进同一张图:横轴是一次前向里塞了多少个 token,纵轴是耗时。

按 H100 SXM(3.35 TB/s,BF16 稠密 989 TFLOPS)和 8B 模型估算的理想上限,不含注意力和其他开销

左边那段是平的:塞 1 个 token 和塞 100 个,都得先把 16 GB 读一遍,耗时都在 4.8 ms 左右。过了拐点(大约 300 个 token),算力才成为瓶颈,耗时开始跟着 token 数往上涨。

于是,读一段 2,000 个 token 的提示词,只要 30 多毫秒,平均每个不到 0.02 ms;写 1 个 token,却要 4.8 ms。摊到每个 token 上,写比读贵了将近 300 倍。各家 API 的输出单价普遍是输入的好几倍,和这笔账对得上。

单个请求的生成速度也因此有个硬上限。一张 H100 跑 BF16 的 8B 模型,理论上每秒最多 200 来个 token;量化到 8 位,权重小一半,上限才翻倍。想再快,要么把模型切到几张卡上一起读,要么用投机解码:让一个小模型先猜几个 token,大模型做一次前向,把它们一起验证掉。验证是“读”,不是“写”,便宜得多。

一块显卡,很多人

那张图的左半边还藏着一个好消息:既然塞 1 个 token 和塞 64 个差不多快,那就把 64 个用户各自的下一个 token 拼进同一次前向。权重读一遍,64 个人同时往前走一步。

这就是推理服务的批处理。现在的推理框架按“步”调度:每一步都可能有请求写完离开,有新请求补进来,不用等整批一起结束。你的请求,多半一直和一群陌生人挤在同一个批次里。

这甚至会影响结果。温度设成 0,线上服务也未必每次一字不差:浮点加法不满足结合律,求和顺序一变,最后几位就可能不同;而一个批次里塞了多少请求,会改变 GPU 算子的切分方式和求和顺序。Thinking Machines 在 2025 年专门分析过这件事。分数差那么一点点,偶尔就够两个候选换个位置。

批次能开多大,要看显存。每个正在生成的请求都占着自己的 KV cache,而且每走一步,都得把各自的缓存再读一遍。把账摊开:

H100 80 GB 跑 Llama-3.1-8B(BF16)的理想化估算;权重之外另留 6 GB 给激活和运行时

默认是 32 个请求、每个 4K 上下文:每走一步要读 16 GB 权重加 17 GB 缓存,约 10 ms,每个人每秒 100 个 token,整张卡每秒 3,200 多个。把人数拖到 1,每个人快了一倍,整张卡却闲得只剩每秒 200 个——这就是为什么推理服务一定要拼批次。

再把上下文拖到 128K。一个请求的缓存就有 17 GB,整张卡只放得下 3 个人,其余的全在排队;每一步大半时间都在读缓存,真正的乘加连零头都不到。高峰期变慢,长上下文更贵,都写在这本账上。

推理框架为这本账想了不少办法。vLLM 的 PagedAttention 把 KV cache 切成固定大小的块,像操作系统管内存页那样按需分配,碎片少了,同一张卡能多塞不少请求。前缀缓存让开头相同的请求共用同一份 KV:同样的系统提示、同一段对话历史,算过一次就不必再算。

回到第一节那笔账:多轮对话每轮都把历史重发一遍,可历史正好是上一轮的前缀。缓存命中时,prefill 只要算新增的那几句。不少 API 给“缓存命中的输入”单独定价,便宜得多,原因就在这里。

还有个麻烦:新请求的 prefill 和老请求的 decode 抢同一块卡。一个几万 token 的 prefill 挤进来,同批次所有人的下一个字都得等它算完。所以有的系统把长 prefill 切成小块,穿插在 decode 之间做;有的干脆让 prefill 和 decode 跑在不同的机器上。

思考和工具:同一个循环多转几圈

有的模型回答之前会先“思考”。思考没有什么特殊的算法,就是同一个循环先写一段草稿。Qwen3 用 <think> 和 </think> 把草稿包起来,界面再把这段折叠或者藏起来。

所以思考模型首字慢,常常不是 prefill 慢,而是它先写了几百上千个你看不见的 token。这些 token 一样按输出计费。反过来,Qwen3 关掉思考的办法也很直白:模板在 assistant 后面,直接替它写好一段空的思考。

<|im_start|>assistant
<think>
</think>

模型读到一段已经收尾的思考,就直接开始回答。能替模型写开头,也再次说明聊天只是一篇文档:哪一段归模型写,全看程序把笔交到哪里。

工具调用也是一回事。问它“深圳今天要带伞吗”,模型自己联不了网,它能做的,是按约定的格式写出一段调用。点“下一步”,看看这篇文档每一段是谁写的:

Qwen 系列的工具调用格式,系统提示有删节

模型写完 </tool_call> 和 <|im_end|>,这一次调用就结束了。真去查天气的是程序:解析出函数名和参数,调天气接口,把结果包进 <tool_response> 接到文档末尾,再发起一次请求。这又是一整趟 prefill 和 decode,只是前面大半段都能命中缓存。

整篇文档里,模型只写了橙色的两段。所谓 Agent,就是让这个循环一直转下去:模型写调用,程序执行,结果接回去,直到模型决定直接回答。

回到屏幕

每挑出一个 token,服务端就要把它变回文字。词表里存的其实是字节串,按 UTF-8 拼起来才是字。麻烦在于,一个 token 不一定是完整的字符:生僻字和不少表情在词表里没被完整合并过,会拆成好几个 token。只收到前一半字节时,解码器只能先攒着,等后一半到了,再一起吐出去。

开头慢放里的第 9 圈就是这样:那个 token 只是一只猫的前三个字节,第 10 圈的 b1 到了,屏幕上才出现 🐱。

假设 🐱 在词表里被拆成了两个 token;字节解码用的是浏览器自带的 TextDecoder

文字走 SSE(Server-Sent Events)推回浏览器:HTTP 响应一直不结束,服务端隔一会儿往里写一段 data: …,以空行分隔,一段就是一个事件。浏览器自带的 EventSource 只能发 GET,而聊天接口要 POST 一大段 JSON,所以前端一般用 fetch 读响应流,自己按空行切出事件。

前端拿到增量,拼到已有文本后面,再重新渲染一遍 Markdown。这一步也有坑:**橘座 刚到、后面的 ** 还没来,按标准语法,它只是两个星号加两个字。不少聊天界面里星号一闪、随即变成粗体,就是这么来的。

最后一圈,模型挑中的是 <|im_end|>。这个“字”不会出现在屏幕上,服务端也不必为它再调用一次模型,只往流里补一个 finish_reason 为 stop 的事件,再写一行 data: [DONE]。这个请求占着的 KV cache 随即释放,那块显存,下一步就分给了排在后面的人。

资料