从粗放推理把大模型当普通函数串行同步一个个调 GPU 利用率常年趴在十几个百分点海量并行算力白白空转大量请求却在外面排队几十秒超时昂贵算力闲置与请求超时荒谬并存 + 按最大长度悲观预留 KV cache 一个短请求也按几千 token 占满显存且预留切碎了显存导致显存明明够用却凑不出一块连续大块而 OOM + FP16 全精度原封不动把整个模型塞进显存几十上百亿参数吃掉几十上百 G 一张主流卡根本放不下勉强放下也没显存做并发 + 对涌进来的请求来者不拒全往 GPU 上死命挤洪峰一来 KV cache 瞬间挤爆显存 OOM 进程连环崩溃连容量内请求也玉石俱焚还陷入崩溃重启再崩溃死亡循环 + 必须死等整个答案几百 token 全部生成完毕才一次性整坨返回用户对着无尽旋转的加载圈干等十几几十秒不知是在干活还是卡死耐心撑不过几秒愤然离开 + 既无超时约束又无优先级区分一个用户构造的异常 prompt 让模型停不下来狂吐几千 token 单个请求死霸 GPU 槽位把后面所有正常请求全堵到超时实时对话请求和后台离线批处理请求平等排队 + 单模型单实例硬编码写死要换模型就得改代码重部署单实例挂了服务整个不可用毫无冗余固定实例数白天高峰被打爆深夜低谷昂贵 GPU 大量空转烧钱 + 推理是黑盒 GPU 利用率显存吞吐 TTFT 队列长度全然不知出了推理变慢偶尔超时只能两眼一抹黑靠猜靠重启撞运气一长串环节根本不知卡在哪一环 → 2026 现代大模型推理服务工程体系 连续批处理在途请求动态组批喂满 GPU 把利用率拉满 + PagedAttention 按页管理 KV cache 用多少分多少消灭碎片化 + INT8/INT4 量化压缩单卡放下更大模型还腾出显存做并发 + 队列加并发上限加令牌桶限流把负载控制在 GPU 稳定承载内 + SSE 流式输出每生成一个 token 即时推送亚秒级见首字 + 请求级超时超预算即中止释放加优先级调度高优先级优先可抢占 + 多模型多副本加智能路由加按负载自动弹性伸缩峰扩谷缩 + TTFT/TPOT/吞吐/GPU 利用率指标大盘加全链路 TraceID 追踪 87 天战役复盘:47 套工程修法 + 7 个 P0 复盘 + 6 条工程哲学

7 人的大模型推理平台团队 87 天把一套支撑几十个大模型在线服务、三年里模型从几亿参数长到几十上百亿参数调用量从每天几千涨到每秒上千、却一直停留在把大模型当普通函数串行同步一个个调的原始推理体系——推理执行还是来一个请求就在 GPU 上把它从头到尾算完再处理下一个海量并行算力被串行任务喂不饱常年趴在十几个百分点白白空转而大量请求却在外面排着几十秒的长队超时昂贵算力闲置与请求超时荒谬地并存、显存与 KV cache 还在按每个请求可能的最大长度去悲观预留一个只生成几十 token 的短请求也被按几千 token 的最大长度占满了显存而且这种按最大预留还把宝贵的显存切得七零八落导致经常显存总量明明还够却凑不出一块足够大的连续空间来而触发 OOM、模型还在用 FP16 全精度原封不动地整个塞进显存一个几十上百亿参数的大模型光权重就吃掉几十上百 G 一张主流 GPU 卡根本放不下得多卡切分勉强放下也没显存留给 KV cache 和并发、服务入口还对涌进来的请求来者不拒有多少就全往 GPU 上死命挤一旦遇到流量洪峰海量请求的 KV cache 瞬间就把显存挤爆触发 OOM 推理进程崩溃连那些本在容量之内可以被正常服务的请求也跟着一起玉石俱焚还陷入崩溃重启再崩溃的死亡循环、返回结果还在用毫秒级 Web 接口那套必须死等整个几百 token 的答案全部生成完毕才一次性整坨返回用户点了发送后面对的是长达十几几十秒毫无反馈的漫长空白只有一个无尽旋转的加载圈不知服务是在认真干活还是已经卡死耐心撑不过几秒就反复点击加重负担或判定服务坏了愤然离开、调度还处于既无超时约束又无优先级区分的双重失序一个用户构造的异常 prompt 让模型停不下来狂吐几千 token 这单个请求就长时间死死霸占着宝贵的 GPU 槽位把后面排队的大量正常请求全堵到超时而用户正盯着屏幕等的实时对话请求和后台批量跑根本没人等的离线分析请求竟在同一个队列里平等排队、模型部署还僵化得令人窒息要用哪个模型硬编码写死在代码里想换模型或多提供几个模型就得改代码重部署单实例一挂对应模型服务就整个不可用毫无冗余而且实例数是拍脑袋定死的固定值白天高峰扛不住被打爆深夜低谷昂贵 GPU 又大量空转烧钱、整个推理服务更是一片空白的黑盒它此刻 GPU 利用率多少显存占了多少吞吐多少首字延迟快不快多少请求在排队统统不知道对自己服务的了解贫乏到只剩进程还活着没有这么一个最粗浅的信号每当线上推理变慢偶尔超时只能两眼一抹黑靠猜靠经验拍脑袋靠重启撞运气而一个请求要流经限流排队路由批处理 GPU 推理流式返回这一长串环节出了慢根本不知道卡在哪一环——系统性地重构成 2026 年现代大模型推理服务工程体系:把推理执行从串行同步换成连续批处理让在途请求动态组批把 GPU 的并行算力喂得满满当当利用率拉满吞吐翻好几倍、把 KV cache 从按最大长度悲观预留换成 PagedAttention 像操作系统管内存那样按页用多少分多少彻底消灭碎片化让同一张卡的并发数成倍增长、把模型从 FP16 全精度换成 INT8/INT4 量化用用户几乎无感的微小精度代价换来单卡放下更大模型还腾出大把显存做并发、给服务入口装上队列加在途并发上限加令牌桶限流两道闸门把同时上 GPU 的请求控制在其真实承载力之内超出整体容量的流量在门口快速明确拒绝、把返回方式从一次性整坨返回换成 SSE 流式输出顺应大模型逐 token 生成的天然特性每蹦出一个 token 就即时推送让用户亚秒级就见答案像打字机般往外蹦、给每个请求套上请求级超时超预算就果断中止释放资源绝不让单个异常超长请求霸占 GPU 连累全局再按实时交互离线批处理的真实优先级做调度让高优先级请求优先获得低延迟服务甚至可抢占、把模型部署从单模型单实例硬编码固定容量换成多模型多副本加智能路由加按队列长度 GPU 利用率等实时负载自动弹性伸缩峰时扩谷时缩让容量动态贴合真实负载、给整个推理服务装上仪表盘采集 TTFT/TPOT/吞吐/GPU 利用率/显存占用/队列长度等大模型推理专属黄金指标汇成大盘再给每个请求打全局 TraceID 做全链路追踪串起限流排队路由批处理推理流式返回全过程让任何一个慢请求都能精准定位慢在哪一环,从此再没因为一次流量洪峰把推理服务搞崩过同样数量的 GPU 服务能力翻了好几倍用户再不用对着转圈干等深夜的 GPU 账单大幅下降出了问题分钟级就能定位到瓶颈,沉淀 47 套工程修法 + 7 个 P0 复盘 + 6 条工程哲学。

我们是一个 7 个人的大模型推理平台团队,负责把公司里十几个业务线要用的大语言模型,封装成稳定、低延迟、高吞吐的在线推理服务对外提供。三年里,我们的模型从最初一个几亿参数的小模型,长成了好几个几十上百亿参数的大模型,调用量从每天几千次涨到了每秒上千次,可我们的推理服务,却长期停留在一种"把大模型当成一个普通函数、调一下拿到结果"的天真阶段——来一个请求就同步地、串行地在 GPU 上从头跑一遍完整推理、全精度地把模型一股脑塞进显存、对涌进来的请求来者不拒、生成完整个答案才一次性返回,直到一次再普通不过的运营推送,把请求量瞬间推高了十几倍,我们的推理服务在几分钟内就彻底崩了:GPU 显存被并发的请求挤爆、CUDA 报出 OOM、推理进程一个接一个地挂掉,侥幸没挂的请求也在队列里排了几十秒迟迟得不到响应、纷纷超时,前端大面积转圈白屏,而我们除了手忙脚乱地重启进程、再眼睁睁看着它被流量再次冲垮,几乎束手无策。那一次,我们用一场长达 87 天的攻坚,把这套粗放的推理服务,系统性地重构成了一套现代的大模型推理服务工程体系,期间沉淀了 47 套工程修法、7 个 P0 事故复盘和 6 条工程哲学。这篇文章,就是这场战役的完整复盘。

先说最痛的领悟:大模型推理和传统的 Web 请求处理,在工程上是两种截然不同的物种。一次普通的 Web 请求,是毫秒级的、无状态的、消耗的是 CPU 和内存这种相对廉价且充裕的资源;而一次大模型推理,是秒级的、自回归地一个 token 一个 token 往外蹦的、消耗的是 GPU 算力和显存这种极其昂贵且稀缺的资源,而且单次推理的显存占用会随着上下文长度动态膨胀。如果还用处理 Web 请求的那套粗放思路(来一个处理一个、同步串行、资源来者不拒)去处理大模型推理,等于是用对待自来水的方式去对待金子,昂贵的 GPU 被大量浪费、稀缺的显存被轻易挤爆、用户还体验极差。下面这张表,是我们这套推理服务在重构前后的全面对比:

维度 重构前(粗放推理:大模型当普通函数调) 重构后(2026 现代推理服务工程)
推理执行 来一个请求就同步串行地在 GPU 上跑一遍,GPU 大半时间空转、利用率极低 连续批处理(continuous batching)把多个请求动态拼成一批一起算,GPU 吃满
显存/KV 管理 按最大长度预分配 KV cache、碎片化严重,并发一高就显存 OOM 崩溃 PagedAttention 分页管理 KV cache,按需分配、几乎零浪费,显存利用率翻倍
精度/量化 FP16 全精度把模型整个塞进显存,一张卡放不下大模型、显存动辄爆掉 INT8/INT4 量化压缩,显存占用降到几分之一,单卡能放下更大模型或更多并发
并发控制 对涌进来的请求来者不拒、全往 GPU 上挤,瞬间挤爆显存或排成长龙 请求队列 + 并发上限 + 排队调度,把并发控制在 GPU 能稳定承载的范围内
限流/过载保护 没有任何限流,一次流量洪峰就把推理服务整个打爆、全员玉石俱焚 令牌桶限流 + 超容量快速拒绝,保住容量内请求的稳定服务
响应方式 必须等整个答案全部生成完才一次性返回,用户干等几十秒、以为卡死了 流式输出(SSE)token 边生成边逐个吐给用户,首字延迟从几十秒降到亚秒级
超时/调度 没有超时、没有优先级,一个超长生成的请求能把后面所有请求都堵死 请求级超时 + 优先级调度,高优先级请求优先、超时请求及时释放资源
模型部署/路由 单模型单实例硬编码,换模型要改代码重启、一个实例挂了全线不可用 多模型多副本 + 智能路由,按模型名路由、副本间负载均衡、坏副本自动摘除
弹性伸缩 固定数量的 GPU 实例,峰时不够被打爆、谷时闲置烧着昂贵的 GPU 钱 按负载(队列长度/GPU 利用率)自动扩缩容,峰时扩谷时缩,既扛得住又省钱
可观测 推理是个黑盒,GPU 利用率多少、延迟多少、吞吐多少全然不知,出事靠猜 采集 TTFT/TPOT/吞吐/GPU 利用率/显存占用等指标 + 链路追踪,一图看穿

一、推理执行:从来一个同步串行跑一遍 GPU 大半空转到连续批处理把 GPU 吃满

