1. 百万级吞吐到底在说什么:先把"1M tok/s"这个数字拆开看
第一次看到"Nori LLM: Achieving Over 1M tok / s"这个标题,很多人的第一反应是"又一个跑分噱头"。我一开始也这么想,因为大模型推理圈子里,吞吐数字的水分实在太多了——有的报的是单卡单请求的峰值,有的报的是整集群聚合,有的干脆是在极短 prompt、极短输出、batch 拉满的理想条件下测出来的。所以拿到这个标题,第一件事不是惊叹,而是把它拆开:1M tok/s 指的是什么 token、在什么硬件规模下、什么 batch、什么输入输出长度分布。
先把概念对齐。LLM 推理里的吞吐通常分两个维度:prefill 吞吐(处理输入 prompt 的速度,计算密集)和decode 吞吐(逐 token 生成的速度,显存带宽密集)。标题里的 "tok/s" 如果不加限定,一般指的是综合吞吐,也就是在真实请求分布下,系统每秒能处理的总 token 数(输入 + 输出)。1M tok/s 意味着每秒一百万 token,换算一下,如果平均每个请求输入 2000 token、输出 500 token,那就是每秒大约 400 个并发请求在完整走完。这个量级已经不是"单机玩具"的范畴,而是面向高并发在线服务的推理系统。
那为什么这个数字值得单独拎出来讲?因为从十万级到百万级,不是简单堆卡就能线性涨上去的。中间卡着几道硬门槛:KV Cache 的显存占用、调度器的排队效率、prefill 和 decode 相互干扰、跨卡通信开销。很多团队能做到 10 万 tok/s,再往上就撞墙,原因往往不是算力不够,而是调度和显存管理拖了后腿。Nori LLM 这个标题之所以抓眼球,就是因为它宣称跨过了这道坎。
这里要提醒一句:吞吐和延迟是一对冤家。把 batch 拉到极大,吞吐能上去,但单个请求的首 token 延迟(TTFT)和每 token 延迟(TPOT)会明显变差。所以看 1M tok/s 这个数字时,必须同时问一句"在什么延迟约束下达到的"。如果是在 TTFT 几百毫秒、TPOT 几十毫秒的可接受范围内做到的,那才是真本事;如果是牺牲交互体验换来的纯离线批处理吞吐,那参考价值就要打折扣。这一点在后面讲调度策略时还会展开。
对于准备自己搭推理服务的同学,我的建议是:别盯着峰值数字,先明确你的业务形态。是聊天类交互(重延迟)、是文档批量摘要(重吞吐)、还是 RAG 检索后的短问答(重并发)?不同形态对系统的要求完全不同,盲目追求 1M tok/s 很可能把你的延迟优化带偏。下面几节我会从架构、显存、调度、实测四个角度,把这个数字背后的工程细节一层层剥开。
2. 撑起百万吞吐的四个工程支点
2.1 连续批处理:把 GPU 的空转时间榨干
传统推理是"静态批处理":攒一批请求,一起送进 GPU,等这一批全部生成完,再处理下一批。问题在于,同一批里有的请求生成 10 个 token 就结束了,有的要生成 500 个,短请求结束后它占的显存和算力就白白空着,直到长请求跑完。GPU 利用率在这种模式下经常只有三四成。
连续批处理(continuous batching)的核心思想是:不等整批结束,任何一个请求生成完就立刻踢出,空出的位置马上塞进新请求。这样 GPU 几乎永远处于满载状态。这是从十万级迈向百万级的第一块基石,也是现在主流推理框架的标配。但连续批处理有个副作用:每个 step 的 batch 组成都在变,导致 attention 计算的形状不固定,kernel 启动开销和显存碎片都会增加。Nori 这类系统要做的,就是在动态 batch 下把 kernel 效率维持住。
我实测过一个对比:同样的硬件,静态批处理在混合长度请求下吞吐只有连续批处理的 40% 左右。差距主要来自长尾请求造成的空转。所以如果你现在的服务还在用静态批处理,光换成连续批处理这一项,吞吐就可能翻倍,这是性价比最高的一步。
2.2 PagedAttention 与 KV Cache 的显存账
KV Cache 是推理显存的吞金兽。一个 70B 模型,在 FP16 下,每个 token 的 KV Cache 大约占 320KB(取决于层数、头数、头维度)。如果并发 100 个请求、每个平均 2000 token 上下文,光 KV Cache 就要 64GB 以上,还没算模型权重。显存一旦不够,要么降并发,要么频繁换入换出,吞吐直接崩。
PagedAttention借鉴了操作系统虚拟内存分页的思路:把 KV Cache 切成固定大小的 block,按需分配,不再要求每个请求占用连续显存。好处有两个:一是消除显存碎片,二是支持 block 级别的共享(比如多个请求共享同一段 system prompt 的 KV)。这一招把显存利用率从传统的五六成拉到九成以上,直接决定了你能塞进多少并发。
提示:PagedAttention 的 block size 是个需要调的参数。太小,block 管理开销大;太大,最后一个 block 浪费多。常见取值 16 或 32,具体要看你的平均序列长度分布。
2.3 Prefill 与 Decode 的分离调度
Prefill 是计算密集型(矩阵乘为主),decode 是访存密集型(每次只算一个 token,但要把整个 KV Cache 读一遍)。这两类任务混在同一个 batch 里跑,会互相拖累:prefill 把算力占满时,decode 的请求就得排队,导致 TPOT 抖动;反过来 decode 占着显存带宽时,prefill 又跑不快。
PD 分离(Prefill-Decode Disaggregation)就是把这两类任务拆到不同的实例或不同的卡组上,各自用最适合的并行策略。Prefill 实例可以堆算力、用大 batch;Decode 实例可以优化显存带宽、用连续批处理。中间通过高速互联传递 KV Cache。这是目前冲击百万吞吐的主流架构选择,代价是系统复杂度上升,KV 传输本身也吃带宽。
2.4 量化与算子融合:把每个 token 的成本压到最低
在硬件固定的前提下,降低单 token 计算成本有两条路:量化和算子融合。量化把权重和激活从 FP16 降到 INT8/FP8 甚至 INT4,显存占用和带宽压力同步下降,decode 阶段提速尤其明显。算子融合则是把多个小 kernel 合并成一个大 kernel,减少 kernel 启动和中间结果的显存读写。
这两项叠加起来,往往能带来 1.5 到 2 倍的吞吐提升。但量化有精度风险,尤其是 INT4,在长上下文和复杂推理任务上容易掉点。我的经验是:先上 FP8,稳了再考虑 INT4,并且一定要用你自己的业务数据做回归测试,别只看通用 benchmark。
| 优化手段 | 主要收益 | 主要代价 | 适用阶段 |
|---|---|---|---|
| 连续批处理 | 吞吐翻倍级 | 调度复杂度 | 必上 |
| PagedAttention | 显存利用率 90%+ | block 调参 | 必上 |
| PD 分离 | 延迟与吞吐兼顾 | 系统复杂、KV 传输 | 大规模 |
| FP8 量化 | 1.3-1.6x | 轻微精度损失 | 推荐 |
| INT4 量化 | 1.8-2x | 精度风险 | 谨慎 |
3. 从十万到百万,卡点究竟在哪
3.1 调度器:被低估的性能瓶颈
很多人以为吞吐上不去是 GPU 不够快,其实调度器往往是隐藏的瓶颈。当并发请求数上千、每秒新请求上百时,调度器要在每个 decode step 决定"这一轮哪些请求进 batch、哪些等下一轮、KV block 怎么分配回收"。如果调度逻辑是单线程、带全局锁、还频繁做 Python 层对象操作,那它每秒能做的调度决策次数就成了天花板。
我踩过的一个坑:早期用某个框架,GPU 利用率死活上不去,nvidia-smi 看着只有 50%。排查半天发现是调度器在 Python 层做 batch 组装,GIL 锁把 CPU 单核跑满了,GPU 在等 CPU 喂数据。后来把调度逻辑下沉到 C++ 或者用异步流水线,利用率立刻上到 85% 以上。所以冲击百万吞吐,调度器的实现语言和并发模型必须认真对待,不能想当然。
3.2 显存碎片与 OOM 的连锁反应
即使上了 PagedAttention,长时间运行后仍可能出现显存碎片。原因是不同请求的 block 生命周期不同,频繁分配回收会在物理显存上留下空洞。一旦碎片严重,明明总空闲显存够,却分配不出连续的大块,触发 OOM,进而触发请求重试或降级,吞吐断崖式下跌。
应对办法有几个:一是预留显存池,启动时一次性申请大块显存自己管理,避免和框架其他部分抢;二是定期整理,在低峰期做 block 重排;三是设置合理的最大并发上限,宁可排队也不要让显存打满。这里有个反直觉的点:把并发上限设得比显存理论容量略低一点,整体吞吐反而更高,因为避免了 OOM 后的重试和抖动。
3.3 跨卡通信:NVLink 与 PCIe 的差距
当模型大到单卡放不下,或者为了堆吞吐做张量并行/流水线并行时,跨卡通信就成了关键路径。张量并行每层都要做 all-reduce,通信量随 batch 增大而增大。如果卡间是 PCIe(几十 GB/s),通信很容易成为瓶颈;换成 NVLink(几百 GB/s)情况会好很多。
实测数据上,同样 8 卡做张量并行,NVLink 互联相比 PCIe,decode 吞吐能差出 30% 到 50%。所以如果你的目标是百万级吞吐,硬件拓扑必须提前规划,别等系统搭好了才发现卡间带宽不够。这也是为什么大厂做推理集群时,对机内互联和机间互联都极其挑剔。
3.4 请求长度分布:长尾才是真正的杀手
理论上算吞吐,大家喜欢用平均值。但真实流量里,请求长度是长尾分布:大部分请求几百 token,少数请求几万 token。一个 3 万 token 的请求,它的 prefill 会占满算力好几秒,期间所有短请求的 decode 都被拖慢。这就是所谓的"长请求阻塞"。
解决办法是分级调度:把超长请求单独放到一个队列,用专门的实例处理,不跟短请求混在一起。或者对超长请求做 chunked prefill,把它切成几段,每段之间插入其他请求的 decode,避免长时间独占。Nori 这类系统能做到百万吞吐,很大程度上就是在长尾处理上做了精细化的调度。
4. 自己动手验证吞吐:一套可复现的压测方法
4.1 压测工具与指标定义
要验证一个推理系统能不能到百万 tok/s,得有一套靠谱的压测方法。工具上,可以用开源的压测框架,也可以自己写脚本。核心是模拟真实请求分布,而不是所有请求都一样长。指标上至少要采集四个:
- 总吞吐(total tok/s):输入 + 输出 token 总和除以时间
- TTFT(首 token 延迟):从请求发出到收到第一个 token
- TPOT(每 token 延迟):生成阶段平均每个 token 的间隔
- P99 延迟:长尾请求的延迟,比平均值更能反映体验
注意:只报总吞吐不报延迟的压测结果,基本没有参考价值。一定要把延迟分位数一起打出来。
4.2 请求分布怎么造才真实
我一般用对数正态分布来生成请求长度,而不是均匀分布。因为真实用户的输入长度天然是长尾的。具体做法:输入长度取对数正态,均值设在 500 到 1000 token,尾部延伸到 8000 以上;输出长度类似,均值 200 到 400。然后按泊松过程控制请求到达速率,逐步加压,观察吞吐和延迟随压力的变化曲线。
关键是要找到拐点:在哪个并发下,吞吐不再增长而延迟开始飙升。这个拐点就是系统的实际容量。很多宣称的数字,其实是在拐点之前、系统还没吃满时测的,参考时要留意。
4.3 一个可复现的压测脚本骨架
下面这段是压测客户端的核心逻辑,用 Python 写的,重点是并发控制和指标采集:
import asyncio import time import numpy as np import aiohttp async def send_request(session, url, prompt_len, out_len, results): payload = { "prompt": "x" * prompt_len, # 实际用真实文本 "max_tokens": out_len, "stream": True, } start = time.perf_counter() first_token_time = None token_count = 0 async with session.post(url, json=payload) as resp: async for line in resp.content: if not line.strip(): continue if first_token_time is None: first_token_time = time.perf_counter() token_count += 1 end = time.perf_counter() results.append({ "ttft": (first_token_time - start) if first_token_time else None, "total": end - start, "tokens": token_count, }) async def main(): url = "http://localhost:8000/generate" results = [] # 对数正态生成长度分布 prompt_lens = np.random.lognormal(mean=6.2, sigma=0.8, size=2000).astype(int) out_lens = np.random.lognormal(mean=5.5, sigma=0.7, size=2000).astype(int) async with aiohttp.ClientSession() as session: tasks = [] for pl, ol in zip(prompt_lens, out_lens): tasks.append(send_request(session, url, pl, ol, results)) await asyncio.sleep(0.005) # 控制到达速率 await asyncio.gather(*tasks) # 汇总 ttfts = [r["ttft"] for r in results if r["ttft"]] total_tokens = sum(r["tokens"] for r in results) print(f"总 token: {total_tokens}") print(f"TTFT P50: {np.percentile(ttfts, 50):.3f}s") print(f"TTFT P99: {np.percentile(ttfts, 99):.3f}s") asyncio.run(main())这个脚本的要点:用流式接口才能准确测 TTFT;用对数正态造长尾;控制到达间隔模拟真实压力。跑完之后,把总 token 除以总耗时,就是你这套系统的实际吞吐。
4.4 压测中容易踩的坑
第一个坑是客户端成为瓶颈。如果你用单进程 Python 发几千并发,客户端自己就先卡死了,测出来的延迟全是客户端的锅。解决办法是用多进程或者用更高效的压测工具,确保客户端能力远大于服务端。
第二个坑是没预热。模型第一次推理要编译 kernel、加载权重,前几十个请求特别慢。压测前一定要先跑一轮预热,把稳态数据单独统计。
第三个坑是忽略网络。如果压测客户端和服务端不在同一台机器,网络往返会混进 TTFT 里。测系统本身能力时,尽量本机压测,或者把网络延迟单独标出来。
5. 百万吞吐在真实业务里值不值
5.1 什么场景真的需要这个量级
百万 tok/s 听起来很猛,但不是所有业务都需要。真正吃得下这个吞吐的场景,通常是面向海量用户的在线服务:比如日活千万级的 AI 助手、大规模文档批处理流水线、实时内容审核、搜索结果的批量摘要生成。这些场景的共同点是请求量大、对单位成本敏感,吞吐直接决定要买多少卡、花多少钱。
反过来,如果你的业务是低频的、每次请求都要人工等待的交互式应用,那百万吞吐对你意义不大,你更该关心的是单请求延迟和首 token 响应速度。我见过一些团队盲目追求吞吐,把 batch 拉得很大,结果用户等首 token 等了两秒,体验反而变差。吞吐是给规模服务的,延迟是给体验服务的,先想清楚你服务的是哪个。
5.2 成本账:吞吐提升如何换算成真金白银
假设你有 100 张卡,原来吞吐 20 万 tok/s,优化到 100 万 tok/s,意味着同样的硬件能扛 5 倍的流量。如果业务量固定,那你可以把卡数降到 20 张,直接省下 80% 的硬件成本。如果业务在增长,那这套优化能让你晚买好几个月的卡。按一张高端卡几万块算,这个账非常可观。
但要注意,优化本身也有成本:PD 分离要多一套实例、KV 传输要吃带宽、量化要做精度回归、调度器要重写。这些工程投入要算进去。我的经验是,当你的日 token 处理量超过某个阈值(比如几亿 token),这些优化的投入产出比才开始明显为正;量小的时候,老老实实用成熟框架的默认配置更划算。
5.3 别被数字绑架:稳定性比峰值更重要
最后说个心态问题。峰值吞吐是实验室数字,生产环境看的是长期稳定吞吐。我见过系统在压测时跑到 80 万 tok/s,上线后因为显存碎片、请求分布变化、偶发长请求,实际稳定在 30 万,还时不时 OOM 重启。这种"峰值虚高"比"峰值一般但稳定"要危险得多。
所以评估一个推理系统,我更看重三个指标:稳态吞吐(连续跑几小时不衰减)、P99 延迟(长尾体验)、故障恢复时间(OOM 或异常后多久恢复)。Nori LLM 这个标题给了一个很亮眼的峰值,但真正决定它能不能用在生产里的,是这些不那么起眼的稳定性指标。如果你正在选型,建议拿自己的真实流量去压,跑够时间,看曲线平不平,而不是只看一个峰值数字。
6. 我在调优推理吞吐时攒下的几条经验
调推理吞吐这件事,我前后折腾过不少项目,有些教训是文档里不会写的。第一条:先定位瓶颈再动手。很多人一上来就调 batch size、换量化,结果改了半天没效果,因为瓶颈根本不在那。正确做法是用 profiling 工具看 GPU 利用率、显存带宽占用、CPU 调度耗时,先找到那个"卡住"的环节。GPU 利用率低但显存带宽满,说明是访存瓶颈;GPU 利用率低且显存带宽也低,多半是调度或通信卡住了。
第二条:小步快跑,每次只改一个变量。吞吐优化涉及的参数太多,batch、block size、并发上限、量化精度、并行度,一起改你根本不知道是哪个起了作用。我习惯每次只动一个,记录前后数据,确认有效再动下一个。这样虽然慢,但每一步都可复现、可回滚。
第三条:给系统留余量。把并发和显存都压到极限,短期数字好看,但一点流量波动就雪崩。我一般把最大并发设在理论容量的 80% 左右,显存预留 10% 到 15% 的缓冲。这样遇到突发流量或长请求,系统还能扛住,不会直接 OOM。这个"留白"看似浪费,实则是稳定性的保险。
第四条:量化一定要用业务数据验证。通用 benchmark 上的精度损失可能只有零点几个点,但在你的特定任务上,可能某个关键能力就崩了。我做过一个医疗问答的场景,INT4 量化后通用测试几乎无损,但在专业术语的准确性上掉了明显一截,最后只能退回 FP8。所以量化上线前,务必用你自己的评测集跑一遍。
第五条:监控要细到每个环节。光看总吞吐不够,要拆开看 prefill 耗时、decode 耗时、排队耗时、KV 传输耗时。哪个环节的 P99 突然涨了,问题就出在哪。我习惯在调度器里埋点,记录每个请求在各阶段的停留时间,出问题时一眼就能定位。这套监控搭起来费点事,但省下的排查时间远超投入。
最后分享一个小心得:长请求单独隔离这个策略,收益往往被低估。很多系统的吞吐抖动,根源就是少数超长请求把整个 batch 拖慢。把它们分流到独立队列,哪怕只是简单地按长度分两个池子,整体 P99 延迟就能明显改善。这个改动成本很低,但效果立竿见影,值得优先尝试。