ARTICLE DETAIL

资讯详情

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

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

AI系统性能工程:从并发控制到KV Cache的生产实战 AI 系统性能工程这个系列写到第三篇了前两篇覆盖了性能工程的方法论框架和单点瓶颈的定位思路这篇我打算把镜头彻底拉回生产现场。做 LLM 推理服务化这一年多最深的感受是模型层面的优化有论文、有框架、有公开 benchmark 可参考真正难的是系统层面的性能工程——几十路并发请求一起打进来时延迟为什么会雪崩显存看着还有余量为什么偏偏 OOMAI 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 这些指标不能直接照搬得先理清三个基础指标。第一个是 TTFTTime To First Token用户发出请求到收到第一个 Token 的时间。它直接决定了首字响应体验也是用户感知最明显的一个数字。第二个是 TPOTTime Per Output Token每生成一个 Token 的平均耗时它决定了整段回答的出字速度。第三个是端到端延迟就是完整请求从发起到流式结束的总时长约等于 TTFT 加上 Token 数乘以 TPOT。这三个指标之间存在内在联动。比如你压到高并发时TTFT 会先涨因为请求在排队TPOT 可能还稳定因为 GPU 的算力是按 Token 摊的。只有把三个指标拆开看才能判断瓶颈在排队环节还是在算力环节。我实测过同一个服务并发从 1 压到 32TTFT 可能从 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 显存。这还没算模型权重和激活值。所以模型才 14GB80GB 显存怎么还会不够的问题答案往往藏在 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 设成 64max_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 服务上都能落地关键是先把前面那几个指标测准、把观测体系搭好剩下的就是跟数据较劲的耐心。
返回列表