第一仗,是把推理的执行方式,从"来一个请求就傻乎乎地同步串行在 GPU 上从头跑一遍"的极度浪费,改造成"把同时在途的多个请求动态地拼成一批、在 GPU 上一起算"的连续批处理(continuous batching)。古早时代我们的推理执行,简单粗暴得近乎奢侈:来一个推理请求,我们就在 GPU 上把这个请求的完整推理过程从头到尾跑一遍、生成完它的答案,这期间这块 GPU 就专属于这一个请求,下一个请求只能排队等着,等前一个彻底跑完了才轮到它。这种"一次只伺候一个请求"的串行方式,对 GPU 这种以并行计算见长、且极其昂贵的硬件来说,是一种触目惊心的浪费——大模型的自回归生成是一个 token 一个 token 地往外蹦的,每生成一个 token 其实只占用了 GPU 算力的一小部分,GPU 在生成相邻两个 token 的间隙、在等待显存读写的间隙,大量的算力都在白白空转,我们实测下来,这种串行推理下 GPU 的实际利用率常常只有可怜的百分之十几,等于是我们花了大价钱买来的金贵 GPU,八成以上的算力都在闲置发呆,与此同时请求却在外面排着长队、等得超时。现代做法是用连续批处理这一关键技术把 GPU 喂饱:它的核心思想是,既然单个请求填不满 GPU 的算力,那就把同一时刻在途的多个请求,在每一步生成时,动态地拼成一个批次(batch)送进 GPU 一起算,让 GPU 在一次计算里同时为这一批里的所有请求各推进一个 token,而且关键在于"连续"二字——这个批次不是固定的,而是动态变化的:某个请求生成完了它的答案、提前退出了,它的位置立刻就被新到来的请求填补进来,整个批次像一条永不停歇的流水线,源源不断地有请求加入、有请求完成、有新请求补位,GPU 始终满载运转、几乎没有空转,吞吐量因此能提升好几倍乃至一个数量级。下面是推理执行的对比:

# 重构前:来一个请求就同步串行在 GPU 上跑完整推理,一次只伺候一个,GPU 大半时间空转
def infer(prompt: str) -> str:
    # ↓ 这一个请求独占 GPU,从头跑到尾,期间后来的请求只能干等
    input_ids = tokenizer(prompt, return_tensors="pt").to("cuda")
    output = model.generate(input_ids, max_new_tokens=512)  # 串行:生成完才返回
    return tokenizer.decode(output[0])
# ↑ 单请求填不满 GPU 算力,实测 GPU 利用率常只有十几个百分点,请求却在外面排长队超时

# 重构后:连续批处理(continuous batching)——多个在途请求动态拼成一批一起算,GPU 吃满
from vllm import LLM, SamplingParams
engine = LLM(model="Qwen2.5-14B-Instruct", gpu_memory_utilization=0.9)
# vLLM 的调度器在每一步生成时,把所有在途请求动态组成一个 batch 送进 GPU 一起推进一个 token
# 某请求生成完提前退出 → 它的槽位立刻被新到来的请求补上 → 批次像流水线一样连续不断
params = SamplingParams(max_tokens=512, temperature=0.7)
outputs = engine.generate(prompts_in_flight, params)  # 一批请求并行推进,GPU 始终满载
# ↑ GPU 利用率从十几个百分点拉满,吞吐量提升数倍到一个数量级,排队超时大幅缓解

推理执行现代化让我们从"来一个推理请求就在 GPU 上把这个请求的完整推理过程从头到尾跑一遍生成完它的答案这期间这块 GPU 就专属于这一个请求下一个请求只能排队等着等前一个彻底跑完了才轮到它、这种一次只伺候一个请求的串行方式对 GPU 这种以并行计算见长且极其昂贵的硬件来说是一种触目惊心的浪费大模型的自回归生成是一个 token 一个 token 地往外蹦的每生成一个 token 其实只占用了 GPU 算力的一小部分 GPU 在生成相邻两个 token 的间隙在等待显存读写的间隙大量的算力都在白白空转实测下来这种串行推理下 GPU 的实际利用率常常只有可怜的百分之十几"进化到了"用连续批处理这一关键技术把 GPU 喂饱既然单个请求填不满 GPU 的算力那就把同一时刻在途的多个请求在每一步生成时动态地拼成一个批次送进 GPU 一起算让 GPU 在一次计算里同时为这一批里的所有请求各推进一个 token而且关键在于连续二字这个批次不是固定的而是动态变化的某个请求生成完了它的答案提前退出了它的位置立刻就被新到来的请求填补进来整个批次像一条永不停歇的流水线源源不断地有请求加入有请求完成有新请求补位 GPU 始终满载运转几乎没有空转":过去我们犯的根本性错误,是把昂贵稀缺的 GPU,当成了廉价充裕的 CPU 来用——处理 Web 请求时一个线程伺候一个请求那套天经地义的串行模型,被我们想当然地照搬到了大模型推理上,却完全没意识到 GPU 和 CPU 是两种工作方式截然不同的硬件:CPU 是为低延迟的串行任务优化的、一个核心一次干一件事干得很快,而 GPU 是为高吞吐的并行计算优化的、有成千上万个计算单元、最怕的就是只喂给它一件事干让绝大多数单元闲着,我们用串行的方式喂 GPU,等于是请了一千个工人却每次只让一个人干活、其余九百九十九个站着看,这种浪费在 GPU 如此昂贵的今天,是我们完全负担不起的;更要命的是,大模型推理的自回归特性,让这种浪费雪上加霜——生成每一个 token 都要把整个模型过一遍,但每一遍的实际计算量又填不满 GPU,于是 GPU 在一次次的 token 生成间隙里大量空转,我们的利用率监控曲线常年趴在百分之十几的地板上,而与此同时,因为一次只能处理一个请求,后面的请求就排起了长队、等得纷纷超时,我们陷入了一个荒诞的窘境:一边是 GPU 的算力在大量闲置,一边是请求在大量超时,昂贵的资源和糟糕的体验,荒谬地同时存在;后来我们引入了连续批处理这项专门为大模型推理而生的关键技术,才终于让 GPU 物尽其用,它的精妙之处在于深刻地顺应了大模型推理"自回归、逐 token、单请求填不满算力"的本质特点,不再是一个请求一个请求地串行喂,而是把同一时刻所有在途的请求,在每一个生成步上,都动态地打包成一个批次塞给 GPU 一起算,让 GPU 的并行算力被这一整批请求充分地占满,而"连续"二字更是点睛——传统的静态批处理要凑齐一批、一起开始、一起结束,有请求快有请求慢就得互相等,而连续批处理让批次成了一条动态的流水线,任何一个请求生成完毕提前下车,它空出的位置马上就有新上车的请求补上,GPU 永远不会因为等一批里最慢的那个而空转,真正做到了让昂贵的算力一刻不停地为尽可能多的请求服务。我们的纪律是"绝不用串行同步、一次只伺候一个请求的方式做大模型推理,所有在线推理必须用连续批处理把在途请求动态组批喂满 GPU,要把 GPU 当成最怕闲着的高吞吐并行硬件而非一次干一件事的 CPU 来对待,要用连续而非静态的批处理让批次像流水线一样请求随到随上随完随下、绝不让 GPU 为等最慢的请求而空转,把吃满昂贵 GPU 的算力当成推理服务降本增效的头等大事来对待"。推理执行的本质认知是:GPU 是一种以高吞吐并行计算见长、且极其昂贵的硬件,它最大的浪费不是算得慢,而是被串行的任务喂得吃不饱、让海量并行算力大量空转——用伺候 Web 请求的串行思路去做大模型推理,就是在用对待自来水的方式挥霍金子;推理执行现代化的智慧,在于用连续批处理这项顺应大模型自回归本质的技术,把同时在途的众多请求动态地、连续地拼批喂给 GPU,让昂贵的并行算力被持续地占满,从而在同样的硬件上压榨出数倍的吞吐,会做推理服务的团队,眼里盯着的核心指标永远是 GPU 利用率有没有被拉满,因为他们深知,每一个百分点的 GPU 空转,在大模型时代都是在白白烧钱。

二、显存与 KV 管理:从按最大长度预分配碎片化 OOM 到 PagedAttention 按需分页

第二仗,是把对显存里那块最吃资源的 KV cache 的管理,从"按最大可能长度一次性预分配、碎片化严重、动不动就 OOM"的粗放浪费,改造成"像操作系统管理内存那样按需分页(PagedAttention)、几乎零浪费"的精细管理。要理解这一仗,得先明白 KV cache 是什么、为什么它是显存里的吞金兽:大模型自回归生成时,为了避免每生成一个新 token 都把前面所有 token 重算一遍,会把前面每个 token 算出的 Key 和 Value 向量缓存在显存里复用,这块缓存就是 KV cache,它的大小会随着上下文(prompt + 已生成内容)的变长而线性膨胀,在长上下文场景下,KV cache 占用的显存常常比模型权重本身还要大,是显存里名副其实的吞金兽。古早时代我们对 KV cache 的管理极其粗放:由于不知道每个请求最终会生成多长,我们干脆按"支持的最大长度"为每个请求一次性预分配一整块连续的、足够装下最大长度的 KV cache 显存,这种做法的浪费令人咋舌——绝大多数请求实际只生成几十上百个 token,却被强行按几千 token 的最大长度预留了显存,这意味着每个请求都白白霸占着大量它根本用不到的显存,导致一张卡明明显存还剩很多、却因为这些被预留霸占而无法接纳新的请求,显存利用率极低;更糟的是,这些大块连续显存的反复申请和释放,会把显存切割得碎片化严重,经常出现"显存总量明明够、却找不出一块足够大的连续空间"的尴尬,新请求一来就申请失败、CUDA 直接报 OOM,推理进程崩溃。现代做法是借鉴操作系统管理物理内存的虚拟内存与分页思想,用 PagedAttention 来精细管理 KV cache:它不再为请求预分配一整块连续的大显存,而是把显存切成许多固定大小的小块(页),每个请求的 KV cache 用多少就按页申请多少、随着生成变长再动态地按页追加,这些页在物理显存里可以是不连续的(由一张页表来维护逻辑顺序),这样一来,既彻底消除了"按最大长度预分配"造成的巨大浪费(用多少分多少)、又因为分配单位是小而统一的页而几乎消除了碎片化,显存利用率因此成倍提升,同样一张卡能同时服务的并发请求数大幅增加,OOM 崩溃也基本绝迹。下面是显存与 KV 管理的对比:

# 重构前:按"支持的最大长度"为每个请求预分配一整块连续 KV cache 显存,浪费且碎片化 OOM
MAX_LEN = 4096
def alloc_kv_cache(batch_size):
    # ↓ 不管请求实际生成多长,一律按最大长度 4096 预留一整块连续显存
    kv = torch.empty(batch_size, MAX_LEN, n_layers, 2, n_heads, head_dim, device="cuda")
    return kv  # 大多数请求只用几十上百 token,却霸占了 4096 的显存 → 巨大浪费
# ↑ 显存被大量预留却用不到 → 卡还很空却接不了新请求;大块连续显存反复申请 → 碎片化 → OOM 崩溃

# 重构后:PagedAttention——借鉴 OS 分页,KV cache 切成固定小页,按需申请、动态追加、可不连续
# (vLLM 内置 PagedAttention,开箱即用;下面示意其分页管理思想)
BLOCK_SIZE = 16          # 每页存 16 个 token 的 KV,固定大小、统一规格
class BlockManager:
    def __init__(self, total_blocks):
        self.free_blocks = list(range(total_blocks))   # 空闲页池
        self.block_tables = {}                         # 每个请求的页表(逻辑→物理页映射)
    def append_token(self, req_id):
        seq = self.block_tables.setdefault(req_id, [])
        if not seq or seq[-1].is_full():               # 当前页满了才申请新页(按需、用多少分多少)
            seq.append(self.free_blocks.pop(0))        # 从空闲池取一页,物理上可不连续
    def free(self, req_id):                            # 请求结束,所有页归还空闲池,零碎片
        self.free_blocks.extend(self.block_tables.pop(req_id))
