ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

大模型推理性能优化:延迟、吞吐与连续批处理实战

大模型推理性能优化:延迟、吞吐与连续批处理实战

这几年我带过不少从算法岗转应用开发的同事,一个普遍的错觉是:模型精度搞定了,产品就搞定了一半。结果真把模型接到服务里,并发一上来,响应要么超时要么直接内存溢出,整个系统像个纸糊的灯笼。如果你也有类似的感受,说明你正卡在"会用模型"和"能交付模型"之间的那条性能河上。

作为"LLM大模型系统学习指南"系列的第36篇,这篇不聊Transformer原理,也不讲微调技巧,专门聊一个被绝大多数学习路线图忽略的环节:推理性能工程。它解决的问题很具体——为什么同一个模型,别人能撑住高并发,你的服务一压就垮?延迟和吞吐到底怎么测、怎么调、怎么定位瓶颈?

内容面向已经掌握LLM基本概念、跑通过开源模型或调用过API、但准备让模型真正服务业务场景的学习者。这篇学习指南更像一份性能工程实操手册,读完你能建立自己的性能基线,知道下一次遇到"模型很聪明但服务很慢"时该从哪里下手。

1. 为什么到了系列第36篇,我把推理性能当成真正的分水岭

1.1 从"模型精度达标"到"服务可用"之间,隔着一条性能河

我见过一个很典型的模拟项目X:一位同学花了三周时间把某个7B量级的开源对话模型微调得非常好,回答质量在测试集上表现令人满意。上线那天,预期并发用户是30,实际压测到20路并发时,平均响应时间已经超过15秒,其中还有大量请求直接报错。他第一反应是"模型是不是出问题了",但单条请求测试时模型明明一切正常。

这个案例不是个例。模型推理和模型训练有一个本质区别:训练时你关心的是收敛曲线,跑慢一点无非多等几小时;推理是线上服务,用户不会等一个转圈超过十秒的聊天机器人。精度只是模型价值的一部分,服务可用性才是模型能不能真正交付的分水岭。

所谓"服务可用",拆开来看是三件事:单位时间能处理多少请求(吞吐)、每个请求等多久才看到第一个字(延迟)、在硬件成本不变的前提下能不能撑住峰值流量(效率)。这三件事都和模型本身的智能水平无关,纯粹是系统工程问题。很多学习路径恰恰在这里出现空白:课程教你怎么训模型,教程教你怎么调API,却很少告诉你怎么把模型变成一台稳定高效的服务机器。

1.2 性能优化不是玄学,它是可拆解、可量化的工程学

我对推理性能优化的第一点体会是:它绝对不是一个"调调参数碰运气"的黑盒过程。相反,它有一套完整的方法论,核心就四个字——测量、定位。

测量指的是用可控的手段压出系统的真实能力边界,得到延迟、吞吐、显存占用这些硬指标。定位指的是当你发现某个指标异常时,能判断瓶颈是出在模型本身、推理引擎的调度策略、还是硬件资源分配上。优化则是针对定位到的瓶颈做针对性改动,可能是调整并发策略,也可能是换一种量化方案。最后用同样的压测手段验证优化效果,形成闭环。

这套方法论之所以重要,是因为LLM推理的性能瓶颈往往不是单点的。一个系统变慢,可能是模型权重复载太慢、可能是KV Cache把显存挤爆了、可能是批处理策略浪费了算力,甚至可能是压测脚本本身没写对。没有测量数据做锚点,所有的优化都是在猜。所以这篇学习指南会从指标讲起,再到压测方法,最后才是优化手段,每一步都建立在前面可量化的基础上。

2. 延迟、吞吐、TTFT:先搞清楚这些指标在说什么

2.1 四个核心指标的定义与关系

评估LLM推理性能,业界最常用的是四个指标:TTFT、TPOT、端到端延迟、吞吐。很多初学者一上来就看"响应速度",但这个词太模糊了,必须拆开看。

TTFT(Time To First Token)指从请求发出到返回第一个token的时间。它包含网络传输、排队等待、prefill阶段处理提示词的全部耗时。在流式输出场景里,TTFT直接决定了用户"感觉快不快"。TPOT(Time Per Output Token)指生成阶段每产出一个token的平均耗时,它决定了一段回答生成的流畅程度。端到端延迟就是整个请求从发出到全部token返回的总耗时,对于非流式接口来说这就是用户感知的全部。吞吐则是指系统在单位时间内能生成的token总数,通常用tokens/s衡量,它代表了系统整体的处理能力。

