拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

AI系统性能工程:从并发控制到KV Cache的生产实战

AI系统性能工程:从并发控制到KV Cache的生产实战

AI 系统性能工程这个系列写到第三篇了,前两篇覆盖了性能工程的方法论框架和单点瓶颈的定位思路,这篇我打算把镜头彻底拉回生产现场。做 LLM 推理服务化这一年多,最深的感受是:模型层面的优化有论文、有框架、有公开 benchmark 可参考,真正难的是系统层面的性能工程——几十路并发请求一起打进来时,延迟为什么会雪崩?显存看着还有余量为什么偏偏 OOM?AI Agent 这种一个任务要调好几次模型的业务,并发控制到底该怎么设计?这篇文章就是围绕这些真实问题展开的,把我在生产环境里跑过的方案、测出来的数据、还有踩着坑换来的经验一起摆出来。适合正在做 LLM 服务化、Agent 落地,或者被"并发一高就崩"搞得焦头烂额的工程师阅读,建议带上自己服务的实际参数边看边对照。

1. 全链路视角:先摸清楚瓶颈到底在哪一层

1.1 一条推理请求的完整生命周期

很多同学拿到性能问题,第一反应就是看 GPU 利用率。我见过不少团队,GPU 跑到 90% 就觉得"没问题了",结果用户端反馈的延迟奇高。问题在于,一条 LLM 请求从发起到返回,GPU 计算只是其中一环。

拿我们生产环境的典型链路来说,用户请求先经过网关层,完成鉴权、限流、路由;然后进入编排层,Agent 场景在这里做工具调用、上下文拼接、记忆读取;接着到推理服务,通常已经是 vLLM 或者同类框架;推理服务内部又要经历 tokenizer、预填充(prefill)、解码(decode)、采样几个阶段;最后响应再原路返回。这个链条上任何一环的延迟都会被放大成最终的用户体验,而 GPU 利用率只能反映其中一个环节的忙闲程度。

我更习惯用"端到端链路拆解法"来做性能分析:先画出请求的生命周期,给每一段标上延迟构成和资源消耗,再带着压测数据逐段定位。网关层的连接池配置、编排层的 Python 异步调度、推理队列的排队长度、乃至网络往返的 RTT,每一处都可能是瓶颈。这个习惯帮我避免了很多"GPU 明明很闲但用户还是慢"的怪问题。

1.2 按层定义指标,别拿一个数值糊弄所有人

全链路视角的另一层含义是:不同层级要有不同的核心指标,不能指望用一个 QPS 或者一个延迟数值把问题说清楚。

网关层关心的是每秒请求数、成功率、限流触发次数;编排层要盯的是单任务耗时、工具调用次数、上下文拼接耗时;推理服务层则是 TTFT(首 Token 延迟)、TPOT(每个输出 Token 的耗时)、吞吐量。我见过一个典型的沟通事故:业务方说"延迟太大",后端查了半天发现推理延迟都正常,最后定位到是网关层在做超时重试,把平均延迟直接翻了一倍。如果一开始就按层定义指标,这个 case 五分钟就能查完。

所以我自己沉淀了一套"指标字典"的做法:每个层级维护一张表,写清楚指标名、统计口径、告警阈值、负责人。新服务上线之前必须先过一遍指标字典,不然出了问题连该查哪儿都不知道。这套方法看起来笨,但当你同时维护五六个 AI 服务的时候,它的价值会明显体现出来。

2. 推理服务压测:吞吐、延迟、并发怎么测才不骗自己

2.1 先理清 TTFT、TPOT、端到端延迟三个基础指标

压测 LLM 推理服务和压测普通 Web 服务有个本质区别:响应不是一个数据包,而是一个 Token 一个 Token"流"出来的。所以传统的 P95 延迟、QPS 这些指标不能直接照搬,得先理清三个基础指标。

第一个是 TTFT(Time To First Token),用户发出请求到收到第一个 Token 的时间。它直接决定了"首字响应"体验,也是用户感知最明显的一个数字。第二个是 TPOT(Time Per Output Token),每生成一个 Token 的平均耗时,它决定了整段回答的"出字速度"。第三个是端到端延迟,就是完整请求从发起到流式结束的总时长,约等于 TTFT 加上 Token 数乘以 TPOT。