# ↑ 用多少分多少 + 统一小页几乎零碎片 → 显存利用率翻倍、同卡并发数大增、OOM 基本绝迹

显存与 KV 管理现代化让我们从"由于不知道每个请求最终会生成多长干脆按支持的最大长度为每个请求一次性预分配一整块连续的足够装下最大长度的 KV cache 显存这种做法的浪费令人咋舌绝大多数请求实际只生成几十上百个 token 却被强行按几千 token 的最大长度预留了显存这意味着每个请求都白白霸占着大量它根本用不到的显存导致一张卡明明显存还剩很多却因为这些被预留霸占而无法接纳新的请求显存利用率极低、更糟的是这些大块连续显存的反复申请和释放会把显存切割得碎片化严重经常出现显存总量明明够却找不出一块足够大的连续空间的尴尬新请求一来就申请失败 CUDA 直接报 OOM 推理进程崩溃"进化到了"借鉴操作系统管理物理内存的虚拟内存与分页思想用 PagedAttention 来精细管理 KV cache不再为请求预分配一整块连续的大显存而是把显存切成许多固定大小的小块页每个请求的 KV cache 用多少就按页申请多少随着生成变长再动态地按页追加这些页在物理显存里可以是不连续的由一张页表来维护逻辑顺序既彻底消除了按最大长度预分配造成的巨大浪费又因为分配单位是小而统一的页而几乎消除了碎片化":过去我们对 KV cache 的管理之所以如此粗放,根子在于我们用了一种最简单、却也最浪费的"悲观预留"策略——因为预测不了一个请求到底会生成多长,我们就干脆按最坏情况(最大长度)给它预留到顶,这种"宁可多占不可不够"的思路,看似稳妥,实则造成了惊人的浪费:一个实际只生成五十个 token 的请求,却霸占着够装四千个 token 的显存,百分之九十八的预留显存就这么白白闲置着、还不许别人用,我们的 GPU 显存监控上,经常是"已分配"很高、可"实际使用"很低,大量显存被预留锁死却空转,直接后果就是一张本可以同时服务几十个请求的卡,因为每个请求都过度预留,实际只能服务可怜的几个,昂贵的显存资源被我们用一种极其低效的方式浪费掉了大半;而碎片化则是压垮骆驼的另一根稻草,这些大块连续显存被反复地申请、释放,把原本完整的显存切割得七零八落,经常陷入"加起来够、连一块都凑不出"的窘境,新请求申请显存失败、OOM 崩溃频发,我们却百思不解"显存明明还有不少啊";后来我们才从操作系统管理内存的古老智慧里找到了答案——操作系统几十年前就面临过一模一样的难题:进程需要内存,但不知道要多少、还怕碎片化,而它的解法正是虚拟内存与分页,把物理内存切成固定大小的页、进程要多少给多少页、逻辑上连续物理上可以不连续、由页表来维护映射,PagedAttention 正是把这套经典思想原汁原味地搬到了 KV cache 的显存管理上:它把显存切成一个个固定大小的小页,每个请求的 KV cache 不再一次性预留到顶,而是随着 token 的实际生成、用满一页再申请下一页,真正做到了"用多少、分多少",彻底消灭了悲观预留的巨大浪费,而又因为分配和回收的单位都是大小统一的小页,显存的分配变得像拼乐高一样规整,碎片化也随之几乎消失,这一项改造下来,我们同一张卡能同时服务的并发请求数成倍地增长,困扰我们已久的 OOM 崩溃也基本绝迹了。我们的纪律是"绝不按最大可能长度为请求悲观预留一整块连续的 KV cache 显存,必须用 PagedAttention 等分页机制把 KV cache 按需、按页、可不连续地动态分配,要把显存当成像物理内存一样宝贵、必须用分页智慧精打细算的稀缺资源,要用多少分多少杜绝预留浪费、用统一小页消灭碎片化,把 KV cache 这个会随上下文膨胀的显存吞金兽的精细管理当成提升单卡并发与杜绝 OOM 的关键来对待"。显存与 KV 管理的本质认知是:KV cache 是显存里会随上下文线性膨胀的吞金兽,而"按最大长度悲观预留连续显存"这种看似稳妥的策略,会同时带来巨大的预留浪费和严重的碎片化,让一张卡的显存被白白锁死大半、还频繁 OOM;显存管理现代化的智慧,在于直接借用操作系统沉淀了几十年的虚拟内存与分页思想,用 PagedAttention 把 KV cache 按需、按统一小页、可不连续地动态管理,从而既杜绝预留浪费、又消灭碎片化,让宝贵的显存被精打细算地用到极致,会做推理服务的团队,看待显存就像操作系统看待物理内存一样精细,因为他们深知,在动辄几十 G 显存就要花大价钱的今天,每一寸被预留浪费、被碎片锁死的显存,都是在拿真金白银打水漂。

三、精度与量化:从 FP16 全精度把模型整个塞进显存一张卡放不下到 INT8/INT4 量化压缩

第三仗,是把模型加载进显存的方式,从"FP16 全精度原封不动地把整个模型塞进显存、一张卡都放不下大模型"的奢侈,改造成"用 INT8/INT4 量化把模型压缩到几分之一大小、单卡轻松放下还能腾出更多显存做并发"的精打细算。古早时代我们加载模型,是最朴素也最奢侈的方式——把训练出来的模型权重,原封不动地以 FP16(16 位浮点)的全精度,整个儿地搬进 GPU 显存,这意味着一个有几十上百亿参数的大模型,光是权重就要吃掉几十上百 G 的显存,我们经常遇到的窘境是:一张主流的 GPU 卡显存就那么几十 G,一个稍大的模型 FP16 加载进去就把显存占得满满当当、甚至根本放不下,得动用好几张卡切分着放,而即便勉强放下了,留给 KV cache 和并发请求的显存也所剩无几,导致并发能力极差。这种全精度加载的奢侈,源于一个未经审视的默认假设:模型必须用它训练时的全精度来推理、否则效果就会崩坏。现代做法是用量化技术戳破这个假设、给模型显存"瘦身":量化的核心思想是,把模型权重从 16 位的浮点数,用特定的算法压缩映射到 8 位整数(INT8)甚至 4 位整数(INT4)来存储和计算,位数降下来,显存占用自然成倍地降——INT8 大约是 FP16 的一半、INT4 更是只有四分之一,而大量的工程实践已经反复证明:对绝大多数应用场景而言,经过良好量化(尤其是 INT8 和精心做的 INT4)的模型,推理效果的损失小到几乎可以忽略不计、用户根本感知不到,但换来的显存收益却是巨大的——原本一张卡放不下的大模型,量化后轻松放下;原本放下了也没显存做并发的,量化后腾出了大把显存去容纳 KV cache 和并发请求,单卡的吞吐能力因此大幅提升,我们用 GPU 的总账也算得更划算了。下面是精度与量化的对比:

# 重构前:FP16 全精度原封不动把整个模型塞进显存,几十上百亿参数吃掉几十上百 G,一张卡放不下
model = AutoModelForCausalLM.from_pretrained(
    "Qwen2.5-14B-Instruct",
    torch_dtype=torch.float16,   # FP16 全精度:14B 模型光权重就约 28G 显存
    device_map="cuda",
)
# ↑ 一张卡显存被权重占满,放不下就得多卡切分;勉强放下也没显存做 KV cache 和并发 → 并发极差

# 重构后:INT8/INT4 量化压缩,模型显存降到几分之一,单卡放下还腾出大把显存做并发
from vllm import LLM
# 方式一:加载 AWQ/GPTQ 等已量化好的 INT4 权重(显存约为 FP16 的 1/4)
engine = LLM(
    model="Qwen2.5-14B-Instruct-AWQ",   # INT4 量化版:14B 权重约 7~8G,单卡轻松放下
    quantization="awq",
    gpu_memory_utilization=0.9,          # 省下的显存全部留给 KV cache → 并发数大增
)
# 方式二:在线 INT8 量化(显存约为 FP16 的 1/2,效果损失极小)
engine_int8 = LLM(model="Qwen2.5-14B-Instruct", quantization="bitsandbytes",
                  dtype="int8")
# ↑ 良好量化下效果损失小到用户无感,但显存省下一半到四分之三 → 单卡能放更大模型/扛更多并发

精度与量化现代化让我们从"把训练出来的模型权重原封不动地以 FP16 的全精度整个儿地搬进 GPU 显存这意味着一个有几十上百亿参数的大模型光是权重就要吃掉几十上百 G 的显存经常遇到的窘境是一张主流的 GPU 卡显存就那么几十 G 一个稍大的模型 FP16 加载进去就把显存占得满满当当甚至根本放不下得动用好几张卡切分着放而即便勉强放下了留给 KV cache 和并发请求的显存也所剩无几导致并发能力极差、这种全精度加载的奢侈源于一个未经审视的默认假设模型必须用它训练时的全精度来推理否则效果就会崩坏"进化到了"用量化技术戳破这个假设给模型显存瘦身把模型权重从 16 位的浮点数用特定的算法压缩映射到 8 位整数甚至 4 位整数来存储和计算位数降下来显存占用自然成倍地降 INT8 大约是 FP16 的一半 INT4 更是只有四分之一而大量的工程实践已经反复证明对绝大多数应用场景而言经过良好量化的模型推理效果的损失小到几乎可以忽略不计用户根本感知不到但换来的显存收益却是巨大的原本一张卡放不下的大模型量化后轻松放下原本放下了也没显存做并发的量化后腾出了大把显存去容纳 KV cache 和并发请求":过去我们抱着一个想当然的执念,觉得模型既然是用 FP16 训练的,推理时就理所应当也用 FP16,降低精度听起来就像是在偷工减料、会让模型变笨变傻,于是我们宁可忍受全精度加载带来的显存窘境——放不下就咬牙加卡、并发差就忍着,也从没认真考虑过给模型降精度,可我们从没真正去验证过那个"降精度效果就会崩"的假设到底成不成立;后来当显存的压力实在让我们喘不过气、不得不去尝试量化时,实测的结果让我们大吃一惊也如释重负:经过良好量化的模型,在我们的业务场景下,推理效果的下降微乎其微,用各种评测集去量化对比,差距小到几乎在误差范围内,用户端更是完全感知不到任何区别,而显存的收益却是立竿见影、实实在在的——INT8 量化直接把权重显存砍掉一半,INT4 更是砍掉了四分之三,那个原本要两张卡才放得下的模型,量化后一张卡绰绰有余,而且因为权重省下了大量显存,我们能把省下来的显存全都拨给 KV cache 和并发请求,单卡能同时伺候的请求数翻着番地涨,这一进一出,我们用同样数量的 GPU,服务能力提升了好几倍,GPU 的总成本账一下子就划算了许多;这件事给我们最深的教训是,工程上很多看似天经地义、不容置疑的默认假设(比如"推理必须用训练精度"),其实从未经过我们自己的严格验证,而正是这些未经审视的假设,默默地让我们承受着本不必承受的高昂代价,真正的工程进步,往往就始于鼓起勇气去质疑、去实测一个被所有人默认的前提。我们的纪律是"绝不盲目地用 FP16 全精度加载模型而无视显存代价,应当用 INT8/INT4 量化给模型显存瘦身、用实测数据而非想当然来判断量化对效果的真实影响,要敢于质疑推理必须用训练精度这类未经验证的默认假设,要把量化省下的显存拨给 KV cache 和并发以提升单卡吞吐,把用可接受的、用户无感的微小效果代价换取巨大显存与并发收益当成 GPU 降本增效的利器来对待"。精度与量化的本质认知是:用 FP16 全精度加载模型,源于一个"推理必须用训练精度否则效果崩坏"的、却从未被严格验证的默认假设,而正是这个假设让我们白白承受着模型放不下、并发上不去的高昂显存代价;量化的智慧,在于鼓起勇气用实测去戳破这个假设——大量实践证明良好量化的效果损失小到用户无感,而显存收益却是成倍的——从而用一点几乎可忽略的精度代价,换来单卡放下更大模型、腾出更多显存做并发的巨大收益,会做推理服务的团队,从不迷信全精度,而是用实测数据去精确权衡精度与显存的得失,因为他们深知,在 GPU 如此昂贵的今天,为一个未经验证的精度执念去多买几张卡,是最不划算的买卖。

