多模态大模型实战:图文理解与生成

2026-07-17 26 min read 0

一、真实场景:电商平台图文搜索的噩梦

去年双11,我们团队负责的电商搜索系统出了个大问题。用户搜"红色连衣裙"时,返回的结果里混了大量黑色T恤——因为商品标题里写了"红色"但图片是黑的。更离谱的是,搜"带帽子的羽绒服"时,系统只匹配标题关键词,完全无视图片里有没有帽子。

CTO在周会上拍桌子:"你们搞的搜索,连图片都看不懂?"

这不是个例。根据我们统计,2023年Q3搜索转化率下降了12%,其中37%的bad case都跟图文不匹配有关。传统方案依赖文本关键词匹配,图片信息完全浪费了。

我们决定上多模态模型。但选哪个?怎么落地?踩了哪些坑?这篇文章全盘托出。

二、问题定义:多模态图文理解到底要解决什么

我们的核心需求三个:

  • 图文检索:用户输入文本,找到语义匹配的图片
  • 图像描述:给一张商品图,自动生成描述文本
  • 视觉问答:针对图片提问,比如"这件衣服是什么颜色?"

这三个任务覆盖了电商搜索、商品管理、客服问答等场景。我们评估了三个主流方案:CLIP、BLIP-2、LLaVA。

三、方案对比:CLIP vs BLIP-2 vs LLaVA

先上结论:

模型参数量推理速度(单卡A100)图文检索图像描述视觉问答部署难度
CLIP ViT-L/14428M15ms/张★★★★★
BLIP-2 (OPT-2.7B)3.7B120ms/张★★★★★★★★★★★★★
LLaVA-1.5 (7B)7B350ms/张★★★★★★★★★★★★★

CLIP是双塔结构,文本和图像分别编码,适合检索。BLIP-2用Q-Former桥接视觉和语言,能生成描述。LLaVA直接端到端训练,视觉问答最强。

我们最终选了CLIP + BLIP-2的组合方案:CLIP做检索,BLIP-2做生成。为什么不用LLaVA?后面避坑部分会说。

四、完整代码实现

4.1 环境准备

硬件:NVIDIA A100 80GB × 1,CUDA 12.1,PyTorch 2.1.0

软件:Python 3.10,transformers 4.36.2,accelerate 0.25.0

# 安装依赖
pip install torch==2.1.0 torchvision==0.16.0 --index-url https://download.pytorch.org/whl/cu121
pip install transformers==4.36.2 accelerate==0.25.0 pillow requests tqdm
pip install open_clip_torch==2.23.0  # CLIP的优化实现

4.2 CLIP图文检索实现

我们使用open_clip的ViT-L/14模型,比官方CLIP快30%。

# clip_retrieval.py
import torch
import open_clip
from PIL import Image
import numpy as np
from typing import List

class CLIPRetrieval:
    def __init__(self, model_name: str = "ViT-L-14", pretrained: str = "openai"):
        self.device = "cuda" if torch.cuda.is_available() else "cpu"
        self.model, _, self.preprocess = open_clip.create_model_and_transforms(
            model_name, pretrained=pretrained
        )
        self.model = self.model.to(self.device)
        self.model.eval()
        self.tokenizer = open_clip.get_tokenizer(model_name)
        
        # 预热模型
        dummy_text = self.tokenizer(["test"])
        dummy_img = torch.randn(1, 3, 224, 224).to(self.device)
        with torch.no_grad():
            self.model.encode_text(dummy_text)
            self.model.encode_image(dummy_img)
        print(f"CLIP模型加载完成,设备:{self.device}")
    
    def encode_images(self, image_paths: List[str]) -> np.ndarray:
        """批量编码图片,返回归一化的特征向量"""
        images = []
        for path in image_paths:
            img = Image.open(path).convert("RGB")
            images.append(self.preprocess(img).unsqueeze(0))
        
        image_tensor = torch.cat(images).to(self.device)
        with torch.no_grad():
            features = self.model.encode_image(image_tensor)
            features = features / features.norm(dim=-1, keepdim=True)
        return features.cpu().numpy()
    
    def encode_texts(self, texts: List[str]) -> np.ndarray:
        """批量编码文本"""
        text_tokens = self.tokenizer(texts).to(self.device)
        with torch.no_grad():
            features = self.model.encode_text(text_tokens)
            features = features / features.norm(dim=-1, keepdim=True)
        return features.cpu().numpy()
    
    def search(self, query: str, image_features: np.ndarray, top_k: int = 10):
        """文本检索图片,返回相似度排序后的索引"""
        query_feat = self.encode_texts([query])
        similarities = np.dot(image_features, query_feat.T).flatten()
        top_indices = np.argsort(similarities)[::-1][:top_k]
        return top_indices, similarities[top_indices]

