最近在业务里遇到一个很典型的需求:让系统能够“看懂图片”。输入是产品截图、工单图片、纸质单据照片,模型不仅要理解图里的内容,还要给出描述或者回答。纯文本大模型肯定做不到,最终我们选了 Qwen2-VL 配合 vLLM 来做服务化部署。这套方案把单卡和多卡场景都跑通了,后面还写了一套爬虫脚本去批量调用模型接口,算是把部署、压测、稳定性验证的完整流程走了一遍。这篇文章会把整个过程拆开来讲,包括环境选型、启动参数、多卡并行原理、多模态请求体构造,以及各种能预见的坑。适合刚接触视觉语言模型部署、想在本地或内网环境快速拉起服务并接业务的人。
1. 部署前的整体规划:为什么是 Qwen2-VL 配合 vLLM
1.1 多模态模型落地的现实约束
实际业务里的图片大多属于内部数据,比如工单截图、产品页、扫描件,不可能全部发到外部接口去处理,私有化部署几乎是唯一选择。多模态开源模型里,Qwen2-VL 的视觉理解能力比较均衡,能处理单图、多图和视频输入,在中文场景下的 OCR、版面理解也表现不错。它有几个不同的规格:2B 可以放在小显存环境里验证链路,7B 适合作为日常服务,72B 交给算力更充足的机器。而且 vLLM 原生支持这类模型架构,直接把权重路径传给 vLLM 就能运行,不需要额外改模型代码。真正决定部署方案的往往是显存,所以硬件约束必须先说清楚。
1.2 vLLM 为什么更适合服务化部署
如果直接用推理框架逐条处理请求,每来一个用户都要重新加载权重、独占显存做前向计算,GPU 利用率非常低。vLLM 做对了三件事:第一,把 KV cache 分成固定大小的块,按需分配,解决了显存碎片化的问题;第二,实现了连续批处理,当一个请求在生成 token 的间隙,其他请求可以插进来一起计算,吞吐量提升非常明显;第三,自带一个兼容常用大模型 API 格式的 HTTP 服务层,业务侧直接用 HTTP JSON 就能调用,不用自己拼底层协议。这三点的组合让 vLLM 成为目前私有化部署推理服务的主流方案。尤其是长上下文和并发请求同时存在时,连续批处理带来的收益非常可观。
1.3 整体链路与资源估算
整条链路是:模型权重文件 → vLLM 进程(加载模型,对外暴露 HTTP 端口)→ 业务脚本(批量请求)。资源评估方面,一个粗略但好用的经验是:半精度下,每 10 亿参数大约占 2GB 显存。7B 模型权重约 14GB,一张 24GB 的卡能放下,但留给 KV cache 的余量取决于上下文长度;72B 模型权重约 144GB,单卡无论如何都放不下,必须上多卡。除了权重,还要考虑 KV cache、图片变换出来的视觉 token、激活值缓存。部署前先按这个量级估算一下,能少踩很多弯路。
2. 环境准备与模型权重获取:先把“原料”备好
2.1 硬件、驱动与基础软件版本基线
显存是最核心的约束,其次是显存带宽。想要流畅跑 7B 级别模型,建议至少一张 24GB 显存的卡;跑 72B 则要准备 4 张 80GB 以上的卡。系统层面,显卡驱动需要支持当前使用的 CUDA 版本,一般建议 CUDA 12.1 以上。Python 版本建议 3.10 或 3.11,太老的版本容易在安装 vLLM 时遇到编译问题。建议先把 PyTorch 装好,确认torch.cuda.is_available()返回正常,再安装 vLLM。这个顺序很重要,先装 vLLM 再装 PyTorch,很容易碰到版本不匹配,然后报一堆算子相关的错误。vLLM 更新很快,不同版本的参数和算子支持有差异,建议用相对新的稳定版本,不要盲目追最新。
2.2 权重获取与目录结构确认
Qwen2-VL 的权重可以从开源模型托管站点下载,选择本地网络访问顺畅的渠道就好。下载完成之后,权重目录里必须包含 config.json、tokenizer.json、tokenizer_config.json,以及一个或多个 safetensors 文件。启动前一定要检查目录完整性,重点看 config.json 里的 model_type 和 vision_config 是否正常。如果是从某些渠道拉下来的文件,可能在传输中出现截断,启动时才报错,到那时再排查就比较被动了。稳妥做法是下载后先做一次哈希校验,或者直接用模型仓库自带的校验记录比对。vLLM 加载模型时会先读取 config.json,如果格式不对或者缺少视觉相关配置,进程会在加载阶段直接退出。
2.3 启动前的三项快速检查
第一,确认 GPU 能被系统识别,执行nvidia-smi能看到卡的数量和显存大小。第二,确认 PyTorch 能看到 GPU,在 Python 里执行python -c "import torch; print(torch.cuda.is_available(), torch.cuda.device_count())",如果输出 False 或者设备数为 0,说明驱动或 CUDA 环境有问题。第三,确认端口没有被占用,vLLM 默认监听 8000 端口,如果机器上已经有服务占用,要么关掉旧进程,要么启动时改端口。这三项检查一分钟就能完成,但能避免后面启动失败时在错误方向上折腾。
3. 单卡部署实操:从启动命令到第一个接口响应
3.1 新版命令与最小启动参数
vLLM 新版直接提供了一个vllm serve命令,早期版本需要写更长的一串启动脚本。先看一个最小启动示例:
vllm serve /data/models/Qwen2-VL-7B-Instruct \ --served-model-name qwen2vl \ --max-model-len 8192 \ --gpu-memory-utilization 0.9 \ --dtype auto这里几个关键参数要说明一下。--served-model-name是给接口层使用的模型别名,调用 API 时 model 字段必须写这个值,不设置的话默认用真实模型名,实际使用中很容易因为名字不一致导致接口 404。--max-model-len控制模型能接受的最大上下文长度,包括文本和视觉 token,这个值直接影响 KV cache 预留大小,通常设为 8192 或 16384 够用;如果业务里没有超长文档,没必要拉太高。--gpu-memory-utilization表示 vLLM 最多可以占用多少比例的显存,默认 0.9,一般建议 0.85 到 0.9,太低会浪费显存,太高则可能把显存撑爆。--dtype auto让系统根据硬件自动选择半精度格式。
3.2 验证服务:模型列表与首轮多模态请求
启动之后,日志里会看到 vLLM 打印模型加载和端口监听信息。先在另一个终端验证服务是否正常:
curl http://localhost:8000/v1/models返回结果里能看到模型列表。接着构造第一个多模态请求。图片有两种传法:传公网可访问的图片 URL,或者传 base64 编码的 Data URI。本地文件建议用 base64,避免服务端还需要出网拉图。示例代码如下:
import requests import base64 image = base64.b64encode(open("test.jpg", "rb").read()).decode() payload = { "model": "qwen2vl", "messages": [ { "role": "user", "content": [ {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image}"}}, {"type": "text", "text": "描述这张图片的内容"} ] } ] } resp = requests.post("http://localhost:8000/v1/chat/completions", json=payload, timeout=120) result = resp.json() print(result["choices"][0]["message"]["content"])多模态请求的 content 必须是数组,不能是一个普通字符串。数组里每个元素包含 type 字段,可以是 image_url,也可以是 text。很多第一次接触的人把 content 直接写成字符串,vLLM 会返回 400 错误。
3.3 单卡场景下的参数调节经验
单卡部署并不只是一个启动命令的事情,参数之间是相互制约的。显存总量固定时,max-model-len 越大,KV cache 占用越多,留给并发 batch 的空间就越少。如果一张卡既想处理长文档,又想同时服务多个请求,很可能出现启动成功后一旦并发上来就 OOM。实际项目里建议先根据业务最长输入设置 max-model-len,再观察显存占用,最后调整 gpu-memory-utilization。另外,--max-num-seqs参数可以控制单个 batch 内最多并行处理的序列数,默认值在某些版本下可能偏大。并发不高时,反而应该调小一点,避免多个请求挤在同一 batch 里拖慢单个请求的响应速度。
4. 多卡部署:张量并行原理、启动参数与显存分配
4.1 什么时候必须从单卡切到多卡
两种情况需要多卡:一是模型权重本身超出单卡显存,比如 72B 半精度需要 144GB 以上;二是单卡吞吐跑不满业务峰值,需要通过并行推理提升并发能力。如果只是显存放不下,用 CPU offload 或量化也可以缓解,但性能损失明显。多卡部署能让多个 GPU 同时承担推理计算,吞吐往往能翻几倍。但也要清楚,多卡不是万能的,卡间通信会成为新的瓶颈,尤其当模型较小而卡数较多时,通信开销可能超过并行收益。
4.2 张量并行与流水线并行怎么理解
vLLM 支持两种并行方式。张量并行是把模型权重切分到多张卡上,每一层都分布在所有卡里,前向计算时卡与卡之间需要同步交换数据,适合单机多卡。流水线并行则是按层切分,模型的第 1 到第 N 层放在卡 A,第 N+1 到第 M 层放在卡 B,数据一层一层往下传,适合跨机场景,但存在流水线气泡,利用率可能不如张量并行。对大多数单机多卡场景来说,优先使用张量并行。实际启动时通过--tensor-parallel-size指定使用多少张卡,比如 2 表示两块卡组成一个并行组。卡数不是随意定的,要能被模型内部结构整除,常见选择是 2、4、8。
4.3 多卡启动命令与通信环境变量
多卡启动和单卡很类似,核心差异是指定并行参数。以两张卡为例:
vllm serve /data/models/Qwen2-VL-7B-Instruct \ --served-model-name qwen2vl \ --tensor-parallel-size 2 \ --pipeline-parallel-size 1 \ --max-model-len 12288 \ --gpu-memory-utilization 0.9启动完成后,日志中能看到张量并行相关信息。多卡之间通信依赖 NCCL 库,如果通信初始化失败,配置环境变量往往能解决:
export NCCL_DEBUG=INFO export NCCL_P2P_DISABLE=1NCCL_DEBUG 会打印通信细节,方便定位是哪一步卡住。NCCL_P2P_DISABLE 主要用在某些不支持 GPU 间直接内存访问的环境,强制走显存拷备路径,牺牲一点速度换取稳定。还有一种情况是只有普通以太网而没有专用高速网络,此时可能需要设置NCCL_IB_DISABLE=1关闭远程直接内存访问,否则 NCCL 会在初始化时等待超时。
4.4 多卡场景的显存分配与并发上限
多卡部署时,gpu-memory-utilization 对每张卡分别生效,建议保持默认 0.9,如果某张卡的显存被其他进程占用,可以单独调低。vLLM 会在启动时根据模型权重、已配置的 max-model-len 和 gpu-memory-utilization 自动计算 KV cache 大小,日志里能看到分配结果。多卡下的并发控制同样靠 max-num-seqs,但这个参数是全局的,作用于整个并行组,而不是单卡叠加。还要注意多卡的总 KV cache 是逻辑上共享的,不要简单用“单卡显存 × 卡数”去算可用 KV cache,实际值要看 vLLM 日志打印。如果发现一张卡显存使用特别高而另一张很低,多半是通信或并行配置出现了问题。
5. 爬虫请求:批量调用多模态接口的适配层实现
5.1 请求体结构:从文本到多模态
vLLM 暴露的接口兼容常见的聊天补全协议,核心是 POST /v1/chat/completions。纯文本请求的 content 可以是一个数组,也可以是一个普通字符串,但多模态请求必须使用数组形式。一个图片请求体包含两个关键部分:model 字段必须与启动时设置的 served-model-name 一致;content 数组里的每个对象要么是 image_url 类型,要么是 text 类型。image_url 支持传入公网 URL 或 base64 Data URI。公网 URL 的好处是请求体体积小,缺点是 vLLM 进程需要能访问外网,而且图片网站可能有限流,导致拉图超时。base64 方式最稳定,缺点是图片越大,请求体越大,服务端解析也会更慢。
如果要传多张图,就在 content 数组里多放几个 image_url,模型会按顺序理解。视频可以拆帧成多个 image_url 传入。调 max_tokens 时要注意,多模态输入的视觉 token 已经占用了上下文长度,给生成的文本留下合理空间即可。
5.2 批量抓取与并发请求的工程化
所谓“爬虫请求”,本质就是写一个 HTTP 客户端去批量调用模型接口。最简单的场景是预先准备一批图片 URL,逐个请求模型。如果图片列表来自网页,可以先写一个爬虫提取图片链接,再用同样的会话去请求模型。批量调用不能用一个 for 循环串行跑,那样吞吐太低,应该用线程池并发。示例思路:
from concurrent.futures import ThreadPoolExecutor, as_completed def call_one_image(model_name, image_path, prompt): # 读取图片转 base64 # 构造 payload # 请求 vLLM 接口 # 返回内容或异常 pass with ThreadPoolExecutor(max_workers=8) as pool: futures = [pool.submit(call_one_image, "qwen2vl", path, prompt) for path in image_paths] for future in as_completed(futures): result = future.result() # 收集结果,记录失败项并发数不要盲目拉高。vLLM 本身有 batch 能力,并发请求越多,单 batch 越大,吞吐会上升,但单请求延迟也会增加。如果业务要求的是稳定单请求延迟,并发数要保守一点;如果追求整体吞吐,可以逐步提高并发,观察显存占用和响应时间的变化。一个稳妥做法是客户端做限速,比如固定每秒只发起 N 个请求,避免把模型服务打垮。
5.3 流式输出的解析与半结构化结果
对话场景中,用户往往希望模型边生成边输出。请求体里加一个"stream": true,服务端会返回 SSE 格式的流式数据。使用 requests 请求时,设置stream=True,然后逐行解析:
import json resp = requests.post("http://localhost:8000/v1/chat/completions", json=payload, stream=True, timeout=120) for line in resp.iter_lines(): if not line: continue text = line.decode("utf-8") if not text.startswith("data: "): continue data = text[6:] if data == "[DONE]": break try: obj = json.loads(data) delta = obj["choices"][0]["delta"].get("content") if delta: print(delta, end="", flush=True) except json.JSONDecodeError: continue流式响应每次返回的是一个片段,通过 delta.content 累积起来才是完整文本。边界是固定的data: [DONE],收到它就表示生成结束。流式接口的超时设置要比普通接口更宽松,因为第一个 token 可能需要较长的思考时间,客户端如果按普通接口的 30 秒超时,极容易误判。
5.4 一个可直接套用的批量调用脚本
综合上面几点,可以整理成一个可复用的适配层。这个脚本适合批量处理一批本地图片或图片 URL,输出每个图片对应的模型回复,同时带超时和重试逻辑。核心注意点有三个:图片读取和 base64 编码尽量在子线程中做好,避免阻塞请求线程;失败请求要记录原因,不要直接丢弃;服务端一旦返回非 200 状态码,要打印响应体,因为 vLLM 的错误信息基本都在 body 里。另外建议把结果写成 JSON 文件,方便后续核对,而不是只打印到终端。
6. 常见问题与排查技巧实录
6.1 显存 OOM
这是遇到最多的问题。模型加载阶段就 OOM,说明单卡显存放不下模型,要么换小规格模型,要么开启量化,要么增加卡数。运行时 OOM 则多半是并发太高或上下文太长,处理方式是把 max-model-len 调低、把 gpu-memory-utilization 从 0.9 降到 0.85,同时调大 max-num-seqs 的下限而不是盲目并发。如果是量化方案,AWQ/GPTQ 这类格式可以把 7B 模型权重压到 8GB 左右,但多模态部分对量化的支持因版本而异,视觉编码器在极端量化下可能有轻微精度损失,需要实测。
6.2 接口返回 400/404 的常见原因
400 错误绝大多数是请求体格式问题,尤其是 content 传成了字符串而不是数组,或者 image_url 缺少 url 字段。404 错误通常是 model 字段和 served-model-name 不一致,启动时设置的别名是 qwen2vl,请求里写的却是别的名字,自然找不到模型。422 错误属于字段校验失败,比如 temperature 超出范围、messages 里缺少 role。排查这类问题最快的方式是打印 vLLM 的响应 body,错误信息会写得非常明确。
6.3 多卡通信失败与 NCCL 配置
多卡部署时如果日志卡在 NCCL 初始化,或者报出无法连接 peer 的信息,先确认各卡是否都空闲,再检查驱动和 NCCL 版本。部分环境不支持 GPU 间的直接内存访问,设置NCCL_P2P_DISABLE=1可以绕过去。如果是多机部署或者网络环境普通,按要求设置好NCCL_IB_DISABLE也有帮助。多卡通信问题最常见的根源是网络权限,而不是模型本身。
6.4 上下文长度与视觉 token 估算
图片进入模型后会先被切分成多个 patch,再转换成视觉 token。Qwen2-VL 采用动态分辨率策略,同一张图在不同尺寸下产生的 token 数不同。一张 1000 像素级别的普通图片,通常会产生数百到上千个视觉 token,具体取决于图像内容和分辨率。这导致一个常见的误判:文本很短,请求看起来很小,但图片 token 已经占了很大上下文。如果 max-model-len 设置得不够,服务端会直接报错。排查时可以看 vLLM 日志中每个请求的 prompt_tokens 字段,它会明确告诉你这个请求消耗了多少上下文长度。
6.5 性能排查与日志分析
vLLM 提供了 metrics 接口,返回 Prometheus 格式的指标,可以看到吞吐、延迟、当前排队请求数。GPU 利用率不高时,先看是不是卡在 CPU 的数据预处理环节,比如图片解码、base64 转码、token 化在大规模并发下会占用不少 CPU。如果 GPU 利用率高但延迟依然很长,多半是 max-model-len 设置过大导致 KV cache 碎片化,或者并发数过高导致 batch 内序列过多。日志方面,启动时重点看模型加载阶段有没有 warning,运行中重点看错误栈和 OOM 信息。
最后说几个实际操作中积累下来的体会。第一,先跑通单卡,再考虑多卡。很多问题其实出在请求体格式上,多卡并行只会让排查更困难。第二,max-model-len 不要贪大,能覆盖业务最长输入就够了,剩下的显存留给 KV cache 去换吞吐。第三,客户端必须做超时和重试,vLLM 处理长 prompt 或大图时,首 token 延迟会比较长,客户端默认 30 秒超时很容易误杀请求。第四,多模态请求的 content 一定用数组,这是我在实际对接中见过最多的一种低级错误。暂时就写到这里,这套链路目前运行稳定,后续如果再压一下视频输入场景,有结论了再回来补充。