这三个指标之间存在内在联动。比如你压到高并发时,TTFT 会先涨,因为请求在排队;TPOT 可能还稳定,因为 GPU 的算力是按 Token 摊的。只有把三个指标拆开看,才能判断瓶颈在排队环节还是在算力环节。我实测过,同一个服务并发从 1 压到 32,TTFT 可能从 300ms 涨到 3 秒,但 TPOT 纹丝不动——这说明问题在调度和排队,不在 GPU 算力。两类瓶颈对应的调优手段完全不一样,混在一起查很容易把自己绕晕。

2.2 压测姿势:并发模型、场景设计和数据解读

压测 LLM 服务最容易犯的错,是拿通用 HTTP 压测工具的默认模式去打。因为没有区分"并发连接数"和"模型推理并发",很多工具开 100 个连接,实际打到推理队列里的有效并发可能只有十几个。正确做法是:先弄清推理服务的最大并发能力,vLLM 里对应 max_num_seqs 参数,然后从低到高逐步加压,记录每一档的 TTFT、TPOT 和吞吐曲线。

场景设计也要分层。我在压测时一般分三类:单用户长对话场景、多用户短请求场景、混合流量场景。单用户场景主要验证 TPOT 是否达标;多用户短请求场景主要看 TTFT 和吞吐;混合流量场景才接近生产状态。特别要关注混合流量,因为长请求会占着 GPU 的 decode 阶段,短请求的预填充会被插队,这种"长尾请求挤压短请求"的现象,单场景压测根本看不出来。

压测数据的解读也有一套讲究。不要只看平均值,要看 TTFT 的分位数曲线——P50 和 P95 的差距如果超过 3 倍,说明排队模型已经失控。还要记录一个关键参数:排队深度,也就是当前正在等待的请求数。我有个经验阈值:排队深度超过推理服务 max_num_seqs 的两倍时,TTFT 大概率开始指数级恶化,这是服务的"拐点"。

2.3 压测工具选择与常见误区

工具方面,开源的方案足够用。我常用 locust 写自定义脚本,配合一个模拟 SSE 流式读取的客户端,这样能拿到真实的 TTFT 和 TPOT,而不是等整个响应读完才算一次。要特别提醒的是,很多压测工具默认不读流式响应,直接一次性拉完,测出来的端到端延迟会失真,首 Token 指标根本拿不到。

另一个常见误区是拿生产流量回放代替压测。生产流量回放适合做回归验证,但不适合用来找极限——因为生产流量本身已经被限流和超时"驯化"过。要摸清系统上限,必须构造比生产更极端的请求分布,比如突然的并发尖峰、超长上下文的请求、大批量短请求混入。我的经验值是:压测目标至少比生产峰值流量高 30%~50%,留足容量余量。容量余量不是浪费,它是你应对流量突刺的缓冲垫。

3. KV Cache 与显存博弈:这是调优中踩坑最多的环节

3.1 KV Cache 是怎么悄悄吃光显存的

先说概念。Transformer 在生成每一个 Token 时都要计算注意力,而注意力需要用到前面所有 Token 的 Key 和 Value 向量。为了避免重复计算,推理框架会把已经算过的 Key/Value 缓存下来,这就是 KV Cache。它的特点是:随着对话长度增加,缓存量线性增长;随着并发请求数增加,缓存量按倍数增长。

有个很直观的计算方式:每个 Token 的 KV Cache 大小大约是 2 × 层数 × 注意力头数 × 头维度 × 字节数。以 7B 模型、20 层、32 个头、128 维、FP16 为例,每个 Token 约占 2 × 20 × 32 × 128 × 2 = 327680 字节,也就是约 320KB。你可能会觉得单个 Token 不大,但 2000 Token 的上下文乘以 16 路并发,就是 320KB × 2000 × 16,约 10GB 显存。这还没算模型权重和激活值。所以"模型才 14GB,80GB 显存怎么还会不够"的问题,答案往往藏在 KV Cache 里。

我在生产环境见过最典型的场景:服务刚启动时显存占用看起来漂亮,跑了一两个小时后,会话变长、并发上来,显存悄悄逼近上限,最后某个瞬间直接 OOM。这种"慢性增长"太隐蔽,日常监控很难提前察觉,所以显存趋势告警比瞬时告警重要得多。

3.2 连续批处理与分页显存:现代推理引擎的两板斧

要解决 KV Cache 的问题,光靠"预留显存"是浪费。现在主流的推理框架(vLLM 这一派)采用了两项核心优化:连续批处理(Continuous Batching)和 PagedAttention 分页管理。

