Skip to main content

01 - 推理服务的基本概念

这一篇是整个专题的地基。下面三个问题你要是都答得上来,可以直接跳到 02 篇

  • KV cache 为什么会越用越多?
  • 预填充和解码有什么区别?
  • 批开大了,为什么总吞吐涨、但每个人反而变慢?

不知道的话,这一篇必须先看 —— 后面十几篇里每一个「为什么会慢」的解释,最后都会落回这六个概念上。

只假设你知道一件事:大模型的回答是一个字一个字冒出来的,像有人在实时打字。

从这一件事出发,剩下的全都能推出来。

一、为什么是一个字一个字

先问个很笨的问题:模型为什么不能一次把整句话输出完?

因为它压根不知道整句话。模型能做的只有一件事:给它一段文字,它告诉你下一个字最可能是什么。就这一个能力,没有别的。

所以要生成一整句,只能这么循环:

生成「好的,少冰」的前四步第 1 次我要一杯少冰的珍珠奶茶模型跑一次,吐一个字第 2 次我要一杯少冰的珍珠奶茶刚吐的字接回输入末尾,再跑一次第 3 次我要一杯少冰的珍珠奶茶好的输入又长了一个字第 4 次我要一杯少冰的珍珠奶茶好的,以此类推,直到模型吐出一个「说完了」的标记两个直接后果① 生成 100 个字,就要把模型完整跑 100 次。生成越长越慢,而且是线性的 —— 这跟输入多长没关系。② 每一次的输入都比上一次长一个字。所以第 100 次要处理的文字,比第 1 次多 99 个字。
这个「拿自己刚生成的东西当下一次的输入」的循环,就叫自回归(autoregressive)。后面所有文章里出现「自回归」三个字,指的都是这张图里的循环,没有别的意思。
记住这一条,后面到处都要用

自回归的成本由「要生成多少字」决定,不由「输入多长」决定。

一个 5 万字的文档摘要成 50 个字,跟一句话扩写成 50 个字,生成阶段的步数是一样的 —— 都是 50 步。

二、KV cache:不想每次都重算

看上面那张图,你应该会立刻起疑:第 4 次的输入包含了前 3 次已经算过的全部内容,难道要整个重算一遍?

如果真的重算,代价高得离谱。生成第 100 个字要处理 100 多个字的输入,生成第 200 个字要处理 200 多个。算下来总计算量是步数的平方 —— 生成翻倍,计算量翻四倍。

好在不用。模型处理一段文字时,会给每个字算出两个中间结果(习惯上叫 K 和 V)。关键性质是:一个字的 K 和 V 只取决于它自己和它前面的字,跟后面还没生成的字无关

既然跟后面无关,那算过一次就不会变,存下来下次直接用就行。

生成 8 个字,每一步要算多少个字(每个方块 = 算一个字)不缓存:每步重算全部历史步 1步 2步 3步 4步 8总共算了 36 个字。步数翻倍,总量变四倍缓存后:每步只算新增的那一个步 1步 2步 3步 4步 8总共只算了 8 个。灰格子是从缓存里读出来的代价是显存:那些灰格子得一直存在显卡里,而且每生成一个字就多存一格 —— 一路存到这个请求结束才能整块释放。这块存下来的东西就叫 KV cache。它是显卡上除了模型权重之外最大的一笔开销,也是「同时能服务多少人」的直接限制。
右边那种「灰格子从缓存读、只算最后一个绿格子」的做法,是所有推理框架的默认行为。你在后面看到的名词 —— PagedAttention(把 KV cache 像操作系统管内存那样分页管理,避免长短请求把显存切得七零八落)、前缀缓存、抢占 —— 全都是围绕「这块缓存怎么管」展开的。
KV cache 是会涨的,而且只涨不落

一个请求生成得越久,它占的显存越多,中途不会释放。

所以推理服务有个纯文本时代就存在的经典问题:同时跑着的请求越多、生成得越长,显存越紧张。紧张到装不下时,框架只能把某个请求踢出去(这个动作叫抢占),等有空间了再从头重算。

