1. 为什么“性能”在 AI 系统里变成了一个全新的命题
过去做后端性能优化,脑子里装的都是 QPS、P99 延迟、连接池大小、GC 停顿这些词。一套服务压测跑下来,瓶颈基本落在数据库、网络 IO 或者锁竞争上,优化路径清晰,工具链成熟。但把这套经验直接搬到 AI 系统上,你会发现很多地方对不上号——不是工具不好用,而是问题的性质变了。
AI 系统的性能工程,核心矛盾从“请求-响应”的确定性链路,变成了“算力-显存-吞吐”之间的动态博弈。一个推理服务,你盯着它的接口延迟看,可能一切正常;但 GPU 利用率只有 30%,显存却快爆了,batch size 稍微调大一点就 OOM。这种“接口指标好看、底层资源浪费”的状态,是传统性能工程很少遇到的。更麻烦的是,AI 系统的性能表现高度依赖输入——同样一个模型,处理 10 个 token 的短请求和处理 4000 个 token 的长请求,延迟能差出两个数量级,而这两种请求在真实流量里往往是混在一起的。
所以我在这个系列的第一篇里,想先把“AI 系统性能工程”这件事的边界划清楚。它不是一个单纯的调参问题,也不是装个监控面板就能解决的事。它要求你同时理解模型的计算特性、推理框架的调度逻辑、硬件的资源模型,以及业务流量的分布规律。这四样东西缺一个,你的优化就会变成盲人摸象。
这篇文章适合两类人看:一类是已经有一定后端或运维基础,正在往 AI 基础设施方向转的工程师;另一类是已经在做 AI 应用,但发现“能跑”和“跑得好”之间差距巨大的开发者。我会尽量把原理讲透,同时给出可以直接上手验证的操作路径,不堆砌术语,也不回避那些实际踩过的坑。
2. 拆解 AI 系统性能的四个核心维度
2.1 延迟:不只是“快慢”,而是分层的
在 AI 系统里谈延迟,第一件事是分清你谈的是哪一层。最粗的划分是端到端延迟和模型推理延迟。端到端延迟包含网络传输、排队、预处理、推理、后处理、返回,而模型推理延迟只是其中一段。很多人优化了半天模型,结果发现瓶颈在预处理的数据拷贝上,这种事太常见了。
再往细里分,推理延迟本身又可以分为首 token 延迟和后续 token 延迟。这两个指标在流式输出场景下意义完全不同。首 token 延迟决定了用户“感觉等了多久才开始有反应”,后续 token 延迟决定了“输出得顺不顺”。一个对话应用,如果首 token 延迟 2 秒、后续每 token 50 毫秒,用户会觉得“开始慢但后面还行”;反过来首 token 200 毫秒、后续每 token 300 毫秒,用户会觉得“一开始挺快怎么越写越卡”。
实测中我习惯用这样一个表格来定位延迟问题的归属:
| 指标 | 典型关注点 | 常见瓶颈 |
|---|---|---|
| 端到端延迟 | 用户体验 | 网络、排队、预处理 |
| 首 token 延迟 | 交互感知 | 调度排队、KV Cache 初始化 |
| 后续 token 延迟 | 输出流畅度 | 显存带宽、batch 竞争 |
| 批处理延迟 | 离线任务 | 算力利用率、IO |
这张表的价值在于,当你拿到一个“慢”的反馈时,能快速判断该往哪个方向查,而不是一上来就怀疑模型本身。
2.2 吞吐:batch 不是越大越好
吞吐量的直觉是“一次多处理一些,总吞吐就上去了”。这个直觉在 CPU 时代基本成立,但在 GPU 上有个前提:你的显存够用,而且计算单元没有被打满。实际的情况是,batch size 增大到某个点之后,吞吐增长会明显放缓,而延迟会快速上升。这个拐点取决于模型大小、序列长度分布和硬件的显存带宽。
更关键的是,AI 系统的吞吐往往不是单一数字。你要区分token 吞吐和请求吞吐。一个系统可能每秒处理 100 个请求,但每个请求只有 20 个 token;另一个系统每秒只处理 10 个请求,但每个请求 2000 个 token。前者的请求吞吐高,后者的 token 吞吐高。如果你的业务是长文本生成,盯着请求吞吐看就会严重误判系统能力。
我在实际项目里会同时记录这两个指标,并且按序列长度分桶统计。因为混合流量下,平均值会骗人——短请求把请求吞吐拉高了,长请求把 token 吞吐拉高了,两个数字都好看,但用户体验可能一塌糊涂。
2.3 显存:最容易被低估的约束
显存是 AI 系统性能工程里最硬的约束。算力不够可以等,显存不够直接 OOM,服务就挂了。显存的占用主要分三块:模型权重、KV Cache、中间激活值。模型权重是固定的,KV Cache 和激活值则随 batch size 和序列长度动态变化。
这里有个很多人踩过的坑:用短序列压测调出来的 batch size,上线后遇到长序列请求直接爆显存。原因是 KV Cache 的大小和序列长度成正比,序列翻倍,KV Cache 翻倍,而你可能只留了 20% 的余量。所以显存规划必须按最长序列来算,而不是按平均序列。
一个粗略的 KV Cache 估算公式是:
KV Cache 大小 ≈ 2 × 层数 × 注意力头数 × 头维度 × 序列长度 × batch size × 精度字节数这个公式不用背,但你要知道它和哪些变量相关。当你发现显存吃紧时,能动的杠杆无非是:减小 batch size、缩短最大序列长度、用量化降低精度、或者用 PagedAttention 这类技术减少碎片。
2.4 算力利用率:GPU 到底在忙什么
GPU 利用率是个容易被误读的指标。nvidia-smi里显示的利用率,很多时候反映的是“有 kernel 在跑”,而不是“计算单元被高效利用”。一个显存带宽受限的操作,GPU 利用率可能显示 90%,但实际算力只用了 20%。这就是所谓的内存墙问题。
判断算力是否真的被用好,要看更细的指标,比如 SM 占用率、Tensor Core 活跃度、显存带宽利用率。在推理场景下,decode 阶段通常是显存带宽受限,prefill 阶段更偏计算受限。这两个阶段的优化策略完全不同:decode 阶段要减少显存访问,prefill 阶段要提升计算并行度。
理解这四个维度的相互关系,是后面所有优化工作的基础。它们不是独立的,调 batch size 会影响延迟和显存,调量化会影响显存和算力利用率,牵一发动全身。
3. 推理框架选型:别只看 benchmark 排名
3.1 选型的真正标准是什么
网上有很多推理框架的 benchmark 对比,吞吐量数字一个比一个漂亮。但我在实际选型时,第一件事不是看这些数字,而是问三个问题:我的模型结构是什么、我的流量分布是什么、我的团队能维护什么。
模型结构决定了框架的适配程度。Transformer 类模型在主流框架上都有深度优化,但如果你用的是 MoE、多模态或者自定义算子较多的模型,很多优化就用不上了。流量分布决定了你该优化 prefill 还是 decode,该不该开连续批处理。团队能力决定了你选一个开箱即用的方案,还是选一个需要深度定制的方案。
我见过不少团队跟风选了某个“性能最强”的框架,结果因为不熟悉其调度机制,配置没调对,实际性能还不如默认配置的简单方案。框架的性能上限和你能达到的性能,是两回事。
3.2 连续批处理:吞吐提升的关键机制
连续批处理是这几年推理框架里最重要的工程进步之一。传统批处理要等一个 batch 凑齐了才开始算,算完才能接下一批。连续批处理则是在每个 decode step 都重新组 batch,已经完成的请求退出,新来的请求插入。这样 GPU 几乎不会空转,吞吐提升非常明显。
但连续批处理不是免费的。它增加了调度的复杂度,对显存管理要求更高,而且在请求长度差异很大时,调度开销会上升。实测中,如果请求长度比较均匀,连续批处理的收益最大;如果长短请求混杂严重,收益会打折扣,甚至因为频繁的显存分配释放导致碎片化。
提示:开启连续批处理前,先确认你的框架是否支持 PagedAttention 或类似的分页显存管理。没有分页管理的话,连续批处理很容易把显存搞碎。
3.3 量化:精度换性能的边界在哪
量化是降低显存占用、提升吞吐的常用手段。从 FP16 到 INT8,显存直接减半,理论上吞吐也能提升。但量化的坑在于精度损失不是均匀分布的。有些模型对量化很敏感,INT8 之后输出质量明显下降;有些模型则几乎无感。
我的经验是,量化一定要做任务级评估,不能只看 perplexity 这种指标。因为 perplexity 变化很小,不代表你的具体任务表现没退化。比如一个代码生成模型,perplexity 只涨了 0.1,但生成的代码通过率可能掉了 5 个点。这种退化在通用指标上看不出来,只有跑真实任务才能发现。
另外,量化对延迟的影响要看具体实现。weight-only 量化主要省显存,对计算速度提升有限;weight+activation 量化才能真正加速计算,但精度风险也更大。选哪种,取决于你的瓶颈是显存还是算力。
4. 从压测到上线:一套可复现的性能验证流程
4.1 压测数据要模拟真实分布
很多团队的压测用的是固定长度的请求,比如全部 512 token。这样压出来的数字很好看,但上线后完全不是那么回事。真实流量的序列长度分布通常是长尾的,大部分请求短,少数请求很长。这个长尾部分才是压垮系统的元凶。
我的做法是,从生产日志里采样真实的输入长度分布,然后按这个分布生成压测数据。如果还没有生产数据,就按业务场景预估一个合理的分布,比如 70% 短请求(<200 token)、20% 中等(200-1000)、10% 长请求(>1000)。压测时重点观察长请求对短请求延迟的影响,这个影响在混合流量下非常显著。
4.2 分阶段压测:先单请求,再并发
压测不要一上来就拉满并发。我习惯分三步走:
- 单请求基线:测出单个请求在无竞争情况下的延迟,作为基准。
- 逐步加压:从低并发开始,每次增加并发数,记录延迟和吞吐的变化曲线。
- 找到拐点:当延迟开始非线性上升时,那个并发数就是系统的实际容量上限。
这个过程能帮你画出系统的性能曲线,而不是只知道一个最大吞吐数字。性能曲线告诉你系统在不同负载下的表现,这对容量规划更有价值。比如你知道在 70% 负载下延迟还很稳,就可以把告警阈值设在那里,而不是等到 100% 才告警。
4.3 上线后的持续观测
上线不是终点。AI 系统的性能会随着流量模式变化、模型更新、框架升级而漂移。我建议至少监控这几类指标:
- 延迟分位数:P50、P95、P99,不要只看平均值。
- 吞吐趋势:按小时或天看 token 吞吐的变化。
- 显存水位:峰值显存和平均显存,留足余量。
- GPU 利用率:区分计算利用率和显存带宽利用率。
- 错误率:特别是 OOM 和超时错误。
这些指标要能按序列长度、请求类型等维度下钻。不然出了问题,你只知道“慢了”,不知道“哪类请求慢了”。
5. 那些文档里不会写的踩坑经验
5.1 显存碎片比显存不足更隐蔽
显存不足会直接 OOM,报错清晰。显存碎片则很隐蔽——总显存明明够,但就是分配不出连续的大块,导致请求失败。这个问题在长时间运行、请求长度变化大的服务里特别常见。
解决办法一是用分页显存管理,二是定期重启服务释放碎片。前者是治本,后者是兜底。我见过一个服务,跑 12 小时之后失败率开始上升,重启就恢复,查了很久才发现是碎片问题。如果你的服务有类似“跑一段时间就变慢/变差”的现象,优先怀疑碎片。
5.2 批处理里的“慢请求拖累”
连续批处理下,一个超长请求会占着 batch 位置很久,导致后面的短请求排队。这就是所谓的队头阻塞。用户感知就是“偶尔特别慢”,但平均延迟看起来正常。
缓解办法有几种:设置单请求的最大 token 数,超长请求走单独的低优先级队列,或者用抢占式调度。具体选哪种,取决于你的业务能不能接受截断或降级。如果业务上不能截断,那就得在容量规划时给长请求留出专门的资源。
5.3 预热不是可选项
模型加载、CUDA kernel 编译、显存池初始化,这些操作在服务刚启动时很慢。如果不预热就接流量,前几十个请求的延迟会高得离谱,甚至超时。我习惯在服务启动后先跑一批预热请求,覆盖不同的序列长度,让各种 kernel 都编译好、显存池都分配好,再接入负载均衡。
预热的请求要覆盖你的典型场景,不能只用短请求预热。因为长序列会触发不同的 kernel 和显存分配路径,不预热的话,第一个长请求还是会慢。
5.4 监控指标要能对得上业务
技术指标和业务指标之间要有映射。GPU 利用率 80% 意味着什么?对业务来说,它意味着“还能不能扛住下一次流量高峰”。所以我会把技术指标翻译成业务语言,比如“当前配置下,系统能支撑每秒 X 个请求,延迟 P99 在 Y 毫秒以内”。这样当流量增长时,能快速判断要不要扩容。
6. 一个最小可用的性能基线模板
如果你刚开始做 AI 系统性能工程,不知道从哪下手,可以先用这个模板建立基线。它不完美,但能让你快速看清系统的状态。
第一步:确定关键指标
- 首 token 延迟 P50/P95
- 后续 token 延迟 P50/P95
- token 吞吐(按序列长度分桶)
- 峰值显存占用
- GPU 计算利用率
第二步:设计压测场景
- 短请求为主(模拟交互)
- 长请求为主(模拟生成)
- 混合分布(模拟真实流量)
第三步:记录基线数据
在默认配置下跑一遍,把所有指标记下来。这就是你的起点。
第四步:逐项调优
每次只改一个变量,记录变化。比如先调 batch size,再调量化,再调调度策略。不要一次改多个,否则不知道哪个起了作用。
第五步:固化配置并监控
把调好的配置固化下来,上线后持续监控。当指标偏离基线时,及时排查。
这个模板的价值在于,它强迫你把“感觉慢”变成“哪个指标慢”,把“优化了”变成“优化了多少”。性能工程最怕的就是凭感觉,有了基线,一切讨论才有依据。
我在实际项目里最大的体会是,AI 系统的性能问题,八成不是模型本身的问题,而是工程配置和流量理解的问题。把这两块吃透,很多所谓的“性能瓶颈”其实都能缓解。这个系列后面会继续拆解具体的优化手段,包括 KV Cache 管理、调度策略、多卡推理这些话题,感兴趣可以接着看。