ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

小模型线上部署实战:vLLM推理优化与KV Cache显存管理

小模型线上部署实战:vLLM推理优化与KV Cache显存管理 1. 小模型线上部署的选型逻辑与整体思路把大模型塞进线上环境最容易被忽略的一个事实是绝大多数业务场景根本用不上70B甚至更大的模型。我做过好几个线上项目最后跑在生产环境里的往往是1B到8B这个量级的小模型比如Llama 3.1 8B、Qwen2.5 7B这类。原因很直接——延迟、成本、并发这三座大山压着大模型在线上几乎没法做到既快又便宜还稳定。先说延迟。一个8B的模型在单张A10或者4090上用FP16推理首token延迟大概在200到400毫秒之间而70B的模型即使上了多卡张量并行首token也经常飙到1秒以上。线上对话场景里用户能接受的等待时间通常不超过1.5秒超过这个阈值体验就崩了。再说成本70B模型至少需要2到4张A100/H100单次推理的显存占用和算力消耗是小模型的十倍以上按token计费的话小模型的单位成本可能只有大模型的十分之一甚至更低。最后是并发小模型单卡能扛的并发数远超大模型配合KV Cache优化和连续批处理单卡支撑几十路并发不是问题。所以“llm小模型线上使用”这个命题的核心不是怎么把模型跑起来而是怎么在有限的显存和算力下把吞吐、延迟、稳定性三者平衡到最优。我一般的思路是先确定业务对延迟和并发的要求再反推模型尺寸和量化方案最后用推理框架把KV Cache、批处理、显存管理这些机制调到位。整个链路里模型本身只占一部分真正决定线上表现的是推理引擎的配置和显存管理策略。这里要特别提一下KV Cache。很多人第一次接触推理优化时会问为什么只缓存K和V不缓存Q。原因在于注意力机制的计算方式Q是当前token的查询向量每次只跟当前token有关用完就丢而K和V是所有历史token的键值对后续每个新token都要跟它们做注意力计算。如果不缓存每生成一个token就要把前面所有token的K和V重新算一遍计算量随序列长度平方增长。缓存之后每步只需要算新token的Q然后跟缓存的K、V做一次注意力复杂度降到线性。这就是KV Cache存在的根本理由也是小模型线上部署必须吃透的第一个优化点。2. 推理框架选型与核心参数拆解2.1 为什么我最终选了vLLM而不是裸跑Transformers裸跑HuggingFace Transformers在小模型上做demo没问题但一上线上就露馅。最大的问题是它默认不做连续批处理每个请求单独走一次前向GPU利用率极低。我实测过一个7B模型用Transformers做单路推理吞吐大概每秒20到30个tokenGPU利用率不到30%。换成vLLM之后同样的硬件吞吐直接翻了四到五倍GPU利用率拉到80%以上。vLLM的核心优势在于PagedAttention和连续批处理。PagedAttention把KV Cache按页管理类似操作系统的虚拟内存分页避免了显存碎片化显存利用率能提升到90%以上。连续批处理则让不同请求的token在同一个batch里动态拼接新请求可以随时插入正在运行的batch不用等当前batch全部结束。这两点加起来就是小模型线上高并发的关键。当然TensorRT-LLM在极致延迟上更强但它编译模型麻烦对模型结构有要求改一个参数就要重新编译迭代成本高。SGLang在结构化生成和前缀缓存上做得好但生态相对新一些。综合下来vLLM在易用性、性能和社区活跃度上最平衡是我目前小模型线上的首选。2.2 关键参数配置与显存计算部署时最常调的几个参数我列一下实际项目里的配置和背后的计算逻辑。gpu_memory_utilization这个参数控制vLLM能占用的显存比例默认0.9。我一般设0.85到0.9之间留一点给CUDA上下文和临时张量。设太高容易OOM设太低浪费显存。max_model_len是最大序列长度直接决定KV Cache的显存占用。KV Cache的显存计算公式是2 * num_layers * num_kv_heads * head_dim * max_model_len * batch_size * dtype_size。以Llama 3.1 8B为例32层8个KV头GQAhead_dim是128FP16下每个token的KV Cache占用是2 * 32 * 8 * 128 * 2 131072字节约128KB。如果max_model_len设8192单条序列的KV Cache就是1GB。这就是为什么长上下文场景显存吃紧——序列越长KV Cache线性增长。max_num_seqs控制同时处理的序列数也就是并发上限。这个值要结合显存算不能拍脑袋设。假设显存剩20GB给KV Cache每条序列平均长度2048那每条序列占256MB理论上能放80条。但实际要留余量我一般设40到60。enable_prefix_caching这个开关很关键。线上很多请求有相同的前缀比如系统提示词、few-shot示例。开启前缀缓存后相同前缀的KV Cache只算一次后续请求直接复用。我实测过一个客服场景系统提示词占了800个token开启前缀缓存后首token延迟降低了40%以上。python -m vllm.entrypoints.openai.api_server \ --model meta-llama/Llama-3.1-8B-Instruct \ --dtype float16 \ --gpu-memory-utilization 0.88 \ --max-model-len 8192 \ --max-num-seqs 48 \ --enable-prefix-caching \ --port 8000这段启动命令是我线上常用的配置你可以根据实际显存和业务长度调整。注意max-model-len不要设得比业务实际需要大太多否则KV Cache预分配会吃掉大量显存。2.3 量化方案的选择与取舍小模型线上部署量化几乎是必选项。FP16下7B模型占14GB显存加上KV Cache单卡24GB的4090很快就满了。量化到INT8或者INT4显存直接减半甚至降到四分之一。我常用的方案是AWQ和GPTQ。AWQ在4bit量化下精度损失很小推理速度也快vLLM原生支持。GPTQ生态更成熟但有些模型转换麻烦。FP8在H100上表现很好但A10、4090这些卡不支持FP8所以不在考虑范围。量化带来的精度损失要实测。我一般用业务数据跑一轮评测对比量化前后的输出质量。大多数场景下4bit量化的BLEU或者ROUGE下降在1到2个点以内对话场景几乎感知不到。但如果业务对数值精度敏感比如金融计算那就老老实实上FP16或者INT8。注意量化模型和原始模型的tokenizer必须一致否则会出现输出乱码或者截断。转换量化模型时一定要校验tokenizer配置。3. 线上实操从模型加载到服务暴露的完整链路3.1 环境准备与依赖安装线上环境我一般用Docker隔离基础镜像选nvidia/cuda:12.1.0-runtime-ubuntu22.04然后装Python 3.10和vLLM。vLLM的版本要和CUDA、PyTorch匹配不然容易出编译错误。我踩过的坑是vLLM 0.4.x和PyTorch 2.2不兼容升级到0.5.x之后才正常。pip install vllm0.5.4 pip install torch2.3.0 --index-url https://download.pytorch.org/whl/cu121模型文件提前下载到本地或者挂载到容器里不要每次启动都从远端拉线上环境网络不稳定拉模型可能超时。用huggingface-cli download提前下好或者用内部模型仓库同步。3.2 模型加载与显存预分配vLLM启动时会做一次显存预分配把gpu_memory_utilization比例的显存拿过来然后按PagedAttention的页大小切分。这个过程大概需要10到30秒取决于模型大小和显存容量。启动日志里会打印KV Cache的块数和可支持的并发序列数这个信息很重要直接告诉你当前配置能扛多少并发。我一般会看日志里的# GPU blocks和# CPU blocks。GPU blocks就是显存里能放的KV Cache页数每个页默认16个token。如果GPU blocks太少说明显存不够要么降max_model_len要么升gpu_memory_utilization要么换更小的量化模型。3.3 服务暴露与负载均衡vLLM自带OpenAI兼容的API server直接暴露HTTP接口就行。线上一般前面挂一层Nginx或者网关做负载均衡和鉴权。如果单卡扛不住就多起几个实例用Nginx的upstream做轮询。upstream vllm_backend { server 127.0.0.1:8000; server 127.0.0.1:8001; server 127.0.0.1:8002; }每个实例绑不同的GPU用CUDA_VISIBLE_DEVICES隔离。这样单机多卡就能横向扩展。注意每个实例的gpu_memory_utilization要算好别把同一张卡分给两个实例还都设0.9那肯定OOM。3.4 请求处理与流式输出线上对话场景基本都用流式输出用户看到token一个个蹦出来体验比等整段生成好得多。vLLM的API支持streamTrue返回SSE流。前端用EventSource或者fetch的ReadableStream接收。流式输出对KV Cache的管理有额外要求。每个流式请求的KV Cache要一直保留到生成结束不能中途释放。所以并发流式请求多的时候KV Cache占用会比较高。我一般会限制单实例的最大流式并发数超过就排队或者拒绝避免显存打满。import requests response requests.post( http://localhost:8000/v1/completions, json{ model: meta-llama/Llama-3.1-8B-Instruct, prompt: 介绍一下KV Cache的原理, max_tokens: 512, stream: True }, streamTrue ) for line in response.iter_lines(): if line: print(line.decode(utf-8))这段代码是最简的流式调用示例实际线上还要加超时、重试、错误处理。4. 性能调优与常见问题排查4.1 延迟优化的几个抓手首token延迟和每token延迟是两个不同的指标优化手段也不一样。首token延迟主要受prefill阶段影响也就是处理输入prompt的时间。prompt越长prefill越慢。优化手段包括开启前缀缓存、缩短系统提示词、用chunked prefill把长prompt分块处理。每token延迟主要受decode阶段影响也就是逐token生成的速度。这个阶段是显存带宽瓶颈KV Cache越大每步读取的数据越多延迟越高。优化手段包括用量化减小KV Cache、用GQA减少KV头数、控制并发数避免显存带宽争抢。我实测过一个场景max_model_len从8192降到4096每token延迟降低了大概15%因为KV Cache小了一半显存带宽压力小了。所以如果业务不需要那么长的上下文果断降下来。4.2 显存OOM的排查思路OOM是线上最常见的问题排查顺序一般是先看KV Cache占用再看模型权重占用最后看临时张量和CUDA上下文。KV Cache占用可以用vLLM的metrics接口看vllm:gpu_cache_usage_perc这个指标直接告诉你KV Cache用了多少。如果接近100%说明并发太高或者序列太长需要限流或者降max_model_len。模型权重占用是固定的量化之后基本不会变。临时张量主要在prefill阶段产生长prompt的prefill会临时占用较多显存。如果OOM发生在prefill阶段可以考虑开chunked prefill把长prompt分块降低峰值显存。注意vLLM的gpu_memory_utilization是预分配不是按需分配。设0.9意味着启动时就把90%显存拿走了即使实际没用那么多。所以设太高会导致其他进程没显存可用设太低又浪费。我一般留10%给系统和CUDA。4.3 输出质量下降的排查量化之后输出质量下降或者线上跑着跑着输出变差一般有几个原因。一是量化精度损失这个前面说过用业务数据评测。二是KV Cache复用出错前缀缓存开启后如果不同请求的前缀被错误复用会导致输出混乱。vLLM的前缀缓存是按token序列哈希匹配的理论上不会错但如果tokenizer有问题哈希可能碰撞。三是采样参数配置不当temperature、top_p这些参数在不同场景下要调线上一般用temperature 0.7、top_p 0.9但代码生成场景可能要更低。我遇到过一次输出重复的问题排查下来是repetition_penalty设得太低模型陷入循环。调到1.1之后正常了。这种问题没有通用解只能根据业务输出实测调参。4.4 常见问题速查表问题现象可能原因排查手段解决方案启动OOMgpu_memory_utilization太高看启动日志显存分配降到0.85或换量化模型首token延迟高prompt太长或前缀缓存未开看prefill耗时开前缀缓存、缩短prompt每token延迟高KV Cache太大或并发太高看KV Cache占用率降max_model_len、限流输出重复采样参数不当检查temperature和repetition_penalty调高repetition_penalty输出乱码tokenizer不匹配对比tokenizer配置统一tokenizer版本并发上不去max_num_seqs太小看GPU blocks数升gpu_memory_utilization或降序列长度5. 小模型线上的扩展玩法与个人经验5.1 多模型路由与级联线上不一定只跑一个模型。我做过一个方案用小模型做意图识别和简单问答复杂问题路由到大模型。这样大部分请求走小模型成本低延迟低只有少数复杂请求走大模型。路由层可以用一个轻量分类器或者直接用规则匹配。级联的另一个玩法是 speculative decoding用小模型做草稿大模型做验证。不过这个在小模型自身上意义不大更多是用在“小模型加速大模型”的场景。5.2 模型热更新与版本管理线上模型不可能一成不变业务迭代就要换模型。vLLM支持动态加载但直接替换模型文件会导致正在处理的请求失败。我一般用蓝绿部署起一个新实例加载新模型流量切过去之后再停旧实例。模型版本用目录区分配置文件里记录版本号方便回滚。5.3 监控与告警线上必须监控几个核心指标QPS、首token延迟P99、每token延迟P99、KV Cache占用率、GPU利用率、OOM次数。这些指标用Prometheus采集vLLM自带metrics接口直接暴露就行。告警阈值我一般设首token延迟P99超过2秒告警KV Cache占用率超过95%告警OOM一次就告警。5.4 我踩过的几个坑第一个坑是max_model_len设太大。一开始我设了32768想着支持长上下文结果KV Cache预分配就把显存吃满了并发只能跑个位数。后来降到8192并发直接翻了四倍。教训是按业务实际需要设不要贪大。第二个坑是前缀缓存没开。线上系统提示词很长每个请求都要重新prefill浪费了大量算力。开了前缀缓存之后首token延迟降了将近一半。第三个坑是量化模型没评测。有一次直接上了4bit量化结果业务数据上输出质量明显下降用户投诉。后来老老实实跑了一轮评测换回INT8才解决。量化不是免费的精度损失必须实测。第四个坑是并发限流没做。有一次流量突增KV Cache打满所有请求都变慢雪崩了。后来加了限流超过并发上限的请求直接排队或者返回繁忙保护了已有请求的稳定性。5.5 关于KV Cache的进一步理解回到热词里提到的“为什么需要KV Cache而不是QKV Cache”其实答案在注意力计算的数学形式里。注意力输出是softmax(QK^T / sqrt(d)) VQ只跟当前token有关K和V跟所有历史token有关。生成第t个token时需要前t-1个token的K和V但不需要它们的Q。所以缓存K和V就够了缓存Q是浪费显存。这个设计不是随便定的是从计算图里推导出来的最优解。至于“lost in middle”那是长上下文场景的问题跟KV Cache本身没关系是注意力机制对中间位置信息衰减导致的。小模型线上一般不会遇到这个问题因为序列长度通常控制在4K到8K远没到注意力衰减的区间。小模型线上部署这件事说到底是在有限资源下做工程取舍。模型选多大、量化到几位、并发开多少、缓存怎么管每一个决策都要结合业务实际。没有万能配置只有最适合当前场景的配置。我上面给的参数和方案都是实际项目里跑过的你可以直接抄但一定要根据自己的硬件和业务调。
RELATED READING

延伸阅读

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