三、预填充与解码:一次请求里的两种计算

回到第一张图。第 1 次和后面几次,其实不是同一种活。

  • 第 1 次:输入是用户完整的那句话,比如 12 个字。这 12 个字之前从没算过,得全部算一遍。但它们可以同时算 —— 因为每个字的 K、V 只依赖它前面的字,12 个字的依赖关系一次就能理清。
  • 第 2 次以后:只有 1 个新字要算,其余全在缓存里。

于是同一个请求被劈成了性质截然不同的两段:

同一个请求,前后两段的脾气完全相反预填充 · prefill只发生一次12 个字一起算,一次前向搞定要算多少:整段输入,可能几百上千个字能不能并行:能,所有字同时算卡的瓶颈在哪:算力。这一步能把显卡吃满用户感觉:这段时间就是「等第一个字出现」输入越长这一段越久,跟要生成多少字无关解码 · decode每个字一次,重复几十上百次1111…每次只算一个字,但要跑很多次要算多少:1 个字。计算量小到几乎可以忽略能不能并行:不能,必须一个接一个卡的瓶颈在哪:带宽。算得少,但每次都要把整个模型的权重从显存搬进计算单元一趟生成越长这一段越久,跟输入多长无关「算得少但每次都要把权重搬一遍」是解码阶段最反直觉的地方:显卡的算力大量闲置,时间全花在搬运上。这正是批处理能救场的原因,见下一节。
这两个词后面会一直出现。看到「预填充慢」,想的应该是输入太长;看到「解码慢」,想的应该是生成太长或者批太大。两者的优化手段几乎没有重叠。

四、批处理:为什么人多了反而划算

上一节最后那句话是关键:解码时显卡在搬运上花的时间,远多于计算

具体一点。假设模型权重有 14 GB,显卡的显存带宽是每秒 3 TB。那么把权重完整搬一趟要 4.7 毫秒。关键在于:这一步是给 1 个人算还是给 64 个人算,这 4.7 毫秒都一样得花

于是:

同时服务搬权重实际计算每步总耗时平均每人
1 人4.7 ms几乎为 0约 4.7 ms4.7 ms
8 人4.7 ms很小约 5 ms0.63 ms
64 人4.7 ms开始有分量约 8 ms0.13 ms

同一趟搬运,顺手把 64 个人的活一起干了。这就是批处理:把多个请求的这一步凑在一起算。

但直接凑有个问题:每个人生成的长度不一样。甲说完了,乙才说到一半。老办法是等整批都说完再开下一批 —— 那甲说完之后那张卡就替甲空转着,直到乙也说完。

改进办法很直接:谁说完谁走,空出来的位置立刻放一个新人进来,批的成员一直在变。这叫连续批处理(continuous batching),是现在所有推理框架的默认行为。

这是「吞吐」和「延迟」开始打架的地方

批开大,同一趟搬运服务更多人,总吞吐上升。

但批大了每一步的实际计算量也在涨(上表最后一行 4.7 → 8 ms),所以每个人看到的字变慢了。

想让所有人都快,只有一条路:把每一步的固定开销压下去。

那「每一步的固定开销」到底是什么?值得单独说清楚,因为后面十几处优化都在打它。

GPU 执行的最小单位叫 kernel(核函数),一次解码步要跑的不是一个大 kernel,而是几百上千个小 kernel —— 每一层的矩阵乘、每一次归一化、每一次激活,都是一个。这些 kernel 不会自己跑起来,得由 CPU 一个个「发」给 GPU。

发一次的开销只有几微秒,但乘上几百次就是几毫秒 —— 而解码步本身的计算可能也就几毫秒。于是出现一个荒唐局面:GPU 算得飞快,却大半时间在等 CPU 发下一条指令。这种状态叫 launch-bound(受启动开销限制),是解码阶段的常态。

对付它的办法就是 CUDA Graph:把这一串 kernel 的发送顺序预先录一遍,之后每一步直接「回放」这张图,CPU 只需要发一条「放」的指令,几百次发送变成一次。

