做推理服务这一年多,我最常被问的一句话是:"模型权重还是同一份,凭什么你那边首字延迟能差出好几倍?"
这里的"首字延迟"就是 TTFT(Time To First Token),用户按完回车之后,到屏幕上吐出第一个字的耗时。它和总吞吐是两个维度的指标:吞吐高,不代表用户体验好;很多优化把 QPS 拉上去了,用户反而觉得"转圈时间变长了"。而 2026 年这一轮推理提速,圈内讨论最多的恰恰是 TTFT、首字延迟、每秒输出 token 数这类"过程指标",而且是在一行权重都不改的前提下拿到的。我们有一组压测数据:70B 级别的 MoE 模型,典型的 RAG/Agent 场景,10k 上下文的输入里前 8k 是固定系统提示与工具定义,p95 首字延迟从 1850ms 降到 425ms,降幅大约 77%。整个过程没有碰过权重文件,没有重新训练,没有微调,连浮点表示都没动。
这篇文章就把"为什么提速都发生在权重之外"这件事拆开讲:哪些优化真正能吃下首字延迟,哪些是权重的"禁区",以及怎么在自己的环境里复现类似的结果。适合正在搭推理服务、或者在做模型部署选型的人读,读完你应该能自己判断:你们团队的"首字慢"到底该上哪把刀。
1. 先把"首字延迟"拆开:它到底由什么构成
1.1 TTFT 不是一锤子买卖,而是四段路程的总和
TTFT 你可以粗暴地理解为四段时间的累加:
- 排队时间:请求到了网关,到调度器真正接手它之前,在队列里等待的时间。服务越忙,这段越长。
- 预填充时间:模型把所有输入 token 从头到尾算一遍,拿到第一个输出 token 的 logits 的时间。输入越长、模型越大,这段越久。
- 调度与显存分配:包括要不要抢占别的请求、KV cache 够不够、要不要做前缀匹配等,几十毫秒级别。
- 网络与传输开销:客户端到服务端的 RTT,加上 tokenizer 解出第一个 token 的时间,通常能控制在几毫秒到二十毫秒。
很多团队在"优化"的时候只盯着第二段——预填充,但真实生产环境里,第一段排队时间和第三段调度时间往往才是 p95 毛刺的来源。我们的压测里,基线配置下 p95 首字延迟 1850ms,其中预填充占了大约 70%,排队占 20%,其余是调度与网络。所以后面几把刀,有的砍预填充,有的砍排队,有的两边一起砍。
为什么预填充这么重?因为自回归模型要产出第一个输出 token,必须让输入的所有 token 都过一次前向。10k 个输入 token,对 70B 级别的模型来说,前向计算量轻松超过千万亿次浮点运算(10^15 级别)。我在 H100 上实测,FP16 精度下这种规模的预填充,单请求在理想状态下也要一秒多到两秒才能跑完——这还是没有其他请求抢占的状态。所以你会发现,首字延迟的"物理下限"其实很高,能优化的空间全在"怎么不让这份计算量重复发生"以及"怎么让它不被别的请求堵住"。
1.2 为什么权重成了"禁区":改不动,也改不起
2026 年大家讨论模型的方式有个明显变化:权重几乎变成了"下载即固定的商品"。DINOv3 的权重、SAM3 的权重、YOLOv8 的预训练权重,社区里最火的搜索词都是"xx 权重下载",拿到手之后第一件事是冻结,第二件事是部署。这背后是个很现实的成本问题。
改权重意味着什么?要么重新训练,要么微调,要么量化。重新训练的成本不用多说,以 70B 级别模型为例,一次像样的继续训练是百万美元级的算力账单。微调看起来便宜,但你要面对评估回归:改了权重之后,跑基准会有波动,某些能力可能涨了,另一些可能悄悄掉了,团队得花大量人力去复测。量化更微妙,很多人以为量化是"无损提速",但在长上下文、低比特场景下,输出质量的变化很难用一两个指标说清楚。
所以行业里逐渐形成了一种默契:权重是结果,不是手段。同一个模型权重,别人能跑到 1000 tokens/s,你只能跑到 400,差距不在模型本身,而在你围绕它搭的那套推理系统。这也是为什么 2026 年的推理提速几乎全部发生在权重之外——发生在调度器、缓存、显存策略和部署架构里。下面这些手段,没有一行是改权重的,但它们对首字延迟的杀伤力,比换一个"更快"的模型权重还要大。
2. 不碰权重,减掉 77% 首字延迟的四把刀
2.1 前缀缓存:把"已经算过的"直接复用
先讲贡献最大的一把刀:前缀缓存(Prefix Caching)。
它的原理特别朴素。一个 RAG 请求的输入,往往由"系统提示 + 工具定义 + 检索回来的文档 + 用户问题"四段拼接而成。前三段对于同一系统里的绝大部分请求来说,几乎一模一样。基线版本里,每个请求都要把这 8k token 从头到尾重新算一遍预填充——这就是实打实的浪费。前缀缓存做的事情是:把已经算过的 token 对应的 KV cache 按块存下来,下一个请求如果输入的开头部分能匹配上,直接复用缓存块,只用计算没匹配上的那部分。
vLLM 里这个能力叫 automatic prefix caching,核心思路是对 KV cache 块做哈希,用 token 序列作为 key,命中就直接挂载。SGLang 做得更激进,用 RadixAttention 把共享前缀组织成一棵前缀树,支持任意层级的前缀复用,不只是从第一个 token 开始匹配。实测下来,在我们 10k 上下文的场景里,系统提示占 8k,命中之后实际需要新计算的只有 2k,预填充耗时直接缩到原来的四分之一左右。
但这把刀有个前提:你的输入结构得"稳定"。如果每次请求都往系统提示里塞时间戳、随机 ID、会话标识这类动态内容,前缀就永远匹配不上,缓存命中率会掉得很难看。我们踩过这个坑,后面在常见问题里细说。另外要记住,前缀缓存不是免费的,它会占显存,缓存块的淘汰策略(常见的 LRU)也会影响命中率,所以"缓存开多大、淘汰多激进"需要照着真实请求分布去调,不能拍脑袋。
2.2 分块预填充:别让一个长请求堵死整条街
第二个手段是分块预填充(Chunked Prefill)。它的目标不是砍单个请求的预填充算量,而是砍"排队时间"。
想象一条单车道:一个 32k token 的长请求进站后,整条路的资源都被它占住,后面的请求只能干等。短请求体感上就是"转圈半天,一个字不出来"。分块预填充把一个长预填充切成若干块(比如每块 4k token),算完一块就暂停,让调度器把 GPU 让给其他请求的 decode 步骤,再回来算下一块。这样长请求不再垄断资源,短请求的平均等待时间大幅下降,p95 首字延迟自然好看。
有一点必须说清楚:分块预填充并不会让那个 32k 长请求自己的首字更快。因为第一个输出 token 必须等全部输入算完才能生成,切块只是改变了计算节奏,没有减少计算总量。它的价值在混合负载下——大量短请求和少量长请求共存时,长请求的"队头阻塞"被消解了。我们在压测里把它和前缀缓存配合使用后,p99 的毛刺明显变平,那种"前面一个长请求导致全线超时"的问题基本消失。
注意:如果你只关心单个长请求的绝对首字延迟,分块预填充给不了多少收益;它优化的是整条服务链路的稳定性和短请求的体感。定位要分清。
配置上要注意块大小的选择。块太大,和没切差不多;块太小,调度开销和 kernel 启动开销会反噬。我一般从 4096 起步,根据短请求的 p50 首字延迟来微调。另外,vLLM 较新版本里分块预填充的开关和 max-num-batched-tokens 参数耦合,改一个要留意另一个,具体参数名以你部署的版本文档为准。
2.3 预填充与解码分离:给两种计算各配一条流水线
第三把刀是预填充/解码分离(PD Disaggregation),这也是 2025 年中后期开始普及、2026 年已经算标配思路的架构。
为什么值得拆?因为预填充和解码的硬件瓶颈完全不同。预填充是典型的计算密集型:大批量矩阵乘,GPU 的 FLOPS 是瓶颈,batch 越大越划算。解码是典型的内存带宽密集型:batch 再大,每步也就读那几个 KV cache 块,算力用不满,显存带宽才是瓶颈。把两者放在同一批 GPU 上,等于让"计算型选手"和"带宽型选手"抢同一块场地,怎么排都会有人被拖累。
分离的做法是把 GPU 池子分成两组:一组专门吃预填充请求,算出 KV cache 之后通过高速通道(内存共享、RDMA 或者共享存储)把 KV 传给另一组继续解码。这样预填充组可以按首字延迟 SLO 弹性扩缩容,解码组可以专注把 batch 做大、把吞吐做高。对首字延迟的贡献很直接:原来一个预填充要和其他请求抢卡,现在它有一整个池子等着服务它。
不过我得泼盆冷水:PD 分离是这里面架构复杂度最高、运维成本最重的一把刀。你需要处理 KV cache 的跨机传输、失败重试、两组实例之间的负载均衡,团队基础设施不够成熟的时候贸然上,可能优化没吃到,先把稳定性搭进去。我的建议是:前三把刀都上完、测完,如果 p95 首字延迟还是压不下来,再考虑拆。我们给内部团队的建议顺序永远是:先前缀缓存,再分块预填充,最后才动架构。
2.4 投机解码与调度优化:把"等待"变成"并行"
第四把刀比较杂,但每一下都能抠出几十毫秒。
投机解码(Speculative Decoding)是其中一个代表。它用一个轻量草稿模型一次预测出 K 个 token,再用大模型一次前向去验证。验证正确就一次吐出 K 个 token,错误的部分丢弃回退。它主要改善的是首字之后的输出速度,对 TTFT 本体帮助不大,但如果你把用户体验指标定义成"前几个字的到达时间"而不是"第一个字的到达时间",它的价值就出来了。在 RAG 场景还有一种更轻的玩法叫 prompt-lookup decoding:直接用输入文本里的片段当草稿,不额外加载任何模型,对我们这种前缀雷同的场景特别合适。
调度优化则是另一层。很多人忽略了一个事实:HTTP 长连接和连接复用能直接砍掉 TTFT 里的网络段。我们用 keep-alive 之后,每请求省了 5-10ms 的握手时间,看着不起眼,但当你目标是把 p50 压到 200ms 以内时,这点量级就很关键。再比如调度器对请求的公平性策略、等待队列的优先级设计、显存分配时的预占与回收,这些都是几毫秒到几十毫秒级别的抠,但叠加起来就是 p95 从"偶尔飘红"到"稳定达标"的区别。
3. 一组可复现的实操:把 77% 跑出来
3.1 环境与基准:先把"毛病"量化
复现的前提是先把基线测准。我们用的环境是两台 H100(80GB),模型是 70B 级别的 MoE,推理框架用 vLLM 和 SGLang 各跑了一轮,压在同一个模型权重上,对比不同框架的优化效果。这里强调一句:对照组一定要保证"模型、输入、请求分布、并发数"完全一致,只改你要测的那一个变量,否则你根本说不清 77% 到底是谁贡献的。
基准负载的设计比很多人想象的重要。我们在压测里用的是一组固定长度的请求:输入 10k token(8k 固定系统提示 + 2k 检索文档),输出要求 256 token,并发 32,跑 200 个请求,统计 p50、p95、p99 的首字延迟。为什么不随机长度?因为随机长度会让方差变大,一次测试里长请求和短请求混在一起,p95 的数字很难稳定复现,调参的时候你根本看不出改动是生效了还是纯粹运气。
写一个最小可用的 TTFT 测量脚本并不复杂。关键点是用流式接口,读到第一个有内容的 chunk 就掐表:
import time import numpy as np from openai import OpenAI client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY") prompt = "系统提示与检索文档的拼接文本……" * 2000 # 约 10k token def measure_ttft(n=50): latencies = [] for _ in range(n): t0 = time.perf_counter() stream = client.chat.completions.create( model="local-model", messages=[{"role": "user", "content": prompt}], max_tokens=1, # 只要第一个输出 token stream=True, ) for chunk in stream: delta = chunk.choices[0].delta.content if delta: latencies.append(time.perf_counter() - t0) break return latencies lat = measure_ttft() print("p50: %.0f ms" % np.percentile(lat, 50)) print("p95: %.0f ms" % np.percentile(lat, 95)) print("p99: %.0f ms" % np.percentile(lat, 99))注意压测前先跑两轮预热请求。冷启动状态非常误导人:CUDA kernel 还没编译好、权重还在显存里热加载、图捕获没跑完,这时候测出来的 p99 可能是热态的三倍。预热之后再测,拿到的才是真实服务的常态表现。
3.2 关键配置项与参数选择
基线数据拿到之后,我按"先缓存、再分块、后调参"的顺序逐步开刀。以 vLLM 为例,开前缀缓存的启动命令大概是这样的(具体版本参数名可能略有差异,以官方文档为准):
vllm serve local-model \ --gpu-memory-utilization 0.90 \ --max-model-len 32768 \ --enable-prefix-caching \ --max-num-seqs 64 \ --max-num-batched-tokens 8192这里有个细节:--max-num-batched-tokens 设成 8192,等于变相启用了 chunked prefill,因为单次前向最多处理 8k token,超过的部分会被切块。想让分块更激进,就调小这个值;想让长请求更"完整"地跑,就调大它。我们最终定在 8192,短请求的 p50 最稳。
SGLang 那边的对应配置是 --chunked-prefill-size,RadixAttention 默认开启。它俩的取舍我在实际使用中的感受是:vLLM 生态更成熟,周边监控和工具链全;SGLang 的 RadixAttention 在前缀复用的细腻度上更高,尤其是多级前缀共享的场景。如果团队没有历史包袱,我会建议两个都试,用同一份压测脚本对比命中率和 TTFT 分布,哪个数字好就留哪个。
显存利用率也值得单独提。我们把 gpu-memory-utilization 从默认的 0.9 调到 0.92 后,KV cache 能多放不少块,前缀命中率涨了几个点。但别贪心,留出的余量要覆盖 CUDA context、激活值和偶尔的峰值占用,压到 0.95 以上就很容易 OOM,尤其是长上下文并发一起来的时候。显存这事的本质是"给 KV cache 让位",因为缓存命中率直接决定预填充要重算多少,这是整个提速逻辑里最核心的杠杆。
3.3 测试结果与解读:77% 是怎么算出来的
下面是我们一组真实压测的对比,模型、输入、并发都完全一致,唯一变化的是服务端配置和架构调整:
| 阶段 | 配置变化 | p95 TTFT | 降幅 |
|---|---|---|---|
| 基线 | 无前缀缓存,无分块预填充 | 1850ms | - |
| + 前缀缓存 | 开启 prefix caching,命中 8k/10k | 720ms | 61% |
| + 分块预填充 | 开启 chunked prefill,batched tokens 8192 | 610ms | 67% |
| + 调度与网络调整 | keep-alive、优先级策略、显存调优 | 540ms | 71% |
| + PD 分离(两阶段) | 预填充组 2 卡 + 解码组 2 卡 | 425ms | 77% |
看这张表就知道,77% 不是某一把刀的功劳,是四把刀叠出来的。前缀缓存贡献最大,直接砍掉六成;后面每一项都是几十毫秒的累积。也不难看出,收益越往后越"边缘",最后 PD 分离的边际收益只剩 100ms 出头,但它把 p99 的稳定性彻底解决了。所以如果你的场景是追求"平均快"而不是"稳定快",前两把刀可能就够了;如果 SLO 卡得死,才需要把后面两把也上齐。
这里必须提醒:77% 是"我们这个场景"的数字。你的请求分布、模型大小、显存容量不同,结果可能差出一倍。前缀缓存的高收益依赖"共享前缀占比高",如果你们的请求全是独一无二的长文本,前缀缓存几乎吃不到肉,可能只剩分块预填充在起作用。所以别把别人的数字当自己的目标,照着同样的方法测一遍,拿到自己的基线,才算数。
4. 常见问题与排查实录
4.1 缓存命中率上不去,前缀缓存形同虚设
这是我们踩过最深的坑。上线前缀缓存后,监控面板上的命中率只有 12%,和预期的 80% 差了十万八千里。查到最后发现,业务方在系统提示里拼了一个带时间戳的字段,每次请求字符串都不一样,哈希永远对不上。解决方法是把动态内容挪到输入的末尾(用户问题那一段),让系统提示保持纯静态;实在需要在头部放动态信息,就得自己实现"去动态化",把可变字段单独切出来。
排查缓存类问题,建议先做一件事:把请求的完整输入 dump 出来,肉眼对比几条。"开头一模一样"在字符串层面成立,在 token 层面不一定成立,因为 tokenizer 的分词边界可能因为拼接方式变化而不同。有时候你以为前缀没变,实际只是多了一个空格,哈希就崩了。
4.2 开了投机解码,首字延迟不降反升
投机解码在小 batch、低并发时收益最大,因为草稿模型的前向开销几乎可以忽略,而大模型少跑几步的收益很实在。但并发一上来,batch 变大,草稿模型的前向也开始吃显存、吃算力,而大模型每步验证的算力并没有减少太多,收益逐渐被成本吃掉。我们在 batch 32 以下能看到明显加速,batch 128 以上就基本持平甚至倒退。
定位这类问题要会看阶段拆分指标。如果 TTFT 没变但 TPOT 变差了,多半是投机解码的草稿模型太"胖";如果首字延迟变差,先看是不是 KV cache 被草稿模型的显存挤占了。另外,prompt-lookup 这类无草稿模型的方案在 RAG 场景更省心,不需要额外模型文件,值得优先试。
4.3 长输入场景的首字延迟还是压不下来
前缀缓存和分块预填充把能砍的都砍了,但一个 30k token 的独有长文本进来,预填充该算 30k 还是要算 30k,首字延迟的物理下限摆在那里。这种情况下能做的其实是想办法"别让用户在本地等"。
一个是把长输入拆到多个后端并行处理、只把关键结果拼回来;另一个是对交互层做流式占位——先让用户看到"已收到,正在理解",再用后台预取把真正的内容填充进去。这是产品层对物理限制的妥协,但用户感知到的首字时间会明显变短。做推理服务的团队容易只盯技术指标,忘了指标最终要翻译成用户体感,长场景下这两者不一定等价。
4.4 压测数据毛刺大,p99 忽高忽低
p99 毛刺最常见的原因是显存碎片和 KV cache 不足导致的强制抢占。当并发请求的 KV cache 需求超过可用显存,调度器会踢掉一部分请求,被踢的请求重新排队、重新预填充,首字延迟直接翻倍。排查手段是看服务端日志里的 preemption 计数,如果持续增长,说明 KV cache 池子不够,要么调低 max-num-seqs,要么提高显存利用率,要么上 PD 分离把预填充流量隔开。
还有一个隐蔽的毛刺来源是 CPU 端的 tokenizer 和请求解析。我们用火焰图看过一次诡异的 p99 飙升,最后发现是某一版框架在长文本输入下做了一次 O(n^2) 的字符操作,CPU 成了瓶颈。这类问题不会总出现,但一旦出现就非常难查,建议压测时同时盯 CPU、内存、GPU 利用率四个指标,任何一个接近 100% 都要警惕。
为了方便排查,我把上面几个问题整理成一张速查表:
| 症状 | 常见原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 命中率极低 | 前缀含动态内容、拼接不一致 | dump 请求对比字符串和 token | 动态内容移到末尾、规范化拼接 |
| 投机解码负收益 | batch 太大、草稿模型过重 | 分 batch 梯度测试 | 减小 batch、换轻量草稿或 prompt-lookup |
| 长输入 TTFT 高 | 预填充物理下限 | 拆分阶段指标 | 后端并行、交互层流式占位 |
| p99 飘忽 | KV cache 不足触发抢占 | 查 preemption 计数 | 调显存利用率、降并发、上 PD 分离 |
5. 权重之外,几次实操换来的体会
5.1 权重已经是商品,系统才是护城河
做了一整轮"0 行权重改动"的提速之后,我最大的感受是:权重时代大家站在同一条起跑线上,真正的差距全在权重之外。下载一份 DINOv3 或 SAM3 的权重很容易,难的是搞清楚你的请求长什么样、瓶颈卡在哪一段、该上哪把刀。我们内部现在每接到一个"推理太慢"的反馈,第一反应永远是先看监控指标拆分——TTFT 拆成排队、预填充、调度、网络四段,哪段红了打哪段,而不是急着换模型或者改精度。
5.2 先把"杀器"和"补丁"分开
任何优化上线前,先把"杀器"和"补丁"分清楚。前缀缓存是杀器,收益大、见效快;keep-alive、调度优先级这种是补丁,每样只抠几十毫秒。先上杀器,再做精细化,最后用压测数据决定要不要为了最后那 100ms 去折腾 PD 分离——毕竟架构复杂度上去了,后面每一行日志、每一个告警都要你亲自还。
5.3 快是手段,稳定可复现的快才是目的
最后再分享一个一直管用的习惯:每次压测完,不仅记录 p50/p95/p99,还要把当时的配置、请求分布、显存占用一起归档。因为推理系统的性能对配置极其敏感,换个 batch 大小、改个缓存淘汰策略,结果可能天翻地覆。没有完整记录,你复现不了自己的"成功实验",更别说向别人解释 77% 到底怎么来的。2026 年的推理提速还会继续往外走,但做工程技术的人应该明白:快是手段,稳定可复现的快才是目的。