聊到大语言模型(LLM)的并发访问,无论是做API网关的、做模型服务的,还是写业务应用的,八成都会碰到同一个现象:单请求跑得很漂亮,首token延迟稳定在几百毫秒,生成速度也匀称。可一旦并发压上来,响应时间立刻七零八落,超时报错成片出现,GPU利用率看着还不低。你要说它瓶颈在哪,一时半会儿还真说不清楚。我在实际部署和压测里被这个问题折磨过很多轮,最后沉淀了一套自己的分析方法:先搞清楚LLM推理的工作特点,再按指标、压测、链路、资源四层逐步拆。这篇文章就把这套流程完整写出来,正在做模型服务上线、性能调优或者架构选型的朋友可以照着复现,后端新手也能拿它当排查清单用。
1. 先理解LLM服务在并发时的真实运行状态
1.1 prefill和decode:两种完全不同的“性能性格”
LLM推理和普通HTTP服务最大的区别在于,它不是“请求进来、结果直接吐出来”的对称计算。一次完整的生成过程在服务内部被拆成了两个阶段:
- prefill,也叫预填充阶段。用户输入的整段prompt在GPU上并行处理,一次性把所有输入token的隐状态算出来,同时生成一份KV Cache。这个阶段的特点是计算量非常大,输入越长,占用的算力越高,但它是一次性的,跑完就结束。
- decode,也叫解码阶段。模型一个token一个token地生成输出。每生成一个新token,都要把之前所有token对应的KV Cache读出来参与计算。这个阶段的特点是计算密度低,但对显存带宽极其敏感,因为每一步都在搬运大量的中间状态。
这两个阶段的性能性格差别相当大。prefill像一口气读完一整页书,看重的是CPU/GPU的并行吞吐能力;decode像逐字抄写一篇文章,每一步都依赖前一步的结果,没法并行,只能串行推进,而且每写一个字都要回头翻一遍已经写好的内容。
并发加进来之后,麻烦就出现了。不同请求的prefill和decode会交织在一起,有的请求刚进来需要做prefill,有的请求正卡在decode的某个token上。调度器要在它们之间切分GPU算力和显存。如果调度策略太简单,比如必须等当前这个batch全部生成完才允许新请求进入,那么并发一高,新请求的排队时间就会呈线性甚至指数级上涨。这种机制直接决定了你看到的性能曲线是“平滑下降”还是“断崖式恶化”。
1.2 并发真正卡住的四个资源维度
从工程角度看,LLM并发性能瓶颈基本逃不出四个资源维度:
| 资源维度 | 并发升高时的典型表现 | 主要监控手段 |
|---|---|---|
| GPU算力 | 芯片利用率打满,decode速度整体下降 | nvidia-smi、CUDA Event、Profiler |
| 显存 | 显存占用攀升,OOM或被迫换出权重 | nvidia-smi显存、服务内部KV Cache统计 |
| CPU与调度层 | GPU利用率不高但响应慢,服务端线程阻塞 | 服务日志、调度队列长度、火焰图 |
| 网络与IO链路 | 连接超时、请求排队、TCP重传 | 网关监控、请求耗时分析工具 |
这里最容易被忽略的是CPU调度层。很多LLM服务框架基于Python编写,请求进来之后先要经历tokenizer、权限校验、排队调度等多个环节。并发一高,Python层的开销会被放大,尤其是某些框架在调度路径上存在全局锁或者自旋等待时,GPU可能有一大半时间在空转等着CPU投喂数据。我见过不少案例,GPU利用率只有30%,但请求延迟已经高得离谱,一查火焰图,大量时间耗在了调度锁上。
网络和IO层虽然不那么显眼,但同样能成为瓶颈。比如模型服务前面的网关默认连接池太小,或者负载均衡策略没有按服务端队列长度做感知,都会导致请求堆积在接入层。磁盘IO也要注意,如果你的模型权重放在普通机械盘上,服务一重启,冷启动加载权重就要好几分钟,这段时间里所有请求都在排队。
1.3 为什么“单用户快”不等于“并发快”
这个问题我每次给团队做分享时都会强调:单请求的延迟指标再好,都不能作为并发能力的参考。原因有三点。
第一,批处理带来了延迟和吞吐的天然矛盾。为了提高GPU利用率,服务端会把多个请求拼成一个batch跑。batch越大,单位时间处理的token总量越高,但单个请求的完成时间会被拉长,因为要和别人共享计算资源。所以并发从1涨到8时,总吞吐可能翻了几倍,但每个请求的响应时间反而变差了。
第二,请求会排队。所有并发请求不可能同时进入GPU计算,它们要先在调度队列里等待。队列越长,等待时间越长,而且这个等待时间和并发数往往不是线性关系,一旦队列开始堆积,延迟就会滚雪球一样恶化。
第三,KV Cache的分配是动态的。每个请求占用的显存随生成长度变化,服务端无法在请求开始时精确预留资源。并发升高后,显存碎片化问题会越来越严重,表现为明明显存还有剩余,但新请求就是分配不到连续的KV Cache块,只能等待或失败。
理解了这三层原因,再看后面的指标体系和压测方案,就会清晰很多。性能分析不是拿到一堆数字就完事,而是要知道每个数字背后代表的是哪个环节在吃力。
2. 建立一套完整的性能指标与监控体系
2.1 延迟指标:TTFT和TPOT才是用户感知的核心
分析LLM并发性能,如果还停留在普通接口那套“平均响应时间”上,基本等于盲人摸象。LLM的响应时间必须拆成两段来看。
TTFT,也就是首token延迟,指从请求发出到收到第一个输出token的时间。它包含网络传输、排队等待、prefill计算三部分。用户对“快不快”的第一感知就来自这个值。根据我实测的经验,TTFT超过3秒用户就开始烦躁,超过5秒基本留不住人。所以并发压测时,TTFT的P95、P99值比平均值重要得多,因为少数几个被排在后面的请求可能TTFT飙到几十秒。
TPOT,也就是每个输出token的平均生成时间,直接决定了整个回答的生成速度。比如一个TPOT为20毫秒,模型每秒就能生成50个token,一个500字的回答大概需要10秒左右。并发升高时,decode阶段会和别人共享显存带宽,TPOT会逐渐变大。这个变大是正常的,但变大速度如果失控,说明服务已经过载。
我建议在监控面板上同时盯这几条线:TTFT的P50/P95/P99、TPOT的P50/P95/P99、端到端完整响应时间的P95、超时率和错误率。单看平均值的坑我踩过太多次,平均TTFT看着只有800毫秒,实际上P99已经是6秒,这种服务根本没法上线。
2.2 吞吐量与并发:QPS背后的两个口径
普通接口的QPS很简单,就是每秒完成的请求数。LLM服务的吞吐量要比这复杂一点,需要用两个口径分别度量。
第一个口径是请求数吞吐,也就是每秒完成多少个generate请求。这个指标适合衡量业务层的并发能力,但它掩盖了输出长度的差异。一个请求只生成10个token,另一个生成500个token,它们在系统里的资源消耗差了五十倍,但请求数吞吐可能只体现为“都是1个QPS”。
第二个口径是token吞吐,也就是每秒生成的token总数。这个指标更接近底层资源的真实消耗,适合衡量GPU的计算效率和显存带宽利用率。压测时建议两个口径一起记录,只看其中一个很容易得出误导性结论。
还要注意并发数和QPS的关系。理想情况下,QPS会随并发升高先快速增长,然后趋缓,最后下降。那一段“增长放缓”到“开始下降”的区间,就是服务的饱和点。找到这个饱和点比跑一个固定的并发值有意义得多。我一般会把并发从1、2、4、8、16、32、64这样逐步拉升,每个档位跑够3到5分钟,记录对应的请求吞吐和token吞吐,画出曲线后一眼就能看出拐点在哪里。
2.3 KV Cache和显存:一切性能问题的根源
分析LLM并发性能,绕不开KV Cache。简单说,KV Cache是模型在生成过程中保存的历史token的Key和Value向量。prefill阶段算出来,decode阶段反复读取,直到整个请求结束才释放。它的大小决定了同一个GPU上能同时容纳多少个请求。
有一个经验公式可以估算单个token占用的缓存字节数:
每token缓存字节数 = 2 × 层数 × 注意力头数 × head维度 × 精度字节数
以常见的7B模型为例,假设32层、32个注意力头、head维度128,FP16精度下每个token大概需要0.5MB左右。一个请求生成长度2048,也就是输入加输出一共2048个token,那它独占的KV Cache就有1GB。显存是40GB的卡,加上模型权重14GB,还能剩下大概20多GB装缓存,粗算下来同时容纳的并发也就十几个到二十个。如果输入输出序列再拉长,并发容量会进一步缩水。
这就是为什么你会看到服务在并发临界点时突然开始OOM或者性能雪崩。不是因为算力不够,而是显存里KV Cache把容量吃光了。所以在布监控时,我强烈建议把服务端框架暴露的KV Cache已用量、可用量、缓存分配失败次数单独拉出来画图,这比单纯看nvidia-smi的显存利用率更能提前预警问题。
2.4 监控采集与链路追踪的落地方式
指标采集团队里最常用的组合是Prometheus加Grafana。大多数LLM服务框架,比如当前主流的推理服务框架,本身会暴露/metrics接口,包含调度队列长度、当前batch大小、prefill耗时分布、decode耗时分布、KV Cache使用量、显存占用等直接可用的指标。我自己的经验是,在部署服务的第一天就把这些指标接进监控,而不是出了问题再补。
链路追踪同样重要。一个完整的LLM请求要经过网关、鉴权服务、推理服务、tokenizer、GPU计算等多个环节。用OpenTelemetry这类工具给每个请求打上trace ID,记录每一段的耗时,才能在定位瓶颈时快速分清“慢在排队”“慢在prefill”还是“慢在decode”。没有链路追踪的排查,基本就是在靠猜。
我自己常看的监控面板固定放六张图:GPU利用率和显存使用、请求吞吐和token吞吐、TTFT和TPOT的分位数曲线、调度队列长度、KV Cache使用量、超时错误率。这六张图同时摆在一起,绝大多数性能问题的第一眼判断都会变得很直观。
3. 压测方案设计:从单请求到高并发的完整路径
3.1 第一步:先把单请求基线打准
任何性能分析都要从稳定的基线开始,没有基线就谈并发,等于没有参照物的地图。单请求基线测试要控制的变量有三个:输入长度、输出长度、并发数。建议固定一组接近线上真实场景的输入输出,比如输入500个token、输出200个token,连续跑20次,记录TTFT和TPOT的分布。
这里有个容易犯的错:直接用测试脚本里写死的字符串压测,输入长度只有几十个token,输出又很短,得到的基线数据跟线上完全对不上。LLM的TTFT受输入长度影响很大,TPOT受KV Cache大小影响,所以在压测前一定要检查请求里的实际token数量,而不是字符数量。中文字符和英文token的换算比例不一样,一个1000字的请求大概对应700到900个token,具体要看tokenizer的切分规则。
基线测完要顺手做一件事:观察TPOT是否稳定。同一个请求连续跑多次,如果TPOT的波动超过20%,先别急着上并发,检查一下机器上是否有其他进程在抢GPU,或者服务端是否做了不确定性的批处理。基线都不稳,后面所有并发数据都不可信。
3.2 第二步:并发逐级递增,找饱和拐点
打完基线,进入并发递增压测。我比较推荐直接用Python的asyncio配合aiohttp写压测脚本,轻量、灵活、看得懂,改参数也方便。核心思路很简单:维持固定的并发数,持续发请求,统计每一批请求的TTFT和TPOT分布。
下面是我自己常用的压测脚本骨架,发请求的部分做了简化,但统计逻辑是完整的:
import asyncio import aiohttp import time import statistics BASE_URL = "http://127.0.0.1:8000/generate" PROMPT = "你好,请介绍一下大语言模型推理服务的主要优化方向。" CONCURRENCY = 16 REQUESTS = 100 async def send_one(session, idx): payload = { "prompt": PROMPT, "max_tokens": 200, "temperature": 0.0, } start = time.monotonic() first_token_time = None tokens = [] async with session.post(BASE_URL, json=payload) as resp: async for line in resp.content: # 假设服务端按SSE格式流式返回,逐行解析 text = line.decode("utf-8").strip() if not text: continue if first_token_time is None: first_token_time = time.monotonic() - start tokens.append(text) end = time.monotonic() ttft = first_token_time * 1000 # 毫秒 tpot = (end - start - first_token_time) / max(len(tokens), 1) * 1000 return ttft, tpot, end - start, len(tokens) async def main(): async with aiohttp.ClientSession() as session: tasks = [send_one(session, i) for i in range(REQUESTS)] results = await asyncio.gather(*tasks) ttfts = [r[0] for r in results] tpots = [r[1] for r in results] print(f"并发数: {CONCURRENCY}") print(f"TTFT 中位数: {statistics.median(ttfts):.0f} ms") print(f"TTFT P95: {sorted(ttfts)[int(REQUESTS*0.95)]:.0f} ms") print(f"TPOT 中位数: {statistics.median(tpots):.0f} ms") print(f"平均生成长度: {statistics.mean([r[3] for r in results]):.0f} tokens") if __name__ == "__main__": asyncio.run(main())并发递增的档位,我个人习惯是1、2、4、8、12、16、24、32、48、64。每档跑3到5分钟,请求数控制在足够统计的规模,比如每档至少100个请求。记录每个并发档位的请求吞吐、token吞吐、TTFT的P95、TPOT的中位数,然后看两个关键的转折点:
- 吞吐拐点:吞吐从线性增长转为趋缓的位置。超过这个点,加并发对吞吐的帮助开始变小。
- 延迟拐点:TTFT或TPOT开始明显抬升的位置。通常略早于吞吐拐点,因为排队效应的显现要先于资源打满。
这两个拐点之间的区域,就是服务比较健康的运行区间。我给团队定的规矩是,日常负载跑到延迟拐点的70%到80%就需要告警,而不是等到延迟已经飙升了才介入。
3.3 第三步:长时间稳定性压测不能省
很多团队压测只跑10分钟,测完觉得并发能力不错,结果一上线运行半小时就出事故。LLM服务在长时间高并发下有几个隐蔽问题,短时间压测根本暴露不出来。
一个是显存泄漏。某些版本的推理框架在长连接或者流式响应场景下,KV Cache的释放逻辑有bug,跑着跑着显存占用缓缓上涨。我的经验是压测至少要跑30分钟到1小时,并且持续记录显存曲线。如果显存总体趋势是上升的,哪怕速度很慢,也要怀疑存在泄漏,而不是等到OOM了才处理。
另一个是慢查询堆积。长时间运行后,某些特别长的请求会占住宝贵的显存和调度资源,导致后面一大批短请求排队。压测脚本如果只统计平均响应时间,很容易把这种被慢请求拖累的效果平均掉,看不出异常。所以长稳压测的数据一定要按“请求完成时间的分布”来统计,重点观察有没有一批请求的耗时明显拖尾。
还有网络层面的问题,比如TCP连接数打满、网关连接池耗尽、文件描述符不够。这些在高并发持续运行一段时间后才会逐渐浮现。跑长稳压测时,顺手盯一下服务节点的文件句柄数和网络连接数,省得后面抓瞎。
3.4 场景组合压测,更贴近真实业务
固定输入输出的均匀压测只能作为参考,真实业务里的请求样式复杂得多。我建议在基础压测之外,再加上几种场景组合。
输入长度混合:短输入和长输入按一定比例混发。长输入的prefill特别占算力,混入少量长请求,TTFT的P99就会明显变差。这时候才能真正看出服务在非均匀负载下的表现。
输出长度差异:模型生成结果天然有长有短,短的几十个token就结束,长的可能几百上千。输出差异会直接影响KV Cache的持有时间。压测时可以用随机max_tokens参数来模拟这种不确定性。
突发流量:在稳定的负载基础上,突然叠加一波高并发,观察服务恢复平衡需要多长时间。这能暴露调度器的恢复能力和批处理的弹性,对上线前的容量规划特别有参考价值。
4. 瓶颈定位分析框架与典型问题排查
4.1 四步定位法:不看结果,看分层
压测数据拿到手后,最忌讳的就是看到TTFT高就直接怀疑GPU不行。我有一套固定的分层定位流程,每一步只解答一个层次的问题。
第一步,看调度层。先看服务端的调度队列长度和排队等待时间。如果队列里积压了大量请求,说明瓶颈在“入口”的外面,GPU可能还没轮到处理它们。队列堆积的常见原因是前端服务超卖、连接池过小或者调度策略没开连续批处理。这一步的判定标准很直接:如果请求从进入服务到开始prefill的等待时间占了TTFT的大部分,那瓶颈就在调度层。
第二步,看prefill和decode的分账。把TTFT拆成“排队时间”和“prefill计算时间”,把TPOT单独看成“decode的稳定性”。如果prefill时间异常高,说明GPU算力在prefill阶段吃紧;如果prefill正常但TPOT偏高或者波动大,问题大概率出在decode阶段的显存带宽上。
第三步,看GPU资源利用率。用nvidia-smi盯GPU利用率、显存占用、温度、功耗。如果GPU利用率接近100%且显存没满,那是算力瓶颈;如果GPU利用率不高但显存快满了,那是KV Cache容量瓶颈;如果GPU利用率忽高忽低,整体平均只有30%左右,那要回头检查CPU调度和预处理环节,GPU在等数据。
第四步,看服务框架日志与内部事件。现代推理框架大多会记录prefill耗时、decode耗时、batch变化、KV Cache分配失败等关键事件。翻日志时重点找两个模式:一是KV Cache分配失败的warning,二是batch大小频繁归零的记录。batch频繁归零通常意味着流量波动,服务端在反复建立和拆散调度组的间隙里浪费了大量时间。
4.2 症状对照表:一眼定位常见问题
为了让团队排查时能快速对照,我把平时踩过的高频问题整理成了表。遇到某种症状,先看对应的排查方向,再结合日志确认。
| 症状 | 最可能的瓶颈区间 | 优先排查项 |
|---|---|---|
| GPU利用率低于50%,但TTFT和TPOT都高 | CPU调度层 | tokenizer耗时、调度锁、连接池、框架版本 |
| GPU利用率接近100%,TTFT高但TPOT尚可 | prefill算力或并发排队 | 输入token量、并发档位、prefill批处理策略 |
| GPU利用率接近100%,TPOT明显上升 | decode显存带宽 | KV Cache读写、batch过大、量化精度 |
| 显存占用缓慢爬升,最终OOM | 内存泄漏 | KV Cache释放路径、流式连接未关闭、框架已知问题 |
| TTFT偶发超高,整体不稳定 | 慢请求拖累 | batch里是否存在超长输出请求、调度策略 |
| 请求数量多但token吞吐上不去 | 批处理策略 | 是否开启连续批处理、最大batch限制是否过低 |
| 网络层超时和重连频繁 | 网关与连接配置 | 连接池、超时时间、负载均衡按队列感知 |
这张表不能覆盖所有情况,但能给刚接手性能排查的人一个清晰的起点。
4.3 一个真实案例:7B模型服务并发上不去的全过程
讲一个我实际参与排查的模拟案例,匿去所有具体标识,只保留过程,方便你对照自己的场景。
某团队部署了一个7B模型服务,显存40GB,上线前压测发现并发到8以上就开始大量超时,TTFT从500毫秒飙到10秒。负责的同事一开始认为是GPU算力不够,准备直接加卡。我介入后发现几个关键数据对不上:GPU利用率只有40%,显存占用不到30GB,但调度队列的等待时间已经占了TTFT的80%以上。这明显不是GPU能力问题。
顺着调度层查,发现两个问题。第一,框架的连续批处理没有开启,新请求必须等当前batch完全结束才能进入,相当于用FIFO队列硬扛并发。第二,请求的prompt特别长,平均1500个token,prefill本身就耗时大几百毫秒,所有请求在prefill阶段就挤在一起了。
处理方案分两步。先开启连续批处理,让decode中的请求和新进来的prefill请求可以同时占用GPU。这一步实施后,并发8的超时率直接从40%降到5%。然后把入口加了一层简单的请求预过滤,对超长prompt做截断,prefill耗时下降一半。最终并发提到24,TTFT的P95稳定在2秒以内。整个过程GPU利用率也升到了70%以上,说明资源真正用起来了。
这个案例的价值在于,表面上看是并发能力不足,根因却在调度策略和输入特征上。如果按最初的思路直接加卡,钱花了问题大概率还在。
4.4 Profiling工具的使用要点
遇到难以定位的性能问题,可以用Profiling工具深入到内核层面。以CUDA环境为例,NVIDIA的Nsight Systems是排查异构系统瓶颈的首选。它能把CPU端和GPU端的活动放到同一条时间线上,让你看清GPU的kernel启动是不是被CPU的调度逻辑拖着走。
用Nsight查LLM服务时,我习惯重点看三个时间区间:CPU上tok对输入的处理耗时、GPU上prefill对应的kernel耗时、decode每一步的kernel耗时。如果发现CPU启动kernel的间隔明显大于GPU端kernel的实际执行时间,那就是CPU喂不动GPU的典型证据。另一个常见发现是GPU kernel之间有大量空闲gap,GPU利用率虚高但实际计算密度低。
轻量一点的排查可以直接观察框架日志里的耗时字段,配合py-spy等工具打Python调用栈火焰图。火焰图能把CPU上的每一段耗时可视化出来,定位到具体是tokenizer、调度锁还是网络等待吃了时间。Profiling工具的精神是“用数据说话”,不管多深的瓶颈,只要时间线拉得够细,最终都能定位到具体函数或系统调用上。
5. 从分析到优化:实战中反复验证过的方向
5.1 连续批处理:最立竿见影的第一步
分析做得再多,最终要落到优化上。如果让我说一个“投入产出比最高”的优化手段,那一定是连续批处理,也叫Continuous Batching。传统的静态批处理里,一批请求必须一起开始、一起结束,batch里最短的请求完成后,其他请求也得跟着停下来,GPU资源白白浪费。连续批处理则允许decode进行中的请求和新到达的prefill请求共享一个batch,让GPU始终保持高负载。
我的实测经验是,在相同硬件条件下,开启连续批处理后,并发场景下的请求吞吐量能提升2到5倍,TTFT的尾部延迟也能明显收敛。原因很好理解:GPU不再被少数几个短请求拖慢,而是始终在喂满计算。现在主流框架基本都原生支持这个特性,部署时注意别用太旧的版本,旧版本默认关闭或者实现不完整,效果大打折扣。
在验证连续批处理效果时,还是用我最开始说的那套压测流程。对比开启前后在同一并发档位下的吞吐曲线和延迟曲线,数据会非常直观。不用猜,不用感觉,图一出来,差距一目了然。
5.2 KV Cache管理:分页、复用与量化
第二个大的优化方向在KV Cache的管理上。前面提过KV Cache是显存占用的主要变量,所以高效管理它就能直接提高并发容量。当前主流框架普遍采用的思路是类似操作系统的分页机制,把KV Cache切成大小固定的块,按需分配,而不是为每个请求预留一整片连续空间。分页机制加上按需分配后,显存碎片问题明显改善,同一块GPU上能跑的并发请求数量提升非常显著。
再进一步可以做KV Cache的复用。如果多个请求有相同的系统提示词前缀,那么prefill阶段算出来的这部分KV Cache可以直接复用。对于大量使用固定指令模板的场景,比如客服机器人和写作助手,这个优化能省掉很大一部分prefill计算和显存占用。实测中,带有长公共前缀的请求TTFT能下降30%到50%。
另外,模型量化对显存的压缩效果也很直接。从FP16压到INT8,模型权重和KV Cache的显存占用接近减半,INT4还能再往下压。精度损失取决于具体模型和量化方法,对多数业务场景来说,INT8的损失几乎可以忽略。我在GPU显存不足、并发容量受限的项目里,通常第一步就是评估量化,先把容量瓶颈挪开再谈其他优化。
5.3 多卡部署与实例扩缩容:把并发摊开
当单实例的优化做到头了,还是撑不住业务并发,方向就转成横向扩展。多卡部署有两条路线,各有适用场景。
张量并行适合单卡显存装不下模型权重的情况,比如一个70B模型单卡40GB显存根本加载不了,需要把模型切到多张卡上共同推理。张量并行的代价是卡间通信开销,一般来说卡间带宽越高、并行度越低,性能损失越小。但在显存不足的前提下,它能让你跑起原来跑不动的模型。
数据并行则是把同一个模型复制到多张卡或者多个节点上,每个实例独立处理一部分请求,配合负载均衡把流量分摊开。这是提高并发吞吐的最直接手段。决定副本数时,最好基于单实例的饱和点来算,而不是单实例最大并发。比如单实例并发到32就接近延迟拐点,那就让每个实例只跑24左右,用多个实例撑总量,这样整体P99会稳定得多。
弹性扩缩容的实施上,有两种常见策略:按请求吞吐扩缩容,或者按队列长度扩缩容。我自己更偏向后者,因为队列长度更直接地反映了服务端的积压情况。设定一个阈值,比如队列中待处理请求持续30秒超过当前容量的一半,就扩容一个实例,冷却10分钟后再判断是否缩容。这套机制能有效抵抗流量突刺,又不至于频繁抖动。
5.4 我在实际调优中反复确认的几条经验
最后分享几条个人心得,都是踩过坑之后才真正理解的。
第一条,不要在压测机器上跑生产服务。压测本身会占用大量CPU和网络资源,如果压测客户端和服务端在同一台机器,数据只会互相干扰。压测机最好单独一台,或者至少部署在不同物理节点上。
第二条,压测结果必须带上“请求长度分布”这个上下文。脱离输入输出长度谈TTFT和TPOT没有意义。同样一个服务,空请求跑100个并发很轻松,换成2000token输入的请求,可能并发10就崩了。每次汇报性能数据时,先说明压测用的输入输出长度,再谈指标数值。
第三条,所有优化动作一次只改一个变量。连续批处理、量化、KV Cache复用、扩容,这些优化之间会互相影响。如果一次改三四项,出了问题根本分不清是哪一步引起的。我自己的原则是:一次改动,跑一轮完整压测,记录一份数据,再决定下一步。
第四条,性能分析的产出不只是一份报告,更该是一套持续运行的监控基线。把压测得到的饱和点、拐点、正常区间固化到监控告警规则里,让服务在上线后能自动预警。性能问题最难处理的从来不是“已经出事怎么查”,而是“还没出事怎么知道快出事了”。这一套分析体系跑得越久,你对自家服务的性能边界就掌握得越准,后面再遇到类似问题,基本第一眼就能做出判断。
这个方向如果继续深入,还可以把分析框架延伸到更长上下文的场景,处理更大的输入和更复杂的批调度,不过那已经是另一个工程问题了。