ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

大模型API并发与KV Cache显存管理实战解析

大模型API并发与KV Cache显存管理实战解析 1. 这不是一次简单的 HTTP 请求大模型 API 背后的真实执行链路很多人点开 Postman填好https://api.example.com/v1/chat/completions塞进一段 JSON点下发送看到返回的content: 你好我是大模型就以为这是一次和调用天气 API 差不多的操作。我最初也是这么想的——直到我在生产环境里连续三天盯着 Grafana 看 GPU 利用率曲线发现请求进来时显存占用飙升 30%但推理耗时却卡在 2.8 秒不动而 CPU 却在疯狂做 tokenization直到我把并发数从 8 拉到 16QPS 反而掉了 40%错误日志里反复刷出CUDA out of memory直到我第一次手动 dump 出 KV Cache 的 tensor 形状发现它占了整整 1.2GB 显存而模型权重本身才 800MB。这根本不是一次“发个请求、等个响应”的线性过程。它是一条横跨七层的精密流水线最上层是用户发起的 RESTful 请求中间是服务框架比如 Harness做的路由、鉴权、限流、日志埋点再往下是模型推理引擎vLLM / TensorRT-LLM / llama.cpp的调度器然后是 CUDA kernel 层面对 GPU 的显式控制底层是 GPU 显存中动态生长的 KV Cache 张量而所有这些还必须在毫秒级延迟约束下与成百上千个并发请求共享同一块 A100 显存。关键词大模型、API、GPU、并发、KV Cache每一个都不是孤立概念。它们像齿轮一样咬合KV Cache 的大小直接决定单卡能承载多少并发并发数的设定不是拍脑袋而是由 GPU 显存带宽、KV Cache 增长速率、prefill 阶段计算密度共同约束的数学结果Harness 不是透明代理它对请求头、流式响应 chunk 大小、超时策略的微小改动会直接放大或抑制底层 GPU 的利用率波动。这篇文章不讲抽象理论也不堆砌公式。我会带你完整走一遍一次真实请求从curl -X POST发出到最终拿到{delta:{content:...}}的全过程。每一步我都告诉你它在做什么、为什么必须这么做、如果做错了会触发什么具体错误比如你搜到的api error: 400 this models maximum context length is 1048576 tokens就是 KV Cache 容量超限的典型报错、以及我在三个不同规模的线上服务中踩过的坑。这不是教程是解剖报告。2. Harness被严重低估的“交通指挥中心”而非透明管道很多团队把 Harness 当作一个高级 Nginx——加个鉴权、打个日志、转发给后端模型服务就完事。这是最大的认知偏差。Harness 在大模型 API 场景下是整条链路的“交通指挥中心”它的配置细节直接决定了 GPU 能不能被喂饱、能不能稳住延迟、会不会在高并发下雪崩。2.1 请求预处理Tokenization 不在 GPU 上发生但它决定 GPU 的工作量当你发送一个含 500 字中文的 promptHarness 接收到的原始字节流首先要经过 tokenizer通常是 HuggingFace 的AutoTokenizer。这个步骤100% 在 CPU 上完成且无法绕过。我见过太多团队在压测时发现 CPU 使用率飙到 95%而 GPU 利用率只有 30%问题就出在这里。Harness 必须完成三件事字符标准化统一处理全角/半角空格、BOM 头、emoji 表情编码。我们曾遇到用户粘贴微信聊天记录里面混入了\u200e左向隐式格式化符tokenizer 把它当做一个独立 token导致实际输入 token 数比预期多出 12 个最终触发context length exceeded错误。长度预检在转发前Harness 就该计算len(tokenizer.encode(prompt)) max_tokens。如果总和超过模型最大上下文如 Llama-3-70B 的 8192立刻返回 400 错误而不是把请求丢给 GPU 让它失败。否则GPU 会在 prefill 阶段分配完 KV Cache 后才发现超长白白浪费显存和时间。这就是你搜到的api error: 400 this models maximum context length is ...的真实来源——它本该在 Harness 层拦截。流式响应分块控制stream: true不是开关而是一个精细的 buffer 策略。Harness 需要设置stream_buffer_size例如 4 字节。如果设得太小如 1 字节每个 token 都触发一次 HTTP chunk网络开销爆炸设得太大如 1024 字节用户端感知延迟升高。我们实测在 100Mbps 内网环境下stream_buffer_size32是延迟与吞吐的最优平衡点。提示不要让模型服务自己做 tokenization。Harness 统一做既能提前拦截错误又能为后续限流提供精确的 token 粒度依据。我们把 tokenizer 模块封装成独立的 gRPC 服务Harness 通过短连接调用避免 tokenizer 成为单点瓶颈。2.2 并发调度Harness 的 connection pool 和 timeout 是 GPU 利用率的隐形开关Harness 的max_connections和timeout参数表面看是网络配置实则深度耦合 GPU 调度max_connections 100不代表你能稳定支撑 100 QPS。因为每个连接背后可能对应一个正在 GPU 上运行的 decode 步骤。如果 decode 平均耗时 200ms那么理论最大 QPS 是100 / 0.2 500。但现实远非如此——GPU 的 decode 是串行的单个 sequence而 prefill 是并行的。Harness 如果把所有请求都塞进同一个 connection pool会导致长 promptprefill 耗时高阻塞短 promptdecode 快的响应。我们的解决方案是connection pool 分层prefill_pool: 专用于处理新请求的 prefill 阶段max_connections20超时30sprefill 可能涉及大矩阵乘。decode_pool: 专用于已进入 streaming 状态的请求的持续 decodemax_connections80超时5s单步 decode 应极快。这样一个 2000-token 的长 prompt 在 prefill_pool 里慢慢算完全不影响 decode_pool 里 50 个短对话的流畅输出。上线后P99 延迟从 3.2s 降到 1.1s。timeout的陷阱设timeout60s看似保险但当 GPU OOM 时请求不会优雅失败而是卡死在 CUDA kernel 中。Harness 等待超时后强行关闭连接但 GPU 显存并未释放导致后续请求全部失败。我们必须设置timeout30s并配合retry_on_timeoutfalse让失败请求快速退出避免僵尸连接堆积。2.3 日志与监控Harness 是唯一能关联“用户请求”与“GPU 显存峰值”的环节GPU 监控工具如nvidia-smi或 DCGM只能告诉你“此刻显存用了 32GB”但无法回答“是哪个用户的哪个请求导致了这次 spike”。Harness 是唯一同时持有request_id、user_id、prompt_length、response_length和start_time/end_time的组件。我们在 Harness 日志中强制注入四个关键字段{ request_id: req_abc123, kv_cache_bytes: 1245184, // 本次请求实际分配的 KV Cache 显存 prefill_ms: 1842, // Prefill 阶段耗时毫秒 decode_steps: 47, // 共进行了 47 步 decode gpu_util_pct: 87 // 请求结束时 GPU 利用率 }这些字段被实时写入 Loki并与 Prometheus 的nvidia_gpu_memory_used_bytes指标做关联查询。当某次kv_cache_bytes突增到 2GB我们就能立刻定位到是某个用户上传了 10MB 的 PDF 文本经 OCR 后变成 15000 token从而针对性地增加 prompt 长度硬限制。注意kv_cache_bytes不是固定值。它随prompt_length max_tokens线性增长公式为kv_cache_bytes 2 * num_layers * (prompt_length max_tokens) * hidden_size * sizeof(float16)。以 Llama-3-8B 为例num_layers32,hidden_size4096,sizeof(float16)2代入得kv_cache_bytes ≈ 2 * 32 * (L M) * 4096 * 2 524288 * (L M)字节。所以当(L M) 4096时KV Cache 就占了 2GB。这个数字Harness 必须在请求进来时就心知肚明。3. GPU不是“插上就能跑”而是需要显式内存编排的计算单元把大模型部署到 GPU绝不是pip install torch然后model.to(cuda)就完事。GPU 显存是一种稀缺、不可抢占、且访问模式高度特殊的资源。一次 API 请求的 GPU 执行本质是一场精心编排的显存舞蹈。3.1 显存布局权重、KV Cache、中间激活值三者争夺同一片空间A100 40GB 的显存不是一块可以随意 malloc 的大内存。它被严格划分为三个逻辑区域区域用途特点典型大小Llama-3-8BWeight Memory存储模型权重q_proj.weight,o_proj.weight等静态、只读、常驻~8.2GBFP16KV Cache Memory动态存储每个 token 的 Key/Value 向量动态、读写频繁、按需增长可变最大约 2.5GB见上文公式Activation Memory存储前向传播中的中间张量如 attention scores, FFN 输出临时、生命周期短、峰值高~1.8GBPrefill 阶段问题来了Weight Memory是固定的Activation Memory在 Prefill 时达到峰值而KV Cache Memory在整个 decoding 过程中持续增长。三者之和不能超过 40GB。一旦超限就是CUDA out of memory。我们曾在线上遇到一个诡异问题单请求测试一切正常但并发到 12 时第 10 个请求必然失败。nvidia-smi显示显存占用 39.8GB。排查发现Prefill 阶段的Activation Memory峰值是 1.8GB但它是瞬时峰值结束后会被释放而 KV Cache 是累积占用12 个并发请求每个平均分配 1.5GB KV Cache就是 18GB。加上 8.2GB 权重已经 26.2GB看似还有余量。但问题在于GPU 的内存分配器CUDA Memory Manager有内部碎片。当它尝试为第 12 个请求分配一块连续的 1.5GB 显存时由于前面 11 次分配/释放造成的碎片找不到足够大的连续块于是失败。解决方案是显存预分配Memory Pooling在服务启动时就向 GPU 申请一大块显存如 30GB并由推理引擎如 vLLM自己管理这块内存的切分与复用。vLLM 的 PagedAttention 机制就是把 KV Cache 按 page如 16x16 的 block来管理类似操作系统的虚拟内存页表彻底解决碎片问题。启用后12 并发稳定运行显存占用恒定在 31.2GB。3.2 并发的本质不是“同时跑多个模型”而是“共享一个模型轮流喂 token”这是最常被误解的概念。“提高并发数”不是启动 16 个模型副本。那成本太高且无法共享权重。真正的并发是在同一个模型实例内维护多个独立的 KV Cache 实例让它们轮流获得 GPU 的计算时间片。想象一个单核 CPU 跑 10 个线程OS 调度器让每个线程执行几毫秒然后切换。GPU 上的并发 decode 也是类似逻辑但调度器是推理引擎vLLM/Triton请求 A 进入 decode 阶段引擎为其分配一个 KV Cache slot执行forward()生成第 1 个 token。立即切换到请求 B为其分配另一个 slot执行forward()生成第 1 个 token。再切回 A用它自己的 KV Cache生成第 2 个 token。如此往复。这个过程要求KV Cache 的访问必须是 batched 的。vLLM 会把当前所有处于 decode 状态的请求按其 KV Cache 的当前长度聚合成一个 batch例如A 有 5 个 tokenB 有 3 个C 有 7 个就组成一个batch_size3的 decode batch。GPU 一次性计算这 3 个序列的下一个 token。这带来了巨大的效率提升——矩阵乘法的并行度远高于单个序列。但这也带来约束所有 batch 内的序列其 KV Cache 的当前长度必须能被高效对齐。vLLM 采用 PagedAttention允许不同长度的序列共享同一个物理显存 page完美解决此问题。而 naive 的实现如早期的 HuggingFace Transformers要求所有序列等长导致大量 padding显存浪费严重。3.3 性能瓶颈诊断如何判断你的 GPU 是被计算卡住还是被显存带宽卡住nvidia-smi只显示GPU-Util%但这远远不够。一个GPU-Util% 95%的实例可能正经历两种截然不同的瓶颈Compute-Bound计算瓶颈CUDA core 满载但显存带宽利用率sm__inst_executed/dram__bytes_read很低。表现是增加模型层数更多计算QPS 下降但增大 batch size更多数据并行QPS 上升。这是理想状态说明 GPU 核心被充分利用。Memory-Bound显存带宽瓶颈GPU-Util%很高但dram__bytes_read也接近 100%而sm__inst_executed却不高。表现是增大 prompt 长度需要读取更多 KV CacheQPS 断崖下跌但减小max_tokens减少 KV Cache 写入QPS 显著回升。这是常见病尤其在长文本场景。我们用nsys profile工具抓取一次典型请求的 trace如果看到大量cudaMemcpyAsync主机到设备的数据拷贝和__nv_cvt_half2float类型转换耗时很长说明数据搬运是瓶颈。如果看到gemm矩阵乘kernel 耗时占比 70%说明是计算瓶颈。针对 Memory-Bound我们的优化是Kernel Fusion将q_projk_projv_proj三个线性层融合成一个 kernel减少一次显存读取。FlashAttention-2使用更高效的 attention 实现将O(N²)的 memory access 降到O(N√N)对长 prompt 效果显著。在 4096-token prompt 下decode 速度提升 2.3 倍。实操心得永远不要只看GPU-Util%。用dcgmi dmon -e MEM_COPY_UTIL和dcgmi dmon -e SM_UTIL同时监控才能看清真相。我们把这两个指标做成 Grafana 的双轴图运维同学一眼就能判断是该加卡还是该优化模型。4. KV Cache大模型推理的“心脏”也是并发能力的“天花板”如果说 GPU 是身体那么 KV Cache 就是大模型推理的心脏。它不参与训练却是推理过程中最核心、最消耗资源、也最容易被误解的组件。4.1 KV Cache 是什么为什么不能像 CPU Cache 那样“自动管理”KV Cache 的全称是 Key-Value Cache。在 Transformer 的 Self-Attention 层中对于一个长度为T的序列计算attention(Q, K, V)时K和V矩阵的每一行都对应输入序列的一个 token。当模型开始生成第T1个 token 时它需要Q_{T1}与K_{1..T}、V_{1..T}做点积。如果每次都重新计算K_{1..T}和V_{1..T}计算量是O(T²)无法实时。KV Cache 的本质就是把K_{1..T}和V_{1..T}这两个(T, head_dim)的矩阵预先计算好并缓存在 GPU 显存中。下次生成T1时只需计算Q_{T1}然后与缓存的K/V做matmul复杂度降到O(T)。它不能被“自动管理”因为它不是硬件 cacheCPU 的 L1/L2 cache 是硬件自动完成的程序员无感。KV Cache 是软件显式分配、显式读写的 tensor必须由推理引擎vLLM自己管理生命周期。它没有“失效”概念CPU cache 有 dirty bit、coherency protocol。KV Cache 一旦分配就必须一直存在直到整个 sequence 结束eostoken 生成。中间不能被其他 sequence 覆盖。它的大小是动态的随着生成 token 数增加T增加KV Cache 就要不断追加新的行。这要求显存分配器支持高效的 append 操作。4.2 KV Cache 的显存计算一个必须手算的公式你搜到的kv cache 计算核心就是这个公式。我们以 Llama-3-8B 为例一步步拆解模型参数num_layers 3232 层 Decodernum_heads 32每层 32 个 Attention Headhead_dim 128每个 Head 的维度hidden_size / num_heads 4096 / 32dtype float162 字节单个 token 的 KV Cache 大小每层需要存储K和V两个矩阵每个矩阵形状是(1, num_heads, head_dim)。所以单层大小 2 * num_heads * head_dim * sizeof(float16) 2 * 32 * 128 * 2 16384字节。32 层总大小 32 * 16384 524288字节 ≈0.5MB per token。整个 sequence 的 KV Cache 大小如果 prompt 长度L 1024max_tokens 512则总长度T L max_tokens 1536。总 KV Cache T * 0.5MB 1536 * 0.5 768MB。并发下的总显存需求如果目标并发数C 20则总 KV Cache 显存 C * T * 0.5MB 20 * 768MB 15.36GB。再叠加Weight Memory (8.2GB)和Activation Memory (1.8GB)总需求≈ 25.4GB。这解释了为什么一块 A100 40GB 能轻松跑 20 并发但 A10 24GB 就捉襟见肘。关键洞察并发数C不是独立变量它由GPU_total_memory、T平均序列长度和model_kv_per_token共同决定。公式为C_max floor((GPU_mem - Weight_mem - Activation_mem) / (T * kv_per_token))。任何脱离T谈并发都是耍流氓。4.3 KV Cache 的生命周期管理从分配、增长到优雅释放一个请求的 KV Cache经历三个阶段Allocation分配在 Prefill 阶段结束时根据prompt_length为该 sequence 分配初始 KV Cache 空间。vLLM 会为其分配prompt_length个 page。Growth增长每生成一个新 token就向该 sequence 的 KV Cache 中追加一行K和V。vLLM 的 PagedAttention 会动态为其分配新的 page并更新 page table。Deallocation释放当 sequence 生成eostoken或达到max_tokens限制或请求超时vLLM 会立即将其所有 page 标记为 free供其他 sequence 复用。这个过程的健壮性直接决定服务稳定性。我们曾遇到一个严重 bug某个客户端在收到第一个delta后就断开了 WebSocket 连接但服务端没有及时收到 FIN 包TCP Keepalive 时间过长。vLLM 以为这个 sequence 还在活跃继续为其分配 KV Cache page几天后所有 page 都被这个“僵尸 sequence”占满新请求全部失败。解决方案是双重心跳检测Harness 层对每个 streaming 连接设置ping_interval10s如果 30s 内没收到客户端pong主动关闭连接。vLLM 层在engine.py中增加sequence_timeout60s任何 sequence 如果 60s 内没有新的 decode 请求即没有新 token 生成强制终止。上线后“僵尸 sequence”归零。5. 并发数一个需要动态调整的运营指标而非静态配置项并发数这个词在搜索热词里高频出现并发数、jmeter压测怎么确认系统的并发数、agent多并发配置但它常被当作一个写死的数字。实际上在大模型 API 服务中最优并发数是一个随流量、prompt 分布、GPU 型号、甚至时间白天/夜晚动态变化的运营指标。5.1 如何科学地压测并确定你的“黄金并发数”JMeter 或 k6 压测不能只看 QPS 和平均延迟。必须监控以下五个黄金指标指标健康阈值超出含义诊断方法P99 Latency 2000ms用户明显感知卡顿查看prefill_ms和decode_steps日志GPU Utilization70% ~ 90%低于70%没喂饱高于90%可能过载dcgmi dmon -e SM_UTILKV Cache Memory Usage 85% of total GPU mem接近100%OOM风险极高nvidia-smi --query-compute-appspid,used_memory --formatcsvError Rate (4xx/5xx) 0.1%鉴权、超时、context length 错误增多Harness access log 统计Decode Throughput (tokens/s) 150 tokens/s (A100)单卡 decode 效率低下sum(decode_steps) / sum(decode_time)我们的标准压测流程Baseline用prompt_length128,max_tokens128的均匀请求从并发 1 开始每次 2直到 P99 2000ms 或 Error Rate 0.1%。记录此时的并发数C_base。长 Prompt 压力用prompt_length2048,max_tokens512从C_base/2开始观察 KV Cache Memory Usage 是否突破 85%。如果突破C_base就是上限。混合流量按线上真实比例如 70% 短 prompt, 20% 中 prompt, 10% 长 prompt进行 30 分钟持续压测观察各项指标是否稳定。最终我们得到的不是一个数字而是一个并发数区间[C_min, C_max]。C_min是保证 P99 1500ms 的最小并发C_max是 KV Cache 不超 85% 的最大并发。日常运营中我们将其设为C_target (C_min C_max) / 2并用 Prometheus Alertmanager 设置KV_Cache_Usage 80%的告警一旦触发自动缩容 10%。5.2 “并发”与“吞吐”的辩证关系为什么有时降低并发反而提升 QPS这是一个反直觉但极其重要的现象。我们曾将并发从 16 降到 12QPS 却从 85 提升到 92。原因在于GPU 的 memory bandwidth 是有限的。回忆前面的 Memory-Bound 分析当并发过高大量 sequence 的 KV Cache 同时被读取显存带宽成为瓶颈。GPU core 在等待数据从显存加载到寄存器的过程中空转。此时降低并发减少了同时竞争带宽的 sequence 数量每个 sequence 获得的带宽份额增加decode步骤更快完成整体吞吐tokens/s反而上升。我们用nsys对比了 16 并发和 12 并发的 trace16 并发dram__bytes_read利用率 98%sm__inst_executed利用率仅 65%。12 并发dram__bytes_read利用率 82%sm__inst_executed利用率 88%。这证明12 并发时GPU core 更忙效率更高。因此最优并发数是计算资源SM和内存资源DRAM达成平衡的点而非单纯追求连接数最多。5.3 生产环境的动态并发调控基于实时指标的自动扩缩容静态配置max_connections16是危险的。线上流量是脉冲式的早 9 点、晚 8 点是高峰凌晨是低谷用户行为也变化工作日多问技术问题短 prompt周末多聊生活长 prompt。我们的方案是基于指标的闭环调控数据源Prometheus 抓取harness_http_requests_total{status~2..} / 60每分钟成功请求数、nvidia_gpu_memory_used_bytes、vllm_cache_num_blocks_used。决策引擎一个轻量 Python 服务每 30 秒计算# 计算当前“健康并发度” health_score ( (1 - (gpu_mem_used / gpu_mem_total)) * 0.4 # 显存余量权重 (decode_throughput / target_throughput) * 0.3 # 吞吐达标率 (1 - (p99_latency / 2000)) * 0.3 # 延迟达标率 ) if health_score 0.7: target_concurrency int(current_concurrency * 0.9) # 缩容10% elif health_score 0.95: target_concurrency min(int(current_concurrency * 1.1), max_concurrency) # 扩容10%执行器通过 Harness 的 Admin API或直接修改其 configmap 并kubectl rollout restart动态更新max_connections。这套系统上线后我们实现了高峰期自动扩容至C_max保障用户体验。低谷期自动缩容至C_minGPU 利用率从 35% 提升至 65%节省 40% 的电费。长 prompt 涌入gpu_mem_used骤升5 秒内触发缩容避免 OOM。最后分享一个血泪教训不要在高峰期手动kubectl scale。我们曾因一次误操作将并发从 16 拉到 32瞬间KV Cache Memory占满所有请求503 Service Unavailable恢复花了 12 分钟。自动化是生产环境的底线。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进