四、并发控制与限流:从对涌进来的请求来者不拒挤爆显存到队列加并发上限加令牌桶限流

第四仗,是给推理服务的入口装上两道闸门——并发控制和限流,把过去那种对涌进来的请求来者不拒、全往 GPU 上死命挤、瞬间挤爆显存或排成绝望长龙的失控状态,改造成"用队列和并发上限把同时上 GPU 的请求控制在它能稳定承载的范围内、用令牌桶限流把超出整体容量的流量有尊严地快速拒掉"的从容可控。古早时代我们的推理服务入口,是完全敞开、不设防的:有多少请求涌进来,我们就有多少请求一股脑地往 GPU 上送,完全不考虑 GPU 此刻还吃不吃得下,这在平时流量平稳时倒也相安无事,可一旦遇到流量洪峰(一次运营推送、一个热点事件),海量的请求在同一时刻涌入、全部争先恐后地往 GPU 上挤,后果是灾难性的:要么是这些请求的 KV cache 瞬间就把显存挤爆、触发 OOM、推理进程崩溃,要么是它们在 GPU 前排起了一眼望不到头的长队、队尾的请求等了几十秒还排不上、纷纷超时,而最致命的是,这种过载是会"传染"和"放大"的——显存一爆进程就崩、崩了重启时积压的请求又一拥而上再次把它压垮,陷入崩溃-重启-再崩溃的死亡循环,整个服务彻底瘫痪。现代做法是在入口处设立两道有梯度的防线:第一道是并发控制——我们根据 GPU 的真实承载能力,设定一个"同时在 GPU 上处理的请求数上限",请求来了先进一个队列排队,只有当 GPU 上的在途请求数低于上限时,才从队列里放一个进去,这就保证了 GPU 永远不会被超过它承载能力的请求数挤爆,始终在一个健康的负载下稳定工作;第二道是限流——光有队列还不够,如果流量洪峰持续太久,队列本身也会无限堆积、最终撑爆内存或让请求等到天荒地老,所以我们在最外层用令牌桶等限流算法,设定一个整体的请求速率上限,当涌入的请求速率超过了这个我们压测出来的、服务能稳定支撑的真实容量时,多出来的请求就在入口处被立刻、明确地拒绝掉(返回一个清晰的"服务繁忙请稍后再试"),而不是放进来一起把系统拖垮,用快速拒绝一部分请求的代价,保住容量之内的绝大多数请求能得到稳定的服务。下面是并发控制与限流的对比:

# 重构前:入口完全不设防,涌进来多少就往 GPU 上送多少,洪峰一来瞬间挤爆显存或排成绝望长龙
@app.post("/infer")
async def infer(req):
    return await gpu_infer(req)   # 来者不拒,全往 GPU 挤 → 洪峰下 OOM 崩溃 / 排队几十秒超时
# ↑ 过载还会传染放大:显存爆→进程崩→重启→积压请求一拥而上再压垮 → 崩溃-重启死亡循环

# 重构后:并发控制(队列+在途上限) + 限流(令牌桶超容量快速拒绝),把负载控制在 GPU 稳定承载内
from asyncio import Semaphore
limiter = TokenBucket(rate=200, capacity=400)        # 令牌桶:整体速率上限=压测出的真实容量
gpu_slots = Semaphore(48)                             # 并发上限:同时在 GPU 上处理的请求数上限

@app.post("/infer")
async def infer(req):
    if not limiter.try_acquire():                    # ① 限流:超出整体容量 → 立刻明确拒绝
        raise HTTPException(429, "服务繁忙,请稍后再试")  # 快速拒绝,不放进来拖垮系统
    async with gpu_slots:                             # ② 并发控制:队列排队,在途<上限才放行上 GPU
        return await gpu_infer(req)                   # GPU 永远不被超过承载力的请求数挤爆
# ↑ 用队列+上限护住 GPU 不被挤爆,用令牌桶拒掉超容量流量保住核心 → 洪峰下服务依然稳定可控

并发控制与限流现代化让我们从"推理服务入口是完全敞开不设防的有多少请求涌进来就有多少请求一股脑地往 GPU 上送完全不考虑 GPU 此刻还吃不吃得下这在平时流量平稳时倒也相安无事可一旦遇到流量洪峰海量的请求在同一时刻涌入全部争先恐后地往 GPU 上挤后果是灾难性的要么是这些请求的 KV cache 瞬间就把显存挤爆触发 OOM 推理进程崩溃要么是它们在 GPU 前排起了一眼望不到头的长队队尾的请求等了几十秒还排不上纷纷超时而最致命的是这种过载是会传染和放大的显存一爆进程就崩崩了重启时积压的请求又一拥而上再次把它压垮陷入崩溃重启再崩溃的死亡循环"进化到了"在入口处设立两道有梯度的防线第一道是并发控制根据 GPU 的真实承载能力设定一个同时在 GPU 上处理的请求数上限请求来了先进一个队列排队只有当 GPU 上的在途请求数低于上限时才从队列里放一个进去保证 GPU 永远不会被超过它承载能力的请求数挤爆、第二道是限流在最外层用令牌桶等限流算法设定一个整体的请求速率上限当涌入的请求速率超过了这个压测出来的服务能稳定支撑的真实容量时多出来的请求就在入口处被立刻明确地拒绝掉而不是放进来一起把系统拖垮用快速拒绝一部分请求的代价保住容量之内的绝大多数请求能得到稳定的服务":过去我们对入口流量的态度,是一种近乎天真的来者不拒,我们潜意识里觉得请求来了就应该都接住、都处理,拒绝用户的请求听起来是件很失职的事,可我们完全没意识到,GPU 的处理能力是有一个明确且刚性的物理上限的,显存就那么多、算力就那么强,它在单位时间里能稳定处理的请求数是一个确定的值,一旦涌入的请求超过了这个值,多出来的请求不但得不到服务,还会反过来拖垮那些本可以被正常服务的请求——这就像一座只能容纳一百人的电梯,硬要塞进来三百人,结果不是三百人都能上去,而是电梯直接超载罢工、一个人也上不去,我们当年的推理服务就是那座被硬塞的电梯,流量洪峰一来,我们把所有请求都放进去,结果就是显存被挤爆、进程崩溃,连那些本来在容量之内、完全可以被正常服务的请求,也跟着一起玉石俱焚,我们用"谁都想接住"的善良,换来了"谁都没服务好"的灾难,而且过载引发的崩溃-重启-再崩溃死亡循环,更是让服务长时间地彻底瘫痪;后来我们才痛彻地领悟到,在一个资源有刚性上限的系统里,有节制地拒绝一部分请求,恰恰是为了保护对绝大多数请求的服务质量,这不是失职,而是负责,于是我们在入口处建起了两道有梯度的防线:第一道并发控制,像给那座电梯设定了严格的限载人数,我们根据 GPU 实测的真实承载力,设死了一个"同时在 GPU 上处理的请求数上限",新请求来了先在队列里排队,GPU 上腾出一个空位才放一个进去,如此 GPU 的负载永远被控制在它能稳稳扛住的健康水位上、再也不会被挤爆;第二道限流,则像在电梯外又加了一道按速率放行的闸机,因为光有队列还不够,洪峰要是持续太久,队列自身也会无限堆积、最终撑爆,所以我们在最外层用令牌桶设定了一个整体的速率上限——这个上限是我们认认真真压测出来的、服务能长期稳定支撑的真实容量,一旦涌入速率超过了它,多出来的请求就在门口被干脆利落地、明明白白地告知"服务繁忙请稍后再试"而被快速拒绝,绝不放进来添乱,我们宁可让一小部分请求体面地、快速地失败、还能引导它稍后重试,也绝不让所有请求一起被拖入崩溃的深渊。我们的纪律是"绝不对涌入的请求来者不拒地全往 GPU 上送,必须用队列加在途并发上限把同时上 GPU 的请求控制在其真实承载力之内、护住 GPU 不被挤爆,必须在入口用令牌桶等限流按压测出的真实容量设定速率上限、超出的流量快速明确拒绝,要深刻承认 GPU 处理能力有刚性物理上限、有节制地拒绝超载请求是为了保住对容量内请求的服务质量,把入口的并发控制与限流当成防止过载雪崩、守住核心服务的两道生命闸门来对待"。并发控制与限流的本质认知是:GPU 的处理能力有一个刚性的物理上限,而对请求来者不拒,会在流量洪峰时让涌入量远超这个上限,结果不是多服务了请求、而是把本可正常服务的请求也一起拖垮、陷入过载雪崩;并发控制与限流的智慧,在于清醒地承认这个上限的存在,用队列和并发上限护住 GPU 不被挤爆、用限流把超出真实容量的流量在门口快速拒绝,以牺牲一小部分请求为代价保住绝大多数请求的稳定服务,会做推理服务的团队,从不羞于拒绝超出容量的请求,因为他们深知,在一个资源有刚性上限的系统里,毫无节制地接住所有请求,最终的结果一定是谁都服务不好的全面崩溃。

五、流式输出:从必须等整个答案生成完才一次性返回用户干等几十秒到流式 token 逐个吐

第五仗,是把答案返回给用户的方式,从"必须死等整个答案全部生成完毕才一次性地、整坨地返回、用户对着转圈的界面干等几十秒还以为卡死了"的糟糕体验,改造成"答案生成出一个 token 就立刻通过流式把这个 token 吐给用户、用户在亚秒级就看到答案开始一个字一个字地往外蹦"的顺滑体验。古早时代我们返回推理结果的方式,是最简单的请求-响应模式:用户发来一个请求,我们在服务端把整个答案完完整整地生成出来(对于一个要生成几百个 token 的长答案,这可能要花十几秒乃至几十秒),然后再把这一整坨生成好的答案,作为一个完整的响应一次性地返回给用户,这个模式套在传统的、毫秒级就能处理完的 Web 请求上毫无问题,可套在动辄要生成几十秒的大模型推理上,就成了一种对用户体验的酷刑——用户点了发送之后,面对的是一个长达十几几十秒的、毫无任何反馈的漫长空白等待,界面上只有一个无尽旋转的加载圈,在这段难熬的时间里,用户完全不知道服务到底是在认真干活、还是已经卡死了、或者请求是不是压根就丢了,绝大多数用户的耐心撑不过几秒钟,要么烦躁地反复点击(反而加重了服务负担),要么直接判定"这服务卡死了/坏了"而愤然离开。现代做法是利用大模型自回归生成"逐 token 产出"这一天然特性,采用流式输出:既然答案本来就是一个 token 一个 token 地生成出来的,那我们何必非要等它全部生成完呢?完全可以每生成出一个 token、就立刻通过一个持续的流式连接(通常用 SSE,Server-Sent Events)把这个新鲜出炉的 token 即时地推送给用户,于是从用户的视角看,他点击发送后,只需要等待第一个 token 生成出来的那极短的时间(也就是所谓的首字延迟 TTFT,通常在亚秒级),就能看到答案开始像打字机一样、一个字一个字地、源源不断地往外蹦,他能清清楚楚地感知到服务正在为他实时地工作、答案正在生成,这种即时的、持续的反馈,把过去那个长达几十秒的、令人焦虑的空白等待,化解成了一个生动的、可感知进度的生成过程,用户体验发生了天壤之别的改善,而且用户在读着已经吐出来的前半段时,后半段还在继续生成,等待被巧妙地隐藏了。下面是流式输出的对比:

