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个token,GPU利用率不到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头(GQA),head_dim是128,FP16下每个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 vllm==0.5.4 pip install torch==2.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支持stream=True,返回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 }, stream=True ) 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 常见问题速查表
| 问题现象 | 可能原因 | 排查手段 | 解决方案 |
|---|---|---|---|
| 启动OOM | gpu_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)) V,Q只跟当前token有关,K和V跟所有历史token有关。生成第t个token时,需要前t-1个token的K和V,但不需要它们的Q。所以缓存K和V就够了,缓存Q是浪费显存。这个设计不是随便定的,是从计算图里推导出来的最优解。
至于“lost in middle”,那是长上下文场景的问题,跟KV Cache本身没关系,是注意力机制对中间位置信息衰减导致的。小模型线上一般不会遇到这个问题,因为序列长度通常控制在4K到8K,远没到注意力衰减的区间。
小模型线上部署这件事,说到底是在有限资源下做工程取舍。模型选多大、量化到几位、并发开多少、缓存怎么管,每一个决策都要结合业务实际。没有万能配置,只有最适合当前场景的配置。我上面给的参数和方案都是实际项目里跑过的,你可以直接抄,但一定要根据自己的硬件和业务调。