# 使用示例
if __name__ == "__main__":
    retriever = CLIPRetrieval()
    
    # 假设有1000张商品图
    image_paths = [f"images/{i}.jpg" for i in range(1000)]
    image_features = retriever.encode_images(image_paths)
    
    query = "红色连衣裙 带帽子"
    indices, scores = retriever.search(query, image_features, top_k=5)
    print(f"查询:{query}")
    for idx, score in zip(indices, scores):
        print(f"  图片{idx}: 相似度 {score:.4f}")

4.3 BLIP-2图像描述与视觉问答

BLIP-2使用Q-Former架构,我们加载预训练好的OPT-2.7B版本。

# blip2_caption.py
import torch
from transformers import Blip2Processor, Blip2ForConditionalGeneration
from PIL import Image
import time

class BLIP2Caption:
    def __init__(self, model_name: str = "Salesforce/blip2-opt-2.7b"):
        self.device = "cuda" if torch.cuda.is_available() else "cpu"
        self.processor = Blip2Processor.from_pretrained(model_name)
        self.model = Blip2ForConditionalGeneration.from_pretrained(
            model_name, torch_dtype=torch.float16
        ).to(self.device)
        self.model.eval()
        print(f"BLIP-2模型加载完成,设备:{self.device}")
    
    def generate_caption(self, image_path: str, max_length: int = 50) -> str:
        """生成图像描述"""
        image = Image.open(image_path).convert("RGB")
        inputs = self.processor(images=image, return_tensors="pt").to(self.device)
        
        with torch.no_grad():
            generated_ids = self.model.generate(
                **inputs,
                max_length=max_length,
                num_beams=5,
                temperature=0.7,
                do_sample=True
            )
        caption = self.processor.batch_decode(generated_ids, skip_special_tokens=True)[0]
        return caption
    
    def visual_qa(self, image_path: str, question: str) -> str:
        """视觉问答"""
        image = Image.open(image_path).convert("RGB")
        inputs = self.processor(
            images=image, text=question, return_tensors="pt"
        ).to(self.device)
        
        with torch.no_grad():
            generated_ids = self.model.generate(
                **inputs,
                max_length=30,
                num_beams=3
            )
        answer = self.processor.batch_decode(generated_ids, skip_special_tokens=True)[0]
        return answer

# 使用示例
if __name__ == "__main__":
    captioner = BLIP2Caption()
    
    # 图像描述
    start = time.time()
    caption = captioner.generate_caption("test_image.jpg")
    elapsed = time.time() - start
    print(f"描述:{caption} (耗时:{elapsed:.2f}s)")
    
    # 视觉问答
    answer = captioner.visual_qa("test_image.jpg", "这件衣服是什么颜色?")
    print(f"回答:{answer}")

4.4 完整检索服务API

用FastAPI封装成RESTful服务,支持批量查询。

# api_server.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import numpy as np
import uvicorn
from clip_retrieval import CLIPRetrieval
from blip2_caption import BLIP2Caption
import time
from typing import List

app = FastAPI(title="多模态检索服务")

# 全局加载模型
retriever = CLIPRetrieval()
captioner = BLIP2Caption()

# 假设预计算好的图片特征
image_features = np.load("image_features.npy")
image_paths = [f"images/{i}.jpg" for i in range(image_features.shape[0])]

class SearchRequest(BaseModel):
    query: str
    top_k: int = 10