# 重构前:死等整个答案全部生成完才一次性整坨返回,用户对着转圈圈干等十几几十秒、以为卡死
@app.post("/infer")
async def infer(req):
    answer = engine.generate(req.prompt, max_tokens=512)  # 阻塞:生成完整 512 token 要十几秒
    return {"answer": answer}    # ↓ 这十几秒里用户面对的只有无尽旋转的加载圈,毫无反馈
# ↑ 用户不知是在干活还是卡死,耐心撑不过几秒就反复点击(加重负担)或判定服务坏了愤然离开

# 重构后:流式输出(SSE)——每生成一个 token 就立刻吐给用户,亚秒级见首字、逐字往外蹦
@app.post("/infer")
async def infer(req):
    async def token_stream():
        # 大模型本就逐 token 生成,边生成边推送,无需等全部完成
        async for token in engine.generate_stream(req.prompt, max_tokens=512):
            yield f"data: {json.dumps({'token': token})}\n\n"  # 每个新 token 即时 SSE 推送
        yield "data: [DONE]\n\n"
    return StreamingResponse(token_stream(), media_type="text/event-stream")
# ↑ 用户只等首字延迟 TTFT(亚秒级)就见答案像打字机般逐字蹦出,持续反馈,等待被读取过程隐藏

流式输出现代化让我们从"返回推理结果的方式是最简单的请求响应模式用户发来一个请求我们在服务端把整个答案完完整整地生成出来对于一个要生成几百个 token 的长答案这可能要花十几秒乃至几十秒然后再把这一整坨生成好的答案作为一个完整的响应一次性地返回给用户、这个模式套在传统的毫秒级就能处理完的 Web 请求上毫无问题可套在动辄要生成几十秒的大模型推理上就成了一种对用户体验的酷刑用户点了发送之后面对的是一个长达十几几十秒的毫无任何反馈的漫长空白等待界面上只有一个无尽旋转的加载圈在这段难熬的时间里用户完全不知道服务到底是在认真干活还是已经卡死了绝大多数用户的耐心撑不过几秒钟"进化到了"利用大模型自回归生成逐 token 产出这一天然特性采用流式输出既然答案本来就是一个 token 一个 token 地生成出来的那何必非要等它全部生成完呢完全可以每生成出一个 token 就立刻通过一个持续的流式连接把这个新鲜出炉的 token 即时地推送给用户于是从用户的视角看他点击发送后只需要等待第一个 token 生成出来的那极短的时间就能看到答案开始像打字机一样一个字一个字地源源不断地往外蹦他能清清楚楚地感知到服务正在为他实时地工作":过去我们沿用一次性返回的请求响应模式,是因为我们想当然地把大模型推理当成了又一种普通的 Web 接口——Web 接口不就是收到请求、处理完、返回结果嘛,这套模式我们用了无数年、再熟悉不过,于是我们想都没想就把它套在了大模型推理上,却完全忽视了一个本质的差异:传统 Web 接口的处理是毫秒级的,用户根本感知不到那点等待,而大模型推理的处理是几十秒级的,这点等待对用户而言是天壤之别的煎熬,把一个适用于毫秒级响应的交互模式,生搬硬套到一个几十秒级响应的场景上,就必然制造出极其糟糕的体验,我们让用户对着一个转圈的界面空等几十秒、还得不到任何"我正在为你工作"的反馈,这在体验上是不可接受的,大量用户因为这难熬的、不知所措的等待而流失,我们却一度以为是模型不够快的问题、拼命想着怎么把推理提速,却没意识到症结其实在交互模式上;后来我们才幡然醒悟,大模型推理有一个传统 Web 接口完全不具备的、天赐的宝贵特性——它的答案不是一蹴而就憋出来的,而是一个 token 一个 token 自回归地、渐进地生成出来的,这意味着在最终答案完整出来之前,中间的、部分的结果是持续不断地、一点一点地产出着的,而既然有源源不断的中间结果,我们为什么非要把它们都憋着、非等到最后一个 token 出来才一股脑地给用户呢?于是我们改用了流式输出,顺着大模型逐 token 生成的天然节奏,每蹦出一个新 token,就立刻通过一个持续的 SSE 流式连接把它即时推送给用户,这样用户在点击发送后,只需等待第一个 token 出炉的那极短的首字延迟(亚秒级),就能看到答案如打字机般一个字一个字地、活生生地往外蹦,他时时刻刻都能感知到服务在为他实时地、努力地工作着,那个曾经令人抓狂的几十秒空白焦虑等待,被化解成了一个生动可感、进度可见的生成过程,更妙的是,用户在津津有味地读着已经吐出来的前半段时,后半段还在后台默默地继续生成,真实的等待时间被这"边读边生成"巧妙地隐藏了大半,用户体验由此发生了脱胎换骨的飞跃。我们的纪律是"绝不用一次性返回完整答案的请求响应模式去做几十秒级的大模型推理、让用户对着转圈干等,必须采用流式输出顺应大模型逐 token 生成的天然特性、每生成一个 token 就即时推送给用户,要把首字延迟 TTFT 当成和总耗时同等重要乃至更重要的体验指标去优化,要善用边生成边推送把漫长的真实等待隐藏在用户的阅读过程里,把流式输出当成大模型推理这种长耗时生成场景下必备的、决定用户体验生死的交互方式来对待"。流式输出的本质认知是:大模型推理是几十秒级的长耗时生成,把适用于毫秒级响应的"一次性返回"请求响应模式生搬硬套上去,必然制造出让用户对着转圈干等、不知服务死活的体验酷刑;流式输出的智慧,在于顺应大模型"逐 token 渐进产出"这一传统接口不具备的天然特性,每生成一个 token 就即时推送,让用户在亚秒级的首字延迟后就看到答案活生生地往外蹦、持续感知到服务在实时工作,并用边读边生成把真实等待隐藏起来,会做推理服务的团队,优化体验时眼里盯着的不只是总耗时、更是首字延迟,因为他们深知,在长耗时的生成场景里,让用户尽快看到第一个字、并持续感知到进度,远比让他对着转圈憋一个最终的完整答案,体验上要好出一个境界。

六、超时与调度:从没有超时没有优先级一个超长请求堵死全部到请求级超时加优先级调度

第六仗,是给每一个推理请求都套上"超时"的紧箍咒、给不同重要程度的请求安排上"优先级"的调度秩序,终结过去那种"一个超长生成的请求就能把后面所有请求都堵死、所有请求不分轻重一律平等地傻等"的混乱。古早时代我们的推理调度,处于一种既无超时约束、又无优先级区分的双重失序状态:一方面,我们没有给推理请求设任何超时,一个请求只要进了 GPU 开始生成,就任由它一直生成下去,直到它自然地生成完或者达到最大 token 数,这看起来没什么问题,可一旦遇到那种异常的、会生成超长内容的请求(比如用户构造了一个让模型停不下来、要吐几千个 token 的 prompt),这个请求就会长时间地、死死地霸占着宝贵的 GPU 资源和批处理槽位,而它后面排队的、本来很快就能处理完的大量正常请求,就被这一个超长请求活活堵在后面、迟迟轮不上、最终全部超时,一颗老鼠屎坏了一锅汤;另一方面,我们对所有请求一视同仁、不分轻重缓急,可现实中请求的重要性天差地别——一个用户正盯着屏幕等待的、交互式的实时对话请求,和一个后台批量跑的、用户根本不在线等的离线分析请求,对延迟的敏感度完全不同,前者多等一秒用户都焦躁,后者多等一分钟也无所谓,可我们却让它们在同一个队列里平等地排队、平等地竞争 GPU,结果常常是高优先级的实时请求,被一堆低优先级的离线请求堵在后面干等,把宝贵的低延迟资源浪费在了不在乎延迟的请求上。现代做法是引入请求级超时和优先级调度这两件调度利器:第一,请求级超时——给每个请求设定一个合理的超时上限(还可以结合前面提过的全链路 deadline),一旦一个请求的生成时间超过了它的预算,就果断地中止它、释放它占用的 GPU 资源和批处理槽位,绝不让任何单个请求无限制地霸占资源、连累后面,把异常的超长请求及时地清理出去;第二,优先级调度——我们给请求分级(比如实时交互高优先级、离线批处理低优先级),调度器在从队列里挑选请求送上 GPU 时,优先选取高优先级的请求,甚至在资源紧张时,允许高优先级请求抢占低优先级请求的资源,从而保证那些用户正在焦急等待的实时请求,总能获得优先的、低延迟的服务,而把不着急的离线请求安排在资源空闲时慢慢处理。下面是超时与调度的对比:

# 重构前:无超时(一个超长请求霸占 GPU 堵死后面全部)+ 无优先级(实时请求被离线请求堵着)
async def schedule(req):
    return await gpu_infer(req)   # 来啥跑啥,不设超时、不分优先级,先到先得
# ↑ 一个吐几千 token 的异常请求死霸 GPU 槽位 → 后面正常请求全被堵到超时(一鼠屎坏一锅汤)
#   用户实时等待的对话请求和后台离线批处理请求平等排队 → 宝贵低延迟资源被不在乎延迟的请求占用

# 重构后:请求级超时(超预算就中止释放)+ 优先级调度(高优先级优先/可抢占)
import heapq, time
class Scheduler:
    def __init__(self): self.pq = []                      # 优先级队列(小顶堆,priority 越小越优先)
    def submit(self, req, priority, deadline):
        heapq.heappush(self.pq, (priority, req.id, req, deadline))  # 实时=0,离线=10
    async def run(self):
        while self.pq:
            priority, _, req, deadline = heapq.heappop(self.pq)     # ① 总是先取高优先级请求
            if time.time() > deadline:                              # ② 已超时则直接丢弃不浪费 GPU
                req.reject("timeout"); continue
            budget = deadline - time.time()
            try:
                await asyncio.wait_for(gpu_infer(req), timeout=budget)  # ③ 超预算即中止释放资源
            except asyncio.TimeoutError:
                req.reject("deadline_exceeded")            # 异常超长请求被及时清理,不连累后面
# ↑ 超时让单个请求无法霸占资源连累全局,优先级让实时请求总能抢到低延迟服务、离线请求让路

