很多朋友跟我聊大模型落地时,第一句话往往是“模型已经调通了,跑起来也出来了,就是一上并发就卡成狗”。这句话我太熟了。大模型推理全链路,说白了就是一条请求从进入服务器到吐出一串字的完整路径,可这条路径上的坑,比模型本身多得多。你光会调库没用,得把链路里每一段都弄得明明白白,才能真正把吞吐和延迟抓在手里。
这篇文章就把大模型推理全链路拆开讲透。从请求进来后的预处理、动态批处理,到核心的自回归解码、KV Cache显存管理,再到服务化部署、线上排障,每一个环节讲清楚原理、算法、参数和避坑经验。适合做推理平台的工程师、算法团队里的部署同学,以及想在小规模团队里把模型服务稳定跑起来的技术负责人。内容会尽量说人话,但该上公式和配置的时候绝不绕弯。
1. 大模型推理全链路到底包含什么
1.1 一次请求从进入到返回的完整旅程
大模型推理看起来只是“输入一句话,输出一句话”,实际上这条请求要经过六七个环节才能完成一个“token一个token”的生成。我习惯把这条链路比喻成一家餐厅的后厨:
请求先到前台,也就是网关层和鉴权层,确认身份、检查频率、读超时参数;然后进入“点菜”环节——把文字转成模型认识的整数ID,也就是token化;接着是“备菜”,也就是预填充阶段,模型一次性把整道菜的材料全部看完,形成第一轮计算结果;之后进入“炒菜”环节,一道一道上菜,每上一道菜就要把前面所有菜的状态重新读一遍,这就是自回归解码中的decode阶段;最后服务员把菜端到桌上——这里就是流式返回、字节流拼接、终止判断。
这几个环节里,最容易被忽略的是tokenization。我见过不少人在本地脚本里跑得好好的,一到服务化就发现延迟高得离谱,最后查来查去是不同语言的tokenizer实现不一致,一个用了“按字符拆分”的粗糙实现,一个用了“按词表匹配”的字节对编码,生成的token完全不同。这不只是性能问题,甚至会影响输出质量。所以做全链路优化时,第一步应该把tokenizer版本和环境固定死,前后端统一。
再往后就是真正吃算力的环节。Preflight阶段一次性处理整段输入,模型会把所有输入token的计算结果都生成一份“上下文记忆”,这份记忆就是KV Cache的初始状态。后续decode阶段每生成一个新token,还要把之前的历史拼上,再算一次注意力。从本质上说,整条链路是一个“一次大计算,无数次小计算”的过程,如何放大“大计算”的效率、压缩“小计算”的开销,决定了你最终能扛住多少并法。
1.2 绕不开的两个指标:TTFT 与 TPOT
很多人看模型服务只看一个“延迟”,其实大模型推理要分成两个完全不同的延迟指标来看:
第一个是首token延迟,缩写TTFT。它指的是请求发送出去之后,到收到第一个生成token之间的时间。这个阶段包含了网络、tokenize、排队,以及最重要的prefill计算。用户对这一段的感知非常敏感,如果界面转了1秒以上还没开始打字,基本上就觉得卡了。很多团队把TTFT目标定在1秒以内,严格一点的内部服务会定在500毫秒内。
第二个是单token生成延迟,缩写TPOT。它指的是每生成一个token需要的平均时间。这个时间决定了“打字机”输出的流畅度。如果TPOT是100毫秒,那一秒只能蹦出10个字,用户会明显感觉到一顿一顿的;如果TPOT在30~50毫秒,眼睛看起来就比较流畅。PPOT决定了总生成长度下的完整响应时间,比如生成长度2000个token,TPOT=50毫秒时,光是生成阶段就要100秒。
首token延迟解决的是“什么时候开始”,单token延迟解决的是“每秒能出多少字”。这两个指标在链路的优化方向上经常互相矛盾:增大批量能提升吞吐、降低TPOT分摊成本,但可能把prefill阶段的计算堆积在一起,推高TTFT。所以做调优时,我习惯先用压测工具把这两个值分别测出来,再去定位问题在哪个阶段,不然你看一个“平均延迟”压根看不出是排队排太久还是解码太慢。
吞吐量当然也要关注,通常用每秒生成token数来衡量。低并发时延迟决定体验,高并发时吞吐决定成本。好在大模型推理里有一种调度方法,可以在不显著牺牲延迟的前提下大幅拉高吞吐——这就是后面要重点讲的动态批处理。
2. 数据进场:预处理与动态批处理
2.1 别小看 tokenize 和 padding
细想一下,模型要的是固定维度的Tensor吗?不是,大模型天然支持变长输入。但GPU特别痴迷于“等长计算”,因为矩阵运算对形状敏感,如果一批样本长度参差不齐,最简单粗暴的写法是统一补齐到最长长度,这就是padding。
Padding是推理链路里最常见的浪费源头。假设一个批次里有个请求只有20个token,另一个却长到500个token,全都补到500,那个20token的请求就有480个token里的计算是纯白扔的,注意力权重还要专门mask掉这些填充位。理论上算力被吃掉了好几倍,但一丁点收益都没有。我见过有的简单实现,因为静态batch里的padding浪费高达70%,GPU利用率却低得离谱。
解决思路有两个方向。一个是用左padding,让padding的位置在序列左边,真实token在右边,这样自回归解码时能方便地把padding和真实token分开。另一个更彻底的方式是干脆不让同一个batch内的请求等长,直接用attention mask处理,只让真实token参与计算。但这种方式对底层算子要求高,多数人用现成框架时还是会碰到内部拼接逻辑,所以你要做的第一件事就是确认你用的推理服务框架里,padding到底是怎么处理的,是否会自动按请求长度分组。
我实际踩过的一个坑是,在预处理阶段为了“统一长度”顺手把所有请求都截断成固定长度,比如一律截成512。结果长文档的请求后半段全是截断内容,输出质量严重下降。后来改成动态长度分组加小padding,效果立竿见影,同一批请求的TTFT能下降30%以上。记住一个原则:宁可让每个batch长度参差一点,靠mask解决,也不要一股脑地补零。
2.2 Continuous Batching 为什么是自回归的神器
自回归生成的天然痛点在于:不同请求的生成速度不一样。有的请求生成3个token就停了,有的要生成500个token。早期的推理框架用静态批处理,一批里面只要有一个人还在生成,其他人就得陪着等,直到最后一个请求结束才能一起退出。这种“木桶效应”会让吞吐变成一堆写在木桶底下的数字。
Continuous Batching,也就是连续批处理,逻辑完全不同:它按每一步来调度,每一个解码步完成后,哪个请求已经生成了终止符就立刻让它退出,腾出来的位置马上让排队里的新请求补进来。这样GPU在每一个时间片里都塞满了活,不会因为一个慢请求把整批拖死。
用个具体的例子说明:假设你接到8个请求,其中1个要生成1000个token,7个只要生成20个token。静态批处理要等1000个token全部生成完,这7个短请求虽然老早结束了,但都被锁死占着显存和调度槽位,相当于一个人堵住整条生产线。连续批处理下,第一批生成20个token后,那7个短请求立刻结束,新请求马上进来,只有那个长请求继续解码。同样时间里,系统处理的请求数量差不多能翻倍。
性能收益跟请求长度的方差关系很大。如果所有请求长度都差不多,连续批处理的收益不大;但如果线上请求五花八门,长短不一,收益非常可观。这也是为什么很多开源推理框架都在内部实现了这种调度算法。连续批处理还催生了一件事——你的推理服务不能再像以前那样“请求独占资源”,每个请求的KV Cache生命周期都动态变化,所以调度器必须能实时分配释放显存,这又回到了KV Cache管理的话题。
2.3 请求排队、抢占与超时控制
有了连续批处理之后,系统会同时处理多个请求,但GPU卡只有那么多,算力终究有上限。一旦过来的请求速度大于系统消化能力,队列就会出现。队列本身不可怕,可怕的是没有策略的队列。
我早期做服务时,把队列长度设成无限,想着反正会排队,就把并发数调得很高。结果一到高峰期,队列里的请求排了上百个,最晚的TTFT能到几十秒,用户全部流失掉。后来我把队列长度设成一个固定值,超出部分直接返回“系统繁忙”,配合一个快速失败策略,核心请求的TTFT一下子从十几秒降回1秒以内。看起来是拒绝了用户,实际是保护了服务,也是保护了用户耐心。
抢占策略也得提前想好。有的推理框架支持对长序列请求进行抢占,把它的KV Cache临时挪到CPU,把资源先让给新来的、更短更敏感的请求。这是KV Cache swapping机制,原理不算复杂,但频繁换入换出会让吞吐骤降。我的建议是,线上服务最好别依赖抢占,而是通过合理的队列长度和并发限制,让系统自然处于不饱和状态。抢占机制可以作为兜底,但别设计成常规运行路径。
超时控制也要分层。从整体请求超时、到prefill阶段超时、再到单个decode步超时,都要有独立监控。一次我排查一个“服务卡死”的问题,发现是某个请求的输入长度异常,prefill阶段因为显存碎片等了很久,后续decode也被长期阻塞。用超时策略把这类异常请求踢掉才恢复正常。大模型推理这种“长任务”场景里,没有超时的服务就是堵车不疏通的高速路。
3. 核心推理引擎:自回归解码的每一步
3.1 Prefill 与 Decode 是两种不同性格的阶段
大模型推理的核心引擎里,最需要区分的就是两个计算阶段:PreFill和Decode。很多人糊里糊涂地把它们混在一起调参,结果怎么调都不对。其实这两个阶段的计算特征几乎是相反的。
PreFill阶段的任务是处理整个输入序列,一次性算出所有输入token的注意力结果,并生成KV Cache。这是一次典型的“矩阵乘大餐”——输入的所有token同时参与计算,数据能充分复用,GPU算力利用率很高,属于计算密集型。你可以把它想象成一个大厨站在一排灶台前,把所有菜料一次性切完、配完。
Decode阶段则不同,它只生成一个新token,但为了算出这个token,注意力计算要读取之前所有token的KV Cache。此时每次计算读取的数据量非常大,真正的浮点运算反而少,瓶颈全在显存带宽上,属于访存密集型。相当于你每次只炒一道菜,但要把整个冰箱里所有备好的材抖都翻一遍。
| 维度 | PreFill | Decode |
|---|---|---|
| 输入规模 | 整段输入token | 每步一个token |
| 计算特征 | 高并行矩阵乘,计算密集 | 逐token生成,访存密集 |
| 主要瓶颈 | GPU算力(FLOPs) | 显存带宽 |
| 延迟表现 | 决定TTFT | 决定TPOT |
| 优化重点 | 减少冗余计算、并行切分 | 增大batch、减少KV访问开销 |
这两个阶段的差别对优化方向影响深远。做服务配置时,如果一个请求的输入特别长,prefill时间会肉眼可见地变长;如果目标是让续写快,就得在decode阶段下功夫。这也是为什么成熟的推理框架会把prefill和decode拆分调度,用不同的GPU后端、不同的优化策略。简单点理解:备菜要快,上菜也要快,但它们的快法完全不一样。
3.2 KV Cache 原理与显存占用怎么算
很多人第一次接触KV Cache时觉得这是个高深玩意,其实原理特别朴素。自回归模型在生成第N个token时,需要用到前N-1个token的Key和Value向量。最笨的计算方法是每次都把前面所有token重新算一遍注意力,这样计算量会随序列长度平方级增长,没人扛得住。KV Cache的使用就是把已经算好的Key和Value缓存在显存里,新token只要读取缓存,做矩阵乘和softmax,不用重新算历史。
Cache的占用是推理显存里的一个大头,而且它随并发和序列长度同时增长。它的占用公式是:
KV Cache显存 = 层数 × 多媒体头数 × 每个头维度 × 序列长度 × 批大小 × 数据类型字节数 × 2
公式里的“×2”,是因为Key一份、Value一份。假设你有一个7B参数的开源模型,典型配置是32层、32个注意力头、每个头维度128,用FP16(每个数2字节)。那么一个token的KV Cache占用是:
2 × 32 × 32 × 128 × 2 = 512KB
这个512KB是一个token、一个样本的占用。batch size=16、序列长度2048时,KV Cache总量是:
512KB × 2048 × 16 = 16GB
没概念?一张24GB显存的卡,光KV Cache就把大半显存吃掉了,再加上模型权重实体还有大概14GB多,剩下留给算子的显存空间非常紧张。所以模型权重不是越大的模型越难部署,KV Cache才是真正的显存杀手,而且它跟并发、长度成正比,一压测就爆炸是很正常的事。
明白这个公式之后,你能解释很多线上现象:为什么请求长度一涨,可承载并发就骤降;为什么有人靠动态释放KV Cache能塞进两倍并发;为什么想开更长生成窗口时,首先要算Cape是显存撑不撑得住,而不是模型支不支持。KV Cache这玩意儿,就是大模型推理里的“内存水位线”,必须时刻盯着。
3.3 计算密集与访存密集:瓶颈为什么总在Decode
接着上面的话题,真正让大模型推理慢下来的症结,其实在Decode阶段的显存带宽。这类模型在生成时,每个step读取的KV Cache动辄几百MB甚至几个GB,但实际算的矩阵乘远没那多浮点运算。GPU从显存搬数据的时间,往往比计算本身的时间长得多。
我用生活里的人流量来类比:prefill阶段像是几百人同时从几十个入口涌进体育场,计算资源用得满满当当,这是算力问题;decode阶段则像几十条队伍在同一个窗口办理业务,窗口前一堆人在排队,但真正办业务的人一次就一个,这个窗口再厉害也快不起来,因为吞吐能力取决于队伍移动速度,也就是显存带宽。
所以要提高decode阶段的速度,主要思路是减少每个step的显存读取量。方案有好几个:一是量化KV Cache,比如从FP16压成INT8,读取量直接砍半;二是用多轮头共享、滑动窗口之类的注意力变体,减小缓存规模;三是把KV Cache切到更高速的显存层级里,但代价是容量变小。最通用的还是增大batch size,因为一次读取一批请求的KV Cache,每条请求平均带宽占用就摊薄了。
你可能会问:那prefill阶段呢?大模型的prefill是典型的计算密集,靠模型并行把计算摊到多张卡上非常有效。所以在多卡部署时,prefill和解码并行策略经常不一样——这是那些高性能框架最难设计的地方。理解了这两种模式,你就不会被“扩大批处理能提速”这种话一概而论,反而能知道该扩容的是算力还是带宽。
4. 显存与算力调优:让7B开源模型在单卡上跑稳
4.1 模型量化:从FP16到INT8/INT4的取舍
模型权重是动辄十几个GB的大块头。FP16精度下,7B模型光权重就有14GB左右,单张24GB显卡还能勉强放下,但剩下的空间给KV Cache就捉襟见肘。量化是目前最直接的显存减压手段。
量化本质就是把模型里的浮点权重从FP16压成更低精度的表示,比如INT8或INT4。权重显存直接砍到原来的一半甚至四分之一,还能显著减少计算量。但代价是精度损失。现在量化技术已经比较成熟,常见的做法是per-channel或者per-group的缩放因子补偿。你在部署时最关心的是:这个量化方案是否经过校准,有没有在你自己业务数据上测过。
我踩过的坑是这样的:某次我把一个7B模型开成INT8量化,视觉上感觉输出和FP16差别不大,就直接上生产了。后来线上反馈某些专业领域的问答开始胡说八道,一排查才发现问题是量化时用了通用校准数据,而业务里长尾词特别多,这些词的权重在这种量化方案下丢失严重。后来我换成用真实业务数据做校准,问题才缓解。这里也提醒大家,量化一定不能只看单条样例结果,要准备一个包含业务边角数据的评测集,量化前后分别跑一遍对比。
INT4量化能进一步把权重压到3.5GB左右,但精度风险更大。如果你的应用场景偏开放生成、需要高质量回答,我的建议是优先用INT8,实在显存吃紧再考虑INT4。而且哪怕用INT4,也要关注激活值的处理——有些方案只量化权重,不量化KV Cache,还有方案额外量化KV Cache。量化越深,部署代码和算子的适配问题越多,这时候别吝啬时间做A/B对比。
4.2 一个具体的显存分配实例
为了让概念落地,我拿一个常见场景估算一下:单张24GB显存的显卡,部署一个7B开源模型,推理框架可以配置最大并发数和最大生成长度。假设模型权重使用FP16,大约占14GB;CUDA context、算子缓冲、计算图预留约2GB;剩下大约8GB可以分配给KV Cache和中间激活值。
根据前面KV Cache公式,这个7B模型在batch size=16、单请求生成长度2048时,约需16GB KV Cache,这显然放不下。所以要么减小batch,要么限制最大生成长度。比如把batch降到8,长度限制到1024,KV Cache需求变成512KB×8×1024=4GB,加上权重和预留,还有富余。你要想跑更大batch,就需要进一步压缩其他部分的占用,比如权重量化成INT8后腾出7GB以上,或者用KV Cache量化让同样显存容纳更长的上下文。
处理这个分配时,有一点很容易忽略:中间激活值也是会变的。Transformer每一层的激活值大小随模型维度和batch size变,不是常数。所以不要把所有空余显存都设定成KV Cache上限,至少要预留一部分作为“运行缓冲区”。很多框架有显存利用率参数,比如设置成0.85,含义是最多允许框架使用85%的显存,剩下的当作安全垫。实际工作中,我会先把安全垫调到0.9压测一轮,观察有没有OOM或显存碎片报错,稳定后再慢慢降低到0.85甚至0.8,找到那个“能跑不崩”的临界点。
4.3 并行策略:张量并行、流水线并行和数据并行怎么选
当单卡放不下模型,或者并发吞吐超过单卡极限时,就要把模型分布到多张卡上。并行策略有三种,各有各的适用场景。
张量并行是把每一层的权重切成多块,分别放在不同GPU上,同一个计算步骤多卡协同完成。它的优势是单请求延迟低,适合单个大模型在推理时单并发速度压不下去的场景。短板是通信开销大,每层都要做AllReduce之类的集合通信,跨卡互联带宽跟不上时性能会崩。如果模型本身能放进单卡,上张量并行往往只会增加通信开销,我一般不会这么干。
流水线并行是把模型按层切分成几段,每张卡负责其中一段。它跟张量并行的区别在于单卡不存完整层,显存占用更小,但多个请求在流水线上流动时会有bubble时间,显存利用率不太均衡。除非模型大到单卡放不下,我通常也不会首选它。
数据并行则最简单:每张卡放一份完整模型,独立处理各自收到的请求。如果配合连续批处理,多卡之间只要做负载均衡,不需要频繁通信,吞吐扩展性最好。对于7B这种模型,单卡能放下时,我强烈建议优先用数据并行,只在GPU数量超过一定规模、显存利用率不足时再去考虑张量并行。我见过一个团队为了追求所谓的“高并发”硬把模型做4路张量并行,结果通信占掉一半算力,还比不上4卡独立推理的吞吐。
5. 服务化与部署:从脚本到高并发API
5.1 选一个高吞吐推理框架,而不是自己写解码循环
很多算法工程师习惯在自己的脚本里循环调用模型生成,然后临时起一个Web服务去承接线上请求。短期能跑,但并发一高就会陷入“一人扛不住,十人就崩溃”的窘境。原因是自研脚本里缺少动态批处理、KV Cache管理和显存分配策略,这些正是推理服务框架的核心价值。
真正适合生产使用的推理服务框架,内部一定实现了四件事:高效的动态批处理调度、KV Cache的分页式内存管理、prefill与decode分离的调度策略,以及面向服务化的请求队列管理。选型时重点看它的社区活跃度、支持哪些硬件、典型的可配置项是否丰富,以及有没有可观测性接口。很多人一上来就奔着极限性能选了一个很难配置的框架,结果连最基本的请求排队都做不好,得不偿失。
我的经验是:先拿你要部署的模型,在目标框架里跑一套标准压测,内容包括TTFT、TPOT、吞吐量和显存占用这四个核心数字。设定一个你能接受的最低值,比自己瞎调半天靠谱得多。框架本身不是越新越好,调得稳的旧版本往往比新版本省心,毕竟新版本换算子后可能引入隐藏的行为变化。生产环境锁定版本,别随便升级。
5.2 生产级服务配置的核心参数
真正部署到生产环境时,需要设置的参数比在本地跑会多不少。我这里按优先级帮你梳理最核心的几项。
第一个是最大并发序列数,也就是同一时刻最多允许多少个请求同时参与推理。这个数不是越大越好,它受显存中KV Cache容量限制。如果设得太高,请求进到内部才发现KV Cache不够,只能触发排队或抢占,平均延迟反而更差。我习惯用上一节讲的KV Cache公式反推一个初始值,再压测修正。
第二个是最大模型长度,也就是单请求的输入加输出总长上限。这个参数直接决定KV Cache分配的批次大小。设太高,显存里一个请求就能把缓存吃掉大半;设太低,用户稍微问长一点就报错。要结合业务场景的实际分布来设。比如你主要是问答型场景,设成2048或4096就够了;如果是文档总结,可能要设到8192或更高。
第三个是显存利用率上限。这个参数控制框架能使用显存的比例,预留部分给算子缓冲、CUDA context和临时变量。我建议从0.85开始压测,查看显存峰值日志,再慢慢调整。还要注意一个容易忽略的参数是请求队列的最大长度,以及对应的超时策略。框架往往只是把请求放在内存里,不设上限就会拖垮服务器的整体吞吐。
配置这些东西的时候,别怕做压测,但压测要压得准。一个请求一个请求地按顺序调,测不出并发上线;一次性压一百个毫无业务特征的请求,也不具备参考价值。正确做法是用你线上真实请求长度分布构造压测集,逐渐提升并发直到TTFT和TPOT超过阈值,那个点就是服务上限。压测不是要压出一个“极限数字”,而是要找到那个“还能稳定服务”的临界点。
5.3 流式输出、接入网关与可观测性
大模型服务给用户的体验,很大程度取决于流式输出。如果你等整段生成完再一次性返回,用户会盯着空白页等半天,体感极差。流式输出就是每生成一个token就往客户端推一次,用户看到的是“正在打字”的效果。实现流式输出要注意HTTP连接保持、字节流编码一致性,以及遇到生成中止时能正确识别,不然会在返回内容的末尾夹带一堆奇怪的终止标记。
接入网关后,还要处理好负载均衡和超时重试。推理服务不像普通API,单个请求可能持续几十秒甚至几分钟,网关默认的几秒超时肯定不够用。这里我会单独为推理请求配置一套超时策略:首token延迟超时、全链路超时、以及空转超时。空转超时尤其重要,可以防止某个死循环生成停在半路占用资源。
可观测性是整个链路里最容易忽略但最重要的部分。至少要把三类指标暴露出来:吞吐类(每秒处理请求数、每秒生成token数)、延迟类(TTFT分布、TPOT分布、端到端延迟分布)、资源类(显存占用、KV Cache使用量、GPU利用率)。特别值得加的是“排队等待时间”,这个指标经常能暴露调度问题。日志方面,我建议每个请求带上一个唯一ID,同时记录请求长度、生成长度、各阶段耗时,这样排查问题时能直接拉一条请求的完整轨迹,而不是面对一堆孤立的日志碎片。
6. 常见问题与排查实录
6.1 显存溢出(OOM)最容易踩的三个坑
显存溢出的报错见得多,原因却五花八门。第一个高发的坑是KV Cache预留不足。你可能以为已经按公式算好了,但框架内部为了减少碎片,通常把KV Cache按大块预留,你看到的余量只是“未分配”,并不是“内部可写入”。所以我的习惯是压测时专门写一个脚本,用不同长度的请求连续打上几百次,观察显存增长曲线和碎片情况,而不是只看启动时的空闲显存。
第二个坑是CUDA context和计算图占用。你本地跑一个模型,看着显存刚够,但上线部署一旦启用更大batch、更长序列的新shape,框架会在运行时为这些shape重新编译或分配中间缓冲,瞬时显存可能高得吓人。解决方式是在服务启动后、正式接流量前,先用一批各种长度请求预热一遍,把计算图和缓冲都“催熟”,再开始压测。
第三个坑是动态shape导致的碎片化。连续批处理会让每次KV Cache分配释放的形状都不一样,时间长了显存里空的碎片很多,但都不够大,新请求分不到整块内存就报OOM。很多框架的显存分配器会用“按块大小分类”的策略减少碎片,但万一阵列里大块用完,也会卡死。判断碎片问题的技巧是看“已分配显存没到上限,但还是OOM”,这种时候把显存利用率上限降低一点,往往能立竿见影。
6.2 为什么并发一高,所有请求都变慢了
这大概是线上反馈最多的问题:“并发从10升到20,理论上每请求延迟应该翻倍,结果是所有请求都慢了十倍。”这种异常背后通常是调度队列和KV Cache的双重瓶颈。
先看队列。当并发请求超过系统处理速度,请求会在排队队列里无限堆积。只要队列一长,后到的请求TTFT自然暴涨,但同时因为排队请求占用了大量系统内存,前端的调度器会变得很迟钝。你从监控上看,GPU利用率可能还是满的,但实际有效token生成量却不高,因为大量时间花在等待和切换上。
再看KV Cache。高并发意味着batch size变大,单步KV Cache读取量变大,decode的访存瓶颈被彻底引爆。即使显存还在安全线内,每个token的生成延迟也在明显上升,所有请求一起变慢。这时候不能只靠加大显存去解决,而是要限制并发上限,或者把请求按优先级分成快慢两个池子,长文本请求单独限流。我曾经把线上服务改成“短请求优先,长请求限速”的策略后,整体TPOT恢复正常,长请求也没被饿死,只是排队时间稍长。这不是理想方案,但在资源有限时确实好用。
6.3 输出变“笨”了:量化与服务化的精度陷阱
还有一类问题不走显存,而是质量层:明明同一个模型,本地脚本输出挺聪明,一到线上服务就变“笨”了。这种问题的本质,大概率是链路里的某个环节动了精度。
量化是最常见的原因。权重从FP16压成INT4,在线下测试时看起来还行,但真实业务数据分布和你测试的分布不一样时,长尾词的输出质量会明显下降。我会在部署前专门准备一个“业务敏感集”,包含你最关心的典型case、边界case、长文本case,对比量化前后的输出,哪怕只是肉眼扫一遍,都能发现不少问题。
第二种陷阱是采样参数不一致。本地调试时你用了贪心解码,线上却在顶层增加了随机温度,结果输出看起来“天马行空”,让人怀疑模型坏了。这类问题不怪模型,怪代码里隐藏的sample过程。排查时先固定采样参数,逐步对比。
第三种是KV Cache量化引入的误差积累。有些框架为了省显存会对KV Cache做低精度存储,短文本时误差很小,长文本时误差在数千个token上不断累积,导致生成后期开始混乱。你观察到某个长回答“前长后乱”,可以先关闭KV Cache量化再做A/B测试,基本就能定位。
6.4 并发升高后服务不稳定,怎么办
服务不稳定通常不是单一原因,而是多个微小的配置错误叠在一起。我见过一个团队线上有几十个实例,每个实例配置都略有不同,结果高峰期一到,有些实例满载,有些却空闲。这是典型的负载均衡策略错误——没有按“当前活跃请求数”而是按“连接数”分发,导致连接少的后一瞬就塞满,连接多的可能都在等长输出。改成活跃请求数感知的负载均衡后,服务立刻稳定了很多。
另外要关注长连接数。大模型服务流式输出容易把连接保持很久,如果一个实例上长期挂着一堆慢请求,新请求排队时间又被无限拉长。这时候要区分“活跃请求数”和“连接数”,别让连接数把监控数据搞混。稳定性调优没有银弹,我能给的经验是:把队列长度、并发上限、超时、负载均衡四个配置做成一组“套餐”,根据压测结果整体调节,而不是单拧一个参数。这套餐调好后,就是把版本稳定下来,再谈优化。
如果你刚开始做这条全链路,我的建议是先拿一个7B开源模型在一张卡上跑通,把显存分配、动态批处理和监控这三件事做扎实,再考虑分布式和框架选型。我自己踩过最大的坑就是跳过基本功,直接上高并发,结果每天在日志里捞异常,浪费了大量时间。等你把单卡调稳了,很多所谓的高级问题会自然消失,你也能踩着这个基础往更高的并发和更长的上下文走。