在生产环境里跑过大模型推理的人,基本都遇到过这种场景:用户丢进来一份几百行甚至几千行的上下文,服务端的GPU风扇瞬间拉满,可结果迟迟不出来,页面上的第一个token愣是等了好几秒甚至几十秒。有人说这不就是模型运算太多嘛,忍忍就好,但忍完就是用户流失。这个延迟有个专门的名词——TTFT(Time To First Token),也就是从请求到达服务到返回第一个token的时间。而TTFT的命门,恰恰就在Prompt Prefill(预填充)阶段:模型要把整段提示词一次性过一遍Transformer,把中间状态全部算好,才能开始逐个吐token。
这篇文章想把Prefill慢这件事彻底讲清楚。我会先从Prefill和Decode两个阶段的计算特性差异说起,拆出TTFT拖慢的真正根源,再给出一套从算子优化、KV Cache管理到调度策略的工程优化路线图。最后会有可落地的排查工具箱和调参顺序,适合正在做在线推理服务的人、想优化自家大模型应用延迟的工程师,以及被长上下文折磨得够呛的算法同学参考。
1. 先看懂模型推理的两个阶段:Prefill与Decode的底层差异
1.1 为什么TTFT是用户体验的生死线
用户体验对延迟的敏感程度,比大多数技术指标都要残酷。对一个问答助手或对话式应用来说,用户敲下回车后的第一秒钟里,如果页面没有任何反应,体感就是“卡了”。这不是网络波动那种偶发,而是每次请求都会撞上的确定性延迟。
我常把TTFT比作餐厅上菜前的等待:哪怕你后面每道菜都是飞快端上来,只要第一道菜让人等了十分钟,客人对整家餐厅的评价就已经定型了。在线推理服务的核心体验,很大程度上由首token决定。
业界比较常见的服务目标一般是:
| 指标 | 参考目标 |
|---|---|
| TTFT P50 | 300 - 800ms |
| TTFT P95 | 1 - 2s |
| TTFT P99 | 2 - 3s |
| Decode吞吐 | 越高越好,但优先级低于TTFT SLO |
这里有个容易混淆的点:TTFT不等于模型计算时间。TTFT是端到端指标,它包含了请求排队等待、网络传输、输入长度校验、Prefill计算、kernel调度开销、以及把第一个token通过反序列化推回给调用方的全部时间。模型计算只是其中一段,但这往往是最长的一段。
所以优化TTFT,表面上是在压Prefill耗时,实际是在同时解决调度、排队、计算和IO协同的问题。
1.2 Prefill为什么比Decode慢一个数量级
很多人刚开始做推理优化时会有一个疑问:模型不是一样的吗,为什么处理整段提示词反而比逐个生成token还慢那么多?这不合理。
合理的地方在于,两者根本不是同一种计算形态。
Prefill阶段,输入是一整段prompt,假设有S个token,模型要并行计算所有token的注意力、前馈网络,把每一层的K和V写入KV Cache。这个阶段有一整个大方块矩阵乘等着计算,计算强度高,模型能用满GPU的FP16/BF16算力。从FLOPs角度看,处理一个token的前向计算量大约是2倍模型参数量。
举个例子:一个13B参数量的模型,处理单个token的前向计算大约是26 GFLOPs。当prompt长度是4096个token时,Prefill一次要算 26 × 4096 ≈ 106 TFLOPs。就算用一张BF16算力约300 TFLOPs的GPU,理论时间也接近0.35秒,实际打上折扣,加上注意力计算和各种kernel开销,上秒很正常。
而Decode阶段是逐token生成的,每一步只算当前这一个token的注意力、MLP,但需要反复读取完整的KV Cache。模型权重和KV Cache都得从显存搬到计算单元里,参数读取量远超计算量,这是一个彻底的访存密集型过程。
我见过很多人只盯着Decode速度优化,却忽略了一个事实:如果Prefill占掉了整体请求延迟的60%甚至70%,那Decode再快,用户的等待体验也不会好。尤其当prompt越来越长,几万token的上下文已经开始出现在一些文档分析、代码库理解场景中,Prefill耗时会被极度放大,TTFT的优化优先级正在被推到和Decode吞吐同等的位置。
1.3 两个阶段的计算特性对比
我用一个表格把这层区别说透:
| 维度 | Prefill | Decode |
|---|---|---|
| 输入形态 | 整段prompt并行处理 | 逐token串行生成 |
| 计算类型 | 计算密集(compute-bound) | 访存密集(memory-bound) |
| 核心矩阵 | 大矩阵乘,shape规整 | 每次一个小向量乘大矩阵 |
| 注意力矩阵 | [batch, heads, S, S],S是输入长度 | [batch, heads, 1, S],只有一个query |
| KV Cache写入 | 第一次写入全部K、V | 每次追加一行 |
| GPU利用率 | 高但一次耗时很长 | 低但持续占用 |
| 对TTFT贡献 | 直接决定首token来得快慢 | 影响后续token间隔,对TTFT影响小 |
从这表能看出来,Prefill和Decode对资源的需求完全不同。一个拿手电筒和一个拿探照灯的人站在一起,谁先被看到,取决于调度怎么分配光线。如果调度策略把两者混在一个通道里,不用巧劲,就会出现“一个长prompt请求把后面所有短请求都堵死”的惨剧。
2. Prefill性能瓶颈的三层拆解:计算、访存与调度
2.1 计算层:注意力矩阵的O(S²)陷阱
自注意力的核心是 QK^T。对于长度为S的输入序列,这个矩阵乘的结果是一个 [S, S] 的矩阵,意味着计算量和显存占用都随序列长度平方增长。
S=4096 时,注意力矩阵包含约1600万个元素,这还好说。但S=32768时,元素数量超过10亿,单是这个分数矩阵在FP16下就有2GB。如果不做融合,直接把完整的注意力分数写进显存再读出来做softmax,长序列Prefill会被直接打穿显存。
这也是为什么长上下文场景下,Preflfill的耗时曲线不是线性上涨,而是带一个明显的平方翘尾。模型参数规模决定了一个基本耗时基准,但序列长度决定了注意力部分会不会成为新的主导项。
还有一个理解关键:注意力矩阵的计算量并不因为用了高效的kernel就消失。FlashAttention这类高效实现能减少访存、避免实例化完整S×S矩阵,但FLOPs依然在那里。所以处理长序列时,计算量的天花板始终是存在的。
2.2 访存层:KV Cache与中间激活的读写放大
Prefill阶段要做的第一件大事,就是把每一层的K、V写入KV Cache。这听起来简单,但KV Cache的大小很容易超出直觉。
我算一个具体的数。假设一个13B模型,40层Transformer层,隐藏维度5120,采用GQA让KV头数为8,每个KV头维度128,序列长度4096,batch size为1,FP16精度:
KV Cache大小 = 2份(K和V)× 40层 × 4096序列长度 × 8头 × 128维度 × 1 batch × 2字节 ≈ 671MB。
如果一个请求就要吃掉671MB,并发8个请求就是5.4GB。如果没有GQA,KV头数还是32个,这个数字直接翻四倍,变成26.8GB,很多GPU直接就爆了。KV Cache的分配、写入、读取、释放,每一步都在跟显存带宽较劲。
再补一刀:Prefill阶段也存在中间激活的放大问题。虽然推理不需要保存反向传播的梯度,但每层前向计算过程中产生的中间结果还是要往下一层传,而注意力分数、Softmax输出这些如果算子没做融合,就会在显存和计算单元之间来回拷。
我经常用一个类比:Prefill像在食堂开饭前把上万份饭一次性做出来,光是把这些饭从厨房端到窗口就要累死好几个传输带;Decode则像是开饭后的零星加餐,但每一次都要把整个仓库的食材重新翻一遍。传输带宽的问题,两个阶段都存在,只是放大方式不同。
2.3 调度层:Batch策略与GPU空转
很多人的第一反应是,既然Prefill计算量大,那就把多个请求捆成一个batch,把GPU塞满不就行了?理论上是这样,实际却很难。
长prompt请求的Prefill计算时间可能是短请求的十倍以上。如果按传统方式调度,一个请求完整算完,再调度下一个,那么排在长请求后面的短请求TTFT会变得不可接受。而如果为了照顾短请求频繁打断大请求,又会造成大请求的Prefill迟迟做不完,GPU在切换时也有一层切换损耗。
更深层的问题是,Prefill和Decode混跑时的资源分配。GPU的算力是有限的,Prefill疯狂吃算力时,Decode的token生成速度会明显下降;反过来,如果一堆decode请求占住了显存带宽,Prefill虽然拥有巨大计算量,也未必能跑出理论峰值。
调度层优化的核心目标,是在“让大请求快点算完”和“不让小请求等太久”之间找平衡点。这个平衡点固定不下来,因为模型、GPU型号、prompt长度分布、请求实时负载全都在变。所以后续的工程优化,本质上是把这个问题拆成可配置的策略,然后针对实际流量做调参。
3. 工程化优化的四条主线
3.1 算子融合与高效注意力实现
算子融合是降低Prefill耗时最直接、也最成熟的手段。思路很简单:不要每一步都把中间结果写进显存,而是把一连串操作塞进一个kernel里,让数据留在寄存器里多转几圈。
举个例子,标准的注意力计算流程是:算QK^T、除以缩放因子、做Softmax、乘以V。如果不融合,这四步就有四次显存读写。融合之后,整个注意力计算可以在kernel内部完成,S×S的注意力矩阵从头到尾都不落显存。
我在实际项目里测过,单是这一步,长序列的Prefill耗时通常能下降20%到30%。但这还不够,因为计算量没有被削减,只是访存开销变小了。
要真正压计算量,有几个方向:
- 稀疏化注意力:窗口注意力只让每个token看附近一部分token,再加几个全局token做信息锚点。长序列下的计算量直接从O(S²)降到O(S·w)。
- MQA/GQA机制:让多个query头共享少数KV头,KV Cache大小和注意力计算量都能降。前面算过,KV头数从32降到8,KV Cache就降为原来的四分之一。
- 滑动窗口加分层下采样:本质上就是让网络在长序列上放弃“全局精确连接”,转而用局部的密集注意力和全局的稀疏注意力配合。
这些方案各有代价:稀疏注意力可能影响长距离依赖的建模,MQA可能让模型效果轻微下降。工程上一般不会在推理阶段直接改这些结构,而是希望在训练阶段就把带GQA和稀疏注意力的模型训好。如果你拿到的模型本身没有GQA,那么KV Cache膨胀是绕不过去的坎。
3.2 存储层:KV Cache的分配、复用与量化压缩
既然KV Cache这么大,第一件事就是别浪费显存。
传统的做法是按一个请求的最大可能长度预留KV Cache,这会浪费大量显存。更精细的做法是按块的动态分配策略:把KV Cache切成固定大小的block,需要时按需分配,不用的block可以被重新拿回来。这个思路跟操作系统的分页内存管理类似,碎片少了,并发请求多了,显存利用率明显上升。
但动态分配解决的是“怎么放”,缓存复用解决的是“怎么省”。
在大模型实际使用场景中,很多请求是共享前缀的。比如一个聊天机器人,系统提示词可能很长,每个用户请求都带着同一段开头。如果没有前缀缓存,这段公共前缀会在每个请求的Prefill阶段被反复计算。我见过一个文档问答类项目,系统提示词加一些固定指令占了2000个token,请求量一上来,这几千token的重复开销直接把GPU打满。
Prefix Caching的工程实现也不算复杂:把Prefill计算好的KV Cache以“前缀token序列”为key存下来,后续请求来做匹配,命中公共前缀就直接复制或者引用对应的KV Cache块,只对新增部分重新计算。匹配算法上通常用前缀树来管理缓存块,这样能最大程度复用祖先链上的缓存。
这里有个关键点:缓存的KV Cache是显存换时间。如果缓存了太多不同前缀,占用的显存反过来会挤压正常请求的KV Cache空间,导致并发度下降。所以生产环境一般要给前缀缓存设置显存上限,再配一个淘汰策略,按最近最少使用这类规则把不活跃的前缀踢掉。
KV Cache量化是另一条有效路径。用INT8精度存储KV Cache,显存占用直接减半;用INT4可以再减半。但量化不是白给的,低比特KV Cache会引入精度损失,在长上下文、需要高精度检索信息的场景中,效果下降可能明显。我见过有人把KV量化一刀切打开,结果长文档问答的准确率掉了好几个点。正确做法是先做效果对比测试,再决定哪一层量化、量化到什么程度。
我个人倾向的生产配置是:FP8或INT8作为默认选项,INT4只对不敏感的辅助模型开,主模型想压到INT4,必须过完整的效果评估集。
3.3 调度层:连续批处理与分块Prefill
调度层的优化,往往是整个优化链条里收益最快、也最隐蔽的一环。
先讲连续批处理。传统批处理以请求为单位,一批请求必须全部结束后才能调度下一批。这会让早到的短请求被同批的长请求拖住。连续批处理的粒度更细,以一次模型迭代为单位:每次迭代结束,系统就去检查有没有已经准备好的请求可以插入空位。一个长Prefill请求不会独占GPU一整轮,短请求可以在它计算的间隙插进来。
我举个简化例子:假设模型一次decode迭代要50ms,一次长Prefill要500ms。传统批处理下,排在后边的短请求要等500ms才能开始;连续批处理下,Prefill被拆成多个可抢占的片段,每50ms就有一次调度机会,短请求的TTFT大幅缩短。
然后是分块Prefill。连续批处理解决的是“调度机会”,分块Prefill解决的是“长Prefill本身的原子性”问题。如果不拆分,一个长Prefill在处理过程中会持续占用GPU的计算单元,期间decode请求即使能插队,计算资源也竞争不过Prefill。
分块Prefill把一个大请求的prefill切成长度合适的chunk,每算完一个chunk就插入一些decode步骤。这样做的副作用是:模型权重需要被反复读取多次,因为每个chunk都要从头过一遍所有层。
chunk到底切多大,是个需要实测的平衡点:
- chunk太大:长Prefill依然会长时间霸占GPU,短请求的TTFT改善有限。
- chunk太小:权重重复读取的开销会被放大,GPU整体吞吐下降,所有请求一起变慢。
我在实际调优时一般会把chunk大小从256、512、1024、2048扫一遍,观察TTFT的P50/P99和整体吞吐,再结合平均prompt长度选一个折中值。对大多数对话场景,512到1024是比较稳的起步区间,长文档场景则可能需要调大。
调度层还有一个容易被忽视的细节:优先级。对在线业务来说,不是所有请求都值得同等对待。我希望短请求优先返回首token,而长prefill请求愿意多等。通过给请求设置优先级,让短请求在调度队列里插到前面,TTFT的P99优化效果会非常明显。代价是长请求的尾部延迟有所上升,但换来的是整体用户体验提升,这笔账通常划算。
3.4 模型与推理框架协同:从输入压缩到并行计算
如果模型本身太大,或者prompt太长,单靠推理引擎的调度和算子优化,天花板还是会被顶到。这时候得从模型和系统设计上再想想办法。
输入压缩是最容易立竿见影的手段。现在很多产品为了让模型理解“全程对话”,会把历史对话、检索片段、系统指令一股脑拼进prompt。几千甚至上万token的输入,对Prefill来说是巨大的负担。把不太重要的历史记录做摘要、把检索片段截断到真正的关键段落、把固定指令精简,都能直接缩短prompt长度,Prefill耗时差不多是线性下降的。
投机采样是另一种被反复提及的优化。它的核心思路是用一个小模型先草稿出几个token,让大模型一次性验证。这个技巧主要收益在Decode阶段,能让整体token生成速度明显提升。但如果首token本身就慢,投机采样救不了Prefill。所以我的建议是:先压Prefill和TTFT,再把投机采样作为Decode吞吐的补充手段来用。
并行策略上也有一笔账。Tensor Parallel把模型权重切到多张卡上,Prefill的计算量可以被多卡并行消化,实测中Prefill耗时接近线性下降。比如8卡并行处理一个超大batch的Prefill,理论上比单卡快好几倍。但每切一刀都会引入通信开销,卡数增加之后,通信占比会上升,可能从接近线性退化成增量收益很小。千万不能无脑堆卡,而是要实测通信占比。
最后是精度选择。FP8训练还不算特别普及,但FP8推理已经被不少框架支持。对某些模型来说,FP8能减少显存占用并提高计算吞吐,Prefill延迟确实能降一截。同时也要注意,FP8的数值范围不如FP16宽,个别层的输出可能溢出。稳妥做法是先跑一个量化前后的效果对拍,让评估集告诉你能不能用。
4. 实操:TTFT排查工具箱与参数调优实录
4.1 第一步先量化现状:TTFT到底慢在哪
没有数据的优化都是瞎忙。拿到任何优化需求,我第一件事永远是先把TTFT拆开。
拆的方法很直接:在请求进入服务的地方打一个时间戳,Prefill开始前打一个,Prefill结束后打一个,发出第一个token时再打一个。这样就得到了排队时间、Prefill计算时间、其余开销三个分段。多数时候你会发现,Prefill本身占了50%以上,这种时候别浪费时间调网络或者序列化,直接打Prefill就行了。
算子级别的问题要用性能分析工具看。主流的深度学习框架自带Profiler,能告诉你每个算子的耗时、显存占用、kernel启动次数。GPU厂商也提供更底层的分析工具,可以看GPU利用率、显存带宽利用率、算子之间的空档。有一次我排查一个TTFT异常,从业务指标看一切正常,但用底层工具一测,发现某个版本的融合算子没有生效,注意力那一步居然在跑未融合的版本,Prefill直接翻倍。这种问题不 profiling 是根本找不出来的。
我每次调优都会做一张基准表,记录以下内容基线:
- GPU型号和数量
- 模型规模和精度
- 平均/最大prompt长度
- batch size和并发配置
- 连续批处理和分块Prefill开关
- TTFT的P50、P95、P99
- Decode阶段的token生成速度
- GPU利用率和显存占用
拿这张表去对比每一次改动前后的差异,比凭感觉调参可靠一万倍。
4.2 分块Prefill参数怎么定
分块Prefill的chunk大小和调度窗口这两个参数,值得单独拿出来说,因为它俩直接决定了系统在“计算大块”和“响应小块”之间的姿态。
chunk size的实操方法,我前面提过,扫参数。我一般这样操作:
- 固定其他配置不变
- 用一组带真实长度分布的prompt流量回放
- 分别测 chunk = 256、512、1024、2048
- 记录TTFT P50/P99和总吞吐
举例来说,在某13B模型的实验里,我测到这样的趋势:chunk从256调到512时,TTFT P99从1.9s降到1.3s,吞吐只掉了3%;继续调到1024时,TTFT P99进一步降到1.1s,但吞吐掉到了基线的88%。这种情况下,512或1024都可选,取决于业务更在意首token还是整体吞吐。
max_num_seqs和max_batch_tokens这两个调度上限也要一起看。它们决定了GPU的一次前向最多容纳多少请求和多少token。如果max_batch_tokens设得太低,大prompt请求会被被动拆成多轮,Prefill变慢;设得太高,极端情况下一个大请求就能占满整个batch,其他请求全部堵塞。建议让它服从一个经验值:max_batch_tokens ≈ 平均prompt长度 × 期望并发请求数 × 0.6到0.8,再实测微调。
4.3 可复现的调优顺序建议
调优不要同时开很多开关,改完一个就测一轮,才能知道每个改动到底带来了什么。我给出一个我自己比较推荐的顺序:
- 打开连续批处理。这一步通常零风险,收益明显,让调度器具备插队能力。
- 打开前缀缓存,先设置一个保守的显存上限,观察命中率和TTFT变化。
- 检查算子融合情况。确认当前框架里注意力、LayerNorm、激活函数这些算子是不是真的融合了,没有融合的版本先升版本或手动开启。
- 评估KV Cache量化。先做效果对拍,再决定量化层级和精度。
- 调整分块Prefill。配合前几步的结果,扫chunk size找最佳点。
- 优先级调度。如果业务场景对短请求有明显偏好,这一步能显著拉低P99。
- 最后考虑并行策略和精度调整。这是改动成本最大的一步,能不动就不动。
每走一步,都要跟基线表对比。如果某一步的收益低于噪声水平,果断回滚,不要因为“别人说这个有效”就硬留着。
5. 常见问题与排查技巧实录
5.1 问题速查表
| 现象 | 可能原因 | 排查手段 |
|---|---|---|
| 长prompt一进来,GPU利用率瞬间飙高但TTFT依然很高 | Prefill计算量本身太大,算子可能未融合 | Profiler看attention算子耗时,确认是否走融合kernel |
| 短请求被严重拖慢,P99飙升 | 长Prefill请求占满GPU计算单元,无抢占机制 | 开启连续批处理和分块Prefill,调小chunk |
| 开启某些优化后TTFT反而变差 | chunk太小导致权重重复读,或KV量化精度问题引发重试 | 扫chunk size,量化效果对拍 |
| 并发一到峰值就显存OOM | KV Cache分配碎片化,或前缀缓存占用过多 | 检查KV Cache block管理和前缀缓存上限 |
| Prefill阶段GPU利用率有,但显存带宽打满 | 访存瓶颈,可能存在未融合的中间结果写读 | Profiler检查显存带宽利用率,找未融合算子 |
| 长文档回答问题效果下降 | KV Cache量化把关键信息丢了 | 关掉KV量化或改回INT8/FP8,重新对拍效果 |
5.2 几个易踩的坑
指标口径不统一,是最容易被忽略的坑。有些人统计TTFT把排队时间刨掉,有些人把网络传输算进去,结果两个团队对同一个系统得出的结论截然相反。在做优化之前,先定清楚TTFT的起止点:从服务端收到请求,到调用方收到第一个token。所有优化对比都按这一个口径走。
第二个坑是只调参数不验证业务效果。有的优化把TTFT压下来了,但下游任务准确率少了三四个点。这种“快了但蠢了”的优化,在线上是事故。KV量化、稀疏注意力一类的改动,一定要搭配效果评估集来做回归。
第三个坑是忽略请求长度分布。有些优化方案在平均prompt 1k的场景里表现优秀,到了平均prompt 8k的场景直接垮掉。因为长序列下注意力复杂度的平方项会重新变成主导,甚至带动GPU显存分配策略失效。选方案前先看看你线上真实的prompt长度分布,别拿别人家的经验直接抄。
第四个坑是调优过度。所有优化项全开之后,系统的复杂度和不稳定因素都在增加。实际项目里我见过因为前缀缓存和分块Prefill同时开,且缓存块管理有bug,导致某些前缀的KV Cache被错误引用,输出质量突然下降。优化要留一点余地,不要把所有开关都怼到极限。
写在最后的一点体会
做Prefill和TTFT优化这么久,我最大的感受是:大多数人的第一反应是调模型、换框架、上并行,但真正的问题往往出在“不知道慢在哪”。没有一份清晰的TTFT分段数据,谈优化就是猜。而一旦把请求拆到“排队、Prefill、Decode、传输”这四段,90%的情况会立刻锁定到一个方向上,接下来就是拿着工具一点点啃。
另一个心得是,性能优化要懂取舍。TTFT、吞吐、显存、效果,这四个维度互相牵制。短请求的P99被压得很漂亮时,长请求的尾延迟可能已经惨不忍睹。系统的姿态由业务决定:C端交互产品优先保首token,批处理任务则优先保吞吐。想清楚自己的核心目标,再去做工程手段,才不会被一堆好看的中间指标带偏。
最后分享一个小技巧:线上版本一定要留一个全局开关,能一键回退所有可选的优化项。我曾经在灰度一个分块Prefill改动时,发现效果对拍到了晚上就异常,直接把开关拉回去,线上立刻恢复。这种“保命”机制,比任何精妙的优化都重要。