先解释连续批处理。传统静态批处理是:攒够一批请求,一起推理,全部结束后再接收下一批。问题在于一个长请求会把整批短请求全拖住,GPU 利用率忽高忽低。连续批处理则是:每完成一个请求就立刻让位给新请求,配合迭代级调度,GPU 的空闲窗口被大幅压缩。从我的实测数据看,同样的模型和硬件,从静态批处理切到连续批处理,吞吐量能提升 2~3 倍,代价是单请求的延迟方差变大。这一点在容量规划时要心里有数,不要用平均延迟去预测极端场景的体验。

再讲 PagedAttention。它的思路和操作系统的虚拟内存分页很像:KV Cache 不再为每个请求连续分配一整块显存,而是按固定大小的块来分配,物理上允许不连续,逻辑上通过块表串联。这样解决了两个问题:显存碎片化,长请求不再需要预占一段连续空间;显存浪费,不再为可能超长但实际没用上的长度预留空间。我的经验是,启用 PagedAttention(或同类方案)之后,同样的显存容量大约能多跑 40%~60% 的并发请求。这两项技术已经成为推理服务性能的标配,如果还在用朴素的静态批处理,性能差距是肉眼可见的。

3.3 显存预估公式与参数配置实操

落到配置实操,我一般按这个公式预估显存需求:总显存需求 = 模型权重 + 激活值 + KV Cache + 推理引擎自身运行时开销。

模型权重好算,FP16 就是参数量乘以 2 字节;激活值在推理阶段相对可控,不同框架差别不大;大头通常在 KV Cache 和框架自身的预留空间。以 vLLM 为例,gpu_memory_utilization 参数默认 0.9,意思是把 90% 的显存用于模型和 KV Cache,留 10% 给运行时。我强烈建议不要拉到 0.95 以上——CUDA context、碎片化以及驱动层面的占用会把你坑死。我遇到过 0.98 配置下频繁 OOM 的案例,把 util 调回 0.9 之后反而稳定下来。看起来"多用了显存",实际上"更早崩了",这个亏我吃过。

另外一个实操细节是 max_model_len 和 max_num_seqs 的配合。这两个参数直接决定 KV Cache 的最大可分配量:max_model_len 越长,每条请求能吃掉的空间越多;max_num_seqs 越大,并发请求数越多。假如业务以短对话为主,但默认参数给的 max_model_len 是 8192,等于拿 8K Token 的预算去服务 1K Token 的请求,显存浪费相当严重。我做过一次参数裁剪:把 max_model_len 从 8192 降到 4096,同时把 max_num_seqs 调高一倍,同等显存下并发能力提升了接近 90%,TTFT 反而更低。核心思路就一句话:不要为了极小概率的超长请求,透支大部分普通请求的并发资源。

4. AI Agent 场景的并发治理:别让编排层拖垮推理层

4.1 Agent 请求与普通推理请求的本质差异

AI Agent 的业务模式和传统 LLM 服务有一个非常大的区别:一个 Agent 任务,内部可能调用 3 到 10 次模型推理。比如一个做数据分析的 Agent,先要调一次 LLM 做意图理解,然后调工具查询数据,再把结果拼接进上下文,调第二次 LLM 生成结论,中间可能还有多轮反思、校验、改写。这意味着:从外部看只接收了 1 个用户请求,从推理服务的视角看,相当于 3~10 个推理请求在短时间内连续打进来。

这种"一对多"的放大效应,直接改变了并发治理的规则。如果只按外部请求数来做限流,推理层实际承接的负载可能是预期的好几倍。我见过一个真实事故:某个 Agent 服务上线后,外部 QPS 只有 20,但推理集群被打满了,因为平均每个 Agent 任务内部会触发 8 次推理调用。所以治理的第一原则是:所有并发控制和限流指标,都以"推理调用次数"为口径,而不是以"外部请求数"为口径。

4.2 编排层的三道闸:入口限流、信号量控制、下游熔断

编排层的并发控制,我通常分三道闸:入口限流、信号量控制、下游熔断。

入口限流针对外部请求,用令牌桶算法,比如每分钟允许 200 个 Agent 任务、突发允许 50,防止用户侧无节制打流量。信号量控制在编排层内部,作用是限制同时进行中的 Agent 任务数。每个任务会占用若干推理配额,信号量让并发任务数不超过一个安全阈值。这是我踩过坑的地方:一开始没加信号量,只靠入口限流,结果某个用户提交了一个特别大的并行子任务,瞬间把内部并发放大了 20 倍。加上信号量之后,内部并发被严格钳制,虽然个别任务排队久了点,但整体稳定性直线上升。