这几个指标之间是相互关联又不完全同步的。端到端延迟大约等于TTFT加上TPOT乘以输出token数,但吞吐高不代表延迟低——服务器可以同时处理很多请求,积累出很高的吞吐,但每个请求因为排队而变慢。反过来,某些量化方案能显著降低单请求延迟,但牺牲了并发吞吐能力。实际操作中,要根据业务场景决定优先优化哪个指标:对话产品优先TTFT和TPOT,离线批量任务优先吞吐。

2.2 一个请求为什么会慢:自回归解码的串行宿命和显存带宽瓶颈

理解LLM推理的性能瓶颈,首先要接受一个事实:LLM天生是"串行"的。它生成回答的方式是自回归——每生成一个token,都把它拼到已有的序列后面,再重新送入模型计算一次,才能得到下一个token。这个过程没法并行,第二十一个token必须等第二十个token算完。

这个串行特性带来一个严重后果:生成阶段的计算效率极其低下。每生成一个token,模型实际上要把全部参数从头到尾算一遍前向传播,但真正新增的计算量只有一个token那么多。打个比方,这就像每次只做一道菜,却要把整个厨房的锅碗瓢盆全部清洗一遍再开工。

更关键的是,这个过程的瓶颈不在算力,而在显存带宽。生成token时,模型权重从显存搬运到计算单元的速度,决定了生成速度的上限。主流GPU的算力远大于显存带宽,所以哪怕算力利用率不高,只要权重搬运得快,生成速度也能接受。这也是为什么同样一个模型,在显存带宽更高的硬件上跑得更快的根本原因。

KV Cache的显存占用也必须重视。手头一个常见的7B量级开源模型,假设32层、KV头数为8、每个头的维度是128、加载精度为FP16,那么每个token的KV Cache占用大约是2(K和V各一份)乘以32层乘以8头乘以128维度乘以2字节,算下来约128KB。看起来单个token不多,但并发16个请求、上下文长度2048时,KV Cache总量就是128KB乘以16乘以2048,约4GB显存。很多优化方案的核心,本质上都在想办法压住这笔开销。

3. 高并发下的隐性瓶颈:批处理策略与KV Cache管理

3.1 传统静态批处理的"木桶效应"

单请求性能搞清楚之后,高并发场景下又会冒出新问题:多个请求同时进来,怎么调度才能让GPU高效运转?最直觉的做法是静态批处理——攒够一批请求,一起送给模型计算,等整个批次全部生成完毕再统一返回。

静态批处理的问题在于"木桶效应"。一个批次里,A请求要生成20个token就结束了,B请求需要生成200个token,那么A请求必须空等着B请求跑完,整个批次的GPU资源被最慢的请求拖住。一批请求处理完之前,新进来的请求只能在缓冲区排队,系统吞吐被切割成一个个"气泡",实际利用率远低于理论值。

我实际压测过这样的系统:静态批处理下,GPU算力利用率能到60%就已经不错,更多的算力都浪费在等待和排队上。请求输出长度越不均匀,浪费越严重。这也是所有高性能推理框架都在努力解决的问题。

3.2 连续批处理:请求级调度的核心思想

针对静态批处理的问题,连续批处理给出了一个很朴素但有效的方案:不再以"批"为单位,而是以"请求"为单位动态调度。

连续批处理的思路是:GPU上同时存在多个请求,但每个请求独立计算。某个请求生成结束时,立刻释放它占据的显存和计算资源,新的请求马上补进来,不用等整个批次全部完成。这样GPU上的"坑位"始终被填满,气泡被压到最低。打个比方,静态批处理像是旅行团的固定大巴,一人没逛完景点全车人等;连续批处理则像随时发车的公交,下车一个就能上来一个,站台永远不空。

这个策略对吞吐的提升非常明显。在模拟项目X的压测里,同样硬件、同样模型,从静态批处理迁到支持连续批处理的推理框架后,吞吐直接翻了一倍多,TTFT也有明显下降。如果你现在自己搭推理服务,不建议自己实现调度逻辑,直接用现成的推理框架,它们底层默认就做了这层优化。

3.3 KV Cache分页管理:减少碎片,提升容量利用率

连续批处理解决的是"资源能不能及时腾出来",KV Cache管理解决的是"腾出来的显存能不能被有效用上"。前面算了账,KV Cache是显存消耗大户,但它的分配问题常被忽略。

问题在于KV Cache的显存不是一次性分配好的,而是随着生成过程不断增长。不同请求的生成长度不一样,有些开头就分配了大块显存,结果没写几个token就结束了,留下无法再利用的细碎空洞。这个问题很像操作系统的内存碎片问题,你手里显存总量看着够,但真正能分配给新请求的连续空间不够了。

主流的解决思路是分页管理,也就是说,把KV Cache切成固定大小的"小页面",按需分配,不要求物理连续。这跟操作系统的虚拟内存分页是同一个道理,逻辑上是一段连续的序列,物理上落在分散的页框里,用完随时回收。配合前面说的连续批处理,显存利用率和灵活度都大幅提升。对做应用层的开发者来说,你不需要自己实现这套机制,但理解它有助于你正确设置最大并发数——那个参数背后其实就是在约束KV Cache的内存预算。

