1. AI 系统性能工程的核心命题拆解
1.1 为什么“性能”在 AI 系统里是个完全不同的物种
传统后端系统的性能工程,核心指标无非是 QPS、P99 延迟、CPU 利用率、内存占用这几样,调优手段也相对成熟——加缓存、拆服务、异步化、连接池调参,一套组合拳打下来基本能解决八成问题。但 AI 系统一上来就把这套经验打碎了。你面对的不再是一个确定性执行的函数,而是一个概率性的推理过程:同一个 prompt 两次调用,输出长度可能差三倍,耗时可能差五倍,显存占用会因为 KV Cache 的动态增长而持续漂移。这意味着你没法用“压测出固定 QPS 然后按这个容量规划”的老思路来做。
我在实际项目里踩过最典型的一个坑:早期做推理服务容量评估时,按平均输出 200 token 去算显存和吞吐,结果上线后遇到长文本摘要场景,单请求输出飙到 2000 token,KV Cache 直接把显存打爆,服务开始疯狂 OOM 重启。这件事让我彻底意识到,AI 系统性能工程的第一性原则是面向分布而非面向均值。你必须关注 P50、P95、P99 的 token 长度分布、请求到达的突发性、以及显存随并发数的非线性增长曲线。
所以这一篇我想聊的不是某个具体框架的调参技巧,而是把 AI 系统性能工程拆成几个可以独立攻克的层面:推理引擎层、服务编排层、资源调度层、以及观测与压测层。每一层都有它独特的性能瓶颈和优化逻辑,混在一起谈只会越谈越乱。
1.2 推理引擎层:性能的第一战场
推理引擎是整个 AI 系统性能的地基。不管你上层用的是什么服务框架,最终 token 是一个一个从引擎里吐出来的。这一层的核心矛盾是计算密度与显存带宽的博弈。
大模型推理分两个阶段:Prefill(预填充)和 Decode(解码)。Prefill 阶段是把整个输入 prompt 一次性过一遍模型,计算密集,GPU 算力吃满,属于 compute-bound;Decode 阶段是逐 token 生成,每生成一个 token 都要把整个模型的权重读一遍,属于 memory-bound。这两个阶段的性能特征完全不同,优化手段也完全不同。
我见过很多团队在优化时犯的一个错误:拿 Prefill 的吞吐数据去推算 Decode 的性能,或者反过来。这两个阶段的耗时占比会随着输入输出长度比例剧烈变化。短输入长输出(比如聊天)以 Decode 为主,长输入短输出(比如分类、抽取)以 Prefill 为主。你得先搞清楚自己的业务落在哪个区间,才知道该往哪个方向使劲。
具体到引擎选型,目前主流的有几条路线:vLLM 的 PagedAttention、TensorRT-LLM 的 kernel 融合、SGLang 的 RadixAttention 前缀缓存。它们解决的核心问题不一样。PagedAttention 解决的是 KV Cache 的显存碎片化问题,把 KV Cache 按页管理,显存利用率能从 60% 出头拉到 90% 以上;RadixAttention 解决的是多请求共享前缀的重复计算问题,在 system prompt 很长或者多轮对话场景下收益巨大。选哪个不是看谁 benchmark 分数高,而是看你的业务请求有没有共享前缀、并发模式是长连接还是短请求。
1.3 服务编排层:被低估的性能杀手
很多人把模型部署起来就以为完事了,实际上服务编排层的开销在中小规模下可能比推理本身还大。我做过一次 profiling,一个看似简单的请求链路——网关鉴权、prompt 模板拼接、tokenize、排队、推理、detokenize、流式返回——其中 tokenize 和 detokenize 在某些实现下能占到端到端延迟的 15% 到 20%。尤其是当你的 tokenizer 是 Python 实现且没有做批处理时,CPU 会成为隐形瓶颈。
还有一个更隐蔽的问题:请求排队策略。默认的 FIFO 队列在 AI 场景下是灾难。因为 AI 请求的耗时差异极大,一个长请求堵在前面,后面所有短请求都得等,P99 延迟直接爆炸。正确的做法是引入**连续批处理(Continuous Batching)**配合优先级调度,让短请求能插队,长请求在后台慢慢跑。vLLM 和 TensorRT-LLM 都内置了 continuous batching,但调度策略需要你自己根据业务调。
流式返回也是个容易出问题的地方。SSE(Server-Sent Events)连接如果没做好背压控制,客户端消费慢的时候,服务端会积压大量未发送的 token,内存持续上涨。我建议在流式链路上加一个带缓冲上限的 channel,超过阈值就暂停从引擎拉取,形成反压。
2. 显存管理与 KV Cache 的深度优化
2.1 显存到底被谁吃掉了
做 AI 系统性能优化,第一件事是把显存账算清楚。一张 80G 的 A100,跑一个 70B 的模型(FP16),权重本身就占 140G,根本放不下,所以必须做量化或者张量并行。就算跑 7B 模型,权重占 14G,剩下的显存要分给 KV Cache、激活值、CUDA context、通信 buffer。很多人算容量时只算了权重,结果一上线就发现 KV Cache 根本不够用。
KV Cache 的大小是可以精确计算的。公式是:2 × num_layers × num_kv_heads × head_dim × seq_len × batch_size × dtype_bytes。注意这里的num_kv_heads在 GQA(Grouped Query Attention)架构下远小于num_attention_heads,这是 LLaMA 2 70B、Qwen 等模型能省显存的关键。以 LLaMA 2 7B 为例,32 层,32 个 KV head,head_dim 128,FP16 存储,那么每个 token 的 KV Cache 是2 × 32 × 32 × 128 × 2 = 512KB。一个 4096 长度的请求就要占 2G 显存。如果你并发 32 个这样的请求,KV Cache 就要 64G,比模型权重还大。
这个计算说明一个道理:在长上下文高并发场景下,KV Cache 才是显存的主要消费者,而不是模型权重。所以优化方向就很明确了——要么压缩 KV Cache,要么提高它的复用率。
2.2 PagedAttention 与显存碎片
在没有 PagedAttention 之前,KV Cache 是按请求预分配的连续显存块。问题是每个请求的实际生成长度不可预知,你只能按最大长度预留,造成大量内部碎片。实测下来,传统方案的显存有效利用率只有 50% 到 60%,一半显存被浪费在预留但没用上的空间里。
PagedAttention 的思路借鉴了操作系统的虚拟内存分页:把 KV Cache 切成固定大小的 block(比如 16 个 token 一块),用 block table 做逻辑到物理的映射。这样请求之间可以共享物理块,前缀相同的请求直接指向同一块物理显存,不用复制。显存利用率能拉到 90% 以上,而且支持了前缀共享和 beam search 的显存复用。
但 PagedAttention 不是没有代价的。block 的粒度选择是个权衡:block 太小,block table 本身占显存且寻址开销大;block 太大,内部碎片又回来了。vLLM 默认 16,实测在大多数场景下是个合理的平衡点。如果你的请求长度普遍很短(比如 128 以内),可以调到 8;如果普遍很长,调到 32 能减少 block table 开销。
2.3 前缀缓存:多轮对话的性能救星
多轮对话场景有个特点:每一轮请求都包含之前所有轮次的完整历史。如果每轮都重新计算整个历史的 KV,那计算量随轮次线性增长,延迟越来越慢。前缀缓存(Prefix Caching)就是解决这个问题的——把之前算过的 KV 按前缀 hash 缓存起来,新请求来了先查缓存,命中的部分直接复用。
SGLang 的 RadixAttention 用基数树管理前缀缓存,支持任意长度的公共前缀匹配,不只是完整请求级别。实测在多轮对话场景下,首 token 延迟能降低 60% 到 80%。但这里有个坑:缓存淘汰策略。如果缓存无限增长,显存会被吃光;如果淘汰太激进,命中率又上不去。我一般用 LRU 配合一个显存水位线,超过 85% 就开始淘汰最久未用的前缀块。
注意:前缀缓存对 prompt 的格式非常敏感。如果你的 system prompt 里带了时间戳、随机 ID 这类每次都变的内容,缓存永远命中不了。做缓存优化前,先把 prompt 里的动态部分挪到末尾,让公共前缀尽可能长。
3. 批处理策略与吞吐延迟的平衡术
3.1 静态批处理为什么不够用
最早的推理服务用的是静态批处理:攒够 N 个请求或者等 M 毫秒,凑一批一起送进模型,等这批全部生成完再返回。这个方案的问题在于,批内请求的生成长度不一样,短的早就生成完了,但必须等最长的那个结束才能释放资源。GPU 在等待期间大量空转,利用率可能只有 30% 到 40%。
更糟的是延迟。一个短请求如果运气不好跟一个长请求分到同一批,它的延迟会被长请求拖累。在聊天场景下,用户等 10 秒才看到第一个字,体验直接崩了。所以静态批处理只适合离线批量推理,不适合在线服务。
3.2 连续批处理的调度逻辑
连续批处理(Continuous Batching,也叫 iteration-level batching)的核心思想是:批的粒度从“请求级”降到“迭代级”。每一步 decode 时,调度器检查哪些请求已经生成完了(遇到 EOS 或达到 max_tokens),把它们踢出批,同时把队列里等待的新请求加进来。这样 GPU 每一步都在处理一个满批,利用率能拉到 80% 以上。
但连续批处理引入了一个新问题:批内请求的 Prefill 和 Decode 混在一起。新加入的请求需要做 Prefill,而已在批内的请求在做 Decode,两者的计算特征不同,混在一起会互相干扰。vLLM 的处理方式是把 Prefill 和 Decode 分到不同的 step,或者用 chunked prefill 把长 Prefill 切成小块穿插在 Decode 之间。这个策略的选择对延迟影响很大:优先 Prefill 能降低首 token 延迟,优先 Decode 能提高整体吞吐。我的经验是,交互式场景优先 Prefill,离线批处理优先 Decode。
3.3 调度参数的实战调优
连续批处理的性能高度依赖几个关键参数,我列一个实战中总结的对照表:
| 参数 | 作用 | 调大影响 | 调小影响 | 推荐起点 |
|---|---|---|---|---|
| max_num_seqs | 单批最大请求数 | 吞吐升,显存压力大 | 吞吐降,延迟低 | 显存的 70% 能容纳的请求数 |
| max_num_batched_tokens | 单批最大 token 数 | Prefill 吞吐升 | 首 token 延迟降 | 2048 到 4096 |
| block_size | KV Cache 块大小 | 寻址开销降,碎片增 | 碎片降,开销增 | 16 |
| gpu_memory_utilization | 显存使用上限 | 缓存多,命中率高 | 安全但浪费 | 0.90 |
调这些参数没有银弹,必须结合你的实际流量做 A/B 测试。我一般先用默认值跑一轮压测拿到基线,然后每次只调一个参数,观察 P50、P99 延迟和吞吐的变化,找到拐点。记住一个原则:吞吐和延迟在大多数情况下是矛盾的,你要先明确业务更在乎哪个。在线聊天在乎 P99 延迟,离线数据处理在乎吞吐,两者的最优参数完全不同。
4. 性能观测与压测体系搭建
4.1 该观测哪些指标
AI 系统的可观测性和传统服务有本质区别。传统服务看 CPU、内存、QPS 就够了,AI 系统必须深入到 token 级别。我建议至少采集以下几类指标:
第一类是请求级指标:端到端延迟、首 token 延迟(TTFT)、每 token 输出延迟(TPOT)、输入 token 数、输出 token 数。TTFT 和 TPOT 要分开看,因为它们对应不同的瓶颈——TTFT 高通常是 Prefill 或排队问题,TPOT 高通常是 Decode 或显存带宽问题。
第二类是引擎级指标:GPU 利用率、显存占用、KV Cache 使用率、批大小、队列长度、Prefill 和 Decode 的耗时占比。这些指标决定了你的容量水位。
第三类是业务级指标:不同接口的调用量、token 消耗分布、错误率、超时率。这些指标帮你把性能问题和业务变化关联起来。
4.2 压测怎么做才真实
AI 系统的压测比传统服务难得多,因为请求本身是变长的。用固定长度的请求去压测,得到的结论会严重偏离真实情况。我的做法是从生产流量里采样真实的 prompt 长度分布和输出长度分布,然后按这个分布生成压测请求。
具体步骤:先从日志里统计输入 token 的 P50、P90、P99,输出 token 的 P50、P90、P99,以及请求到达的间隔分布。然后用这些分布参数驱动压测工具生成请求。压测工具我推荐用 locust 或 k6 做请求编排,配合自定义的 token 长度采样逻辑。不要用那些只会发固定请求的简单工具,测出来的数据没有参考价值。
压测时还要注意预热。模型第一次加载、CUDA kernel 第一次编译、缓存第一次填充,这些都会让初始阶段的性能数据失真。我一般先跑 5 分钟预热流量,等各项指标稳定后再开始正式采集。
4.3 一个真实的性能问题排查案例
说个我实际遇到的案例。某次上线后,监控显示 P99 延迟从 2 秒涨到了 8 秒,但 GPU 利用率反而从 75% 降到了 50%。这个组合很反直觉——利用率降了,延迟反而涨了,说明瓶颈不在 GPU 计算上。
排查思路是这样的:先看队列长度,发现队列在涨,说明请求进得来出不去;再看 TTFT 和 TPOT,发现 TTFT 正常但 TPOT 暴涨,说明 Prefill 没问题,Decode 阶段卡住了;最后看 KV Cache 使用率,发现接近 100%,触发了频繁的缓存淘汰和重计算。根因是那段时间业务侧上线了一个长上下文功能,平均输出长度翻倍,KV Cache 需求激增,显存不够导致频繁换出换入。
解决方案分两步:短期把gpu_memory_utilization从 0.85 提到 0.92,同时把max_num_seqs降下来,牺牲一点并发换显存余量;长期引入前缀缓存,把多轮对话的重复计算消掉。调整后 P99 回到 2.5 秒,GPU 利用率回到 70%。
这个案例的教训是:AI 系统的性能问题往往不是单点故障,而是资源配比失衡。GPU 利用率低不代表没瓶颈,可能是显存先成了瓶颈,导致 GPU 在等数据。
5. 常见性能陷阱与排查速查
5.1 那些年踩过的坑
第一个坑是tokenizer 成为瓶颈。HuggingFace 的 Python tokenizer 在单线程下每秒只能处理几千个 token,高并发时 CPU 直接打满。解决方案是用 Rust 实现的 tokenizer(比如 tokenizers 库的 fast 版本),或者把 tokenize 做成批处理,一次处理一批请求。
第二个坑是日志和监控本身拖慢服务。有些团队把每个 token 的生成都打一条日志,或者每次推理都同步上报监控,IO 开销比推理还大。正确做法是异步批量上报,日志采样,关键路径上不做任何同步 IO。
第三个坑是模型加载和卸载的开销被忽略。在多模型场景下,如果频繁切换模型,加载时间可能比推理时间还长。解决方案是用模型池预热,或者用 LoRA 这种轻量适配器避免全量加载。
第四个坑是网络传输成为隐形瓶颈。流式返回时,如果每个 token 都单独发一个 SSE 事件,网络包数量巨大,TCP 开销显著。应该做小批量聚合,比如攒 4 到 8 个 token 发一次。
5.2 排查速查表
| 现象 | 可能原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| TTFT 高,TPOT 正常 | Prefill 慢或排队久 | 看队列长度、Prefill 耗时 | 加 chunked prefill、调调度优先级 |
| TTFT 正常,TPOT 高 | Decode 慢或显存带宽瓶颈 | 看 KV Cache 使用率、GPU 带宽 | 降并发、开前缀缓存、量化 KV |
| GPU 利用率低但延迟高 | 显存或 CPU 瓶颈 | 看显存占用、CPU 利用率 | 调显存配比、优化 tokenizer |
| 延迟随并发线性增长 | 资源不足 | 看各项资源水位 | 扩容或降级 |
| 延迟突然跳变 | 缓存淘汰或 GC | 看缓存命中率、GC 日志 | 调缓存策略、优化内存分配 |
| 吞吐上不去 | 批大小受限 | 看 max_num_seqs、显存 | 调批参数、量化模型 |
5.3 几个反直觉的经验
经验一:量化不一定能提速。INT8 量化能省显存,但在某些 GPU 上,反量化开销可能抵消掉省显存带来的收益。实测在 A100 上,FP16 到 INT8 的吞吐提升只有 20% 到 30%,远低于理论值。量化前一定要做 benchmark。
经验二:更大的批不一定更快。批大小超过某个阈值后,GPU 计算单元饱和,延迟线性增长但吞吐不再提升。这个阈值取决于模型大小和 GPU 型号,必须实测。
经验三:多卡并行不是免费的。张量并行能放下更大的模型,但卡间通信开销随卡数增加。2 卡并行的通信开销可能只占 5%,8 卡可能占到 30%。如果单卡能放下,就别用多卡。
6. 从性能工程到成本工程
6.1 性能优化的终点是成本
做性能工程做到最后,你会发现所有优化最终都指向同一个问题:单位 token 的成本。吞吐提升一倍,意味着同样的硬件能服务两倍的流量,成本减半。延迟降低一半,意味着可以用更少的实例支撑同样的并发。所以性能指标和成本指标是同一枚硬币的两面。
我习惯用一个综合指标来衡量优化效果:每美元每小时的 token 产出量。这个指标把吞吐、硬件成本、利用率全揉在一起,比单纯的 QPS 或延迟更能反映真实价值。优化前先算这个基线,优化后再算一次,提升幅度一目了然。
6.2 成本优化的几个杠杆
最大的杠杆是提高 GPU 利用率。一块 A100 满载和半载,成本是一样的,但产出差一倍。把利用率从 40% 拉到 80%,等于成本直接减半。手段就是前面说的连续批处理、前缀缓存、显存优化。
第二个杠杆是选对硬件。不是所有场景都需要 A100/H100。7B 以下的模型在 L4、A10 这类卡上跑,性价比可能更高。推理场景对显存带宽敏感,对算力相对不敏感,选卡时优先看显存带宽和容量,而不是算力峰值。
第三个杠杆是弹性伸缩。AI 流量往往有明显的波峰波谷,按峰值配置资源意味着低谷期大量浪费。用 K8s 的 HPA 配合自定义指标(比如队列长度)做弹性伸缩,能把平均利用率再拉高 20% 到 30%。但要注意冷启动时间,模型加载慢的话,伸缩跟不上流量变化,需要保留一定的预热实例。
6.3 一个成本优化的实际账本
拿一个真实项目举例。原来用 8 张 A100 跑一个 13B 模型的推理服务,日均处理 500 万次请求,平均输入 300 token,输出 150 token。优化前 GPU 平均利用率 45%,P99 延迟 3.2 秒。
做了三件事:一是上 vLLM 替换原来的静态批处理方案,利用率拉到 72%;二是开前缀缓存,因为 system prompt 很长且固定,命中率 65%,TTFT 降了 40%;三是把 KV Cache 量化到 INT8,显存省了 30%,并发能力提升。
最终结果:GPU 从 8 张减到 5 张,P99 延迟降到 1.8 秒,日均处理能力反而提升到 700 万次。按 A100 每小时 3 美元算,每月省下 2160 美元,一年两万六。这个账算下来,性能优化的投入产出比非常可观。
7. 写在最后的一些个人体会
做 AI 系统性能工程这两年,我最大的感受是这行没有放之四海皆准的最优解。同一个模型,同样的硬件,换个业务场景,最优配置可能完全不一样。所以比起记住某个参数的最优值,更重要的是建立一套自己的性能分析和调优方法论:先观测,再定位,后优化,最后验证。每一步都要有数据支撑,不能凭感觉。
另外一点是,不要过早优化。我见过太多团队在流量还没起来的时候就开始折腾各种高级特性,结果复杂度上去了,收益没看到。正确的节奏是先让系统跑起来,用最简单的方案满足需求,等性能真的成为瓶颈了,再针对性地优化。性能工程是解决问题的工程,不是炫技的工程。
最后分享一个我常用的排查习惯:遇到性能问题,先问三个问题——瓶颈在计算、显存还是通信?瓶颈在单请求内部还是请求之间?瓶颈是稳态的还是突发的?把这三个问题回答清楚,问题的范围就缩小了一大半,剩下的就是按图索骥。这个习惯帮我省下了大量瞎试的时间,希望对你有用。