class SearchResponse(BaseModel):
    results: List[dict]
    latency_ms: float

class CaptionRequest(BaseModel):
    image_path: str

class CaptionResponse(BaseModel):
    caption: str
    latency_ms: float

@app.post("/search", response_model=SearchResponse)
async def search_images(request: SearchRequest):
    """图文检索接口"""
    start = time.time()
    indices, scores = retriever.search(request.query, image_features, request.top_k)
    results = [
        {"image_path": image_paths[idx], "score": float(score)}
        for idx, score in zip(indices, scores)
    ]
    latency = (time.time() - start) * 1000
    return SearchResponse(results=results, latency_ms=latency)

@app.post("/caption", response_model=CaptionResponse)
async def generate_caption(request: CaptionRequest):
    """图像描述接口"""
    start = time.time()
    caption = captioner.generate_caption(request.image_path)
    latency = (time.time() - start) * 1000
    return CaptionResponse(caption=caption, latency_ms=latency)

@app.post("/vqa")
async def visual_qa(image_path: str, question: str):
    """视觉问答接口"""
    start = time.time()
    answer = captioner.visual_qa(image_path, question)
    latency = (time.time() - start) * 1000
    return {"answer": answer, "latency_ms": latency}

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)

4.5 批量预处理脚本

生产环境需要预计算所有图片特征,用GPU批量处理。

# precompute_features.py
import os
import numpy as np
from clip_retrieval import CLIPRetrieval
from tqdm import tqdm
import glob

def precompute_all_features(image_dir: str, output_path: str):
    """预计算所有图片特征并保存"""
    retriever = CLIPRetrieval()
    
    # 获取所有图片路径
    image_paths = glob.glob(os.path.join(image_dir, "*.jpg"))
    image_paths += glob.glob(os.path.join(image_dir, "*.png"))
    print(f"找到 {len(image_paths)} 张图片")
    
    # 分批处理,避免OOM
    batch_size = 64
    all_features = []
    
    for i in tqdm(range(0, len(image_paths), batch_size)):
        batch_paths = image_paths[i:i+batch_size]
        batch_features = retriever.encode_images(batch_paths)
        all_features.append(batch_features)
    
    # 合并并保存
    all_features = np.concatenate(all_features, axis=0)
    np.save(output_path, all_features)
    np.save(output_path.replace(".npy", "_paths.npy"), image_paths)
    print(f"特征已保存到 {output_path},形状:{all_features.shape}")

if __name__ == "__main__":
    precompute_all_features("images/", "image_features.npy")

4.6 性能压测脚本

用locust做压力测试,模拟真实并发。

# locustfile.py
from locust import HttpUser, task, between
import random
import json

class MultimodalUser(HttpUser):
    wait_time = between(0.1, 0.5)  # 模拟用户思考时间
    
    queries = [
        "红色连衣裙",
        "黑色运动鞋",
        "带帽子的羽绒服",
        "白色T恤 圆领",
        "蓝色牛仔裤 修身"
    ]
    
    @task(3)
    def search_test(self):
        """图文检索压测,权重3"""
        query = random.choice(self.queries)
        payload = {"query": query, "top_k": 10}
        with self.client.post("/search", json=payload, catch_response=True) as response:
            if response.status_code == 200:
                data = response.json()
                if data["latency_ms"] > 500:  # 超过500ms告警
                    response.failure(f"延迟过高:{data['latency_ms']}ms")
            else:
                response.failure(f"状态码:{response.status_code}")
    
    @task(1)
    def caption_test(self):
        """图像描述压测,权重1"""
        image_path = "test_image.jpg"
        payload = {"image_path": image_path}
        with self.client.post("/caption", json=payload, catch_response=True) as response:
            if response.status_code == 200:
                data = response.json()
                if data["latency_ms"] > 2000:  # 超过2s告警
                    response.failure(f"延迟过高:{data['latency_ms']}ms")
            else:
                response.failure(f"状态码:{response.status_code}")

五、效果数据

我们在内部测试集上做了评估,测试集包含5000张商品图,1000个查询。

