
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 实例可以堆算力、用大 batchDecode 实例可以优化显存带宽、用连续批处理。中间通过高速互联传递 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 互联相比 PCIedecode 吞吐能差出 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 延迟从请求发出到收到第一个 tokenTPOT每 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, jsonpayload) 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(mean6.2, sigma0.8, size2000).astype(int) out_lens np.random.lognormal(mean5.5, sigma0.7, size2000).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(fTTFT P50: {np.percentile(ttfts, 50):.3f}s) print(fTTFT 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 延迟就能明显改善。这个改动成本很低但效果立竿见影值得优先尝试。