超时与调度现代化让我们从"调度处于一种既无超时约束又无优先级区分的双重失序状态一方面没有给推理请求设任何超时一个请求只要进了 GPU 开始生成就任由它一直生成下去一旦遇到那种异常的会生成超长内容的请求这个请求就会长时间地死死地霸占着宝贵的 GPU 资源和批处理槽位而它后面排队的本来很快就能处理完的大量正常请求就被这一个超长请求活活堵在后面迟迟轮不上最终全部超时一颗老鼠屎坏了一锅汤、另一方面对所有请求一视同仁不分轻重缓急可现实中请求的重要性天差地别一个用户正盯着屏幕等待的交互式的实时对话请求和一个后台批量跑的用户根本不在线等的离线分析请求对延迟的敏感度完全不同可我们却让它们在同一个队列里平等地排队平等地竞争 GPU 结果常常是高优先级的实时请求被一堆低优先级的离线请求堵在后面干等"进化到了"引入请求级超时和优先级调度第一请求级超时给每个请求设定一个合理的超时上限一旦一个请求的生成时间超过了它的预算就果断地中止它释放它占用的 GPU 资源和批处理槽位绝不让任何单个请求无限制地霸占资源连累后面、第二优先级调度给请求分级调度器在从队列里挑选请求送上 GPU 时优先选取高优先级的请求甚至在资源紧张时允许高优先级请求抢占低优先级请求的资源从而保证那些用户正在焦急等待的实时请求总能获得优先的低延迟的服务":过去我们的调度之所以失序,根子在于我们把"公平"和"无约束"当成了理所当然——我们觉得所有请求都应该被平等对待、先来后到、谁也别插队,这听起来很公道,可这种绝对的平等,在请求重要性天差地别、且单个请求可能异常霸占资源的现实面前,恰恰造成了最大的不公和最严重的低效:一个用户正在屏幕前焦急等待的实时对话,凭什么要和一个后台慢慢跑、根本没人催的离线任务,在同一个队列里平起平坐地排队?让前者为后者让路、干等,这对那个活生生在等待的用户才是真正的不公平,我们用一种机械的平等,牺牲了真正重要的、对延迟敏感的请求的体验;而无超时的放任,则让我们的系统失去了对单个请求的最后控制力——我们天真地假设每个请求都会在合理的时间内结束,却没料到总有那么一些异常的请求(无论是恶意构造的还是意外触发的)会失控地生成超长内容、长时间死霸着 GPU 不放,而由于我们没设任何超时,我们对这种霸占毫无办法,只能眼睁睁看着它把后面一长串正常请求全都堵死、拖垮,一个异常请求就能搞瘫一片;后来我们才明白,优秀的调度,从来不是机械的平等和无原则的放任,而是有秩序的区别对待和有约束的资源管控,于是我们引入了两件利器:其一是请求级超时,我们给每个请求都套上了一个时间预算的紧箍咒,任何请求一旦生成时间超出了它合理的预算,就被果断地中止、它霸占的 GPU 资源和批处理槽位被立刻释放出来还给那些嗷嗷待哺的正常请求,从此再没有哪个异常的超长请求能无限制地霸占资源、连累全局,系统重新掌握了对每个请求的控制权;其二是优先级调度,我们摒弃了那种虚假的绝对平等,转而承认并尊重请求之间真实的重要性差异,给请求分了优先级,让调度器在挑选请求上 GPU 时优先服务那些用户正在实时等待的高优先级请求、甚至在资源吃紧时允许它们抢占低优先级请求,而把那些不着急的离线请求安排在资源空闲时从容处理,如此一来,宝贵的低延迟资源被精准地用在了真正需要它的实时请求上,而离线请求也并没有被饿死、只是被安排在了更合适的时机,整个系统的资源,终于被按照请求真实的轻重缓急,高效而有序地分配了。我们的纪律是"绝不让推理请求无超时地放任运行、必须给每个请求设请求级超时、超预算就果断中止并释放资源、绝不让单个异常超长请求霸占资源连累全局,绝不对重要性天差地别的请求搞机械的绝对平等、必须按实时交互/离线批处理等真实优先级做调度、让高优先级请求优先获得低延迟服务甚至可抢占,把超时和优先级调度当成既防止单点霸占、又按轻重缓急高效分配宝贵 GPU 资源的调度秩序来对待"。超时与调度的本质认知是:在一个资源稀缺、且请求重要性天差地别、单个请求又可能异常霸占资源的系统里,无超时的放任会让一个异常请求堵死全局,而无优先级的绝对平等则会让宝贵的低延迟资源被不在乎延迟的请求占用——看似公道的"无约束的平等",实则是最大的低效与不公;超时与调度的智慧,在于用请求级超时给每个请求套上时间预算的紧箍、夺回对单点霸占的控制权,用优先级调度尊重请求真实的轻重缓急、把宝贵资源精准地用在真正需要的实时请求上,会做推理服务的团队,从不机械地平等对待所有请求,而是给每个请求都设好时间预算、并按真实优先级有序调度,因为他们深知,稀缺资源的高效利用,靠的从来不是无原则的平均主义,而是有约束、有秩序的精明分配。

七、模型部署路由与弹性伸缩:从单模型单实例硬编码固定容量到多副本智能路由加按负载自动扩缩

第七仗,是把模型的部署形态,从"单模型、单实例、硬编码、固定容量"的僵化,改造成"多模型、多副本、智能路由、按负载自动弹性伸缩"的灵活。古早时代我们的模型部署,僵化得令人窒息:我们的推理服务里,要用哪个模型是硬编码写死在代码里的,一个服务实例就加载这一个写死的模型对外服务,这带来了两个致命的僵化——其一是模型维度的僵化:想换一个模型、或者想同时提供好几个不同的模型供不同业务调用,就得改代码、重新部署、甚至重新搭一套服务,极其笨重,而且单个实例一旦挂了,它提供的那个模型服务就整个不可用了,毫无冗余;其二是容量维度的僵化:我们部署的是固定数量的 GPU 实例,这个数量是按某个估计的负载拍脑袋定下的、之后就一成不变,可流量是有明显峰谷的——白天业务高峰时,这固定的容量根本扛不住汹涌的流量、被打爆,而深夜流量低谷时,这些昂贵的 GPU 实例又大量地闲置着、空烧着每小时不菲的费用,我们在"峰时不够"和"谷时浪费"之间两头受气、怎么都不划算。现代做法是从部署形态和容量管理两个维度同时破局:在部署形态上,我们走向多模型、多副本加智能路由——每个模型都可以部署多个副本(实例)以提供冗余和横向扩展能力,前面架一个智能路由层,请求进来时按它指定的模型名,被路由到对应模型的某个健康副本上,副本之间做负载均衡(结合前面讲过的健康检查和 P2C,自动摘除坏副本、优先送给空闲副本),如此一来,增删模型、给热门模型多加副本都变得轻而易举,且任何单个副本挂掉都不影响整体服务;在容量管理上,我们走向按负载自动弹性伸缩——不再用拍脑袋定死的固定实例数,而是让系统根据实时的负载指标(比如请求队列的长度、GPU 的利用率)自动地增减实例:负载上来了、队列开始堆积了,就自动扩容、拉起更多 GPU 实例来分担;负载下去了、实例闲置了,就自动缩容、释放掉多余的实例、停止为闲置的 GPU 付费,从而在任何时刻,容量都自动地、动态地贴合着真实的负载,既在高峰时扛得住、又在低谷时不浪费。下面是模型部署路由与弹性伸缩的对比:

# 重构前:单模型单实例硬编码 + 固定容量(峰时被打爆、谷时空烧钱),换模型要改代码重部署
# model = load_model("/models/qwen-14b")   # 模型写死在代码里,一个实例就这一个模型,挂了全没
# replicas: 4                              # 固定 4 个实例:白天高峰扛不住被打爆,深夜低谷空烧钱

# 重构后:多模型多副本 + 智能路由 + 按负载(队列长度/GPU利用率)自动弹性伸缩(K8s 示意)
apiVersion: apps/v1
kind: Deployment
metadata: { name: infer-qwen14b }          # 每个模型一套 Deployment,可独立部署/增删/扩缩
spec:
  selector: { matchLabels: { model: qwen14b } }
  template:
    spec:
      containers:
      - name: vllm
        image: vllm/vllm-openai:latest
        args: ["--model", "Qwen2.5-14B-Instruct-AWQ"]   # 多模型:各模型独立副本,路由按模型名分发
        resources: { limits: { nvidia.com/gpu: 1 } }
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler                # 按负载自动扩缩容:峰时扩、谷时缩
metadata: { name: infer-qwen14b-hpa }
spec:
  scaleTargetRef: { kind: Deployment, name: infer-qwen14b }
  minReplicas: 2                             # 谷时缩到 2(保底冗余),不再空烧大量 GPU
  maxReplicas: 20                            # 峰时自动扩到 20,扛住洪峰
  metrics:
  - type: Pods
    pods: { metric: { name: gpu_queue_length }, target: { type: AverageValue, averageValue: "8" } }
# ↑ 多副本+智能路由让增删模型/加副本轻而易举、坏副本自动摘除;自动伸缩让容量动态贴合真实负载

模型部署路由与弹性伸缩现代化让我们从"模型部署僵化得令人窒息要用哪个模型是硬编码写死在代码里的一个服务实例就加载这一个写死的模型对外服务这带来了两个致命的僵化其一是模型维度的僵化想换一个模型或者想同时提供好几个不同的模型供不同业务调用就得改代码重新部署甚至重新搭一套服务极其笨重而且单个实例一旦挂了它提供的那个模型服务就整个不可用了毫无冗余、其二是容量维度的僵化我们部署的是固定数量的 GPU 实例这个数量是按某个估计的负载拍脑袋定下的之后就一成不变可流量是有明显峰谷的白天业务高峰时这固定的容量根本扛不住汹涌的流量被打爆而深夜流量低谷时这些昂贵的 GPU 实例又大量地闲置着空烧着每小时不菲的费用"进化到了"从部署形态和容量管理两个维度同时破局在部署形态上走向多模型多副本加智能路由每个模型都可以部署多个副本以提供冗余和横向扩展能力前面架一个智能路由层请求进来时按它指定的模型名被路由到对应模型的某个健康副本上副本之间做负载均衡自动摘除坏副本优先送给空闲副本任何单个副本挂掉都不影响整体服务、在容量管理上走向按负载自动弹性伸缩不再用拍脑袋定死的固定实例数而是让系统根据实时的负载指标自动地增减实例负载上来了就自动扩容负载下去了就自动缩容从而在任何时刻容量都自动地动态地贴合着真实的负载":过去我们的部署之所以如此僵化,是因为我们用了一种最静态、最想当然的思维去对待两件本质上高度动态的事情——要提供哪些模型、需要多大容量,我们都当成了一次性拍板定死、之后就一劳永逸的静态决策,可现实是,业务要用的模型是会不断变化和增加的、流量更是时时刻刻在峰谷间剧烈起伏的,用静态的、写死的部署去硬接这些动态变化的需求,必然处处碰壁:模型写死在代码里,业务每提一个新模型需求、每想调整一下用哪个模型,我们就得大动干戈地改代码重部署,笨重得跟不上业务的节奏,而且单实例毫无冗余,它一挂对应的模型服务就彻底中断;容量定死成固定实例数,更是让我们在流量的峰谷之间反复挨打——按高峰配吧,深夜大量 GPU 空转烧钱心疼,按低谷配吧,白天高峰直接被打爆服务崩溃,按平均配吧,那就是高峰也扛不住、低谷也浪费,怎么定都是错,我们就在这种静态容量与动态流量的根本矛盾里,长期两头受气;后来我们才彻底转变了思路,用动态的方式去对待这两件本就动态的事:在模型部署上,我们走向了多模型、多副本加智能路由的灵活架构,每个模型都不再写死、而是作为一套可独立部署、独立增删、独立扩缩的服务单元存在,每个模型还能部署多个副本来提供冗余和横向扩展,前面架起一个智能路由层,请求按它要的模型名被自动分发到对应模型的健康副本上、副本间还做着负载均衡和坏副本的自动摘除,如此增删模型、给热门模型加副本都变得轻而易举,任何单个副本的故障也都被冗余悄悄消化、不影响整体;在容量管理上,我们更是彻底拥抱了按负载自动弹性伸缩,把容量这件事从一个静态的拍脑袋决策,变成了一个由系统根据实时负载(队列长度、GPU 利用率)自动调节的动态过程——流量高峰一来、队列一开始堆积,系统就自动拉起更多 GPU 实例去分担,流量低谷一到、实例一闲下来,系统就自动释放掉多余的实例、停止为闲置的昂贵 GPU 付费,容量像一只无形的手,时时刻刻、自动地紧贴着真实负载的起伏而伸缩,我们终于既在高峰时稳稳扛住、又在低谷时分文不浪费,从那个静态容量与动态流量的死结里彻底解脱了出来。我们的纪律是"绝不把要用的模型硬编码写死、绝不用单实例无冗余地提供服务,模型必须以可独立部署增删扩缩的多模型多副本形态存在、前置智能路由按模型名分发并做副本负载均衡与坏副本自动摘除,绝不用拍脑袋定死的固定实例数硬接峰谷起伏的流量、必须按队列长度 GPU 利用率等实时负载做自动弹性伸缩、峰时扩谷时缩让容量动态贴合真实负载,把用动态的部署与容量去对待动态变化的模型需求和峰谷流量当成既保障可用又控制成本的根本来对待"。模型部署路由与弹性伸缩的本质认知是:要提供哪些模型、需要多大容量,本质上都是会不断变化的动态需求,而用单模型硬编码、固定实例数这种静态僵化的部署去硬接它们,必然在模型变更时笨重跟不上、在流量峰谷间两头受气;部署与伸缩现代化的智慧,在于用动态去匹配动态——用多模型多副本加智能路由让模型的增删扩缩与故障冗余都游刃有余,用按真实负载自动弹性伸缩让容量像无形的手一样时刻紧贴流量起伏,从而既在高峰扛得住、又在低谷不浪费,会做推理服务的团队,从不用一个静态的方案去硬扛动态的现实,因为他们深知,在模型需求多变、流量峰谷剧烈的世界里,任何写死的、固定的部署,都注定在某个时刻要么不够用、要么白烧钱。