代价有两条,后面会反复撞上:

  • 录的时候形状必须固定。所以框架要按批大小分桶,每个桶录一张图;来的批大小对不上任何一个桶,就只能退回逐个发。
  • 录制区间内不能有主机同步。任何一次 .cpu().item() 都会让录制失败 —— SGLang-Omni 那章的案例四正是被这个卡住的。

五、前缀缓存:开头一样就别重算了

再看一个具体场景。你的点单机器人有一段系统提示词,每次请求都一样:

你是一家奶茶店的点单助手。菜单如下:珍珠奶茶 18 元、
四季春 12 元、杨枝甘露 22 元……(此处省略 800 字)

用户那句「我要一杯少冰的珍珠奶茶」只有 12 个字,但每次实际送进模型的是 800 多个字。而这 800 字每次都一模一样。

第二节说过,一个字的 K、V 只取决于它自己和它前面的字。这 800 字前面没有别的东西,所以它们的 K、V 每次算出来都完全相同

那就别算第二遍了 —— 把它们留在显存里,下次直接拿。这叫前缀缓存(prefix caching)。

同一段系统提示词,两个请求请求 A系统提示词 800 字 —— 老老实实算一遍12 字预填充 812 字请求 B同样这 800 字 —— 命中缓存,一个字都不用算12 字预填充 12 字命中的条件很苛刻:必须从第一个字开始就完全一样。系统提示词改一个标点,后面全部作废 —— 每个字的 K、V 都依赖它前面的内容。所以工程上有条规矩:固定不变的内容放最前面,每次都变的(用户输入、时间戳)放最后。顺序反了,缓存永远命中不了。这个机制到了图片和音频上会失灵,而且是以一种很危险的方式失灵。
前缀缓存是精确复用:命中之后算出来的结果和不命中时逐位相同,纯赚。这一点很重要,因为后面会遇到另一类「近似」的加速手段,那种要拿质量换速度。图里最后那句「到了图片和音频上会失灵」是 09 篇 的主题,失灵的方式是会把一个用户的图片结果交给另一个用户。

六、四个指标,分别在问什么

最后把衡量快慢的词统一一下。这四个后面会反复出现,混用会得出完全错误的结论。

在问什么谁关心
TTFT(首字延迟)我按下回车,多久看到第一个字用户。它约等于预填充耗时
TPOT(每字间隔)之后每个字之间隔多久用户。它决定「读起来卡不卡」
吞吐这张卡一秒能服务多少人你的钱包
延迟 p95最慢的那 5% 的人等了多久你的投诉率
只报均值等于没报

延迟的分布是长尾的:可能 95% 的请求都在 200 毫秒内完成,剩下 5% 要等 3 秒。均值算出来 340 毫秒,看着挺好,但那 5% 的人已经在骂了。

后面所有性能数字,只要没带分位数(p95、p99),就只能当参考,不能当结论。

七、这六个概念之间的关系

串一遍,你会发现它们是一条因果链:

  1. 模型只会预测下一个字 → 所以要自回归地循环,生成 N 个字跑 N 次;
  2. 每次重算历史太贵 → 所以有 KV cache,代价是它会一直涨,涨到装不下就得抢占
  3. 第一次算整段、后面每次算一个 → 所以分成预填充解码两阶段,一个吃算力一个吃带宽;
  4. 解码时带宽是瓶颈、算力闲置 → 所以批处理划算,而且要做成连续批处理才不浪费;
  5. 批大了吞吐涨但每个人变慢 → 所以吞吐和延迟天然对立,只能靠压低每步固定开销来同时改善;
  6. 相同的开头每次都重算太亏 → 所以有前缀缓存,条件是从第一个字起完全一致。

这条链是纯文本服务的全部。接下来这个专题要做的事,就是往里面加进声音和图片,然后看这条链在哪些环节断掉。

第一处断裂就在最开头:这条链假设输入是「字」。可用户发来的是一段波形,它不是字。下一篇从这里开始。