戰(zhàn):從單卡到多卡的多模態(tài)服務(wù)化方案)
最近在業(yè)務(wù)里遇到一個很典型的需求讓系統(tǒng)能夠“看懂圖片”。輸入是產(chǎn)品截圖、工單圖片、紙質(zhì)單據(jù)照片模型不僅要理解圖里的內(nèi)容還要給出描述或者回答。純文本大模型肯定做不到最終我們選了 Qwen2-VL 配合 vLLM 來做服務(wù)化部署。這套方案把單卡和多卡場景都跑通了后面還寫了一套爬蟲腳本去批量調(diào)用模型接口算是把部署、壓測、穩(wěn)定性驗(yàn)證的完整流程走了一遍。這篇文章會把整個過程拆開來講包括環(huán)境選型、啟動參數(shù)、多卡并行原理、多模態(tài)請求體構(gòu)造以及各種能預(yù)見的坑。適合剛接觸視覺語言模型部署、想在本地或內(nèi)網(wǎng)環(huán)境快速拉起服務(wù)并接業(yè)務(wù)的人。1. 部署前的整體規(guī)劃為什么是 Qwen2-VL 配合 vLLM1.1 多模態(tài)模型落地的現(xiàn)實(shí)約束實(shí)際業(yè)務(wù)里的圖片大多屬于內(nèi)部數(shù)據(jù)比如工單截圖、產(chǎn)品頁、掃描件不可能全部發(fā)到外部接口去處理私有化部署幾乎是唯一選擇。多模態(tài)開源模型里Qwen2-VL 的視覺理解能力比較均衡能處理單圖、多圖和視頻輸入在中文場景下的 OCR、版面理解也表現(xiàn)不錯。它有幾個不同的規(guī)格2B 可以放在小顯存環(huán)境里驗(yàn)證鏈路7B 適合作為日常服務(wù)72B 交給算力更充足的機(jī)器。而且 vLLM 原生支持這類模型架構(gòu)直接把權(quán)重路徑傳給 vLLM 就能運(yùn)行不需要額外改模型代碼。真正決定部署方案的往往是顯存所以硬件約束必須先說清楚。1.2 vLLM 為什么更適合服務(wù)化部署如果直接用推理框架逐條處理請求每來一個用戶都要重新加載權(quán)重、獨(dú)占顯存做前向計算GPU 利用率非常低。vLLM 做對了三件事第一把 KV cache 分成固定大小的塊按需分配解決了顯存碎片化的問題第二實(shí)現(xiàn)了連續(xù)批處理當(dāng)一個請求在生成 token 的間隙其他請求可以插進(jìn)來一起計算吞吐量提升非常明顯第三自帶一個兼容常用大模型 API 格式的 HTTP 服務(wù)層業(yè)務(wù)側(cè)直接用 HTTP JSON 就能調(diào)用不用自己拼底層協(xié)議。這三點(diǎn)的組合讓 vLLM 成為目前私有化部署推理服務(wù)的主流方案。尤其是長上下文和并發(fā)請求同時存在時連續(xù)批處理帶來的收益非??捎^。1.3 整體鏈路與資源估算整條鏈路是模型權(quán)重文件 → vLLM 進(jìn)程加載模型對外暴露 HTTP 端口→ 業(yè)務(wù)腳本批量請求。資源評估方面一個粗略但好用的經(jīng)驗(yàn)是半精度下每 10 億參數(shù)大約占 2GB 顯存。7B 模型權(quán)重約 14GB一張 24GB 的卡能放下但留給 KV cache 的余量取決于上下文長度72B 模型權(quán)重約 144GB單卡無論如何都放不下必須上多卡。除了權(quán)重還要考慮 KV cache、圖片變換出來的視覺 token、激活值緩存。部署前先按這個量級估算一下能少踩很多彎路。2. 環(huán)境準(zhǔn)備與模型權(quán)重獲取先把“原料”備好2.1 硬件、驅(qū)動與基礎(chǔ)軟件版本基線顯存是最核心的約束其次是顯存帶寬。想要流暢跑 7B 級別模型建議至少一張 24GB 顯存的卡跑 72B 則要準(zhǔn)備 4 張 80GB 以上的卡。系統(tǒng)層面顯卡驅(qū)動需要支持當(dāng)前使用的 CUDA 版本一般建議 CUDA 12.1 以上。Python 版本建議 3.10 或 3.11太老的版本容易在安裝 vLLM 時遇到編譯問題。建議先把 PyTorch 裝好確認(rèn)torch.cuda.is_available()返回正常再安裝 vLLM。這個順序很重要先裝 vLLM 再裝 PyTorch很容易碰到版本不匹配然后報一堆算子相關(guān)的錯誤。vLLM 更新很快不同版本的參數(shù)和算子支持有差異建議用相對新的穩(wěn)定版本不要盲目追最新。2.2 權(quán)重獲取與目錄結(jié)構(gòu)確認(rèn)Qwen2-VL 的權(quán)重可以從開源模型托管站點(diǎn)下載選擇本地網(wǎng)絡(luò)訪問順暢的渠道就好。下載完成之后權(quán)重目錄里必須包含 config.json、tokenizer.json、tokenizer_config.json以及一個或多個 safetensors 文件。啟動前一定要檢查目錄完整性重點(diǎn)看 config.json 里的 model_type 和 vision_config 是否正常。如果是從某些渠道拉下來的文件可能在傳輸中出現(xiàn)截斷啟動時才報錯到那時再排查就比較被動了。穩(wěn)妥做法是下載后先做一次哈希校驗(yàn)或者直接用模型倉庫自帶的校驗(yàn)記錄比對。vLLM 加載模型時會先讀取 config.json如果格式不對或者缺少視覺相關(guān)配置進(jìn)程會在加載階段直接退出。2.3 啟動前的三項(xiàng)快速檢查第一確認(rèn) GPU 能被系統(tǒng)識別執(zhí)行nvidia-smi能看到卡的數(shù)量和顯存大小。第二確認(rèn) PyTorch 能看到 GPU在 Python 里執(zhí)行python -c import torch; print(torch.cuda.is_available(), torch.cuda.device_count())如果輸出 False 或者設(shè)備數(shù)為 0說明驅(qū)動或 CUDA 環(huán)境有問題。第三確認(rèn)端口沒有被占用vLLM 默認(rèn)監(jiān)聽 8000 端口如果機(jī)器上已經(jīng)有服務(wù)占用要么關(guān)掉舊進(jìn)程要么啟動時改端口。這三項(xiàng)檢查一分鐘就能完成但能避免后面啟動失敗時在錯誤方向上折騰。3. 單卡部署實(shí)操從啟動命令到第一個接口響應(yīng)3.1 新版命令與最小啟動參數(shù)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這里幾個關(guān)鍵參數(shù)要說明一下。--served-model-name是給接口層使用的模型別名調(diào)用 API 時 model 字段必須寫這個值不設(shè)置的話默認(rèn)用真實(shí)模型名實(shí)際使用中很容易因?yàn)槊植灰恢聦?dǎo)致接口 404。--max-model-len控制模型能接受的最大上下文長度包括文本和視覺 token這個值直接影響 KV cache 預(yù)留大小通常設(shè)為 8192 或 16384 夠用如果業(yè)務(wù)里沒有超長文檔沒必要拉太高。--gpu-memory-utilization表示 vLLM 最多可以占用多少比例的顯存默認(rèn) 0.9一般建議 0.85 到 0.9太低會浪費(fèi)顯存太高則可能把顯存撐爆。--dtype auto讓系統(tǒng)根據(jù)硬件自動選擇半精度格式。3.2 驗(yàn)證服務(wù)模型列表與首輪多模態(tài)請求啟動之后日志里會看到 vLLM 打印模型加載和端口監(jiān)聽信息。先在另一個終端驗(yàn)證服務(wù)是否正常curl http://localhost:8000/v1/models返回結(jié)果里能看到模型列表。接著構(gòu)造第一個多模態(tài)請求。圖片有兩種傳法傳公網(wǎng)可訪問的圖片 URL或者傳 base64 編碼的 Data URI。本地文件建議用 base64避免服務(wù)端還需要出網(wǎng)拉圖。示例代碼如下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: fdata:image/jpeg;base64,{image}}}, {type: text, text: 描述這張圖片的內(nèi)容} ] } ] } resp requests.post(http://localhost:8000/v1/chat/completions, jsonpayload, timeout120) result resp.json() print(result[choices][0][message][content])多模態(tài)請求的 content 必須是數(shù)組不能是一個普通字符串。數(shù)組里每個元素包含 type 字段可以是 image_url也可以是 text。很多第一次接觸的人把 content 直接寫成字符串vLLM 會返回 400 錯誤。3.3 單卡場景下的參數(shù)調(diào)節(jié)經(jīng)驗(yàn)單卡部署并不只是一個啟動命令的事情參數(shù)之間是相互制約的。顯存總量固定時max-model-len 越大KV cache 占用越多留給并發(fā) batch 的空間就越少。如果一張卡既想處理長文檔又想同時服務(wù)多個請求很可能出現(xiàn)啟動成功后一旦并發(fā)上來就 OOM。實(shí)際項(xiàng)目里建議先根據(jù)業(yè)務(wù)最長輸入設(shè)置 max-model-len再觀察顯存占用最后調(diào)整 gpu-memory-utilization。另外--max-num-seqs參數(shù)可以控制單個 batch 內(nèi)最多并行處理的序列數(shù)默認(rèn)值在某些版本下可能偏大。并發(fā)不高時反而應(yīng)該調(diào)小一點(diǎn)避免多個請求擠在同一 batch 里拖慢單個請求的響應(yīng)速度。4. 多卡部署張量并行原理、啟動參數(shù)與顯存分配4.1 什么時候必須從單卡切到多卡兩種情況需要多卡一是模型權(quán)重本身超出單卡顯存比如 72B 半精度需要 144GB 以上二是單卡吞吐跑不滿業(yè)務(wù)峰值需要通過并行推理提升并發(fā)能力。如果只是顯存放不下用 CPU offload 或量化也可以緩解但性能損失明顯。多卡部署能讓多個 GPU 同時承擔(dān)推理計算吞吐往往能翻幾倍。但也要清楚多卡不是萬能的卡間通信會成為新的瓶頸尤其當(dāng)模型較小而卡數(shù)較多時通信開銷可能超過并行收益。4.2 張量并行與流水線并行怎么理解vLLM 支持兩種并行方式。張量并行是把模型權(quán)重切分到多張卡上每一層都分布在所有卡里前向計算時卡與卡之間需要同步交換數(shù)據(jù)適合單機(jī)多卡。流水線并行則是按層切分模型的第 1 到第 N 層放在卡 A第 N1 到第 M 層放在卡 B數(shù)據(jù)一層一層往下傳適合跨機(jī)場景但存在流水線氣泡利用率可能不如張量并行。對大多數(shù)單機(jī)多卡場景來說優(yōu)先使用張量并行。實(shí)際啟動時通過--tensor-parallel-size指定使用多少張卡比如 2 表示兩塊卡組成一個并行組。卡數(shù)不是隨意定的要能被模型內(nèi)部結(jié)構(gòu)整除常見選擇是 2、4、8。4.3 多卡啟動命令與通信環(huán)境變量多卡啟動和單卡很類似核心差異是指定并行參數(shù)。以兩張卡為例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啟動完成后日志中能看到張量并行相關(guān)信息。多卡之間通信依賴 NCCL 庫如果通信初始化失敗配置環(huán)境變量往往能解決export NCCL_DEBUGINFO export NCCL_P2P_DISABLE1NCCL_DEBUG 會打印通信細(xì)節(jié)方便定位是哪一步卡住。NCCL_P2P_DISABLE 主要用在某些不支持 GPU 間直接內(nèi)存訪問的環(huán)境強(qiáng)制走顯存拷備路徑犧牲一點(diǎn)速度換取穩(wěn)定。還有一種情況是只有普通以太網(wǎng)而沒有專用高速網(wǎng)絡(luò)此時可能需要設(shè)置NCCL_IB_DISABLE1關(guān)閉遠(yuǎn)程直接內(nèi)存訪問否則 NCCL 會在初始化時等待超時。4.4 多卡場景的顯存分配與并發(fā)上限多卡部署時gpu-memory-utilization 對每張卡分別生效建議保持默認(rèn) 0.9如果某張卡的顯存被其他進(jìn)程占用可以單獨(dú)調(diào)低。vLLM 會在啟動時根據(jù)模型權(quán)重、已配置的 max-model-len 和 gpu-memory-utilization 自動計算 KV cache 大小日志里能看到分配結(jié)果。多卡下的并發(fā)控制同樣靠 max-num-seqs但這個參數(shù)是全局的作用于整個并行組而不是單卡疊加。還要注意多卡的總 KV cache 是邏輯上共享的不要簡單用“單卡顯存 × 卡數(shù)”去算可用 KV cache實(shí)際值要看 vLLM 日志打印。如果發(fā)現(xiàn)一張卡顯存使用特別高而另一張很低多半是通信或并行配置出現(xiàn)了問題。5. 爬蟲請求批量調(diào)用多模態(tài)接口的適配層實(shí)現(xiàn)5.1 請求體結(jié)構(gòu)從文本到多模態(tài)vLLM 暴露的接口兼容常見的聊天補(bǔ)全協(xié)議核心是 POST /v1/chat/completions。純文本請求的 content 可以是一個數(shù)組也可以是一個普通字符串但多模態(tài)請求必須使用數(shù)組形式。一個圖片請求體包含兩個關(guān)鍵部分model 字段必須與啟動時設(shè)置的 served-model-name 一致content 數(shù)組里的每個對象要么是 image_url 類型要么是 text 類型。image_url 支持傳入公網(wǎng) URL 或 base64 Data URI。公網(wǎng) URL 的好處是請求體體積小缺點(diǎn)是 vLLM 進(jìn)程需要能訪問外網(wǎng)而且圖片網(wǎng)站可能有限流導(dǎo)致拉圖超時。base64 方式最穩(wěn)定缺點(diǎn)是圖片越大請求體越大服務(wù)端解析也會更慢。如果要傳多張圖就在 content 數(shù)組里多放幾個 image_url模型會按順序理解。視頻可以拆幀成多個 image_url 傳入。調(diào) max_tokens 時要注意多模態(tài)輸入的視覺 token 已經(jīng)占用了上下文長度給生成的文本留下合理空間即可。5.2 批量抓取與并發(fā)請求的工程化所謂“爬蟲請求”本質(zhì)就是寫一個 HTTP 客戶端去批量調(diào)用模型接口。最簡單的場景是預(yù)先準(zhǔn)備一批圖片 URL逐個請求模型。如果圖片列表來自網(wǎng)頁可以先寫一個爬蟲提取圖片鏈接再用同樣的會話去請求模型。批量調(diào)用不能用一個 for 循環(huán)串行跑那樣吞吐太低應(yīng)該用線程池并發(fā)。示例思路from concurrent.futures import ThreadPoolExecutor, as_completed def call_one_image(model_name, image_path, prompt): # 讀取圖片轉(zhuǎn) base64 # 構(gòu)造 payload # 請求 vLLM 接口 # 返回內(nèi)容或異常 pass with ThreadPoolExecutor(max_workers8) 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() # 收集結(jié)果記錄失敗項(xiàng)并發(fā)數(shù)不要盲目拉高。vLLM 本身有 batch 能力并發(fā)請求越多單 batch 越大吞吐會上升但單請求延遲也會增加。如果業(yè)務(wù)要求的是穩(wěn)定單請求延遲并發(fā)數(shù)要保守一點(diǎn)如果追求整體吞吐可以逐步提高并發(fā)觀察顯存占用和響應(yīng)時間的變化。一個穩(wěn)妥做法是客戶端做限速比如固定每秒只發(fā)起 N 個請求避免把模型服務(wù)打垮。5.3 流式輸出的解析與半結(jié)構(gòu)化結(jié)果對話場景中用戶往往希望模型邊生成邊輸出。請求體里加一個stream: true服務(wù)端會返回 SSE 格式的流式數(shù)據(jù)。使用 requests 請求時設(shè)置streamTrue然后逐行解析import json resp requests.post(http://localhost:8000/v1/chat/completions, jsonpayload, streamTrue, timeout120) 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, flushTrue) except json.JSONDecodeError: continue流式響應(yīng)每次返回的是一個片段通過 delta.content 累積起來才是完整文本。邊界是固定的data: [DONE]收到它就表示生成結(jié)束。流式接口的超時設(shè)置要比普通接口更寬松因?yàn)榈谝粋€ token 可能需要較長的思考時間客戶端如果按普通接口的 30 秒超時極容易誤判。5.4 一個可直接套用的批量調(diào)用腳本綜合上面幾點(diǎn)可以整理成一個可復(fù)用的適配層。這個腳本適合批量處理一批本地圖片或圖片 URL輸出每個圖片對應(yīng)的模型回復(fù)同時帶超時和重試邏輯。核心注意點(diǎn)有三個圖片讀取和 base64 編碼盡量在子線程中做好避免阻塞請求線程失敗請求要記錄原因不要直接丟棄服務(wù)端一旦返回非 200 狀態(tài)碼要打印響應(yīng)體因?yàn)?vLLM 的錯誤信息基本都在 body 里。另外建議把結(jié)果寫成 JSON 文件方便后續(xù)核對而不是只打印到終端。6. 常見問題與排查技巧實(shí)錄6.1 顯存 OOM這是遇到最多的問題。模型加載階段就 OOM說明單卡顯存放不下模型要么換小規(guī)格模型要么開啟量化要么增加卡數(shù)。運(yùn)行時 OOM 則多半是并發(fā)太高或上下文太長處理方式是把 max-model-len 調(diào)低、把 gpu-memory-utilization 從 0.9 降到 0.85同時調(diào)大 max-num-seqs 的下限而不是盲目并發(fā)。如果是量化方案AWQ/GPTQ 這類格式可以把 7B 模型權(quán)重壓到 8GB 左右但多模態(tài)部分對量化的支持因版本而異視覺編碼器在極端量化下可能有輕微精度損失需要實(shí)測。6.2 接口返回 400/404 的常見原因400 錯誤絕大多數(shù)是請求體格式問題尤其是 content 傳成了字符串而不是數(shù)組或者 image_url 缺少 url 字段。404 錯誤通常是 model 字段和 served-model-name 不一致啟動時設(shè)置的別名是 qwen2vl請求里寫的卻是別的名字自然找不到模型。422 錯誤屬于字段校驗(yàn)失敗比如 temperature 超出范圍、messages 里缺少 role。排查這類問題最快的方式是打印 vLLM 的響應(yīng) body錯誤信息會寫得非常明確。6.3 多卡通信失敗與 NCCL 配置多卡部署時如果日志卡在 NCCL 初始化或者報出無法連接 peer 的信息先確認(rèn)各卡是否都空閑再檢查驅(qū)動和 NCCL 版本。部分環(huán)境不支持 GPU 間的直接內(nèi)存訪問設(shè)置NCCL_P2P_DISABLE1可以繞過去。如果是多機(jī)部署或者網(wǎng)絡(luò)環(huán)境普通按要求設(shè)置好NCCL_IB_DISABLE也有幫助。多卡通信問題最常見的根源是網(wǎng)絡(luò)權(quán)限而不是模型本身。6.4 上下文長度與視覺 token 估算圖片進(jìn)入模型后會先被切分成多個 patch再轉(zhuǎn)換成視覺 token。Qwen2-VL 采用動態(tài)分辨率策略同一張圖在不同尺寸下產(chǎn)生的 token 數(shù)不同。一張 1000 像素級別的普通圖片通常會產(chǎn)生數(shù)百到上千個視覺 token具體取決于圖像內(nèi)容和分辨率。這導(dǎo)致一個常見的誤判文本很短請求看起來很小但圖片 token 已經(jīng)占了很大上下文。如果 max-model-len 設(shè)置得不夠服務(wù)端會直接報錯。排查時可以看 vLLM 日志中每個請求的 prompt_tokens 字段它會明確告訴你這個請求消耗了多少上下文長度。6.5 性能排查與日志分析vLLM 提供了 metrics 接口返回 Prometheus 格式的指標(biāo)可以看到吞吐、延遲、當(dāng)前排隊(duì)請求數(shù)。GPU 利用率不高時先看是不是卡在 CPU 的數(shù)據(jù)預(yù)處理環(huán)節(jié)比如圖片解碼、base64 轉(zhuǎn)碼、token 化在大規(guī)模并發(fā)下會占用不少 CPU。如果 GPU 利用率高但延遲依然很長多半是 max-model-len 設(shè)置過大導(dǎo)致 KV cache 碎片化或者并發(fā)數(shù)過高導(dǎo)致 batch 內(nèi)序列過多。日志方面啟動時重點(diǎn)看模型加載階段有沒有 warning運(yùn)行中重點(diǎn)看錯誤棧和 OOM 信息。最后說幾個實(shí)際操作中積累下來的體會。第一先跑通單卡再考慮多卡。很多問題其實(shí)出在請求體格式上多卡并行只會讓排查更困難。第二max-model-len 不要貪大能覆蓋業(yè)務(wù)最長輸入就夠了剩下的顯存留給 KV cache 去換吞吐。第三客戶端必須做超時和重試vLLM 處理長 prompt 或大圖時首 token 延遲會比較長客戶端默認(rèn) 30 秒超時很容易誤殺請求。第四多模態(tài)請求的 content 一定用數(shù)組這是我在實(shí)際對接中見過最多的一種低級錯誤。暫時就寫到這里這套鏈路目前運(yùn)行穩(wěn)定后續(xù)如果再壓一下視頻輸入場景有結(jié)論了再回來補(bǔ)充。