指标传统文本搜索CLIP检索提升
Recall@100.420.87+107%
mAP@100.350.79+126%
平均延迟(单查询)8ms15ms+7ms

图像描述质量评估(人工打分1-5分):

模型平均分准确率流畅度
BLIP-2 OPT-2.7B4.289%4.5
BLIP-2 FlanT5-XL4.085%4.3
LLaVA-1.5 7B4.592%4.7

压测结果(100并发,持续5分钟):

接口QPSP50延迟P99延迟错误率
/search85018ms45ms0.02%
/caption120135ms280ms0.1%
/vqa95160ms320ms0.15%

上线后,搜索转化率从3.2%提升到4.8%,bad case减少62%。

六、避坑指南

以下是我们实际踩过的坑,每个都花了至少一周时间解决。

坑1:CLIP对中文支持差

CLIP的预训练数据主要是英文,直接用于中文检索效果很差。我们测试了"红色连衣裙"这个查询,英文版CLIP的Recall@10只有0.31。

解决方案:使用中文CLIP变体,比如Chinese-CLIP(https://github.com/OFA-Sys/Chinese-CLIP)。我们换用chinese-clip-vit-large-patch14-336px后,Recall@10提升到0.85。

坑2:BLIP-2生成描述太啰嗦

默认生成的描述像"这是一张图片,上面有一件红色的连衣裙,裙子是长袖的,领口是圆领设计...",用户根本不想看。

解决方案:在generate时设置repetition_penalty=1.2,并限制max_length=30。同时用prompt工程:"简短描述这张商品图,不超过20个字"。

# 优化后的生成参数
generated_ids = self.model.generate(
    **inputs,
    max_length=30,
    num_beams=3,
    repetition_penalty=1.2,
    temperature=0.6,
    do_sample=False  # 确定性生成
)

坑3:为什么不用LLaVA?

LLaVA-1.5 7B在视觉问答上确实最强,但有两个致命问题:

  • 推理速度太慢:单张图350ms,QPS只有30左右,无法支撑线上流量
  • 显存占用高:7B模型需要14GB显存,加上图像编码器,单卡A100只能部署1个实例

我们试过量化到4bit,速度提升到200ms,但准确率掉了5%。最终决定只在离线场景用LLaVA做数据标注,线上用BLIP-2。

坑4:批量编码时OOM

第一次跑precompute_features.py时,10000张图直接OOM了。因为CLIP的encode_images一次性加载了所有图片到显存。

解决方案:分批处理,batch_size=64。同时用torch.no_grad()和float16精度。

# 显存优化:使用float16和梯度关闭
self.model = self.model.to(self.device).half()
with torch.no_grad():
    features = self.model.encode_image(image_tensor.half())

坑5:图片预处理不一致

CLIP和BLIP-2对图片的预处理不同。CLIP用224x224,BLIP-2用364x364。如果混用,特征会偏移。

解决方案:各自使用对应的processor,不要手动resize。

坑6:生产环境模型加载慢

BLIP-2模型加载需要30秒,导致服务启动慢。K8s健康检查总是失败。

解决方案:使用模型预热,在启动脚本中先跑一次推理。同时设置readiness probe延迟到60秒。

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: multimodal-api
spec:
  replicas: 3
  template:
    spec:
      containers:
      - name: api
        image: multimodal-api:latest
        ports:
        - containerPort: 8000
        readinessProbe:
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 60  # 给模型加载留时间
          periodSeconds: 10
        resources:
          limits:
            nvidia.com/gpu: 1
            memory: "32Gi"
          requests:
            memory: "16Gi"

七、总结

多模态模型不是银弹。CLIP适合检索,BLIP-2适合生成,LLaVA适合离线分析。选型要看业务场景和延迟要求。

我们这套方案上线跑了4个月,日均处理50万次检索,2万次描述生成。搜索转化率提升50%,客服人工成本降低30%。

代码都在GitHub上:https://github.com/your-company/multimodal-search

IT搬运工

专注技术分享,坚持原创深度内容。

分类
链接
订阅

RSS 订阅关注最新文章

© 2026 IT搬运工 · 站点地图 · 鄂ICP备19007640号-3