八、推理可观测:从推理是黑盒 GPU 利用率延迟全然不知出事靠猜到 TTFT/TPOT 指标全链路追踪

第八仗,是给整个推理服务装上一整套精密的"仪表盘",把过去那个 GPU 利用率多少、延迟多少、吞吐多少全然不知的黑盒,变成一个每一项关键指标都看得清清楚楚、出了问题能一图定位的透明系统。古早时代我们的推理服务,在可观测性上几乎是一片空白的黑盒:服务跑在那里,它此刻的 GPU 利用率是高是低、显存占用了多少、还能不能再接请求、当前的吞吐量(每秒能处理多少 token)是多少、请求的延迟分布是怎样的、首字延迟快不快、有多少请求在排队、有多少在超时被拒……这些对推理服务至关重要的运行状态,我们统统不知道、也没有任何手段去看,我们对自己服务的了解,贫乏到只剩下"进程还activ着没有"这么一个最粗浅的信号,于是每当线上出现"推理变慢了""偶尔超时""吞吐上不去"之类的问题,我们都只能两眼一抹黑地靠猜、靠经验拍脑袋,猜是不是 GPU 满了、猜是不是显存爆了、猜是不是哪个环节慢了,排查全凭运气,而且因为一个推理请求要经过限流、排队、路由、批处理、GPU 推理、流式返回这一长串环节,出了慢或错根本不知道是卡在了哪一环。现代做法是建立起一套覆盖推理服务全貌的可观测体系:第一,采集大模型推理专属的关键指标——除了通用的 QPS、错误率,我们尤其紧盯几个大模型推理特有的黄金指标:TTFT(Time To First Token,首字延迟,直接决定用户的等待体感)、TPOT(Time Per Output Token,每个输出 token 的生成耗时,决定答案吐字的流畅度)、整体吞吐(每秒生成的 token 数)、GPU 利用率、显存占用率、批处理的批大小、队列长度、排队等待时长等,把推理服务的健康状况量化成一块信息丰富的指标大盘;第二,建立分布式链路追踪——给每个请求分配一个全局唯一的 TraceID,串起它流经限流、排队、路由、批处理、GPU 推理、流式输出的全过程,记录下它在每一个环节耗费的时间,这样任何一个慢请求,我们都能顺着它的 TraceID 一眼看出它到底是慢在了排队、还是慢在了推理、还是慢在了别的哪一环,精准定位、不再靠猜。下面是推理可观测的对比:

# 重构前:推理是黑盒,GPU利用率/显存/吞吐/TTFT/队列长度全然不知,出事只能靠猜哪一环
# 唯一的"监控":ps 看进程还在不在 + 偶尔 nvidia-smi 手动瞄一眼 → 慢在哪一环全凭经验拍脑袋

# 重构后:采集大模型推理专属黄金指标(TTFT/TPOT/吞吐/GPU利用率) + 全链路 TraceID 追踪
from prometheus_client import Histogram, Gauge
TTFT = Histogram("infer_ttft_seconds", "首字延迟", buckets=[.1,.3,.5,1,2,5])
TPOT = Histogram("infer_tpot_seconds", "每输出token耗时", buckets=[.01,.02,.05,.1])
GPU_UTIL = Gauge("infer_gpu_util", "GPU利用率")
QUEUE_LEN = Gauge("infer_queue_length", "排队请求数")

async def infer_traced(req):
    span = tracer.start_span("infer", trace_id=req.trace_id)   # 全链路 TraceID 串起每一环
    t0 = time.time()
    with span.child("queue"): await wait_in_queue(req)          # 记录排队耗时
    with span.child("gpu"):
        first = True
        async for tok in engine.generate_stream(req.prompt):
            if first: TTFT.observe(time.time() - t0); first = False  # 首字延迟
            else: TPOT.observe(...)                                   # 每 token 耗时
            yield tok
    span.end()   # 一个慢请求顺着 TraceID 一眼看出慢在排队/推理/还是别的环节,精准定位不靠猜
# ↑ 关键指标量化成大盘 + 全链路追踪定位瓶颈 → 黑盒变透明,出事一图看穿而非两眼一抹黑靠猜

推理可观测让我们从"在可观测性上几乎是一片空白的黑盒服务跑在那里它此刻的 GPU 利用率是高是低显存占用了多少还能不能再接请求当前的吞吐量是多少请求的延迟分布是怎样的首字延迟快不快有多少请求在排队有多少在超时被拒这些对推理服务至关重要的运行状态我们统统不知道也没有任何手段去看我们对自己服务的了解贫乏到只剩下进程还活着没有这么一个最粗浅的信号、于是每当线上出现推理变慢了偶尔超时吞吐上不去之类的问题我们都只能两眼一抹黑地靠猜靠经验拍脑袋猜是不是 GPU 满了猜是不是显存爆了猜是不是哪个环节慢了排查全凭运气而且因为一个推理请求要经过限流排队路由批处理 GPU 推理流式返回这一长串环节出了慢或错根本不知道是卡在了哪一环"进化到了"建立起一套覆盖推理服务全貌的可观测体系第一采集大模型推理专属的关键指标尤其紧盯 TTFT 首字延迟 TPOT 每个输出 token 的生成耗时整体吞吐 GPU 利用率显存占用率批大小队列长度排队等待时长等把推理服务的健康状况量化成一块信息丰富的指标大盘、第二建立分布式链路追踪给每个请求分配一个全局唯一的 TraceID 串起它流经限流排队路由批处理 GPU 推理流式输出的全过程记录下它在每一个环节耗费的时间这样任何一个慢请求我们都能顺着它的 TraceID 一眼看出它到底是慢在了排队还是慢在了推理还是慢在了别的哪一环":过去我们的推理服务之所以是个黑盒,是因为我们在建设服务时,满脑子想的都是"怎么让它能跑起来、能处理请求",却几乎没花心思在"怎么让它的运行状态能被看见"上,我们把可观测性当成了一个可有可无的、事后再说的附加品,而不是和功能本身同等重要的基础设施,可正是这个被我们轻视的盲区,让我们在每一次线上问题面前都沦为了睁眼瞎——大模型推理服务是一个内部状态极其丰富、且对资源高度敏感的复杂系统,它的好坏快慢取决于 GPU 利用率、显存水位、批大小、队列深度等一大堆动态变化的内部指标,可我们对这些指标一无所知,就好比开着一辆没有任何仪表盘的车在高速上狂奔,既不知道时速多少、也不知道油量水温,全凭感觉和运气,一旦出点状况,我们连最基本的判断依据都没有,只能靠猜、靠拍脑袋、靠一次次重启撞运气,排查效率极其低下,而且因为一个请求要流经那么长的一串处理环节,即便我们隐约感觉"某些请求慢",也完全无法定位到底是慢在了哪一环,只能像无头苍蝇一样在整条链路上乱撞;后来我们才痛下决心,把可观测性提升到了和推理功能本身同等重要的高度,给这辆狂奔的车装上了一整套精密的仪表盘:其一,我们专门针对大模型推理的特点,采集了一整套关键指标,除了通用的 QPS 和错误率,我们尤其死死盯住那几个大模型推理独有的黄金指标——TTFT 首字延迟,它直接决定了用户点下发送后要干等多久才见到第一个字,是体验的命门;TPOT 每个输出 token 的耗时,它决定了答案吐字流不流畅;还有整体吞吐、GPU 利用率、显存占用、批大小、队列长度、排队时长等等,我们把这些指标全都采集起来、汇成一块信息丰富的大盘,让推理服务每一刻的健康状况都一目了然、量化可见;其二,我们建立了贯穿全链路的分布式追踪,给每个请求都打上一个全局唯一的 TraceID,用它像一根线一样串起这个请求流经限流、排队、路由、批处理、GPU 推理、流式输出的每一个环节、并记录下它在每一环花掉的时间,从此任何一个慢请求,我们都能顺着它的 TraceID 把它的完整旅程摊开来看,一眼就看出它究竟是慢在了排队、慢在了推理、还是慢在了别的哪个环节,精准定位、对症下药,彻底告别了过去那种两眼一抹黑、靠猜靠撞的排查噩梦。我们的纪律是"绝不让推理服务停留在只知进程死活的黑盒状态、必须建设覆盖全貌的可观测体系,必须采集 TTFT/TPOT/吞吐/GPU 利用率/显存占用/批大小/队列长度等大模型推理专属的黄金指标并汇成大盘,必须给每个请求打全局 TraceID 做分布式链路追踪、串起限流排队路由批处理推理流式返回全过程以便精准定位慢在哪一环,要把可观测性当成和推理功能本身同等重要、决定排障效率与服务可控性的基础设施来对待"。推理可观测的本质认知是:大模型推理服务是一个内部状态极其丰富、对 GPU 显存等资源高度敏感、且请求要流经一长串环节的复杂系统,没有可观测就如同开一辆没有仪表盘的车狂奔,出了问题只能靠猜、且根本无法定位是卡在了哪一环;推理可观测的智慧,在于把可观测性当成和功能本身同等重要的基础设施,用 TTFT/TPOT/吞吐/GPU 利用率等推理专属的黄金指标把服务的健康量化成可见的大盘、用全局 TraceID 的分布式追踪把每个请求流经各环节的耗时清晰摊开,从而让黑盒变透明、让排障从靠猜变成一图定位,会做推理服务的团队,在服务上线的第一天就把仪表盘装得明明白白,因为他们深知,一个看不见自己内部状态的推理服务,出起问题来,排查者就只能在黑暗中靠运气和直觉去和一个看不见的故障搏斗。

九、7 个 P0 事故复盘

7 事故:(1) 一次运营推送让请求量瞬间涨十几倍、推理服务因来者不拒把显存挤爆触发 OOM 进程连环崩溃、连容量内请求也全部玉石俱焚,事后建并发上限 + 令牌桶限流把负载控制在 GPU 稳定承载内;(2) 一次串行推理下 GPU 利用率常年趴在十几个百分点、大量请求却在外面排队超时,昂贵算力闲置与请求超时荒谬并存,事后上连续批处理把在途请求动态组批喂满 GPU;(3) 一次长上下文请求暴增、KV cache 按最大长度预留把显存撑爆 + 碎片化导致显存够却凑不出连续块而 OOM,事后上 PagedAttention 按页管理 KV cache;(4) 一次用户构造的异常 prompt 让模型停不下来狂吐几千 token、单个请求死霸 GPU 槽位把后面正常请求全堵到超时,事后给每个请求加请求级超时、超预算即中止释放;(5) 一次实时对话请求被一堆后台离线批处理请求堵在队列里干等、用户体验崩坏,事后上优先级调度让实时请求优先甚至可抢占;(6) 一次单实例加载的模型进程挂掉、对应模型服务整个不可用且无冗余,事后改多副本部署 + 智能路由 + 坏副本自动摘除;(7) 一次推理莫名变慢、因服务是黑盒只能靠猜重启撞运气、排查数小时仍不知慢在哪一环,事后建 TTFT/TPOT 等指标大盘 + 全链路 TraceID 追踪。每个 P0 都做 5-Why 复盘,固化成并发限流红线、批处理规范、显存管理标准、超时与优先级调度规约或推理可观测基线,确保同类问题不再复发。