下游熔断是面向推理服务的保护:给每个推理调用设置超时,我一般设 30~60 秒;同时监控推理服务的错误率和延迟分位数,超过阈值就快速失败,不再往上游堆积请求。熔断在 Agent 场景尤其重要,因为任务链路长,任何一个环节故障都会被内部的多次调用放大成雪崩,快速失败反而能让系统更快恢复。

还有一个偏进阶的做法:自适应并发。服务启动时用一个保守值,比如内部并发上限 10,运行中根据推理服务的实测 TPOT 和排队深度动态调整上限。比如检测到 TPOT 超过目标值 20% 时,自动把内部并发上限下调 10%,等 TPOT 恢复后再缓慢加回去。这个策略我跑了几个月,比手动调参稳定得多。核心逻辑就是 PID 控制器思想:以 TPOT 为反馈信号,以并发上限为控制变量。想尝试的同学,建议先用固定并发跑一段时间,积累数据之后再切自适应,不然没有基准值做对比。

4.3 缓存与请求合并:最划算的两个性能杠杆

Agent 场景里有个特别划算的优化:语义缓存。因为 Agent 任务经常会有重复的意图识别、重复的上下文片段,甚至完全相同的子问题。把用户请求的 embedding 存到 Redis,计算相似度,命中就直接返回缓存结果,跳过推理。我在一个客服 Agent 上做过统计,语义缓存命中率约 30%,意味着整个推理负载直接少了近三分之一。这不是降低质量——完全相同的意图和问题,复用结果没有副作用。

请求合并则适用于一些可以批量处理的子任务。比如 Agent 需要对一批商品做分类,与其逐条调用 LLM,不如把 20 条商品信息拼成一个请求让 LLM 批量输出,再在编排层拆分。一次调用代替二十次调用,代价只是返回结构解析稍微复杂一点。实测下来,批量合并能把这类场景的推理次数降低 60% 以上。

这两类优化属于"治本"的手段——它们直接减少了对推理资源的真实消耗,而不是靠堆机器硬扛。在做并发治理时,我建议先把这两件事做好,再做限流和扩容,性价比完全不同。

5. 观测体系搭建:没有数据,一切调优都是盲人摸象

5.1 必盯的七个核心指标,一个都不能少

性能工程要做到"可调优、可验证",观测体系必须先行于优化动作。我总结了一套推理服务必盯的指标清单,不多不少七个,从集群到请求逐层覆盖。

指标反映的问题我的告警思路
GPU 利用率算力是否在忙持续低于 30% 且伴随高延迟,查调度
显存占用率容量是否有余量按趋势告警,上涨斜率异常才处理
排队深度请求是否堆积超过 max_num_seqs 两倍立即告警
TTFT 分位数首字响应体验P95 超过 2 秒告警
TPOT 分位数出字速度P95 超过 100ms 告警,看场景微调
端到端延迟分位数完整请求体验业务自定义,一般 P95 不超过 10 秒
Token 吞吐量算力实际产出关注波动,不设死值

这里要特别强调两条。第一条,GPU 利用率不等于效率高,利用率高可能是排队叠出来的假象,判断效率要看 TPOT 和吞吐的组合。第二条,排队深度是我最看重的前瞻性指标,它通常比 TTFT 提前 1~2 分钟反映问题。TTFT 超标是滞后指标,等它告警说明用户已经感知到了,而排队深度是前置指标,能在用户感知之前提前干预。

5.2 一次完整分析演示:从指标矛盾到根因定位

指标不是关键,关键是从指标到根因的推理能力。我用一个真实案例来演示完整路径。

某天早上,TTFT 的 P95 突然从 500ms 涨到 2.5 秒,但 GPU 利用率只有 60%,TPOT 也正常。第一反应是"GPU 没打满怎么会慢",这就是典型的指标矛盾。接下来看排队深度,果然从平时的 8 涨到了 80。问题于是从"为什么慢"变成了"为什么排队"。

继续往下查,发现编排层在早上 8 点有个定时任务,向 Agent 批量推了一批数据,每个数据项都会产生一次推理调用。入口限流没拦住,是因为定时任务走的是内部通道,绕过了网关限流。最后给内部服务单独加了一个令牌桶,排队深度回落到正常。整个排查花了二十分钟。如果没有排队深度这个指标,单看 TTFT 涨了,大多数人会先去查 GPU 或者模型版本,方向很可能就带偏了。

