卷 IV · 写出来CH 15深度 15/20

读是并行的,写是一个一个的

用过大模型 API 的人都注意过一件事:输出的 token 比输入的贵好几倍。直觉的解释是「写比读难」——生成需要更多智能。这一章拿 GPT-2 在本机量一遍就会发现,这个解释完全不对:每个 token 的计算量,读和写一模一样。贵的是别的东西。弄清楚它,你会顺便看懂价目表上的另外两行:为什么长上下文贵,为什么「缓存命中」便宜得离谱。

读 5947 / 写 98 token/sKV 缓存 36 KiB/token输出贵 5 倍
◉ 这一章在 GPT-2 的哪一段

▷ 先猜一下

同一台 GPT-2、同一台笔记本,做两件事:

  • 读:一次把 512 个 token 的前文喂进去,算出每个位置的候选表;
  • 写:一个 token 一个 token 地往外生成 511 个(已经用上了下面要讲的「KV 缓存」,不做任何重复计算)。

问:按「每秒处理多少个 token」算,读比写快多少倍?

A 差不多。每个 token 的计算量是一样的 B 2 倍左右。写要多做一步「挑」 C 10 倍左右 D 60 倍左右

答案在本章末尾。

读:一次前向,算出所有位置

第 9 章讲过,GPT-2 的注意力带着一道因果遮罩:每个位置只能看它前面的位置。这道遮罩有一个重要的副作用:一次前向就能同时算出所有位置的候选表,而且第 t 个位置的表和「后面写了什么」毫无关系。

所以「读」一段已经存在的文字,是彻底并行的:512 个 token 的向量排成一个 512 行的矩阵,每一层都是几次大矩阵乘法,一口气推到底。训练也正是这样做的——一段 1024 个 token 的文字跑一次前向,就同时得到了 1023 个「猜下一个」的练习题,一起算损失、一起反向传播(这种「每个位置都用真实的前文去猜」的做法,叫 teacher forcing)。

写:只能一个一个来

生成就不同了。第 t+1 个 token 要等第 t 个 token 定下来才能算——而第 t 个是从一张表里抽出来的,抽之前谁也不知道是什么。于是写 511 个 token,就得老老实实跑 511 次前向,每次只处理 1 个新 token。

每次只处理 1 个 token,计算量并不大:每个参数大约一次乘、一次加。麻烦在于,为了这一点点计算,整个模型的权重都得从内存里搬到计算单元上走一趟。GPT-2 small 的权重是 500 MB(float32),每写一个 token 就要搬 500 MB;而读的时候,搬一次权重可以喂饱 512 个 token 的计算。本机实测:

同一台 GPT-2,每秒能处理多少个 token 读:一次吃下 512 个 5947 token/s 写:一个一个吐 511 个 98 token/s
用时每秒处理
读:一次吃下 512 个 token0.086 s5947 token
写:一个一个吐出 511 个(带 KV 缓存)5.21 s98 token
读比写快60.6 倍

同一个模型、同样的计算量,读比写快 60 倍。读的时候,瓶颈是算力,硬件被喂得饱饱的;写的时候,瓶颈是搬运权重的内存带宽,算力大部分时间在等。这就是为什么所有大模型服务都把这两段分开对待:处理你的提示词叫 prefill(预填充),一个个生成叫 decode(解码),各有各的指标——「第一个字出来要多久」主要是 prefill,「之后每秒吐几个字」就是 decode。

KV 缓存:读过的不要再算一遍

写的过程还有一个隐藏的浪费。每生成一个新 token,注意力要拿它的查询去和前面所有位置的键、值做运算。如果每一步都把整段前文从头算一遍,第 100 步就要重算前 99 个位置的键和值——而它们和上一步算的一模一样。

省掉这份浪费的办法是把每一层、每个位置的键和值存起来,叫 KV 缓存。新的一步只算新 token 自己的那一份,前面的直接查缓存。本机用 transformers 量「带不带缓存」写同样多的 token(两种方式写出来的 token 逐个相同):

写多少个 token不带缓存带缓存快了
2568.41 s2.58 s3.26 倍
76846.83 s8.33 s5.62 倍