4. 实战压测:把性能问题从"感觉"变成"数据"

4.1 一套简单可复现的压测脚本

前面讲了这么多理论,实际动手的时候第一步永远是压测。一套能复现的压测脚本是性能工程的基石,我每次搭新服务都会先写好这套工具。

下面这个Python脚本思路很通用:并发发送一批请求,分别统计TTFT、TPOT和端到端延迟。重点是记录分位数而非平均值,因为平均值会被个别慢请求拉高,掩盖大多数用户的真实体验。

import time import concurrent.futures import numpy as np def single_request(url, prompt, max_tokens): # 这里根据你的服务实际协议改写请求逻辑 ttft_total = 0 tokens = [] start = time.time() first_token_time = None with requests.post(url, json={"prompt": prompt, "max_tokens": max_tokens}, stream=True) as resp: for line in resp.iter_lines(): if line: token = parse_token(line) # 按你的流式协议解析 if first_token_time is None: first_token_time = time.time() - start tokens.append(token) end_time = time.time() tpots = [] if len(tokens) > 1: delta = (end_time - start - first_token_time) / (len(tokens) - 1) tpots.append(delta) return first_token_time, np.mean(tpots), end_time - start, len(tokens) def run_load(url, prompts, concurrency, max_tokens): ttfts, tpots, latencies, total_tokens = [], [], [], 0 with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as pool: futures = [pool.submit(single_request, url, p, max_tokens) for p in prompts] for f in concurrent.futures.as_completed(futures): ttft, tpot, latency, n_tokens = f.result() ttfts.append(ttft) tpots.append(tpot) latencies.append(latency) total_tokens += n_tokens total_time = time.time() - start_time print("TTFT p50/p95: %.2fs / %.2fs" % (np.percentile(ttfts, 50), np.percentile(ttfts, 95))) print("E2E p50/p95: %.2fs / %.2fs" % (np.percentile(latencies, 50), np.percentile(latencies, 95))) print("Throughput: %.2f tokens/s" % (total_tokens / total_time))

有几个细节必须注意。第一,压测前要留预热时间,让模型加载完、显存分配稳定,否则前几个请求的数据会异常慢。第二,请求的prompt长度和max_tokens要固定,变量越多,测出来的数据越难比较。第三,用固定并发而不是固定QPS来压测,因为QPS本身受系统性能影响,会掩盖真实能力。

4.2 从压测数据反向定位瓶颈

拿到压测数据后,下一步是根据指标特征定位瓶颈。我整理了实际排障中常见的几类现象和对应方向,你压测时可以直接对照。

现象可能原因排查手段常见解决方向
TTFT高,但TPOT正常prefill阶段太长或排队严重看并发数、请求输入长度分布缩短输入、限制并发、提高prefill并行度
TPOT高,端到端延迟高显存带宽吃满或量化不到位观察生成阶段GPU利用率和带宽换低精度推理、减少batch size
吞吐上不去,CPU空闲批处理策略低效检查服务线程数和推理引擎配置启用连续批处理、调大请求并发上限
显存OOMKV Cache占满查看KV Cache监控指标降低最大上下文长度、开启KV Cache量化、减少并发数

这里还想强调一个经常被忽视的点:TTFT高并不一定是服务端慢,可能只是请求排队时间长了。压测时如果并发开得很大,TTFT的p95会迅速恶化,但TPOT指标还正常——这种时候不要急着优化模型,先看看并发配置是不是超过了系统的合理上限。性能优化的第一步永远是确认瓶颈在哪一层,而不是对着模型硬调。

5. 性能优化三板斧:投机采样、量化、并行策略

5.1 投机采样:用"小模型打草稿、大模型做审核"换速度

压测数据定位到模型生成本身就慢之后,第一板斧是投机采样。它的思路很巧妙:既然自回归生成是串行的,而且每生成一个token都要搬一遍全部权重,那我们能不能用小一点的模型先快速草拟一串候选token,再让大模型一次性校验?

具体流程是:一个小草稿模型快速自回归生成K个候选token,大模型把这K个token一次性前向计算,校验哪些能被接受。因为大模型验证K个token的计算量只比验证一个token多一点,如果小草稿模型写得够好,大模型一次计算就能"跳过"多个生成步骤,实际生成速度接近小草稿模型,而质量由大模型兜底。

这套机制适合生成任务文本重复度较高的场景,比如代码生成、固定格式输出,草稿模型命中率高了提速明显。但对开放式创意写作,草稿命中率低,收益会缩水。实际使用时要拿自己的业务数据测一下,草稿接受率能到60%以上就是划算的,长期低于40%建议别开。

