最近在一个项目里要做批量图片内容理解,试过直接加载权重推理,也试过封装成服务,最后还是选定 Qwen2-VL 配合 vLLM 这套方案。Qwen2-VL 本身是很能打的多模态模型,vLLM 又解决了服务化场景最头疼的显存管理和并发吞吐问题。整轮折腾下来,单卡部署、多卡张量并行、对外 HTTP 接口、爬虫批量调用,基本都趟了一遍。把部署过程、关键启动参数、接口对接细节,还有踩过的坑统一记下来,给后面要上这套组合的同学做个参考。
1. 部署前的方案选型与整体架构
1.1 为什么是 vLLM 加 Qwen2-VL
做图片识别类功能,模型候选不少,但 Qwen2-VL 有几个点很合适:它同时吃图片和文本输入,输出文本,既能做图片内容描述,也能做结构化信息抽取,比如从截图里把关键字段按 JSON 格式整理出来。而且它对中文场景的理解明显比一些通用英文模型稳。7B 这个体量,单卡能跑,多卡也能继续扩展,落地成本相对可控。
vLLM 被选中的原因更直接。先不说推理速度,单是PagedAttention这套显存管理方式,就把 KV Cache 碎片的浪费省下来了。长时间运行的服务,显存就是生命线。vLLM 还自带 continuous batching,多个请求到达后会动态拼 batch,而不是等满一个 batch 才处理。这意味着爬虫并发打过来几十个请求时,服务端的吞吐表现比朴素的做法好很多。另一个非常实际的原因,是 vLLM 直接提供 OpenAI 兼容的 HTTP 接口,客户端只需要拼一个标准 messages 结构,不用自己写协议层,接入成本低。
如果只是自己离线跑几张图,那直接用 transformers 加载模型也够用。但一旦要做服务化、要接爬虫、要支持并发,自己处理线程安全、队列、显存回收、请求排队这些问题会非常痛苦。vLLM 把这些都内置了,这是选择它的核心理由。
1.2 整体部署架构与请求链路
整套系统的链路并不复杂:
- 爬虫侧负责采集页面、解析图片链接、下载图片并做初步过滤,转成 base64 字符串。
- 调用侧通过 HTTP POST 请求,把文本指令和图片数据以 OpenAI 多模态消息格式发给 vLLM 服务。
- vLLM 服务内部完成 tokenize、图像编码、自回归生成,最后以 JSON 返回结果。
- 调用侧再解析返回值,做结果清洗、落库或二次处理。
把推理单独拆成服务有几个好处:爬虫逻辑和模型推理解耦,模型升级不用改爬虫;多个爬虫任务可以共用同一个推理服务;vLLM 内部的请求队列和批次调度,比自己在爬虫里开线程调模型要稳得多。
部署时主要分三条线来做:模型运行环境准备、推理服务参数调优、客户端请求与容错。后面逐个展开。
2. 环境准备与模型获取
2.1 硬件与软件环境要求
先说硬件。Qwen2-VL 有 7B、72B 等不同规模,实际项目里 7B 在多数业务场景下已经够用。7B 模型用 bf16 加载,权重约占 16GB 左右,加上推理时的中间激活和 KV Cache,一张 24GB 的显卡是起步配置。我用的是两张 24GB 卡,既覆盖单卡部署,也能验证多卡场景。如果显存紧张,可以开 AWQ 或 GPTQ 量化版模型,权重大概能降到 6GB 附近,但量化版对多模态任务的精度影响需要自己实测。
软件环境方面,比较稳妥的组合是:
- Python 3.10 及以上
- CUDA 11.8 或 12.1 以上
- PyTorch 2.x,且要和 vLLM 版本匹配
- vLLM 的新版本对 Qwen2-VL 支持已经比较完善,直接用较新的 0.6 以上版本
提示:vLLM 和 PyTorch 的版本绑定关系比较严格。安装时最好先看看 vLLM 当前版本要求哪个 PyTorch 版本,不要先装一个最新的 torch 再回头装 vLLM,很容易出现
torch._C相关符号找不到的问题。
2.2 安装 vLLM 及依赖
安装 vLLM 我用的是 pip 方式。如果网络条件允许,直接跑:
pip install vllm装完检查版本和 GPU 可用性:
python -c "import vllm; print(vllm.__version__)" python -c "import torch; print(torch.cuda.is_available()); print(torch.cuda.device_count())"如果是在国内部署,pip 建议加镜像源,否则几个 GB 的依赖包下载时间会很长。另外,vLLM 安装时会自动拉起对应版本的flashinfer或flash-attention,这些编译组件偶尔会因为网络问题失败。真出现这种情况,可以单独先装对应版本再装 vLLM,但版本必须对准。
这里有一点值得说:Qwen2-VL 的多模态处理部分依赖额外的图像编码器。vLLM 在模型加载阶段会自动识别并加载这部分权重,不需要手动拆分。但前提是模型目录结构完整,不要只拷贝权重文件而漏掉配置文件。
2.3 模型下载与目录组织
模型权重一般从开源模型仓库拉取。以 7B Instruct 版本为例,完整目录里要包含:
config.jsontokenizer.json、tokenizer_config.json- 多个
*.safetensors权重分片 - 图像处理相关的
preprocessor_config.json
下载方式根据你的实际网络情况来。最省事的是用官方命令行工具直接拉整个 repo,指定--local-dir把模型存到本地目录。模型比较大,建议先落盘到本地磁盘再做软链部署,省得每次启动都走一遍网络加载。
目录组织上,我习惯把所有模型统一放在/data/models/下面,每个模型一个子目录,部署命令里直接引用绝对路径,避免和默认缓存目录混在一起。这样做的好处是,后续如果同时部署多个模型,路径清晰,也不会因为磁盘缓存占满出问题。
3. 单卡部署实操与验证
3.1 启动参数逐个拆解
单卡部署是基础。先给出一份我实测可用的启动命令:
vllm serve /data/models/Qwen2-VL-7B-Instruct \ --served-model-name qwen2vl \ --max-model-len 8192 \ --tensor-parallel-size 1 \ --gpu-memory-utilization 0.9 \ --trust-remote-code \ --limit-mm-per-prompt image=4 \ --host 0.0.0.0 \ --port 8000 \ --dtype bfloat16逐个参数说下为什么这样设。
--max-model-len控制模型最大上下文长度。Qwen2-VL 支持很长的上下文,但实际业务里图片 token 消耗非常快。一张普通分辨率图片经过视觉编码后可能产生 1000 到 4000 个 token,如果 max-model-len 开太大,比如 32768,显存会被 KV Cache 瞬间吃完。先定 8192 是稳妥起步,后面再按压力测试结果调整。
--gpu-memory-utilization设置显存利用率上限为 0.9。这个参数不能乱调,设太低影响 KV Cache 容量,设太高容易在并发高峰时触发 OOM。0.9 是一个相对安全的阈值,给 PyTorch 和 CUDA context 留了一点余量。
--trust-remote-code在多模态模型上建议加上。Qwen2-VL 的 modeling 文件里有一些自定义类,如果 config 里指定了 auto_map,缺少这个参数会直接报错。
--limit-mm-per-prompt image=4是我强烈建议加上的。它限制了单条请求中最多携带的图片数量。不限制的话,某人传 20 张图片进来,视觉 token 很容易把整批请求拖垮。限制之后,服务端对异常请求也有了兜底。
--served-model-name qwen2vl是为了客户端方便。调用接口时传这个别名即可,不必须和模型目录同名。
启动后,日志会逐步打印加载 process:
- 加载 config
- 初始化 tokenizer
- 加载权重分片
- 注册多模态处理器
- 启动 HTTP server
看到Application startup complete和Uvicorn running on http://0.0.0.0:8000就说明服务起来了。
3.2 单卡实测与接口验证
服务起来后,先做健康检查:
curl http://localhost:8000/health返回OK后,再确认模型列表:
curl http://localhost:8000/v1/models然后跑一个最简单的多模态请求,验证图片理解链路是否通。这里直接给 Python 代码,因为后面爬虫也要用同一套请求格式。
import base64 import requests def encode_image(image_path): with open(image_path, "rb") as f: return base64.b64encode(f.read()).decode("utf-8") base64_img = encode_image("test.png") payload = { "model": "qwen2vl", "messages": [ { "role": "user", "content": [ {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{base64_img}"}}, {"type": "text", "text": "请描述这张图片的内容,并提取图中所有的文字。"} ] } ], "temperature": 0.2, "max_tokens": 512 } resp = requests.post("http://localhost:8000/v1/chat/completions", json=payload) result = resp.json() print(result["choices"][0]["message"]["content"])单卡跑 7B 模型,单次请求的生成速度体感很快。图片预处理占了一部分时间,生成阶段如果不带流式,512 个 token 大概几秒内能返回。如果追求更低延迟,可以把max_tokens降低,或者开启流式输出,但流式对爬虫场景意义不大,所以不重点展开。
注意:
image_url里的 URL 字段不只是可以放 base64 data URI,也能放公网图片 URL。vLLM 会尝试直接下载图片。但爬虫场景里我建议还是自己下载后转 base64,原因很实际:如果你爬到的图片链接本身需要登录、有防盗链、或者访问超时,让 vLLM 去下载会把失败直接算到请求头上,排查起来更麻烦。自己下载可以做更多控制和重试。
4. 多卡部署与扩展要点
4.1 张量并行的原理与参数设置
单卡部署验证通过后,下一个问题是:显存不够用怎么办,或者并发多了单卡算力跟不上怎么办。
vLLM 的多卡部署走的是张量并行(tensor parallelism),和 DataParallel 的思路完全不一样。DataParallel 是每张卡放一份完整模型,各算各的,只是数据分片;张量并行是把一个模型层里的参数按行或列切到多张卡上,前向计算时通过卡间通信把部分结果合并起来,相当于一张逻辑大卡承载一个模型。
对 Qwen2-VL 这种几十 GB 级别的模型,如果单卡装不下,张量并行是主要解法。设置方式很简单,启动命令里加一个参数:
--tensor-parallel-size 2vLLM 会自动检测当前节点的 GPU 数量,把模型按层切分到 2 张卡上。假设你有 4 张 24GB 卡,跑 7B 模型开--tensor-parallel-size 2和--tensor-parallel-size 4都能跑,但是效果不一样。
需要说清楚一个偏差:张量并行不是无脑开越多越好。它把单卡显存压力降下来了,但层之间的 all-reduce 通信也有成本。单卡能装下的模型,开多卡不一定更快,因为通信开销可能抵消计算收益。多卡的价值主要体现在两个方向:一是模型大到单卡装不下;二是并发请求量很高时,张量并行配合连续批处理能利用多卡算力提升整体吞吐。
4.2 多卡部署实操命令
两张 24GB 卡部署命令:
vllm serve /data/models/Qwen2-VL-7B-Instruct \ --served-model-name qwen2vl \ --max-model-len 16384 \ --tensor-parallel-size 2 \ --gpu-memory-utilization 0.92 \ --trust-remote-code \ --limit-mm-per-prompt image=4 \ --host 0.0.0.0 \ --port 8000 \ --dtype bfloat16这里我把max-model-len提到了 16384,因为显存容量翻倍后 KV Cache 有了更多余量,可以容纳更长的上下文和更多图片 token。
启动过程中可以另开一个终端用nvidia-smi -l 2观察显存变化。模型加载完成后,两块卡上的显存占用应该是接近均匀的,每块卡都有一层模型的参数和对应计算中间结果。如果出现一张卡占 90%、另一张卡只占 30%,优先检查tensor-parallel-size是否传对,以及多卡通信是否正常。
提示:多卡部署前最好先用
torch.cuda.is_available()和device_count()确认一下机器上的卡是不是都可用。有些环境下会存在显存被其他进程占用、卡间 P2P 通信被禁用、或者驱动版本不一致等问题,这些在单卡模式下不会被暴露,等到开多卡时才会突然报错。
4.3 多卡性能验证与显存观察
多卡部署完成后,我做了两组压力对比。
第一组是单请求延迟。同样的图片和文本,单卡和双卡在 batch 数为 1 时,生成延迟差别很小,双卡甚至可能略高一点,原因就是通信开销。这完全符合预期,所以如果你的业务只是低频少量调用,双卡意义不大。
第二组是并发吞吐。我用脚本同时打 20 个请求,单卡时服务端虽然能排进去,但整批完成时间明显拉长,因为 continuous batching 在一个 batch 内同时处理的请求有限,显存里的 KV Cache 空间也限制了并行度。双卡模式下,显存空间翻倍,KV Cache 容量更大,20 个请求基本能在一个大 batch 内消化,总完成时间大概能压缩到单卡的一半左右。这个测试结果说明,多卡的真实收益在并发场景里才能体现出来。
做压测时要注意vllm默认的并发处理逻辑:它会先把请求排进队列,而不是立刻拒绝,所以客户端看到的往往是延迟上升,而不是连接错误。如果你压测时遇到很多超时,优先检查--max-num-seqs这个参数,它限制了同时处理的序列数量。并发过高时可以适当调大,但对显存要求也会提高。
5. 爬虫请求接入与并发调用
5.1 图片采集侧的预处理逻辑
部署好推理服务后,重头戏就落在爬虫侧了。这里说的爬虫请求,不是简单的 HTTP 调用,而是包含“批量采集网页图片 → 过滤筛选 → 转码 → 请求模型 → 解析结果”的一条完整链路。
爬虫第一步是拿页面 HTML。用 requests 或 httpx 抓取页面后,常见做法是用解析库取出所有img标签的src属性。
不过这里有个关键问题:网页里的图片链接五花八门,有相对路径、有懒加载的>def batch_analyze(images, instruction, api_url="http://localhost:8000/v1/chat/completions"): content = [] for img_b64 in images: content.append({ "type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"} }) content.append({"type": "text", "text": instruction}) payload = { "model": "qwen2vl", "messages": [{"role": "user", "content": content}], "temperature": 0.1, "max_tokens": 1024 } resp = requests.post(api_url, json=payload, timeout=120) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"]
实际测试里,用单张图做 OCR 或商品属性识别,效果比较理想。比如我从电商页面抓下商品主图,让模型提取品牌、名称、价格标签文字、包装上的文案,只要图片清晰,结构化输出基本可用。多张图同时传入的场景也可以,但要注意,图片越多视觉 token 越多,响应时间越长,且不要超过启动时--limit-mm-per-prompt的限制。
5.3 并发请求与容错处理
爬虫采集是天然的高并发场景,一张页面可能带几十张图片,一个任务下来会有几百到上千次推理请求。如果串行调用模型,时间完全不可接受。我这边用线程池做并发,核心逻辑是:
- 维护一个图片 URL 列表。
- 下载阶段用线程池并发下载,注意控制连接数,避免被目标网站封掉。
- 下载完成后,把图片分组成批提交给 vLLM。
- 对每个请求设置超时,并对超时做指数退避重试。
代码骨架大致如下:
from concurrent.futures import ThreadPoolExecutor, as_completed def process_one(item): try: text = batch_analyze(item["images"], item["instruction"]) return {"url": item["url"], "result": text} except Exception as exc: return {"url": item["url"], "error": str(exc)} with ThreadPoolExecutor(max_workers=8) as pool: futures = [pool.submit(process_one, item) for item in tasks] for fut in as_completed(futures): data = fut.result() # 落库或写入JSON有几个点要特别提醒:
- 线程数不要盲目开高。vLLM 服务端虽然有排队机制,但并发太高时,每个请求排队时间会线性增长,整体吞吐反而下降。8 到 16 路并发是实测比较稳的范围。
- 请求超时设置要看图片数量和
max_tokens。图片越多,单次请求越慢,超时时间要放宽。图片已全部预处理好的前提不会有下载超时,只需要考虑模型生成时间。 - 重试逻辑要有上限。vLLM 偶尔会因为持续高负载或显存碎片问题返回 500,这时直接对同一请求重试 3 次,每次间隔递增,基本能缓解。
生产级做法是加消息队列。爬虫把待分析图片丢进队列,消费端从队列取任务调用 vLLM,失败的任务重新入队。这套结构比直接在爬虫里开线程池更稳,但项目初期不一定要搞这么重。
6. 常见问题与排查实录
6.1 显存与模型长度问题
部署阶段最常见的就是 CUDA OOM。
单卡 24GB 环境下,如果按默认参数直接启动,然后一股脑塞长文本加多图进请求,很容易把 KV Cache 打爆。我遇到的一次典型场景是:并发 4 个请求,每张图都带 4 张图片,max-model-len 设为 4096,结果服务日志里直接出现CUDA out of memory的错误,随后整个服务响应变慢,部分请求报 500。
排查思路是这样的:先看错误是不是来自显存分配。如果是,就把--max-model-len往下调,或者把--limit-mm-per-prompt收紧。另一个调法是降低--gpu-memory-utilization,但这会减少可用 KV Cache 空间,影响并发能力。一般优先压上下文长度和图片数量,不要轻易动显存利用率。
另外,显存碎片会随着长时间运行和大量变长请求累积。我在一次连续跑了十几个小时后,发现单个请求的耗时明显变长,重启服务后恢复。这类问题不是代码 bug,而是长驻进程的常见现象,定期重启或做服务热迁移是常见的运维策略。
6.2 多模态请求格式问题
爬虫接入阶段最容易出错的不是爬虫代码,而是请求格式。
一个常见错误是把content直接写成纯字符串,服务端返回 400 或者提示content格式不对。多模态模型要求content是数组,这样才能区分文本和图片。
第二个常见错误是 base64 前缀写错。data:image/jpeg;base64,里的 MIME 类型和图片实际格式不一致,会导致解析失败。写代码时最好从图片二进制里拿到实际类型,或者统一转成 JPEG 再组装前缀。
第三个问题是图片过大导致请求体超过框架限制。HTTP server 端和 vLLM 侧对请求体大小都有默认限制,图片太多或 base64 太长时,请求会在 HTTP 层被拒。解决方法是控制单次请求图片数量,以及先把图片压缩到合理尺寸。
还有个隐蔽的问题,模型返回内容可能包含 Markdown 代码块标记,比如把 JSON 结果用 ```json 包起来。如果你直接把返回结果做json.loads,大概率报错。我后来统一加了一步清洗,去掉代码块标记再解析。这属于模型输出风格问题,不是 bug,但很影响使用体验。
6.3 多卡部署中的坑
多卡部署我遇到最多的问题是tensor-parallel-size设置后启动报错,提示无法在指定设备上初始化通信。
这类问题通常不是参数写错,而是卡间通信环境问题。单卡模式下不会初始化进程组,多卡模式一启动就需要卡间通信,如果系统里存在显存已经被占用的卡,或者 P2P 通信被限制,就会启动失败。排查时可以先用nvidia-smi topo -m看卡的拓扑,确认是不是 NVLink 连接,如果不是,也没关系,PCIe 环境下张量并行也能跑,只是通信慢一些。
另一个让我印象很深的问题是:双卡部署时如果两张卡型号不同,比如一张 24GB、一张 32GB,vLLM 启动时不会主动拒绝,但推理过程会出现显存分配不均、任务排队到较小卡上 OOM 的情况。建议多卡部署务必使用同型号同显存大小的卡,省掉一堆奇怪的坑。
此外,双卡部署后如果模型加载慢,别急着怪网络。两张卡同时加载权重时,磁盘 IO 会成为瓶颈,尤其是机械硬盘环境。把模型放到 SSD 上之后,加载时间能缩短很多。
最后一个容易被忽略的:vLLM 启动多卡时,会把主进程绑定到特定 GPU 上,如果你用容器部署,需要在容器启动参数里开放对应 GPU 设备,否则服务起来后 GPU 数量识别不到,tensor-parallel-size会报错。
收尾
跑完这套部署,我最深的感受是:单卡才是调试的主战场,参数都调顺了,多卡就是加一个参数的事。真正耗时间的反而是爬虫侧那堆图片预处理和容错逻辑。Qwen2-VL 本身对多模态输入的处理已经比较成熟,vLLM 把服务化的复杂细节也封装得很干净,整条链路的核心难点其实在请求格式的一致性和并发控制上。
最后分享一个小技巧:我在爬虫侧维护了一个图片指纹缓存,同一张图(按内容哈希判断)只分析一次,后面直接复用结果。图片站点里的重复资源非常多,这个缓存能省掉一大半推理请求,等于变相把服务吞吐翻倍。项目跑起来之后别忘了监控服务的排队长度和显存水位,这两个指标能比请求延迟更早暴露问题。