这个案例说明一个方法论:先看队列,再看资源,最后才看单点配置。队列异常说明流量或调度问题,资源满说明算力或容量问题,配置不合理说明参数问题。按这个顺序排查,很少会走弯路。这套排查顺序我已经用了大半年,每次都能快速收敛到根因。

6. 生产环境排查实录:三个有代表性的翻车案例

6.1 TTFT 飙升事故:问题在队列,不在 GPU

第一个案例是压测时发现的。当时我们在验证一个 13B 模型的服务,并发压到 32 时,TTFT 的 P95 从 400ms 直接飙到 4 秒,但 GPU 利用率只有 55%,TPOT 稳定在 40ms 左右。我的第一感觉也是"算力没打满啊",但直觉告诉我问题出在调度上。

查了 vLLM 的配置,max_num_seqs 设的是 32,理论上 32 路并发都能被接收。再细看日志,发现请求数超过 32 之后,出现了"预填充阶段阻塞 decode 阶段"的情况:每当新的短请求进来,它会抢占正在 decode 的长请求的 GPU 资源,导致所有长请求的生成速度时快时慢,TTFT 也随之恶化。问题的根源是 max_num_seqs 和请求长度分布不匹配。排查之后,把 max_num_seqs 调到 16,同时把短请求和长请求分流到不同的优先级队列,TTFT 的 P95 回落到 800ms。这个案例教会我一件事:并发不是越大越好,存在一个与请求长度分布匹配的最优值,需要通过实验来找。

6.2 高峰 OOM:静态分配的 KV Cache 惹的祸

第二个案例是生产环境的 OOM 事故。服务白天一切正常,到了晚上七八点的流量高峰,突然批量报显存 OOM,一查,是 KV Cache 的分配策略出了问题。

当时用的配置里,max_num_seqs 设成 64,max_model_len 设成 8192。理论上一算,峰值 KV Cache 需求量是 64 × 8192 × 320KB,超过单卡容量。为什么平时不炸?因为白天大多数请求的实际长度只有 2K Token,不会触顶;到了晚上,有人批量导入数据,几个超长文档的对话把 Cache 占满,瞬间触顶 OOM。换句话说,静态预留的 Cache 上限完全覆盖不了极端请求组合。

解决思路是把"静态预留"改成"动态感知"。我做了三件事:一是把 max_num_seqs 从 64 调到 32,降低触顶概率;二是在应用层增加请求长度检测,超过 6K Token 的请求单独路由到另一组配置了更大 max_model_len 的实例,避免长短混杂;三是给 OOM 加了一层自动恢复,检测到显存压力时先清理空闲会话的 Cache。改完之后,这个服务再没因为 KV Cache 的极端组合出过事。

6.3 吞吐卡死:Python 编排层成了隐形瓶颈

第三个案例是个 Agent 服务,推理集群扩容了两次,整体吞吐一直上不去。检查推理层指标,GPU 利用率只有 40%,TPOT 正常,排队深度也正常——推理层看起来完全没压力。问题一定出在上游。

把编排层的火焰图导出来一看,发现 Python 的 asyncio 事件循环里有个同步 Redis 调用,每次上下文读取都阻塞事件循环 100ms 以上。在异步环境里,一个同步阻塞会卡住整批协程的调度。所以虽然推理层很闲,但编排层每条请求都在排队等那个 Redis 调用。换成异步客户端之后,编排层的请求处理能力直接翻了三倍,推理层的 GPU 利用率终于跑到了 80% 以上。

这个案例提醒我:AI 系统的性能瓶颈经常不在 AI 本身,而在应用的工程实现。排查时不要条件反射地只看 GPU,要把链路每一层都纳入检查范围。现在我会定期把编排层的火焰图拉出来看看,特别是那些"看起来正常"的日常。

最后分享一个个人习惯:每次调完一个性能参数,我都会把当时的压测曲线截图保存,标注清楚场景、并发、参数值,攒成一个"调整档案"。三个月后回看,很多当初以为正确的调优,实际效果跟直觉完全不同。性能工程没有一成不变的"最佳配置",只有不断验证、不断校准的过程。这套方法论放到任何 AI 服务上都能落地,关键是先把前面那几个指标测准、把观测体系搭好,剩下的就是跟数据较劲的耐心。

返回列表