同一套大模型服务,白天处理短问答时很顺,接入长文档摘要后却开始出现一种奇怪现象:显卡利用率并不总是满的,平均延迟看起来也还能接受,但聊天用户会周期性地感到“打字机卡住了”。监控进一步显示,首个 Token 等待时间和相邻 Token 间隔在互相牵制——提高一次调度允许处理的输入 Token 数,长提示词更快完成,正在生成的请求却被更长的计算占住;缩小输入块,流式输出平稳了,长提示词又排起队。
这不是简单地多加副本就必然能解决的问题。自回归大模型的一次请求天然分成两个形态差异很大的阶段:Prefill 一次处理大量输入 Token,偏向矩阵计算;Decode 在已有 KV Cache 上逐个产生新 Token,每步计算量小,却反复读取权重和缓存。把二者塞进同一批 GPU、同一个调度队列,优点是简单且没有跨实例搬运缓存;代价是两类工作会抢同一套资源,并且只能一起扩缩容。
Disaggregated Serving,也就是 Prefill/Decode 分离服务,把它们放进独立工作池:Prefill 节点负责计算提示词并生成 KV Cache,随后把缓存交给 Decode 节点继续生成。它不是一个“打开就提速”的开关,而是一笔交换:用额外路由、缓存传输和运维复杂度,换取两个阶段的隔离与独立扩缩容。本文从 SLO 出发,给出一条可复现的容量估算、调度、验证和故障排查路线。
1. 先把用户感知拆成三个指标
谈推理性能时只看“整次请求耗时”会掩盖问题。流式生成至少要分开观察:
- TTFT(Time To First Token):请求到达至首个输出 Token 返回的时间,主要包含排队、Prefill、KV 传输和 Decode 首步。
- ITL/TPOT(Inter-Token Latency / Time Per Output Token):生成期间相邻输出 Token 的间隔,直接影响流式阅读是否顺滑。
- E2E Latency:从请求到完整输出结束的总时间,同时受输入长度、输出长度和排队影响。
例如知识库问答可能有数万 Token 的检索上下文,但回答只有几百 Token;代码生成的输入较短,输出却很长。前者常给 Prefill 施压,后者常让 Decode 槽位长时间被占用。若把二者的平均值混在一起,一个流量占比高的短请求桶会把长请求的尾延迟完全遮住。
因此容量分析的第一张表不应是 GPU 型号,而应是工作负载分布:输入 Token 的 P50/P95/P99、输出 Token 的 P50/P95/P99、到达率、并发数、可复用前缀比例,以及不同业务各自的 TTFT 和 TPOT 目标。SLO 也不能只写“平均小于两秒”,至少要明确分位数和请求类别,例如“交互流量 P95 TTFT”和“批处理流量完成时限”。平均值对值班人员很友好,对尾部用户却没有保护。
2. 为什么两个阶段会互相干扰
在标准 Transformer 解码器中,Prefill 对整段提示词做前向计算,并为每层生成 Key 和 Value。输入越长,注意力和线性层要处理的 Token 越多。Decode 则在每一步只新增一个 Token,复用之前的 KV Cache,直到满足停止条件。
可以用一个故意简化的模型理解资源形态。设输入长度为L_in,输出长度为L_out,模型层数为N。Prefill 的总工作随L_in增加,一次迭代能把大量 Token 组成较大的矩阵;Decode 需要执行L_out次迭代,每次都要读取模型权重以及与历史长度相关的缓存。真实内核、注意力实现、并行策略会改变常数项,不能用这组符号直接预测毫秒数,但它能说明二者为何适合不同的批处理和并行配置。
聚合部署通常使用连续批处理,把新请求的 Prefill 与已有请求的 Decode 放进相邻甚至同一次调度迭代。Chunked Prefill 会把长输入切成小块,限制单次 Prefill 对 Decode 的阻塞。这个方案简单、没有远程 KV 传输,通常应当作为基线。只有在以下信号持续出现时,拆分才值得进入实验:
- 长输入明显抬高正在生成请求的 TPOT 尾延迟;
- Prefill 与 Decode 的峰值出现在不同时间,无法用一个副本数同时满足;
- 两个阶段需要不同的张量并行度、批大小或硬件;
- 业务可以承担高速 KV 传输网络和更多故障点;
- 真实压测证明传输成本小于隔离收益。
反过来,小模型、短提示、低并发或者仅有 PCIe/普通以太网的环境,经常是聚合部署更合算。拆分不会减少模型计算,也不会让 KV Cache 消失;它只是改变计算发生在哪里以及两阶段怎样排队。
3. 一次拆分请求到底经过什么
完整路径比“两个服务”多得多。入口首先要认证、限流并计算 Token 长度,然后选择 Prefill worker。Prefill 生成缓存后,控制面把块编号、目标地址等传输元数据交给 Decode worker,数据面再通过 NVLink、RDMA/UCX 或对应连接器传送缓存。Decode 只有确认所需块可见后才能开始输出。任何一步超时,都不应把不完整的缓存当成可继续生成的状态。
NVIDIA Dynamo 的官方设计把 Prefill 和 Decode 注册为不同类型的 worker,由 Prefill Router 协调选择与交接,并可通过 NIXL 使用 NVLink、InfiniBand/UCX 等传输路径。官方文档也明确提醒,跨池的模型、数据类型、块大小和 KV 布局必须兼容。这里最危险的故障不是显式报错,而是两个池滚动升级不同步:连接仍然成功,缓存解释却不一致。因此部署标签中应包含模型权重摘要、服务版本、KV dtype、块大小和布局版本,路由器只允许完全兼容的组合配对。
4. 先用可解释模型估算,而不是拍脑袋分卡
第一次划分 Prefill/Decode GPU 时,可以从生产采样数据做一个粗估。下面的脚本不假装模拟 GPU,它把服务实测得到的阶段速率带入排队预算,回答“当前窗口更缺哪一类容量”。prefill_tokens_per_second_per_worker和decode_tokens_per_second_per_worker必须来自同一模型、硬件和并行配置的压测,而不是从别人的博客复制。
from__future__importannotationsfromdataclassesimportdataclassfrommathimportceil@dataclass(frozen=True)classTrafficWindow:requests_per_second:floatmean_input_tokens:floatmean_output_tokens:float@dataclass(frozen=True)classStageProfile:tokens_per_second_per_worker:floattarget_utilization:float=0.70def__post_init__(self)->None:ifself.tokens_per_second_per_worker<=0:raiseValueError("单worker速率必须大于0")ifnot0<self.target_utilization<1:raiseValueError("目标利用率必须位于0和1之间")defworkers_for(token_rate:float,profile:StageProfile)->int:safe_capacity=profile.tokens_per_second_per_worker*profile.target_utilizationreturnmax(1,ceil(token_rate/safe_capacity))defestimate_split(traffic:TrafficWindow,prefill:StageProfile,decode:StageProfile,)->tuple[int,int]:ifmin(traffic.requests_per_second,traffic.mean_input_tokens,traffic.mean_output_tokens,)<0:raiseValueError("流量与Token数不能为负数")prefill_rate=traffic.requests_per_second*traffic.mean_input_tokens decode_rate=traffic.requests_per_second*traffic.mean_output_tokensreturnworkers_for(prefill_rate,prefill),workers_for(decode_rate,decode)def_self_check()->None:traffic=TrafficWindow(2.0,4_000,500)p,d=estimate_split(traffic,StageProfile(5_000,0.8),StageProfile(800,0.8),)assert(p,d)==(2,2)if__name__=="__main__":_self_check()这里保留目标利用率,是因为把平均到达量正好除以理论吞吐量会把队列推到不稳定边缘。目标值不是固定的0.70:突发性越强、SLO 越紧,通常越需要余量。还要分别对业务桶计算,而不是只使用全局均值。生产版本可以把每五分钟窗口的结果作为建议,自动扩缩容仍需考虑启动时间、模型加载、连接预热和最小副本数。
这个模型还故意没有把 KV 传输折算进 Prefill 速率。更稳妥的做法是独立测量:缓存字节数、有效带宽、排队、注册和同步开销。传输不是单纯的bytes / link_bandwidth,因为链路可能和其他作业共享,跨 NUMA、跨交换机以及小块控制消息也会产生差异。
5. KV Cache传输是拆分方案的成本中心
对于常见多头注意力,可以粗略估算每个序列的 KV 字节数:
KV bytes ≈ 2 × 层数 × KV头数 × 头维度 × 序列长度 × 每元素字节数
前面的2代表 Key 和 Value。使用 GQA/MQA 时,KV 头数小于查询头数,因此不能把模型总注意力头数直接代入。不同服务框架还会按块分配,并保留元数据、对齐空间或多级缓存,所以公式只用于数量级检查,显存预算应以运行时指标为准。
当 Prefill 和 Decode 在同一 NVLink 域内时,传输路径相对短;跨节点后通常需要 RDMA 网络。若网络不支持 GPU Direct,数据可能绕经主机内存,额外复制会吞掉分离收益。上线前至少验证四件事:端到端实际带宽、并发传输时的尾延迟、故障重试是否重复占块、网络拥塞是否同时影响模型通信。
缓存传输也有生命周期问题。Prefill 成功并不等于请求成功:Decode 可能在接收前崩溃,客户端也可能取消请求。系统需要明确谁拥有块、何时释放、超时由谁回收,并让取消信号贯穿入口、Prefill、传输层和 Decode。否则低频失败会逐渐变成显存泄漏,直到整池 OOM。
不要在传输失败后无条件重试到任意 Decode 节点。缓存块可能只在原节点预留了地址,或者部分写入。安全策略是使用请求级唯一标识和传输世代号:新的尝试分配新世代,接收方只提交完整且校验匹配的世代,旧块由后台回收。是否回退到聚合节点也要由策略显式决定,而不是悄悄重算,避免一个网络故障把聚合池压垮。
6. SLO调度不能只做最短队列
最短队列只看“有几个请求”,却看不到一个请求包含 200 Token 还是 20 万 Token。合理的调度至少要估计工作量、截止时间和缓存局部性。Prefill 路由可以考虑输入 Token 数、已有前缀命中和实例待处理 Token;Decode 路由关注活跃序列数、剩余输出预算、KV 可用块以及当前 TPOT。
下面给出一个纯标准库的候选打分器。它不是特定框架插件,而是把选择依据显式化,适合离线回放历史请求。分数越低越优;若预测 TTFT 已经超过请求预算,直接排除,不用让必然违约的候选继续污染队列。
from__future__importannotationsfromdataclassesimportdataclassfrommathimportinf@dataclass(frozen=True)classPrefillCandidate:worker_id:strqueued_tokens:inttoken_rate:floatcached_prefix_tokens:inttransfer_seconds:floathealthy:bool=Truedefprojected_ttft(worker:PrefillCandidate,input_tokens:int,router_seconds:float,)->float:ifnotworker.healthyorworker.token_rate<=0:returninf reusable=min(max(worker.cached_prefix_tokens,0),input_tokens)new_tokens=input_tokens-reusable compute=(worker.queued_tokens+new_tokens)/worker.token_ratereturnrouter_seconds+compute+max(worker.transfer_seconds,0.0)defchoose_prefill_worker(workers:list[PrefillCandidate],input_tokens:int,ttft_budget_seconds:float,router_seconds:float=0.01,)->str|None:ifinput_tokens<0orttft_budget_seconds<=0:raiseValueError("输入长度与TTFT预算不合法")ranked=sorted(((projected_ttft(w,input_tokens,router_seconds),w.worker_id)forwinworkers),key=lambdaitem:(item[0],item[1]),)ifnotrankedorranked[0][0]>ttft_budget_seconds:returnNonereturnranked[0][1]def_self_check()->None:workers=[PrefillCandidate("p-a",3_000,5_000,0,0.05),PrefillCandidate("p-b",5_000,5_000,4_000,0.05),]assertchoose_prefill_worker(workers,6_000,2.0)=="p-b"assertchoose_prefill_worker(workers,6_000,0.1)isNoneif__name__=="__main__":_self_check()真实系统还需要防止缓存命中“绑架”负载均衡。某节点虽然已有前缀,但若队列过长,等待时间可能超过重新 Prefill。缓存收益和排队成本应使用同一时间尺度比较。Dynamo 的 KV-aware routing 也把缓存重叠与负载结合,而不是只追求命中率。对 Decode 端则更应谨慎:已经转移的缓存带有位置和所有权,不能像无状态 HTTP 一样随意换节点。
Admission Control 同样属于 SLO 调度。资源不足时,持续接收请求只会让所有请求都超时。入口应根据业务优先级选择排队、降级或拒绝:交互请求可以限制最大上下文和最大输出,离线请求进入独立队列;接近截止时间却无法安排的请求尽早返回可重试错误。拒绝一小部分超预算流量,往往比让整池一起雪崩更诚实。
7. 从聚合到拆分的上线顺序
第一步是把聚合部署测清楚。固定模型版本、量化方式和采样参数,分别回放短输入长输出、长输入短输出、共享前缀和无共享前缀四类流量。记录 GPU 利用率、显存、TTFT、TPOT、吞吐、队列时间和错误率。没有基线,就无法判断拆分后的变化来自架构还是流量波动。
第二步启用 Chunked Prefill 并调节每次调度的 Token 预算。它比拆分少一条传输链路,若已经满足 SLO,工程上没有理由追求更复杂拓扑。调参时同时画 TTFT 与 TPOT 曲线:块越大,通常越有利于 Prefill 吞吐,但可能造成更明显的 Decode 抖动;块越小则增加调度频率与碎片化。结论必须由自己的模型和流量给出。
第三步搭建最小拆分实验,先放在同节点高速互联范围内,只使用一个 Prefill 和一个 Decode worker。验证内容不是“接口能返回”,而是输出一致性、取消、超时、节点重启、缓存回收和版本不匹配拒绝。再逐步扩大到xP-yD,观察增加某一侧副本是否真的改善对应指标。
第四步做影子回放或小流量灰度。影子请求不必把结果返回用户,但要使用与生产相同的 Token 长度分布和到达节奏;同步压测若把所有请求整齐地同时发出,得到的排队行为会失真。灰度阶段设置明确退出条件,例如错误率、P99 TTFT、P99 TPOT 或传输超时任一越界就回切聚合池。
最后才是自动扩缩容。Prefill 可以按待处理输入 Token 和 TTFT 排队时间扩容,Decode 可以按活跃序列、每步 Token 数和 TPOT 扩容。只看 GPU 利用率会误判:Decode 的瓶颈可能是内存带宽,利用率没有达到想象中的满值仍然已经违约。缩容前要先停止接新请求并排空活跃序列,不能直接杀掉持有缓存的 worker。
8. 一套不会自欺的验证矩阵
性能验证要回答三个层次的问题。首先是正确性:同一个提示和确定性采样配置下,聚合与拆分结果是否在框架允许的数值误差范围内一致;停止词、logprobs、结构化输出是否正常;多轮对话的前缀缓存是否引用了正确租户的数据。不要把逐字符完全一致当成唯一标准,浮点计算顺序可能变化,但也不能只凭“看起来通顺”放行。
其次是负载能力。至少按输入/输出长度划分二维桶,分别测量稳态、突发和过载恢复。报告每个桶的 P50/P95/P99 TTFT、TPOT、端到端延迟及成功吞吐,并同时保存两个池的队列深度。一个配置若只提高总 Token/s,却让交互流量的 P99 TPOT 越界,就不算满足目标。
最后是失效行为。主动杀死 Prefill、Decode、路由器和传输连接;注入网络限速、丢包与连接重置;模拟客户端在 Prefill 中、传输中和 Decode 中取消。观察请求是否得到明确错误、缓存是否回收、重试是否有上限,以及系统恢复后是否出现“幽灵占用”。故障演练必须在隔离环境完成,不能用生产用户请求试探回收逻辑。
还要区分冷启动和热态。模型加载、CUDA 图捕获、通信建链以及缓存预热都会影响前几批请求。报告只截取最漂亮的热态会低估扩容后的风险;报告只看冷启动又无法代表稳态。两类数据应分别给出,并记录从扩容指令到副本可接流量的完整时间。
9. 常见故障如何沿链路定位
TTFT升高但Prefill GPU不忙:先看入口和 Prefill 队列,再看 Tokenize、路由决策与缓存传输。低 GPU 利用率不代表没有瓶颈,可能是网络连接、CPU 预处理或等待 Decode 分配块。用统一 trace id 串起所有阶段,避免在不同仪表盘凭时间猜测。
TPOT周期性尖峰:检查 Decode 是否仍混入本地 Prefill、是否发生 KV 换入换出、是否有集体通信拥塞,以及连续批处理是否形成超大批。若尖峰与远程 Prefill 到达同步,可能是传输和 Decode 共享链路或内存带宽。
吞吐提升但OOM更频繁:拆分后 Decode worker 会持有更多并发序列,显存不是只装模型。检查 KV 块水位、高低优先级请求的输出上限、取消回收以及碎片。扩容 Prefill 不能解决 Decode 的缓存容量不足。
偶发乱码或质量突变:立即核对两池的模型摘要、Tokenizer、RoPE 配置、KV dtype、块大小和引擎版本。不要把它当随机采样问题继续观察;不兼容缓存属于正确性事故,应停止配对并回切已知一致版本。
拆分后反而更慢:把 TTFT 分解为排队、Prefill 计算、传输等待和 Decode 首步,通常能直接看出新增成本。若输入短、缓存小且两个池都不拥塞,传输就是纯开销;这正是应回到聚合部署的证据,而不是继续增加复杂参数。
10. 安全与多租户隔离
KV Cache 不是无意义的临时字节,它编码了用户提示词的中间表示。传输网络、共享内存、主机内存和缓存元数据都应视为敏感数据路径。多租户环境至少要做到:服务间双向认证、网络隔离、请求与租户绑定、缓存键包含租户域、日志不记录原始提示或完整传输元数据、块释放后按运行时能力进行安全复用。
前缀缓存尤其容易越界。两个租户恰好拥有相同文本前缀,不代表策略允许共享计算结果。缓存命中前要验证租户、模型、适配器、系统提示、权限上下文和内容版本。若无法证明隔离正确,宁可牺牲命中率。路由器也不应信任客户端自报的 Token 数与优先级;Token 计数、最大上下文和服务等级必须在可信边界重新计算或映射。
控制面接口不能暴露在不可信网络。vLLM 的分布式部署文档明确警告,节点间通信通常不是面向恶意网络设计的;集群内部也需要最小访问范围、防火墙和独立凭据。用于健康检查的接口不要顺便返回模型路径、节点拓扑和显存细节,这些信息对调试有用,也会扩大攻击面。
11. 把可观测性设计进交接协议
拆分架构最怕“每个组件都说自己很快”。入口看到请求已转发,Prefill 看到计算已结束,Decode 看到首步很快,但用户的 TTFT 仍然很高,因为中间的缓存注册与排队没有归属。解决办法不是再建一张总耗时图,而是让同一个请求携带不可伪造的 trace id,并由各阶段记录自己的单调时钟时间戳。
一条最小追踪应包含入口接收、完成 Tokenize、进入/离开 Prefill 队列、Prefill 开始/结束、缓存可传输、Decode 预留完成、传输开始/完成、Decode 首步和首个字节发送。跨机器不能直接相减未经校准的墙上时钟;优先由同一阶段计算 duration,再通过 trace 拼接。若确需跨节点时间线,应部署可靠时钟同步,并把同步误差纳入解释。
高基数请求标识不宜直接作为 Prometheus 标签,否则时序数量会失控。指标用于聚合:按模型、版本、业务等级、输入长度桶、输出长度桶和结果类型统计直方图;trace 用于单请求钻取;结构化日志用于故障事件。三者通过 trace id 关联,但各自保留不同粒度。提示词、Token 内容和完整传输地址不应出现在普通日志中。
Prefill 池至少暴露待处理请求数、待处理输入 Token、计算耗时、前缀命中 Token、产生 KV 字节、可用块和失败类型。Decode 池至少暴露活跃序列、已用 KV 块、每步批 Token、TPOT、抢占/重算、取消回收和输出 Token。传输层则记录排队时间、有效字节、带宽、重试、校验失败及源/目标拓扑域。只看 GPU 利用率无法区分排队、通信和缓存容量。
直方图桶也要围绕 SLO 设计。若 TTFT 目标在秒级,却只配置从 10 秒开始的桶,P95 告警没有意义;Token 长度桶要覆盖真实业务的短对话、普通 RAG 和极长文档,而不是机械等距。延迟必须同时保留成功与失败请求,不能把超时样本从统计中删除,否则越过载,仪表盘反而越“快”。
告警应尽量指向可操作状态。例如“交互流量 P95 TPOT 连续三个窗口越界且 Decode 队列增长”,比“GPU 利用率超过90%”更接近用户问题;“传输完成后未释放块持续增长”直接指向泄漏;“Prefill 与 Decode 兼容标签不一致被拒绝”提示发布不同步。每条告警都应对应一个回退动作:停止接长请求、降级到聚合池、冻结扩缩容或撤销新版本。
可观测数据还能反向校准容量模型。前文脚本用平均 Token 速率做初始估算,运行后应按长度桶提取实际服务时间分布,比较预测与观测误差。若模型持续低估某类多模态或特殊模板请求,不应简单扩大统一安全系数,而应新增工作负载类别。容量模型越能解释差异,扩缩容越不容易在流量变化时振荡。
采样率需要区分正常与异常。稳态成功请求可以低比例采样,超时、传输校验失败、版本拒绝和 OOM 则应完整保留事件上下文,但仍要脱敏。尾延迟分析最好采用 exemplar 从聚合直方图跳到代表性 trace,避免为了排查偶发问题而永久记录所有请求细节。指标保留周期也要覆盖一次容量复盘与版本回归,过早降采样会让周期间歇问题失去证据。
仪表盘还应展示守恒关系:Prefill 产生并移交的请求数,应该与 Decode 接收、显式失败和取消回收之和对应;分配的 KV 块最终应进入活跃、缓存或释放状态。守恒差值持续增长比单次显存高水位更早暴露泄漏与丢失事件。为每个阶段建立这类可核对的计数器,值班人员才能快速判断请求消失在哪一跳,而不是重启整个集群碰运气。
12. 何时应该明确地不拆
如果单个聚合池配合连续批处理、Chunked Prefill 和合理准入已经满足所有 SLO,拆分没有业务价值。若团队还无法稳定采集输入/输出 Token 分布,先补观测而不是设计复杂路由。若跨节点只有普通以太网,先实测 KV 传输;在结果出现前,不要假设远程分离一定更高效。
对流量非常小的内部工具,额外维护两个池和连接器常常得不偿失。对强前缀复用的多轮对话,如果缓存已经在 Decode 节点且本地 Prefill 很短,强制走远程 Prefill可能破坏局部性;可以评估条件拆分,但它会再引入一层策略,只有基准证明有效时才采用。
另一个边界是容错目标。聚合副本的失败域清晰,任一健康副本都能重新执行请求;拆分请求跨越多个组件,恢复语义更难。如果业务要求非常简单可靠而不是极限利用率,聚合架构可能是更好的长期选择。
13. 总结
Prefill/Decode 分离真正解决的不是“GPU不够快”,而是两种差异很大的工作负载共享一个调度域时难以同时守住 TTFT 与 TPOT。它允许独立选择并行配置、容量和扩缩容信号,也让长 Prefill 不再直接打断 Decode;代价则是 KV 传输、状态生命周期、版本兼容和更多故障点。
可靠的落地顺序只有一条:先按业务桶定义 SLO,测清聚合基线;再尝试更简单的 Chunked Prefill;确有干扰证据时搭建最小拆分路径;用真实长度分布验证正确性、尾延迟和故障恢复;最后才加入 SLO 路由与自动扩缩容。能回到聚合部署不是失败,而是实验说明复杂度没有带来收益。
如果记住一个原则,就是不要用总吞吐替代用户体验,也不要用架构名替代测量。Prefill、传输和 Decode 的每段时间都能被记录,每次路由都能解释,拆分服务才从一张漂亮架构图变成可值班、可回滚的生产系统。
参考资料
- NVIDIA Dynamo:Disaggregated Serving架构
- NVIDIA Dynamo:本地Prefill/Decode拆分
- NVIDIA Dynamo:Kubernetes拆分部署指南
- NVIDIA Dynamo:KV-Aware Routing
- NVIDIA Dynamo:Conditional Disaggregation
- vLLM:Disaggregated Prefilling
- vLLM:Chunked Prefill设计说明
- DistServe:Disaggregating Prefill and Decoding for Goodput-optimized LLM Serving
- Splitwise:Efficient Generative LLM Inference Using Phase Splitting
- Sarathi-Serve:Taming Throughput-Latency Tradeoff in LLM Inference
- NVIDIA NIXL:GPU存储与传输加速库
- vLLM:分布式推理与服务