5.2 量化:降低精度、压低带宽需求

第二板斧是量化。前面说过LLM生成阶段的瓶颈在显存带宽,而量化可以直接降低模型权重的字节数,等于减少每次搬运的数据量,生成速度自然提升。

常见的三档量化方案对比如下:

方案权重精度显存占用变化生成速度变化适用场景
FP1616位基准基准追求极致精度的离线分析
INT88位约减少50%提升约20%-40%大部分线上对话服务
INT44位约减少70%提升约40%-70%显存紧张、对精度要求不极端

量化对精度的影响没有很多人想象中那么可怕。现代量化方法会把异常权重单独处理,7B模型在INT8下能力损失通常很小,INT4下在一些逻辑推理任务上会略有下滑,但普通对话和内容生成几乎感觉不到。如果你的服务显存是硬约束,优先考虑KV Cache量化,它不影响权重精度,只压缩缓存部分的占用,对显存紧张是立竿见影的。

5.3 并行策略:单卡、单机多卡、多机的边界

第三板斧是并行策略,但很多初学者在这里会误用。要搞清楚:数据并行(多个副本各处理不同请求)提升的是吞吐,不降低单请求延迟;模型并行(把大模型切到多张卡)是为了放下装不下的大模型,本身不会让单请求更快,甚至因为卡间通信反而更慢。

在实践中,如果你的模型在单机上能装下,优先单卡服务,延迟最稳。模型太大单卡装不下,再考虑模型并行,这里又分为按层切分和按层内切分两种方式,前者适合几十B到百B量级的模型,后者适合更大规模的模型。多机分布式推理的成本很高,通常是万级并发才需要考虑的事。

对你来说,最实际的选择原则是:先量化,再考虑投机采样,最后才上并行。量化是零成本收益最大的操作,并行则要付出额外的工程复杂度。学习指南阶段不用把分布式啃透,能把单机方案的性能榨干,已经超过大多数应用团队了。

6. 在性能优化这条路上,我踩过的几个坑

6.1 默认参数陷阱:被环境配置悄悄偷走的性能

第一条坑来自压测环境本身。我曾经拿一个推理框架的默认配置做压测,发现显存占用异常高,TTFT也偏大,排查了很久才意识到是默认的最大序列长度设得非常大,导致KV Cache提前预分配了巨额显存。

更隐蔽的坑是压测时长不够。短时间压测时,显存和缓存还没触顶,测出来的吞吐虚高;只有压到持续几分钟,让缓存、垃圾回收、并发排队全部进入稳定状态,数据才接近真实水平。我现在做压测的标准流程是:预热1分钟,正式压测至少3分钟,取第2分钟到第3分钟的数据做统计。别嫌麻烦,这部分时间换来的数据可靠性是值得的。

6.2 输入长度分布是被忽略的最大变量

第二个坑和测试数据的构造有关。很多网上教程教你用固定长度的prompt压测,但真实业务里,用户输入长度可能从几十到几千不等。输入长度直接影响prefill阶段的计算量,进而决定TTFT。

我做过一个印象很深的对比:用固定短输入压测,系统TTFT的p95只有0.8秒;换成混合长度输入后,TTFT的p95直接飙到3秒以上。模型完全没变,只是prompt分布变了。所以压测数据要想和线上对得上,必须模拟真实的输入长度分布。最简单的方法是从线上日志里抽样一批真实prompt,按用户真实使用比例混入压测脚本。

6.3 从"调参"到"系统习惯":建立自己的性能基线档案

最后一个建议可能有些反直觉:性能优化真正拉开差距的,不是某一次惊为天人的调参,而是持续记录和追踪性能指标的纪律。

我习惯给每个服务建一份性能档案,内容包括:服务版本、模型版本、并发数、输入长度分布、量化方案、压测得到的TTFT/TPOT/吞吐分位数、显存峰值。每次改动任何配置,都在同一条件下重跑压测,把新旧数据并排对比。这套习惯救了我很多次——有时候改动模型版本后感觉"似乎更快了",一对比数据才发现TPOT其实退步了10%,只是感官骗人。

这么做还有一个额外的好处:你在写性能优化方案时,不需要靠"我觉得",而是有一整条数据链支撑。这个习惯是学习指南里最值得长期保持的一个。

回头再看,从模型到可用服务之间的路确实很长,但每一步都可以被测量、被拆解、被优化。先跑通压测脚本,拿到第一份属于自己的性能基线,剩下的所有优化手段都会在那个基线上清晰显现出来。别急着把网上所有技巧都堆上去,先在真实业务流量的模拟下,把基线数据做扎实,后面每一步优化都看得见回报。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进