1. 从一次线上延迟抖动说起:为什么需要端到端视角
很多人第一次接触大模型推理,都是从一行model.generate()开始的。本地跑个小模型,输入一句话,几秒钟后吐出结果,感觉这事儿挺简单。可一旦把它搬到线上,面对几十路并发、几百毫秒的延迟预算、还有按 token 计费的成本压力,你会发现原来那行代码背后藏着一整条链路,任何一个环节掉链子,用户端感受到的就是"卡"。
我印象很深的一次,是帮一个团队排查推理服务的延迟抖动问题。监控上看 P99 延迟时不时飙到两三秒,但 GPU 利用率却不高,显存也没打满。一开始大家怀疑是模型太大、算力不够,差点就去申请加卡。后来把整条链路拆开逐段打点,才发现真正的瓶颈根本不在 GPU 计算上,而是请求排队和 KV Cache 的显存碎片导致的反复分配释放。这件事让我彻底意识到:大模型推理从来不是"一次前向计算"这么简单,它是一个端到端的系统工程。
所谓端到端流程,指的是从用户发出请求,到最终拿到完整回复,中间经过的每一个环节。它大致包括请求接入与排队、输入预处理与分词、Prefill 预填充阶段、Decode 逐 token 解码阶段、KV Cache 管理、批处理调度、采样与后处理、流式返回,以及贯穿全程的显存与算力资源调度。这条链路上,有的环节吃算力,有的环节吃显存带宽,有的环节纯粹是 CPU 和内存的活儿,还有的环节是调度策略的博弈。
理解这条链路的价值在于:当性能出问题时,你能快速定位到底是哪一段的锅,而不是盲目加卡。加卡能解决算力瓶颈,但解决不了调度不当、显存碎片、分词拖慢这些问题。反过来,一个懂端到端流程的人,往往能在不加硬件的前提下,把吞吐提升一大截。
这篇内容适合几类人看:刚入门想搞懂推理服务到底怎么跑起来的开发者;已经在做推理优化、但总觉得优化点抓不准的工程师;还有做技术选型和容量规划、需要理解各环节资源消耗的架构同学。我会尽量把每个环节讲透,既说清楚"是什么",也讲明白"为什么这么设计",再补上一些实际踩坑的经验。全程不堆术语,能用生活例子讲清楚的,就不绕弯子。
2. 请求进来之后:接入、排队与预处理里藏着的细节
2.1 请求接入层为什么不能只是"转发"
很多人以为推理服务的入口就是个反向代理,把 HTTP 请求转给后端 GPU 就完事了。实际上接入层承担的责任比想象中重得多。它要做协议解析、鉴权、限流、请求体校验,还要把不同格式的输入统一成内部结构。更关键的是,它决定了请求以什么顺序、什么粒度进入后面的调度队列。
举个实际场景:如果接入层不做任何缓冲,来一个请求就直接丢给调度器,那么当瞬时流量突增时,调度器会被大量细碎请求淹没,批处理根本攒不起来,GPU 利用率上不去。反过来,如果接入层做了合理的排队和微批聚合,就能把同一时刻到达的多个请求打包成一个批次一起送进去,吞吐立刻不一样。这就是为什么很多推理框架会在接入层和调度层之间加一个队列缓冲。
提示:队列不是越长越好。队列太长会让请求的排队延迟不可控,用户等半天才轮到,体验反而更差。通常要结合延迟预算反推队列深度。
2.2 分词与输入预处理:被低估的 CPU 瓶颈
分词(Tokenization)这一步,看起来只是把文本切成 token,但它经常成为隐藏的 CPU 瓶颈。尤其是当并发量上来之后,分词、padding、attention mask 构造这些操作都在 CPU 上跑,如果实现得不够高效,GPU 就会空等数据,出现"GPU 利用率上不去但请求也处理不快"的尴尬局面。
我见过一个案例,服务用的是比较重的分词实现,单条请求分词要几毫秒,QPS 一高,CPU 直接打满,GPU 反而闲着一半。后来换成更轻量的分词方案,并做了批量化分词,整体吞吐提升了将近三成。这个教训是:别只盯着 GPU,CPU 侧的预处理同样能决定上限。
预处理还包括几个容易忽略的点:
- Padding 策略:把不同长度的输入补齐到同一长度才能组批。补齐到批次内最长,还是补齐到固定长度,直接影响计算浪费。补齐到批次内最长更省,但批次内长度差异大时,短请求会被长请求拖累。
- 位置编码的准备:不同模型对位置信息的处理方式不同,有的在预处理阶段就要算好,有的在模型内部动态生成,这会影响预处理的复杂度。
- 多模态输入的处理:如果涉及图像、音频,预处理阶段还要做特征提取,这部分往往比文本分词重得多,需要单独规划资源。
2.3 排队策略:先来先服务未必是最优解
请求排队的策略直接决定了延迟分布。最常见的先来先服务(FIFO)实现简单,但在混合负载下表现一般。比如一个长请求排在前面,后面一堆短请求就得干等,这就是所谓的"队头阻塞"。
实际生产中更常用的是带优先级的调度,或者按预估输出长度做排序。有些系统会把短请求优先处理,因为短请求占用资源时间短,能快速释放,整体平均延迟更低。还有的会做"连续批处理"(Continuous Batching),也就是不等整批做完,某个请求一结束就立刻把新请求塞进空出来的位置,让 GPU 始终处于忙碌状态。
这里有个反直觉的点:追求单请求最快,和追求整体吞吐最高,往往是矛盾的。单请求最快意味着它独占资源、不等待组批;整体吞吐最高意味着要攒批、要等待。线上服务通常要在两者之间找平衡,而这个平衡点取决于你的业务是延迟敏感还是吞吐敏感。
3. Prefill 与 Decode:推理计算的两个性格完全不同的阶段
3.1 Prefill 阶段:一次性把输入"读"进去
Prefill 阶段处理的是用户输入的整段 prompt。它的特点是一次性把所有输入 token 一起送进模型,做完整的前向计算,生成第一个输出 token,同时把每一层的 Key 和 Value 缓存下来,供后续解码使用。
这个阶段的算力特征很鲜明:计算密集,矩阵乘法规模大,GPU 利用率容易打满。因为输入的所有 token 可以并行计算,天然适合批处理和并行加速。也正因如此,Prefill 阶段对算力的需求是"爆发式"的——一个长 prompt 进来,瞬间就要吃掉大量算力。
Prefill 的耗时和输入长度基本成正比。输入越长,计算量越大,耗时越长。这就解释了一个常见现象:为什么用户输入一大段文字后,首 token 的等待时间明显变长。首 token 延迟(Time To First Token,TTFT)主要就是被 Prefill 阶段决定的。
注意:长 prompt 的 Prefill 不仅慢,还会占用大量显存来存 KV Cache。如果并发里有好几个长 prompt,显存压力会陡增,甚至触发 OOM。
3.2 Decode 阶段:一个 token 一个 token 地"挤"出来
Decode 阶段是逐 token 生成的过程。每生成一个 token,都要基于之前所有 token 的 KV Cache 做一次前向计算,然后采样出下一个 token,再把它追加到序列里,循环往复,直到遇到结束符或达到长度上限。
这个阶段的算力特征和 Prefill 截然不同:它是显存带宽密集型的,而不是算力密集型的。因为每次只处理一个 token,矩阵乘法的规模很小,GPU 的算力根本用不满,真正的瓶颈在于把庞大的 KV Cache 从显存里读出来。换句话说,Decode 阶段 GPU 大部分时间在"搬数据",而不是"算数据"。
这就带来一个关键认知:优化 Decode 阶段,重点不是提升算力,而是减少显存访问、提高显存带宽利用率。这也是为什么各种 KV Cache 量化、稀疏化、分页管理技术层出不穷——它们的目标都是让 KV Cache 更小、读取更快。
Decode 阶段的另一个特点是它决定了输出 token 的生成速度,也就是所谓的 TPOT(Time Per Output Token)。用户感受到的"打字机"式流式输出,节奏就是由 Decode 速度决定的。
3.3 两个阶段的资源画像对比
把两个阶段放在一起对比,能更清楚地看到它们的差异:
| 维度 | Prefill 阶段 | Decode 阶段 |
|---|---|---|
| 处理对象 | 整段输入 prompt | 单个输出 token |
| 计算特征 | 计算密集 | 显存带宽密集 |
| 并行度 | 高,token 间可并行 | 低,token 间串行依赖 |
| GPU 利用率 | 容易打满 | 通常偏低 |
| 主要瓶颈 | 算力 | 显存带宽与 KV Cache 读取 |
| 对应指标 | 首 token 延迟 TTFT | 每 token 延迟 TPOT |
| 显存影响 | 写入 KV Cache | 读取并追加 KV Cache |
理解这张表,很多优化思路就顺理成章了。比如 Prefill 和 Decode 混在一起跑时,会互相干扰:Prefill 抢占算力,拖慢 Decode 的节奏;Decode 占用显存带宽,拖慢 Prefill。于是就有了"Prefill 与 Decode 分离部署"的思路——把两类负载放到不同的资源池,各取所需,互不干扰。这个思路在近两年被广泛讨论,本质就是承认这两个阶段"性格不合",硬凑在一起效率低。
4. KV Cache:推理服务里最值得精打细算的一块显存
4.1 KV Cache 到底缓存了什么
要理解 KV Cache,得先回到注意力机制。在自注意力计算里,每个 token 都会生成 Query、Key、Value 三个向量。生成新 token 时,它需要和之前所有 token 的 Key 做点积算注意力权重,再对 Value 加权求和。如果每生成一个 token 都把之前所有 token 的 Key、Value 重算一遍,计算量会随序列长度平方增长,完全不可接受。
KV Cache 的思路就是:把已经算过的 Key 和 Value 存下来,下次直接复用,不重算。这样每生成一个新 token,只需要算它自己的 Q、K、V,然后和缓存的 K、V 做运算即可。计算量从平方级降到线性级,这是自回归生成能实用的关键。
代价是显存。KV Cache 的大小和几个因素成正比:层数、注意力头数、头维度、序列长度、批大小,以及数据类型。粗略估算,一个中等规模模型在长序列、大 batch 下,KV Cache 占用几个 GB 到几十 GB 都很正常。这就是为什么显存经常是推理服务的头号约束。
4.2 显存碎片:那个让 P99 延迟飙升的隐形杀手
回到开头提到的那个延迟抖动案例。当时排查到最后,问题就出在 KV Cache 的显存管理上。服务用的是朴素的"按最大长度预分配"策略,每个请求一进来就按可能的最大序列长度预留显存。结果短请求浪费了大量预留空间,长请求又可能因为找不到连续大块显存而等待,显存碎片越来越严重,分配释放的开销也越来越大,延迟自然就抖。
解决思路是引入更细粒度的显存管理,比如分页式管理(Paged Attention 的思路)。它把 KV Cache 切成固定大小的小块,按需分配,不要求连续。这样一来,显存利用率大幅提升,碎片问题也缓解了。实测下来,同样的显存能支撑的并发数明显增加,P99 延迟也稳定了很多。
提示:如果你的服务在并发升高时出现延迟抖动、显存利用率却不高,优先怀疑显存碎片和分配策略,而不是急着加卡。
4.3 给 KV Cache"瘦身"的几种常见手段
既然 KV Cache 是显存大户,围绕它的优化自然很多。常见的几类:
- 量化:把 KV Cache 从 FP16 降到 INT8 甚至更低。显存占用直接减半甚至更多,代价是精度可能略有损失,需要评估对输出质量的影响。
- 稀疏化与淘汰:不是所有历史 token 都同等重要。有些方案会淘汰掉注意力权重低的 token 的 KV,或者只保留关键 token 的缓存,从而压缩占用。
- 共享与复用:多个请求如果有相同的 prompt 前缀(比如相同的系统提示词),可以共享这部分 KV Cache,避免重复存储和计算。这在有大量相同前缀的场景下收益巨大。
- 分层存储:把不常用的 KV 放到更慢但更大的存储里,需要时再换回来。这是拿延迟换显存的思路,适合对延迟不那么敏感的场景。
每种手段都有取舍,没有银弹。量化省显存但可能掉精度,稀疏化省显存但实现复杂,共享复用收益大但依赖请求特征。选哪种,取决于你的业务更在意什么。
5. 批处理与调度:把 GPU 喂饱的艺术
5.1 静态批处理为什么不够用
最早的批处理是静态的:攒够一批请求,一起送进 GPU,等整批全部生成完,再处理下一批。这种方式实现简单,但效率低。因为一批里各个请求的输出长度不一样,短的早就生成完了,却要等最长的那个结束,GPU 在等待期间白白空转。
这就是"长尾拖累"问题。一批里只要有一个超长输出,整批的完成时间就被它拉长,其他请求的资源被浪费。静态批处理在输出长度差异大的场景下,效率损失非常明显。
5.2 连续批处理:让 GPU 一刻不停
连续批处理(Continuous Batching)是解决这个问题的关键思路。它的核心是:不等整批结束,任何一个请求生成完就立刻退出,空出来的位置马上填入新请求。这样 GPU 的批次始终是满的,利用率大幅提升。
打个比方,静态批处理像一辆公交车,必须等所有乘客到终点才发下一班;连续批处理像流水线,有人下车就有人上车,车一直在跑。实测中,连续批处理能把吞吐提升数倍,尤其在输出长度参差不齐的对话场景下效果显著。
实现连续批处理需要调度器精细管理每个请求的状态,还要处理 KV Cache 的动态分配释放,复杂度不低。但收益摆在那里,所以主流推理框架基本都支持。
5.3 调度策略里的取舍:延迟、吞吐与公平性
调度器要同时照顾好几个目标,而这些目标经常打架:
- 延迟优先:让每个请求尽快开始、尽快结束,可能需要牺牲批次大小,吞吐上不去。
- 吞吐优先:尽量攒大批次,让 GPU 满载,但单个请求的等待时间变长。
- 公平性:避免某些请求被无限期饿死,尤其在混合长短请求时。
实际系统里常见的做法是分优先级队列,或者按预估输出长度排序。还有的会做"抢占式"调度,高优先级请求来了可以打断低优先级请求。这些策略没有绝对优劣,关键看业务场景。在线对话服务通常延迟敏感,批处理规模要克制;离线批量生成任务吞吐敏感,可以大胆攒批。
注意:调度策略的调整一定要配合压测验证。纸面上算出来的最优参数,放到真实流量下经常不是那么回事,因为真实请求的长度分布、到达模式都和测试假设不同。
6. 采样、后处理与流式返回:用户真正感知到的部分
6.1 采样策略如何影响输出质量与速度
模型前向计算输出的是每个位置上的 logits,也就是词表上的分数分布。真正决定生成哪个 token 的,是采样策略。常见的几种:
- 贪心解码:每次选概率最高的 token。速度快、结果确定,但输出容易重复、缺乏多样性。
- 温度采样:用温度参数调节分布的平滑程度。温度高更随机,温度低更保守。
- Top-k 采样:只在概率最高的 k 个 token 里采样,过滤掉长尾噪声。
- Top-p 采样(核采样):在累积概率达到 p 的最小集合里采样,动态调整候选范围。
采样本身的计算量不大,但它影响输出长度,进而间接影响总耗时。比如温度设得高,模型可能生成更发散、更长的内容,Decode 阶段就要多跑很多步。所以采样参数不只是"效果调优",也是"性能调优"的一部分。
6.2 后处理:别让最后一步拖了后腿
后处理包括 detokenize(把 token 还原成文本)、特殊字符清理、格式化、敏感内容过滤等。这些操作看起来轻量,但在高并发下同样可能成为瓶颈,尤其是 detokenize 和字符串拼接,如果实现不当,CPU 开销不容小觑。
还有一个容易被忽略的点:流式返回时的分块策略。如果每生成一个 token 就立刻推给客户端,网络往返次数太多,开销大;如果攒一大块再推,用户又觉得不够"实时"。通常要在两者之间找个平衡,比如按固定 token 数或固定时间间隔推送。
6.3 流式返回对整体体验的影响
流式返回是提升用户感知速度的利器。虽然总生成时间没变,但用户能很快看到第一个字,心理上就觉得"快"。这也是为什么首 token 延迟(TTFT)比总生成时间更受关注——它直接决定了用户的第一印象。
实现流式返回需要整条链路都支持增量输出:模型逐 token 生成,后处理逐块处理,网络层用流式协议推送。任何一环不支持流式,整条链路就退化成"等全部生成完再返回",体验大打折扣。
7. 把整条链路串起来:一次请求的完整旅程
7.1 从请求到响应的逐段拆解
现在把前面讲的串起来,看一次请求到底经历了什么:
- 接入:请求到达接入层,完成鉴权、限流、格式校验,进入排队队列。
- 预处理:分词、构造 attention mask、准备位置信息,可能还要处理多模态输入。
- 调度:调度器根据策略决定这个请求何时、和哪些请求一起组批。
- Prefill:整段输入一次性前向计算,生成首 token,写入 KV Cache。
- Decode 循环:逐 token 生成,每步读取 KV Cache、计算、采样、追加缓存。
- 采样与后处理:每个 token 经过采样策略确定,再 detokenize、清理、过滤。
- 流式返回:按分块策略把结果推给客户端,直到生成结束。
- 资源回收:请求结束,释放 KV Cache 和占用的批次位置,供新请求使用。
这条链路上,每一段都有自己的耗时和资源消耗。端到端优化的本质,就是找到当前瓶颈在哪一段,然后针对性地解决。
7.2 各环节的典型瓶颈与排查方向
| 环节 | 典型瓶颈 | 排查方向 |
|---|---|---|
| 接入与排队 | 队列积压、限流误伤 | 看队列深度、排队时长分布 |
| 预处理 | CPU 打满、分词慢 | 看 CPU 利用率、预处理耗时 |
| Prefill | 首 token 延迟高 | 看输入长度分布、算力利用率 |
| Decode | 每 token 延迟高 | 看显存带宽、KV Cache 大小 |
| 显存管理 | 碎片、OOM、抖动 | 看显存利用率、分配释放频率 |
| 调度 | GPU 空转、批次不满 | 看批次大小、GPU 利用率曲线 |
| 后处理 | CPU 开销、推送频繁 | 看后处理耗时、网络往返次数 |
这张表可以当作排查清单用。遇到性能问题,先定位是哪一段,再顺着对应方向查,比盲目加卡高效得多。
7.3 端到端优化的优先级怎么排
优化要有优先级,不能眉毛胡子一把抓。我的经验是:
- 先解决显存问题。显存是硬约束,OOM 了什么都白搭。优先上分页管理、量化、前缀共享这些手段。
- 再优化调度和批处理。这是提升吞吐性价比最高的地方,连续批处理往往能带来数倍收益。
- 然后针对 Prefill 和 Decode 分别优化。长 prompt 场景重点优化 Prefill,长输出场景重点优化 Decode。
- 最后抠预处理和后处理的细节。这些是"锦上添花",但在高并发下也能积少成多。
顺序很重要。如果显存没管好就去调调度,很可能调着调着就 OOM 了,前面的努力全白费。
8. 几个容易踩的坑和我的实际体会
8.1 只盯 GPU 利用率,忽略整条链路
最常见的误区就是"GPU 利用率低就是没优化好"。其实 GPU 利用率低可能是好事,也可能是坏事。如果是因为请求少、负载轻,那正常;如果是因为 CPU 预处理慢、调度不当导致 GPU 空等,那就是问题。判断的关键是看 GPU 空等的原因,而不是单纯看数字。
我一般会同时看几个指标:GPU 利用率、CPU 利用率、显存利用率、队列深度、各阶段耗时。单看一个指标很容易误判。
8.2 盲目追求大 batch
大 batch 能提升吞吐,但不是越大越好。batch 太大,单请求等待时间变长,延迟上升;显存占用也增加,可能触发 OOM。而且 batch 大到一定程度,收益递减,因为 GPU 已经饱和了。找到那个"刚好喂饱 GPU 又不浪费"的 batch 大小,比一味求大更重要。
8.3 忽视请求长度分布的影响
很多优化方案在测试时用的是均匀长度的请求,效果很好,上线后却拉胯。原因是真实请求的长度分布往往是长尾的——大部分短,少数极长。长尾请求会拖累批处理效率,也会占用大量显存。所以压测时一定要用贴近真实的长度分布,最好直接采样线上流量回放。
8.4 我个人的一点体会
做了这么多推理优化,我最大的体会是:端到端流程里,没有哪个环节是孤立的。你优化了 Prefill,可能给 Decode 带来压力;你增大了 batch,可能加剧显存碎片;你上了量化,可能影响输出质量进而改变生成长度。所有环节都是联动的,改一处要观察全局。
所以我的习惯是,每次调整只动一个变量,然后完整观察整条链路的指标变化,确认没有副作用再继续。这种"小步快跑、全程监控"的方式,比一次性大改要稳得多。另外,别迷信任何"最佳实践",因为每个业务的流量特征、延迟预算、成本约束都不一样,适合别人的参数未必适合你。多压测、多观察、多迭代,才是正道。
最后分享一个小技巧:如果条件允许,给推理服务加上细粒度的分段耗时打点,从接入到返回每一段都记录。平时可能觉得这些日志没用,但一旦出问题,这些数据就是定位瓶颈的救命稻草。我排查过的绝大多数性能问题,靠的都是这些分段数据,而不是猜。