做推理服务的人大概率都见过这种诡异现象:同一个模型,同一块显卡,同一个请求。前面你敲了一大段提示词,等了半天才蹦出第一个 token;后面它倒是接二连三地吐字,可你打开监控一看,显存带宽快被吃满了,算力核心却像在看戏。以前我总把这种忽快忽慢归结为“模型负载波动”,直到把整个推理流程掰开揉碎,才发现真正的原因是:一次大模型请求,本质上是被 Prefill 和 Decode 两段完全不同的计算形态拼起来的。
这篇文章就专门拆这两个阶段。不管你是做推理引擎优化、做模型服务治理,还是刚接触大模型链路,先搞清楚 Prefill 和 Decode 的差异,很多性能问题都能少走一半弯路。我尽量用实际能落地的计算账本、调度策略和排查手段来讲,不堆名词,讲完就能用到你自己的服务上。
1. 先搞清楚这两个阶段到底在干嘛
1.1 Prefill:把“你输入的整句话”一次性算完
Prefill,有的地方叫“预填充”,指的是模型拿到你的整段提示词之后,把所有输入 token 并行地过一次 Transformer 前向计算。比如你输入“用三句话介绍一下杭州”,这段文本被切成长度大概是 12 个 token。Prefill 阶段做的事情,就是让这 12 个 token 同时穿过几十层网络,算出每一层的隐状态和注意力结果。
这里有一个关键结果:每个输入 token 在注意力层算出来的 Key 和 Value 向量,会被完整保存下来。这就是常说的 KV Cache,它是给下一阶段用的“记忆条”。Prefill 只负责把你输入的内容“读进去、记下来”,并不产生真正的回答内容——输出侧会在最后给你第一个 token 的预测结果,这个 token 一般被当作 Decode 的第一个输入,也有可能由调度器直接返回。
你可以把 Prefill 理解为老师拿到一篇文章之后先通读一遍,顺手把所有段落重点都标在纸条上。这个过程读得快,因为整篇文章可以同时看,不需要一个字一个字等。它的特点是:计算量巨大,但只需要一次性完成。
1.2 Decode:一个字一个字地“现挂”
Decode 阶段才是真正“写回答”的部分。从第一个生成 token 开始,模型把当前已经生成出来的 token 作为输入,走一遍前向计算,预测下一个 token 的概率分布,采样出一个新 token,再把它接回序列末尾,重复这个过程,直到遇到结束符或者达到最大长度限制。
关键就在“重复”这两个字上。每一步你只能生成一个 token,因为下一个 token 依赖前面所有已经生成的内容,这是因果语言模型的结构决定的。所以 Decode 本质上是一个串行循环:算一步,读一步,写一步,循环几百甚至几千次。
很多人刚接触时会问:为什么不能像 Prefill 那样把生成的 100 个 token 一起并行算?答案是做不到,因为你都不知道第 50 个 token 是什么,怎么可能提前算出第 51 个。这就是 Decode 最让人头疼的地方:单步计算量不大,但步数很多,且每一步之间严格串行。
1.3 因果掩码决定了“先后天”分工
这两个阶段的分工,根源在注意力机制的因果掩码上。Transformer 解码器在设计上就不允许后面的 token 看到前面的 token。Prefill 阶段里,第 i 个 token 在算注意力时,可以同时看第 0 到第 i-1 个 token,所以所有位置可以并行;而 Decode 阶段,你每多生成一个 token,就相当于在序列末尾新开了一个位置,这个位置必须等前面所有位置都算完才能开始。
用一句话概括:Prefill 的并行维度来自“同一时刻的不同位置”,Decode 的串行维度来自“不同时刻的依赖关系”。同样是那一套矩阵乘法,前者可以一次性把矩阵做得又大又宽,后者只能一次做一根细长的计算。
搞清楚这个差异之后,再看性能问题,很多现象就解释得通了:为什么长提示词的请求首字延迟高?因为 Prefill 要算的东西多。为什么生成阶段显存带宽飙升、算力利用率却低?因为 Decode 每一步都在大量读权重和缓存,真正落在计算单元上的活很少。
2. 计算形态:一个算力拉满,一个带宽吃紧
2.1 算术强度:判断瓶颈的最短路径
要判断一个计算任务是“吃算力”还是“吃带宽”,最直接的指标叫算术强度,定义是:总计算量除以总内存访问量,单位是 FLOP/Byte。算出来的数值如果远大于硬件平衡点,就是算力受限;如果远小于硬件平衡点,就是带宽受限。
硬件平衡点怎么来?就是显卡的峰值算力除以峰值显存带宽。拿一张常见的训练卡举例,FP16 峰值算力约 300 TFLOPS,显存带宽约 2 TB/s,那么平衡点就是 300÷2,约 150 FLOP/Byte。也就是说,你的任务每访问 1 个字节的数据,如果计算量远超过 150 次浮点运算,算力会成为瓶颈;如果远低于 150,内存带宽会成为瓶颈。
这个逻辑其实和判断快递站是“分拣慢”还是“送货慢”一模一样。快递站每收到一个包裹,需要搬运 1 次,分拣 100 次。如果货量大,分拣台就忙不过来;如果包裹本身很重,搬运可能先累坏。你必须知道单件包裹里“搬运”和“分拣”的比例,才能判断瓶颈在哪。
2.2 用一张 7B 模型的账本算明白
我们拿一个典型的 7B 参数模型来算账。模型参数约 70 亿,FP16 存储下权重占 14GB。推理一个 token 的前向计算量,业界常用经验公式估算:约等于 2 × 参数量。也就是 14 GFLOPs。
先看 Prefill,假设用户一次输入 4096 个 token。计算量是 2 × 7B × 4096,约 57.3 TFLOPs。内存访问方面,权重 14GB 只需要从显存读一遍;还要把 4096 个 token 的 KV Cache 写进去,以常见的 GQA 结构来算大约是 0.5GB 左右;中间激活量在算子融合做得好的情况下也不大。算下来总内存访问约 14.5GB。算术强度就是 57.3 TFLOPs 除以 14.5GB,约 3950 FLOP/Byte。
这个数字远高于 150 的平衡点,说明 Prefill 是典型的算力受限任务。理论耗时约 57.3 TFLOPs 除以 300 TFLOPS,大概 191 毫秒。考虑到注意力部分的开销和实际算子效率,真实耗时会在两三百毫秒以上,但量级就在这里。
再看 Decode,假设生成阶段已经到了第 4096 个位置,batch 为 1。这一步只算一个 token,计算量就是 14 GFLOPs。内存访问方面,权重 14GB 一份不少地要读一遍;同时还要读前面所有位置的 KV Cache,也就是 0.5GB 左右;再写入当前 token 的 KV,几乎可以忽略。总内存访问也约 14.5GB。算术强度就是 14 GFLOPs 除以 14.5GB,约 0.97 FLOP/Byte。
这个数字远低于 150,说明 Decode 是彻头彻尾的带宽受限任务。理论耗时约 14.5GB 除以 2 TB/s,约 7.25 毫秒。如果你生成 4096 个 token,光是 Decode 部分就要 29 秒左右,而 Prefill 那 191 毫秒相比之下几乎可以忽略。
2.3 为什么 Decode 批得再大也喂不饱算力
有人会问,那我把 Decode 的 batch 放大,是不是就能把算力用起来?确实会好一些,但远不够。把 batch 提到 32,单步计算量变成 448 GFLOPs,这个数字已经不小了;内存访问呢?权重 14GB 依然要读一遍,32 个序列的 KV Cache 大约 16GB,加起来约 30GB。算术强度算下来约 15 FLOP/Byte,仍然远低于 150 的平衡点。
也就是说,即使并行处理 32 个请求,Decode 阶段依然是带宽说了算。单步理论耗时约 15 毫秒,能产出的 token 是 32 个,聚合吞吐约 2100 token/s,看起来不错,但算力利用率只有不到 10%。想真正把算力跑满,batch 需要大到上百,而那时候 KV Cache 的显存会先把你压垮。
这就解释了一个反直觉的现象:Decode 阶段 GPU 利用率可能显示很高,比如 nvidia-smi 里看到 90% 以上,但实际算力核心大部分时间在空转。因为利用率算的是“有没有算子在工作”,不是“算力单元用满了多少”。带宽受限的算子同样会占用执行单元,只是大量时间花在等数据从显存搬过来。这个坑几乎每个人都踩过,后面我在排查清单里会再讲。
3. KV Cache:两个阶段之间的“接力棒”
3.1 Prefill 负责写,Decode 负责读加写
Prefill 和 Decode 不是两个孤立的计算过程,它们之间靠 KV Cache 传递信息。Prefill 阶段把输入序列所有 token 的 Key 和 Value 算出来,写入缓存;Decode 阶段每走一步,都要拿当前 token 的 Query 去和缓存里所有的 Key 做注意力计算,然后用注意力权重去加权求和所有 Value。
同时,Decode 每一步还会把当前 token 新的 Key 和 Value 追加到缓存尾部。所以严格来说,Decode 是“边读边写”。这就像一个读书笔记:Prefill 负责把整本书的重点抄下来,Decode 每写一段新内容,就要回翻笔记,同时还要把新写的那句话也记进笔记里。
为什么非要缓存,而不是每一步都把前面的 token 重新算一遍?因为那样的话,生成第 N 个 token 时,要把前面 N 个 token 全部重算一遍,总计算量会从 O(N) 变成 O(N²),长序列生成直接不可用。KV Cache 是用显存换时间的经典手法,它是整个推理服务的显存大头,也是优化空间最集中的地方。
3.2 KV Cache 的显存账本:为什么一张卡装不下多少并发
KV Cache 的大小有一个很直观的公式:总字节数 = 2(K 和 V 两份)× 层数 × KV 头数 × 每个头的维度 × 精度字节数 × 序列长度 × 并发请求数。
用刚才的 7B 模型举例,假设 32 层、8 个 KV 头(GQA)、每个头 128 维、FP16 精度,那么每个 token 占用的缓存是 128KB。如果每个请求平均生成 4096 个 token,单个请求的 KV Cache 就是 512MB,看起来还行。但并发 64 个请求时,KV Cache 总量到了 32GB;并发 128 个时,直接 64GB。
这是什么概念?一张 80GB 显存的卡,光模型权重就要 14GB,剩下的空间里 KV Cache 占掉 64GB,留给激活值的空间非常紧张。也就是说,限制你在线推理并发量的主要因素,很多时候不是算力,而是 KV Cache 的显存。你加再多的卡,只要 KV Cache 管理不好,吞吐一样上不去。
这也是为什么新出的模型普遍用 GQA(分组查询注意力)或者 MQA(多查询注意力)。把 KV 头从 32 个砍到 8 个,KV Cache 直接降到四分之一,能在不大幅损失质量的前提下,把并发能力提上去。设计模型结构时就要考虑推理期显存,而不是只盯着训练期算力。
3.3 分页管理:用操作系统的思路解决显存碎片
KV Cache 是动态增长的,每个请求每生成一个 token 就要追加一小块内存。如果给每个请求预分配最大长度的连续显存,那么短请求会造成大量浪费,长请求又可能申请不到足够大的连续块;如果按需分配小块,又会产生碎片,而且连续分配在大并发下很难维持。
现在的推理框架普遍采用 PagedAttention 这类分页方案,思路和操作系统虚拟内存几乎一样。KV Cache 被切成固定大小的块,每个序列维护一张块表,按需申请新块。这样既能避免预分配浪费,又能减少碎片;多个序列如果共享相同的前缀,还能通过块复用省下大量显存,所以不少框架都支持自动前缀缓存。
你在优化服务时,如果发现显存增长很突兀,千万别只盯着模型权重。先看 KV Cache 的分配策略:是不是预分配了最大长度?是不是块太小导致块表开销过大?是不是没有做前缀复用?这些细节对并发上限的影响,往往比模型量化那一点省下来的显存大得多。
4. 工程调度:让两个阶段各取所长
4.1 连续批处理:把不同请求“插队”进解码循环
早期推理服务的批处理很简单:攒够一批请求,一起 Prefill,然后一起 Decode,等这批全部生成完,再接收下一批。这种静态批处理的问题很明显:一个短请求生成了 10 个 token 就结束,却要等其他长请求生成几百个 token,GPU 白白空等;新来的请求又必须排队,等这一整批跑完才能插进来。
现在的推理框架普遍用连续批处理,也叫迭代级调度。GPU 不是按“请求”为单位切换的,而是按“解码步骤”为单位。每一步 Decode 结束后,调度器都会重新规划:哪些请求已经结束,可以释放资源;哪些新请求排了太久,可以插进来做 Prefill;哪些请求还在解码途中,继续保留。
这样做的核心收益是让算力和带宽始终有活干。Preflfill 请求插进来,可以短期拉高算力利用率;Decode 请求继续跑,可以维持稳定的内存带宽压力。两者混在一起,GPU 就不容易出现“一半时间忙死、一半时间闲死”的状态。代价是调度器变复杂了,每一步都要检查新请求、清理资源、重新排算子。
4.2 Chunked Prefill:把长提示词切成小块混跑
连续批处理解决了“请求之间”的调度问题,但还没解决“请求内部”的问题。当一个超长提示词请求进入时,它的 Prefill 可能持续几百毫秒甚至几秒。如果调度器让它一口气跑完,这段时间里所有正在 Decode 的请求都会被堵住,用户感受到的就是突然卡顿。
Chunked Prefill 的思路是把一个长 Prefill 切成多个小块,比如每块 256 个 token,允许调度器在两个块之间插入一些 Decode 步骤。这样长的 Prefill 会被“摊薄”到时间线上,不再独占 GPU。其他请求的 Decode 延迟波动会明显减小,长提示词请求自己的首 token 延迟也不会被无限推迟。
但这个方案不是没有代价。Prefill 切块会让大矩阵变成小矩阵,算子效率会下降,整体 Prefill 吞吐也会有一定折损。调度器还要处理 Prefill 的“暂停恢复”状态,复杂度又上一层。工程上是典型的用算力换延迟稳定:如果你的服务里短请求多、对尾延迟敏感,Chunked Prefill 很值;如果全是长文档分析、对首 token 延迟不在乎,那就不如整段 Prefill 跑完来得高效。
4.3 分离部署:Prefill 池和 Decode 池各管一段
既然 Prefill 是算力密集型、Decode 是带宽密集型,那么很自然的想法是:用两类不同的 GPU 分别处理这两个阶段。Prefill 池选算力强、显存带宽不是重点的卡;Decode 池选显存大、带宽高的卡。Prefill 跑完后,通过高速网络把 KV Cache 传给 Decode 池,然后由 Decode 池专门负责生成。
这就是 PD 分离架构。它的好处很明显:两个阶段不再互相干扰,长提示词 Prefill 不会拖垮 Decode 延迟;每个池的批大小、并行度、算子优化都能单独调,硬件选型也更灵活。在大规模高并发服务里,这种架构能把整体吞吐和延迟稳定性同时提上去。
但 PD 分离不是银弹。首先是网络开销,KV Cache 传输要时间,如果提示词特别长,传输成本可能抵消掉分离的收益;其次是模型权重要同时放在两个池里,显存总量变相翻倍;最后是调度复杂度大幅上升,请求要在两个池之间流转,失败重试和负载均衡都要重做。我个人的建议是:中小规模服务先把连续批处理和 Chunked Prefill 调好,PD 分离适合到了单套推理服务明显撑不住吞吐、且有足够流量规模的时候再上。
4.4 盯紧哪些指标:TTFT、TPOT、ITL 和吞吐
做推理服务压测,绝不能只看一个“生成速度”或者“每秒 token 数”,要拆开看四个指标:
| 指标 | 对应阶段 | 定义 | 典型目标 |
|---|---|---|---|
| TTFT | Prefill | 从请求发起到返回第一个 token 的时间 | 百毫秒级,越小越好 |
| TPOT | Decode | 生成每个输出 token 的平均耗时 | 20 毫秒内,看业务要求 |
| ITL | Decode | 相邻两个输出 token 的时间间隔,反映抖动 | 越稳定越好,防止卡顿感 |
| 吞吐量 | 整体 | 单位时间处理的总 token 数 | 在满足前三者的前提下最大化 |
TTFT 基本由 Prefill 耗时决定,所以想降低首 token 延迟,优先优化 Prefill;TPOT 由 Decode 单步耗时决定,想提升生成速度,优先解决带宽瓶颈和 KV Cache 读取效率;ITL 抖动大,多半是调度器混跑了长 Prefill,这时候 Chunked Prefill 和更细粒度的调度能救回来。
很多团队压测时只看平均 token/s,结果线上首 token 延迟奇高无比,用户抱怨“半天没反应”,但压测报告一片飘绿。我踩过这个坑之后,所有服务上线前至少会固定测一组 TTFT 分位数和 ITL 分位数,这两项不过关,平均吞吐再高也不能放量。
5. 实操:怎么验证一个请求到底卡在哪个阶段
5.1 从时间线分别测两个阶段
想知道你的服务瓶颈在 Prefill 还是 Decode,最直接的办法是测两个数字:第一个 token 返回时间,和每步 token 间隔。正常情况下,TTFT 代表 Prefill 加调度延迟,token 间隔代表 Decode 单步耗时。下面这段伪代码的思路,可以直接套到任何流式输出接口上:
t_start = current_time() first_token = stream_read_first_token() ttft = current_time() - t_start # Prefill 主导 last = current_time() for token in stream_read_all_tokens(): now = current_time() itl = now - last # Decode 单步间隔 last = now tpot = (current_time() - t_start - ttft) / total_output_tokens如果测出来 TTFT 高、ITL 正常,说明你的 Prefill 拖了后腿,去看输入长度、并行配置和 KV 预分配;如果 TTFT 正常但 ITL 很高,说明 Decode 的带宽或调度出了问题,去看批大小、KV Cache 读取方式和权重是否量化。如果两个都高,通常是整体资源不足或者调度策略太激进。
这套流程 5 分钟就能跑完,也是我每次接到“模型慢”反馈后做的第一件事。先不看 profiling,先拿真实请求的时间线把阶段分清楚,再往下定位,能省掉大量瞎猜的时间。
5.2 用性能分析工具看内核的耗时分布
时间线只能告诉你“哪个阶段慢”,进一步要搞清楚“慢在哪个算子”。这时需要上 GPU profiler,把一次请求的前向过程完整抓下来。抓完之后,你重点看两类数据:算子耗时排名和内存带宽利用率。
Prefill 阶段抓下来的通常是一批大 GEMM,单个算子耗时占比很高,内存带宽利用率也不低但远没到饱和,算力利用率比较好看。Decode 阶段抓下来的则是一堆小 GEMM,每个算子耗时反而可能不长,但数量非常多,算子启动开销占了相当比例,内存带宽利用率附近贴着上限。
看到一个典型的 Decode profiling,注意事项是不要单看“哪个算子耗时长”,小算子再多也不代表它慢,关键是看内存搬移是不是已经饱和。如果带宽已经打满但算力利用率只有几个点,那这就是纯正的带宽受限,优化方向变成:减权重复读、减 KV Cache 体积、加大并发、或者做算子融合减少中间数据的来回搬运。
5.3 一个简单的消融试验:改长度、改并发
如果你不方便上 profiler,还有一个土办法能判断阶段瓶颈:做几组消融实验。固定模型和显卡,设计四组请求:同样总 token 数,但分成 1 个长提示词、2 个中提示词、4 个短提示词;再固定提示词长度,把输出长度从 128 改成 512 和 2048。
观察结果:如果总耗时随着提示词长度明显增加,说明 Prefill 占了大头,算力或 Prefill 调度有问题;如果总耗时会随输出长度线性上升,而提示词长度变化影响很小,说明 Decode 才是主要时间来源。再配合并发压测,看每增加一个并发,单请求延迟是线性恶化还是突然恶化。
突然恶化基本可以断定是 KV Cache 显存触顶,触发了换入换出或者排队。线性恶化则是资源竞争,需要看算力还是带宽先到上限。我自己常用这个方法来快速判断一个陌生模型的推理服务配置是否合理,不用打开专门的工具,几组数据下来,哪个阶段是短板就现原形了。
5.4 常见误判清单:别被数据骗了
这些是推理性能排查里特别容易踩的误区,整理成一张表:
| 误判 | 实际情况 | 正确思路 |
|---|---|---|
| GPU 利用率高说明算力用满了 | 利用率只表示有算子在工作,带宽受限也会显示高利用率 | 配合看算力占用率和带宽占用率两条曲线 |
| 显存不够就是模型太大 | 在线推理里 KV Cache 才是显存增长主力 | 先统计 KV Cache 占用,再看权重 |
| Decode 慢就加算力卡 | Decode 是带宽受限,加算力不一定有用 | 优先减权重读量、减 KV、加大批、优化调度 |
| Prefill 一定要整段跑完 | 长 Prefill 会造成其他请求停摆 | 用 Chunked Prefill 混跑,或做 PD 分离 |
| 平均吞吐高就代表服务好 | 首 token 延迟和抖动更能体感影响用户 | 固定测量 TTFT 分位数和 ITL 分位数 |
6. 别被“decode”这个词带偏:三种常见报错其实和推理无关
有人搜 Prefill 和 Decode 时,会混进来一堆带着“decode”字样的报错,比如下载图片失败、容器镜像拉取失败、Python 读取文件报错。这些其实跟大模型推理不在一个语境里,但既然经常被一起搜到,顺便把它们的含义和排查思路讲清楚,免得你从推理优化跳到报错排查时摸不着北。
6.1 下载图片时提示 image decode failed
这个报错出现在图片下载或预览时,意思是解码器拿到二进制数据后,无法按预期的图片格式(JPEG、PNG、WebP 等)还原出像素。最常见的原因是文件不完整:网络下载中断、服务器返回的是一个错误页面却被存成图片、或者传输过程中数据损坏。
排查顺序建议是:先看文件大小和预期大小是否一致;再用能打开图片的编辑工具尝试重新保存;最后检查是不是格式伪装,比如实际是 HTML,扩展名却写成.jpg。如果是程序里处理下载,加上完整性校验,比如对比 Content-Length,或者解码头尾标记,能省掉很多后续烦恼。
6.2 拉取镜像时报 failed to decode referrers index
这条报错出现在用容器工具拉取公共镜像时,看起来很长很吓人,但本质是镜像仓库元数据解析失败。referrers index 是镜像清单的一种附加索引,里面指向的是一些附加描述信息。当客户端请求这段索引数据,返回的内容格式不对,或者解析器无法识别,就会报出 “failed to decode referrers index”。
实际遇到时,常见原因包括客户端版本太老或太新,镜像仓库缓存了异常内容,以及网络代理对返回数据做了改动。简单处理办法是:升级容器工具版本、清掉本地缓存、换一个仓库源,或者直接指定镜像的数字摘要来拉取,绕过元数据索引这一层。这个问题和模型推理里的 decode 毫无关系,不要混在一起查。
6.3 UnicodeDecodeError:字节串按错编码读成文本
Python 里最常见的 decode 报错,是把一个二进制字节串按 UTF-8 转成字符串时失败,典型提示是某个位置的字节无法解码。比如 0xD5 这个字节,在 UTF-8 里本身是一个合法多字节字符的首字节,但它不能出现在一个多字节序列的续字节位置,也不能单独存在。
这个报错绝大多数情况是你读取的文件并不是 UTF-8 编码,而是 GBK、GB2312 这类本地编码。尤其在中文本地环境下生成的日志、配置文件,经常默认是 GBK。排查时先确认文件真实编码,用带编码检测的编辑器看一眼;读取时显式指定正确编码,或者用 errors='replace' 临时兜底,先把数据读出来再决定清洗方案。
6.4 一张表分清“解码”的几种含义
| decode 出现的地方 | 实际含义 | 排查关键词 |
|---|---|---|
| 大模型推理 | 自回归逐 token 生成 | TTFT、TPOT、KV Cache、带宽 |
| 图片处理 | 压缩像素数据还原 | 文件完整性、格式、损坏修复 |
| 容器镜像 | OCI 清单与索引解析 | 格式版本、缓存、校验和 |
| Python 文件读取 | 字节串按字符集转字符串 | 编码检测、GBK、errors 参数 |
| 网络协议 | 报文按协议格式解包 | 长度字段、粘包、解包错误 |
7. 从两个阶段到一套调优思路:我的实践心得
讲了这么多,最后聊几句实际调优中的体会。我自己接过好几个“服务变慢”的排查,第一步永远是先分清阶段,没有一个例外。有时候你觉得是模型推理引擎配置不对,结果时间线一拆,发现大部分时间花在加载权重、请求排队、网络传输上,和 Prefill、Decode 一点关系都没有。先把时间线拉出来,再决定往哪个方向用力,这是最值得养成的习惯。
第二个体会是不要迷信新架构。PD 分离好,Chunked Prefill 也好,都是特定规模和特定流量特征下的最优解。团队规模就几个人、并发没到几百,老老实实把连续批处理调好、把指标打点做清楚,比盲目拆两个池子更有性价比。我见过一个团队上了全套分离架构,结果相持不下在 KV 传输网络上,延迟反而变差了。
第三件想提醒的事是,调优时把用户可感知的指标放在第一位。平均吞吐再漂亮,首 token 延迟抖动和生成卡顿才是用户真正骂街的原因。每次改动上线前,固定跑一组 TTFT 和 ITL 的分位数测试,把最差情况记下来,比盯着聚合吞吐更能反映服务真实质量。
还有个小技巧:Decode 阶段如果带宽已经吃满而算力闲置很大,除了常规的量化、批处理和批大小调整,可以关注一下投机解码这类思路,用一个小模型先猜多个 token,大模型一次验证,能有效把 Decode 阶段的算力捡起来。虽然实现复杂度不低,但在带宽受限的场景里,投机解码是少数能把两阶段算力同时用起来的方向之一。
说到底,Prefill 和 Decode 是同一个模型的两个侧面:一个像集中精力读完一整本书并做好笔记,一个像逐字逐句地边查笔记边往外写。把这两个阶段的时间分配、计算瓶颈和调度关系摸清楚,再去调服务,你会突然发现很多之前玄学般的性能问题,其实都是确定的工程问题。