十、推理服务工程师的 6 条工程哲学

6 哲学:(1) GPU 是最怕闲着的昂贵并行硬件——它最大的浪费不是算得慢而是被串行任务喂不饱让海量并行算力空转,把 GPU 利用率拉满永远是降本第一要务;(2) 显存是必须用分页智慧精打细算的稀缺资源——悲观预留连续大块既浪费又碎片化 OOM,要像 OS 管内存那样用多少分多少;(3) 资源有刚性上限时有节制地拒绝是为了保护服务——来者不拒会让超载请求把容量内请求一起拖垮,限流不是失职而是负责;(4) 长耗时生成必须流式——把毫秒级请求响应模式套到几十秒生成上是体验酷刑,让用户尽快见首字远胜憋一个完整答案;(5) 稀缺资源靠有秩序的区别对待而非无原则平均——无超时会让单点霸占全局、无优先级会让宝贵低延迟资源被不在乎延迟的请求占用;(6) 看不见的系统无法被治理——没有 TTFT/TPOT/GPU 利用率指标和全链路追踪,排障就是在黑暗中靠运气和一个看不见的故障搏斗。这 6 条哲学,是我们用 7 个 P0 事故和 87 天攻坚换来的集体共识。它们共同指向一个认知:大模型推理服务的工程,本质是对 GPU 算力和显存这两种极其昂贵且稀缺的资源,进行极致高效、又稳定可控的调度与管理——会做推理服务的团队,用连续批处理吃满算力、用 PagedAttention 与量化精打细算显存、用限流与并发控制守住过载、用流式输出优化体验、用超时与优先级有序调度、用多副本弹性伸缩匹配动态负载、用全链路可观测让一切透明,把大模型推理从一个昂贵、脆弱、动辄崩溃的黑盒,变成一个高吞吐、低延迟、稳如磐石且成本可控的在线服务。

十一、重构收益的量化:7 个关键数字

7 数字:(1) GPU 利用率:串行推理常年趴在十几个百分点 → 连续批处理后被拉满到高位、同样的卡吞吐提升数倍;(2) 单卡并发请求数:按最大长度预留 KV cache 时一张卡只能服务可怜几个 → PagedAttention 按页管理后成倍增长;(3) 模型显存占用:FP16 全精度动辄几十上百 G 一张卡放不下 → INT4 量化后降到约四分之一、单卡轻松放下还腾出显存做并发;(4) 首字延迟体感:一次性返回让用户对着转圈干等十几几十秒 → 流式输出后亚秒级见首字、答案逐字往外蹦;(5) 过载稳定性:流量洪峰一来就 OOM 连环崩溃全员受灾 → 限流 + 并发控制后超容量请求被快速拒绝、容量内服务稳如磐石;(6) 峰谷成本:固定容量峰时被打爆谷时空烧钱 → 自动弹性伸缩后容量动态贴合负载、低谷期 GPU 成本大幅下降;(7) 故障定位时长:黑盒靠猜靠重启撞运气排查数小时 → 指标大盘 + 全链路 TraceID 后慢在哪一环分钟级定位。这些数字背后,是 87 天里 7 个人一个环节一个环节地啃 vLLM、调批处理、抠显存、压测限流阈值、建指标大盘,但每一个都实打实地转化成了推理服务的吞吐、延迟、稳定性和实打实省下的 GPU 真金白银。当我们把这份数据汇报给管理层时,最有说服力的不是任何花哨的 AI 名词,而是"再没因为一次流量洪峰把推理服务搞崩过、同样数量的 GPU 服务能力翻了好几倍、用户再不用对着转圈干等、深夜的 GPU 账单大幅下降"这几条。

十二、留给后来者的最后一句话

87 天的大模型推理服务工程现代化战役,我们走过的不只是一条从串行推理 GPU 空转到连续批处理吃满算力、从按最大长度预留 KV cache 碎片化 OOM 到 PagedAttention 按页管理、从 FP16 全精度放不下到 INT4 量化瘦身、从来者不拒被洪峰挤爆到限流加并发控制守住容量、从一次性返回让用户干等到流式输出亚秒见首字、从无超时无优先级混乱调度到请求级超时加优先级调度、从单模型单实例固定容量到多副本智能路由加自动弹性伸缩、从推理黑盒靠猜到 TTFT/TPOT 指标加全链路追踪的技术升级路,更是一次从"把大模型当成一个普通函数调一下拿到结果、用伺候毫秒级 Web 请求的粗放思路去对待秒级吞金的 GPU 推理"到"把大模型推理当成一项需要对 GPU 算力和显存这两种极昂贵稀缺资源进行极致高效又稳定可控调度管理的严肃工程"的范式跃迁。当一个曾经一次流量洪峰就被挤爆 OOM 连环崩溃的推理服务,在限流和并发控制之后于洪峰下稳如磐石、当一块曾经利用率常年趴在十几个百分点的昂贵 GPU 在连续批处理后被算力吃满吞吐翻倍、当一个曾经 FP16 全精度一张卡都放不下的大模型在 INT4 量化后单卡轻松放下还腾出显存扛起更多并发、当一次曾经让用户对着转圈干等几十秒的推理在流式输出下亚秒级就见答案逐字蹦出、当一个曾经峰时被打爆谷时空烧钱的固定容量在自动弹性伸缩下时刻贴合着真实负载、当一次曾经只能靠猜靠重启撞运气排查数小时的推理变慢在指标大盘和全链路追踪下分钟级就定位到了瓶颈那一刻,真正让我们踏实的,不是用上了多少时髦的 AI 推理框架,而是'推理服务的高吞吐、低延迟、稳定与低成本,终于从依赖运气好不遇洪峰和拼命加卡堆资源的粗放,变成了由连续批处理、PagedAttention、量化、限流并发控制、流式输出、超时优先级调度、多副本弹性伸缩和全链路可观测这套工程方法对昂贵稀缺的 GPU 资源进行极致高效又稳定可控的调度管理'的笃定。大模型推理服务工程没有银弹,关键是理解连续批处理、显存与 KV 管理、量化、并发与限流、流式输出、超时与调度、部署路由与弹性伸缩、可观测各自解决什么问题、又如何共同服务于"把 GPU 算力和显存这两种极昂贵稀缺资源用到极致、同时保证服务稳定可控"这个核心目标,然后从把 GPU 利用率拉满、把显存精打细算这些根本做起——尤其要克制"图省事把大模型当普通函数串行调、图省事按最大长度预留显存、图省事 FP16 全精度加载、图省事对请求来者不拒、图省事一次性返回、图省事不设超时不分优先级、图省事固定容量、图省事不建可观测发完就走"的旧习惯,因为每一次串行推理的算力空转、每一寸被预留浪费的显存、每一次来者不拒的过载崩溃、每一个对着转圈干等的用户,都是在亲手把昂贵的 GPU 资源打水漂、把推理服务推向某次洪峰下的全面崩溃。愿每一位还在和 GPU 空转、显存 OOM、洪峰崩溃和黑盒排障搏斗的同行,都能早日让自己的大模型推理服务被这套工程方法稳稳地托住。共勉,后会有期。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

从粗放发布一个看似无害的小改动全量上线后因一个只在生产才触发的配置差异瞬间让所有用户白屏既无版本化旧制品又无一键回滚只能手忙脚乱翻找旧包 scp 覆盖全站不可用三十多分钟 + 本地手工 build 环境不一致包不可复现出了线上问题对不上是哪次构建产物根本无从查起 + scp 覆盖式部署新包直接盖掉旧包旧版本被销毁得无影无踪想回退连个可用旧制品都找不到 + 人肉点测全凭测试同学手点漏点了边缘功能带 bug 代码因无强制门禁就被合并上线 + SSH 登录到一台台机器凭记忆手工敲停服务传包覆盖改配置起服务的命令漏一步敲错一字多机不一致就酿故障还不可重复不可审计 + 一次性全量上线把新包往所有机器一覆盖所有用户同一瞬间切到新版本一有潜藏 bug 就同时对 100% 用户全面爆发无缓冲无试错损失即全员损失 + 出事才手忙脚乱满世界翻找旧包还可能已被覆盖没了再在火急火燎手抖中重做整套手工部署几十分钟全站瘫痪 + 配置散落各服务器各角落全凭 SSH 上去 vim 手工改改错没人拦改了没记录多机改得不一致诡异故障频发 + 开发测试生产环境各自手工搭野蛮生长成孤岛运行时依赖系统库版本处处不同在我机器上是好的一上生产就诡异崩溃 + 发布完看进程起来日志没刷红就以为成功转身忙别的错误率延迟悄悄劣化全然不知靠用户投诉报障才知翻车 → 2026 现代 CI/CD 流水线与发布工程 CI 统一环境自动构建 + 制品仓库版本化归档关联 commit 可追溯 + 自动化质量门禁编译测试覆盖率安全扫描全绿才许合 + 声明式部署描述期望状态工具自动收敛可重复可审计多机绝对一致 + 金丝雀渐进放量先 1% 验证再逐级加码蓝绿瞬时切换 + 历史制品归档加声明式部署让回滚一键确定性秒级退回稳定版本 + 配置即代码集中加版本化加评审加自动下发 + 容器化加 IaC 让开发测试生产环境处处一致铲除环境幽灵 + 发布与监控联动对比基线指标劣化即时告警自动回滚 87 天战役复盘:47 套工程修法 + 7 个 P0 复盘 + 6 条工程哲学

2026-5-29 1:15:16

技术教程

从粗放架构把用户商品订单库存支付营销所有业务不加边界地堆进同一个一百多万行的巨石单体进程任其强耦合成谁也理不清的乱麻改一个边角功能也要重新打包停服部署整个单体一个不相关模块的内存泄漏 OOM 就把整个进程拖垮导致全站一起宕机陪葬 + 拆开后把对端服务的 IP 端口硬编码写死在配置里实例扩容换机宕机就得满世界改配置重启对端进程崩了 IP 还在照样把请求往死实例上送负载也没法均衡 + 各微服务直接把接口暴露给客户端直连鉴权限流日志跨域这些横切逻辑在每个服务重复写一套既散乱又不一致一处有漏洞就是全系统破口后端结构全暴露给客户端 + 服务间清一色同步阻塞 RPC 调用订单要死等库存积分通知营销一长串下游依次返回可用性被乘法级稀释一个发短信服务抖动竟拖垮核心下单洪峰原封不动砸到每个下游 + 按领域拆库后本地事务跨不了多个独立库订单已落库但扣库存失败数据停在订单有了库存没扣的永久错误中间态还撤不回来 + 服务间无超时无熔断的裸调一个下游变慢就把上游线程池占满耗尽上游自己也挂故障顺调用链一级级反向传染雪崩拖垮大半个系统 + 有副作用的接口不做幂等来一次执行一次网络超时调用方重试同一笔支付被重复扣两三次钱同一个单生成好几个重复订单 + 请求跨网关订单用户库存支付好几个进程几台机器日志散落各处无任何关联线索串联断成谁也不认识谁的碎片排查跨服务慢请求只能逐台机器大海捞针拼凑数小时 → 2026 现代微服务架构 按 DDD 限界上下文沿领域边界拆成独立部署独立库独立进程故障隔离的微服务 + 注册中心自注册心跳按服务名动态发现健康实例 + 统一 API 网关收口横切逻辑写一处屏蔽内部结构 + 区分强一致与最终一致非核心下游改消息事件驱动异步消费解耦削峰填谷 + Saga 为每步配补偿操作失败反向回滚保最终一致 + 熔断器监控失败率慢调用超阈值跳闸快速拒绝走降级兜底故障就地隔离 + 全局幂等键加去重表唯一约束保证重复请求只执行一次副作用 + 全链路 TraceID 入口生成沿途透传把跨服务足迹串成完整链路分钟级定位 87 天战役复盘:47 套工程修法 + 7 个 P0 复盘 + 6 条工程哲学

2026-5-29 1:41:55

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索