写得越长,差距越大:不带缓存时,第 n 步要重算 n 个位置,总代价随长度平方增长。(在本书自己的 numpy 版 GPT-2 上,带缓存逐步算和一次全算,最后一个位置的 logits 最大只差 5.8 × 10⁻⁶——同一个结果,只是浮点舍入的先后不同。)

缓存不是白给的,它吃内存。每个 token 要存:2(键和值)× 12 层 × 768 维 × 2 字节(半精度)= 36864 字节,也就是 36 KiB;装满 GPT-2 的 1024 个位置是 36 MiB。这个数在小模型上不起眼,在今天的模型上是大事:第 19 章会读真实的配置文件算出来,一个 80 亿参数的模型每个 token 要 128 KiB,一段 128K token 的长对话光 KV 缓存就是 16 GiB。长上下文贵,一半贵在注意力的平方级计算,另一半贵在这块随长度线性增长的内存。

顺带把计算量也算清楚:GPT-2 small 不算嵌入表有 8506 万个参数,每处理一个 token 大约要 2 × 8506 万 ≈ 1.7 亿次浮点运算;加上 1024 长度时的注意力和最后乘回 50257 个候选的那一步,约 2.66 亿次。读和写,每个 token 都是这么多——差别只在能不能一起算。

这就是价目表的样子

把这一章的物理,对到一张真实的价目表上(Anthropic 公开的 API 定价,2026 年 10 月):

模型输入(每百万 token)输出缓存命中的输入
Claude Opus 5.54 美元20 美元0.20 美元
Claude Sonnet 5.52 美元10 美元0.20 美元
  • 输出是输入的 5 倍:读能把整段摊开并行算,写只能一个一个来、每个都要把权重搬一遍。不是「写更聪明」,是「写更难并行」。
  • 缓存命中的输入只要原价的 1/20(Opus)到 1/10(Sonnet):如果你每次请求都带着同一段很长的开头(系统提示、文档、工具定义),服务端可以把读这段开头算出来的中间结果存下来,下次直接接着用——原理就是这一章的 KV 缓存(各家的具体实现不公开)。省下的是整段 prefill。
  • 长上下文按 token 线性收费,但服务方的成本不止线性:注意力随长度平方增长,KV 缓存随长度线性占内存、挤掉同时服务别人的余量。所以「把整本书塞进上下文」能用,但不是免费的午餐。
Thank you very ▍:这种位置,小模型就能猜对 Thank ␣you ␣very 下一个 token 概率 意外 ' much' 99.16% 0.01 比特 ' very' 0.25% 8.62 比特 ',' 0.25% 8.63 比特 'much' 0.09% 10.16 比特 ' Much' 0.04% 11.27 比特 熵 0.10 比特 · 投机解码赚的就是这种位置

最后一个有意思的推论。Thank you very 后面,␣much 的概率是 99.16%——这种「下一个几乎确定」的位置,在真实文字里一抓一大把(第 14 章:14.2% 的位置,第一名就超过 90%)。既然写慢、读快,能不能让一个小模型先猜出接下来的好几个 token,再让大模型一次性读一遍、核对哪些猜对了?这就是投机解码(speculative decoding):把「写」偷偷换成「读」。猜对的那几个 token 等于白捡,猜错了就从错的那里重来,结果和大模型自己一个一个写完全相同。

⌨ 自己跑一遍

同样的两组对比——带不带 KV 缓存、读和写——用 transformers 量:

import time, torch
from transformers import AutoTokenizer, AutoModelForCausalLM

tok = AutoTokenizer.from_pretrained("gpt2")
model = AutoModelForCausalLM.from_pretrained("gpt2").eval()
x = tok(open("chatgpt-2026-10-21.txt").read(), return_tensors="pt").input_ids[:, :512]

with torch.no_grad():
    for use_cache in (True, False):              # 同样写 256 个 token:带不带 KV 缓存
        t = time.perf_counter()
        model.generate(x[:, :16], max_new_tokens=256, do_sample=False, use_cache=use_cache, pad_token_id=50256)
        print(f"use_cache={use_cache!s:5}  写 256 个 token 用 {time.perf_counter() - t:.2f} s")

    model(x)                                     # 预热
    t = time.perf_counter(); model(x); read = time.perf_counter() - t
    out = model(x[:, :1], use_cache=True); past = out.past_key_values
    t = time.perf_counter()
    for i in range(1, 512):                      # 一个一个写,每次只喂一个新 token
        out = model(x[:, i:i + 1], past_key_values=past, use_cache=True); past = out.past_key_values
    write = time.perf_counter() - t
print(f"读:一次吃下 512 个 token  {512 / read:6.0f} token/s")
print(f"写:一个一个吐 511 个      {511 / write:6.0f} token/s(慢 {(512 / read) / (511 / write):.0f} 倍)")

本机输出(计时类的数字换台机器、换一次运行都会变;这段代码和正文表格是分开跑的两次,比值都在 60 倍上下):

use_cache=True   写 256 个 token 用 2.78 s
use_cache=False  写 256 个 token 用 8.28 s
读:一次吃下 512 个 token    5951 token/s
写:一个一个吐 511 个          99 token/s(慢 60 倍)

pip install torch transformers && python 15.py

在 Colab 的 GPU 上跑,绝对速度会快很多,但「读远快于写」这一点不会变——GPU 的算力比内存带宽多得更多,这个倍数往往更大。

▸ 在现实里
  • 流式输出(字一个个蹦出来)不是为了好看:第一个字之前的等待是 prefill,之后每个字的间隔是 decode。提示词很长时,第一个字出来得慢;回答很长时,后面慢。
  • 服务端会把很多人的请求拼成一批一起 decode:搬一次权重,同时给几十个用户各算一个 token,把「写」的效率拉回来。代价是每个人的延迟会受别人影响——《撞墙》里讲的「利用率越高、排队越长」在这里一模一样。
  • 写提示词时,把不变的内容放在前面、变化的放在后面,能最大化缓存命中——因为缓存只能按「从头开始一字不差的前缀」复用。
  • 同样的钱,让模型「读」比让它「写」划算得多。给它一份详细的参考资料让它挑出答案,往往比让它从头生成一大段更便宜、也更准。
✗ 这个直觉是错的

「输出 token 更贵,是因为生成比理解更难、需要更多智能。」

每个 token 的计算量,读和写是一样的(GPT-2 small 约 2.66 亿次浮点运算)。差别在于能不能一起算:读可以把整段前文排成一个矩阵并行地推,写却必须等上一个 token 抽出来才能算下一个,每一步都要把全部权重从内存里搬一遍。本机实测,读比写快 60.6 倍。

这个区分很实用:凡是能让模型「读」来完成的事(从给定的资料里找、在给定的选项里挑),都比让它「写」便宜得多;而降低延迟的工程技巧,几乎都是在想办法把写变成读(批处理、投机解码、缓存)。

◇ 揭晓

正确答案是 D:读每秒 5947 个 token,写每秒 98 个,快 60.6 倍。

A 「每个 token 的计算量一样」——前提完全正确,结论却错了:速度不只取决于计算量,还取决于能不能并行、数据要搬多少。这是这一章最想纠正的那个直觉。 B 「写要多做一步挑」——从表里挑一个 token 的开销小到可以忽略。慢的不是挑,是挑完之后才能开始下一步。 C 「10 倍左右」——不带 KV 缓存、写得又长的时候,浪费会更多;但即使带了缓存、没有一点重复计算,写依然比读慢 60 倍——那是带宽的账,缓存省不掉。

这一章的一句话

读和写每个 token 的计算量一样,但读能把整段前文并行地算,写必须等上一个 token 定下来、每步都把权重搬一遍——所以读比写快几十倍。KV 缓存省掉了重复计算,代价是随长度增长的内存;而价目表上「输出贵、缓存便宜、长上下文不便宜」,就是这一章的物理。

下一章回到「读」的那一侧:你写的提示词,在模型眼里是什么?一个实验就够说明问题——直接问 GPT-2「法国的首都是什么」,Paris 只有 1.77%;在前面先摆一个已经答好的例子(德国的首都是柏林),Paris 跳到 63.71%。你没有教它任何新东西,你只是换了一份它要续写的文档。