Qwen2.5 VL 并发推理
flyfish
import base64
import io
import os
from flask import Flask, request, jsonify
from transformers import Qwen2_5_VLForConditionalGeneration, AutoTokenizer, AutoProcessor
from qwen_vl_utils import process_vision_info
import torch
from PIL import Image
from concurrent.futures import ThreadPoolExecutor
import datetime
import threading
import logging
# 日志配置
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
class ConfigManager:
def __init__(self):
self.model_path = "/home/user/.cache/modelscope/hub/models/Qwen/"
self.instance_count = torch.cuda.device_count() # 根据显卡数量确定实例数
self.concurrency = self.instance_count
self.validate_config()
def validate_config(self):
if self.concurrency > self.instance_count:
raise ValueError("CONCURRENCY should not be greater than INSTANCE_COUNT to avoid resource competition.")
class ModelManager:
def __init__(self, config):
self.model_path = config.model_path
self.instance_count = config.instance_count
self.models = []
self.processors = []
self._load_models()
self.instance_counter = 0
self.counter_lock = threading.Lock()
def _load_models(self):
for i in range(self.instance_count):
try:
device = f"cuda:{i}"
model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
self.model_path,
torch_dtype=torch.bfloat16,
attn_implementation="flash_attention_2",
device_map={"": device} # 手动指定设备
)
processor = AutoProcessor.from_pretrained(self.model_path)
self.models.append(model)
self.processors.append(processor)
logging.info(f"Model instance {i} loaded on device {device} successfully.")
except Exception as e:
logging.error(f"Failed to load model instance on device {i}: {e}")
def get_next_model_and_processor(self):
with self.counter_lock:
instance_id = self.instance_counter % self.instance_count
self.instance_counter += 1
return self.models[instance_id], self.processors[instance_id], instance_id
class ImageInference:
def __init__(self, model_manager):
self.model_manager = model_manager
def inference(self, prompt, base64_image):
model, processor, instance_id = self.model_manager.get_next_model_and_processor()
try:
image = self.decode_base64_image(base64_image)
image_path = self.save_image(image, instance_id)
messages = self.prepare_messages(prompt, image_path)
inputs = self.prepare_inputs(model, processor, messages) # 传递 model 参数
generated_ids = self.generate_output(model, inputs)
output_text = self.decode_output(processor, inputs, generated_ids)
return output_text
except Exception as e:
logging.error(f"Inference error: {e}")
return str(e)
def decode_base64_image(self, base64_image):
image_data = base64.b64decode(base64_image)
return Image.open(io.BytesIO(image_data))
def save_image(self, image, instance_id):
if not os.path.exists("saved_images"):
os.makedirs("saved_images")
current_time = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
image_path = os.path.join("saved_images", f"instance_{instance_id}_{current_time}.jpg")
image.save(image_path)
return image_path
def prepare_messages(self, prompt, image_path):
return [
{
"role": "user",
"content": [
{
"type": "image",
"image": image_path,
},
{"type": "text", "text": prompt},
],
}
]
def prepare_inputs(self, model, processor, messages): # 接收 model 参数
text = processor.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
image_inputs, video_inputs = process_vision_info(messages)
inputs = processor(
text=[text],
images=image_inputs,
videos=video_inputs,
padding=True,
return_tensors="pt",
)
return inputs.to(next(model.parameters()).device)
def generate_output(self, model, inputs):
return model.generate(
**inputs,
max_new_tokens=8192,
do_sample=True,
top_p=1,
temperature=0.1,
)
def decode_output(self, processor, inputs, generated_ids):
generated_ids_trimmed = [
out_ids[len(in_ids):] for in_ids, out_ids in zip(inputs.input_ids, generated_ids)
]
return processor.batch_decode(
generated_ids_trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False
)[0]
class ErrorHandler:
@staticmethod
def handle_error(e):
logging.error(f"API error: {e}")
return jsonify({"error": str(e)}), 500
class InferenceService:
def __init__(self, inference_engine, executor):
self.inference_engine = inference_engine
self.executor = executor
def predict(self, prompt, base64_image):
try:
if not prompt or not base64_image:
return jsonify({"error": "Prompt and image are required."}), 400
future = self.executor.submit(self.inference_engine.inference, prompt, base64_image)
result = future.result()
if isinstance(result, str) and result.startswith("error: "):
return jsonify({"error": result[7:]}), 500
return jsonify({"output": result})
except Exception as e:
return ErrorHandler.handle_error(e)
app = Flask(__name__)
config = ConfigManager()
model_manager = ModelManager(config)
inference_engine = ImageInference(model_manager)
executor = ThreadPoolExecutor(max_workers=config.concurrency)
service = InferenceService(inference_engine, executor)
@app.route('/predict', methods=['POST'])
def predict():
data = request.get_json()
prompt = data.get('prompt')
base64_image = data.get('image')
return service.predict(prompt, base64_image)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=6000)
整体架构
代码主要由以下几个部分组成:
- 配置管理:
ConfigManager
类负责管理模型路径、实例数量和并发设置,并进行配置验证。 - 模型管理:
ModelManager
类负责加载多个模型实例到不同的 GPU 设备上,并提供一个方法来轮询获取下一个可用的模型和处理器。 - 图像推理:
ImageInference
类包含了推理过程中的各个步骤,如解码图像、保存图像、准备输入消息、生成输出等。 - 错误处理:
ErrorHandler
类提供了一个静态方法来处理 API 调用过程中出现的错误。 - 推理服务:
InferenceService
类负责协调推理引擎和线程池,处理输入并返回推理结果。 - Flask 应用:使用 Flask 框架创建一个 Web API,提供
/predict
接口,接收图像和文本提示,并返回推理结果。
代码详细分析
1. 导入必要的库
导入了处理图像、编码解码、Web 服务、模型加载、多线程等所需的库。
2. 日志配置
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
设置日志级别为 INFO,并指定日志格式。
3. ConfigManager
类
model_path
:指定模型的存储路径。instance_count
:根据可用的 GPU 数量确定模型实例的数量。concurrency
:并发处理的数量,与实例数量相同。validate_config
:验证并发数是否超过实例数,避免资源竞争。
4. ModelManager
类
_load_models
:循环加载多个模型实例到不同的 GPU 设备上,并记录加载成功或失败的日志。get_next_model_and_processor
:使用线程锁确保线程安全,通过轮询方式返回下一个可用的模型、处理器和实例 ID。
5. ImageInference
类
inference
:推理的主方法,调用其他方法完成图像解码、保存、输入准备、输出生成和解码等步骤。decode_base64_image
:将 Base64 编码的图像数据解码为 PIL 图像对象。save_image
:将图像保存到本地,并返回保存的文件路径。prepare_messages
:准备输入消息,包含图像和文本提示。prepare_inputs
:使用处理器处理输入消息,生成模型所需的输入张量,并将其移动到模型所在的设备上。generate_output
:调用模型的generate
方法生成输出的标记 ID。decode_output
:将生成的标记 ID 解码为文本。
6. ErrorHandler
类
提供一个静态方法来处理 API 调用过程中出现的错误,记录错误日志并返回错误信息和 HTTP 500 状态码。
7. InferenceService
类
predict
:处理输入的文本提示和图像,使用线程池提交推理任务,并根据结果返回相应的 JSON 响应。
8. Flask 应用
- 创建 Flask 应用实例。
- 初始化配置、模型管理、推理引擎、线程池和推理服务。
- 定义
/predict
接口,接收 POST 请求,调用推理服务进行预测,并返回结果。
模型加载与管理
- 模型加载方式:在
_load_models
方法中,通过Qwen2_5_VLForConditionalGeneration.from_pretrained
直接从预训练模型路径加载模型实例。每次循环都会重新从磁盘加载模型到不同的 GPU 设备上,这里并没有对已经加载的模型进行复制操作。 - 模型存储:将加载好的模型实例直接添加到
self.models
列表中,这只是将模型对象的引用存储在列表里,并非对模型进行深拷贝。每个模型实例在各自的 GPU 设备上独立运行,不会出现一个模型数据被重复复制多份的情况。
模型使用过程
- 模型获取:在
ImageInference
类的inference
方法中,通过ModelManager
的get_next_model_and_processor
方法获取模型和处理器。该方法只是从self.models
列表中按顺序取出模型对象的引用,并没有对模型进行任何复制操作。
由于没有进行模型的深拷贝,所以每个模型实例仅在其对应的 GPU 设备上占用一份显存资源,不会出现因为深拷贝导致同一模型数据在多个地方重复存储,从而避免了不必要的资源浪费。
生成参数
def generate_output(self, model, inputs):
return model.generate(
**inputs,
max_new_tokens=8192,
do_sample=True,
top_p=1,
temperature=0.1,
)
各参数含义
max_new_tokens=8192
:指定生成文本时最多生成的新token(数量。这决定了生成文本的最大长度,设定为 8192 个token。do_sample=True
:开启采样策略。当do_sample
为True
时,模型会根据概率分布随机采样生成下一个token,而不是每次都选择概率最大的token,这样可以增加生成文本的多样性。top_p=1
:top_p
是核采样(nucleus sampling)的参数,也称为p
- 采样。它会选择概率累积和达到top_p
的最小token集合,然后从这个集合中进行采样。当top_p = 1
时,实际上是考虑了所有可能的token,因为所有token的概率累积和肯定是 1,所以这种情况下top_p
采样没有起到筛选作用。temperature=0.1
:temperature
用于调整生成时的概率分布。较低的temperature
值(如 0.1)会使概率分布更加尖锐,模型更倾向于选择概率较高的token,生成的文本更加确定和保守;较高的temperature
值会使分布更加平滑,增加随机性。
生成效果方面
do_sample=True
和temperature=0.1
:虽然开启了采样,但temperature
值较低,这会使得生成过程接近贪心搜索(greedy search),因为模型会非常倾向于选择概率最高的token,生成的文本多样性可能不会很明显。top_p=1
:由于top_p
被设置为 1,它实际上没有对采样起到筛选作用,相当于没有使用top_p
采样的优化效果。
调用方法
import requests
import base64
import os
import time
class ImageProcessor:
def __init__(self, prompt_file_path, images_folder, result_folder, api_url):
"""
初始化 ImageProcessor 类。
:param prompt_file_path: 提示文本文件的路径
:param images_folder: 图像文件夹的路径
:param result_folder: 结果保存文件夹的路径
:param api_url: 发送请求的 API 地址
"""
self.prompt = self._read_prompt(prompt_file_path)
self.images_folder = images_folder
self.result_folder = result_folder
self.api_url = api_url
self._ensure_folder_exists(self.images_folder)
self._ensure_folder_exists(self.result_folder)
def _read_prompt(self, prompt_file_path):
"""
读取提示文本文件。
:param prompt_file_path: 提示文本文件的路径
:return: 提示文本
"""
try:
with open(prompt_file_path, "r", encoding="utf-8") as f:
return f.read().strip()
except FileNotFoundError:
print("Error: prompt file not found.")
exit(1)
def _ensure_folder_exists(self, folder):
"""
确保指定的文件夹存在,如果不存在则创建。
:param folder: 文件夹路径
"""
if not os.path.exists(folder):
os.makedirs(folder)
def _encode_image_to_base64(self, image_path):
"""
将图像文件编码为 Base64 字符串。
:param image_path: 图像文件的路径
:return: Base64 编码的图像字符串
"""
with open(image_path, 'rb') as file:
image_data = file.read()
return base64.b64encode(image_data).decode('utf-8')
def _send_request(self, base64_image):
"""
发送 POST 请求到 API 进行预测。
:param base64_image: Base64 编码的图像字符串
:return: 响应对象
"""
data = {
"prompt": self.prompt,
"image": base64_image
}
return requests.post(self.api_url, json=data)
def _process_response(self, response, result_path, inference_time):
"""
处理 API 响应并保存结果到文件。
:param response: 响应对象
:param result_path: 结果保存文件的路径
:param inference_time: 推理时间
"""
try:
result = response.json()
output_text = result.get("output", "")
with open(result_path, "w", encoding="utf-8") as f:
f.write(f"推理结果: {output_text}\n")
f.write(f"推理时间: {inference_time:.4f} 秒")
print(f"处理完成,推理时间: {inference_time:.4f} 秒")
except ValueError:
print(f"响应不是有效的 JSON 数据: {response.text}")
except Exception as e:
print(f"处理时发生错误: {e}")
def process_images(self):
"""
处理图像文件夹中的所有图像文件。
"""
for filename in os.listdir(self.images_folder):
if filename.endswith(('.jpg', '.jpeg', '.png')):
image_path = os.path.join(self.images_folder, filename)
result_filename = os.path.splitext(filename)[0] + ".txt"
result_path = os.path.join(self.result_folder, result_filename)
start_time = time.time()
base64_image = self._encode_image_to_base64(image_path)
response = self._send_request(base64_image)
end_time = time.time()
inference_time = end_time - start_time
print(f"正在处理 {filename}...")
self._process_response(response, result_path, inference_time)
if __name__ == "__main__":
prompt_file = "prompt.txt"
images_folder = "images"
result_folder = "result"
api_url = 'http://localhost:6000/predict'
processor = ImageProcessor(prompt_file, images_folder, result_folder, api_url)
processor.process_images()
代码说明:
-
类的定义:
ImageProcessor
类封装了图像处理的整个流程。__init__
方法:初始化类的属性,包括提示文本、图像文件夹、结果保存文件夹和 API 地址,并确保文件夹存在。_read_prompt
方法:读取提示文本文件。_ensure_folder_exists
方法:确保指定的文件夹存在。_encode_image_to_base64
方法:将图像文件编码为 Base64 字符串。_send_request
方法:发送 POST 请求到 API 进行预测。_process_response
方法:处理 API 响应并保存结果到文件。process_images
方法:遍历图像文件夹中的所有图像文件,依次处理每个图像。
-
主程序:在
if __name__ == "__main__":
块中,创建ImageProcessor
类的实例并调用process_images
方法开始处理图像。
两个线程调用
import requests
import base64
import os
import time
import threading
import queue
class ImageProcessor:
def __init__(self, prompt_file_path, images_folder, result_folder, api_url):
"""
初始化 ImageProcessor 类。
:param prompt_file_path: 提示文本文件的路径
:param images_folder: 图像文件夹的路径
:param result_folder: 结果保存文件夹的路径
:param api_url: 发送请求的 API 地址
"""
self.prompt = self._read_prompt(prompt_file_path)
self.images_folder = images_folder
self.result_folder = result_folder
self.api_url = api_url
self._ensure_folder_exists(self.images_folder)
self._ensure_folder_exists(self.result_folder)
self.task_queue = queue.Queue()
self._populate_task_queue()
def _read_prompt(self, prompt_file_path):
"""
读取提示文本文件。
:param prompt_file_path: 提示文本文件的路径
:return: 提示文本
"""
try:
with open(prompt_file_path, "r", encoding="utf-8") as f:
return f.read().strip()
except FileNotFoundError:
print("Error: prompt file not found.")
exit(1)
def _ensure_folder_exists(self, folder):
"""
确保指定的文件夹存在,如果不存在则创建。
:param folder: 文件夹路径
"""
if not os.path.exists(folder):
os.makedirs(folder)
def _encode_image_to_base64(self, image_path):
"""
将图像文件编码为 Base64 字符串。
:param image_path: 图像文件的路径
:return: Base64 编码的图像字符串
"""
with open(image_path, 'rb') as file:
image_data = file.read()
return base64.b64encode(image_data).decode('utf-8')
def _send_request(self, base64_image):
"""
发送 POST 请求到 API 进行预测。
:param base64_image: Base64 编码的图像字符串
:return: 响应对象
"""
data = {
"prompt": self.prompt,
"image": base64_image
}
return requests.post(self.api_url, json=data)
def _process_response(self, response, result_path, inference_time):
"""
处理 API 响应并保存结果到文件。
:param response: 响应对象
:param result_path: 结果保存文件的路径
:param inference_time: 推理时间
"""
try:
result = response.json()
output_text = result.get("output", "")
with open(result_path, "w", encoding="utf-8") as f:
f.write(f"推理结果: {output_text}\n")
f.write(f"推理时间: {inference_time:.4f} 秒")
print(f"处理完成,推理时间: {inference_time:.4f} 秒")
except ValueError:
print(f"响应不是有效的 JSON 数据: {response.text}")
except Exception as e:
print(f"处理时发生错误: {e}")
def _populate_task_queue(self):
"""
填充任务队列,将图像文件路径和结果保存路径添加到队列中。
"""
for filename in os.listdir(self.images_folder):
if filename.endswith(('.jpg', '.jpeg', '.png')):
image_path = os.path.join(self.images_folder, filename)
result_filename = os.path.splitext(filename)[0] + ".txt"
result_path = os.path.join(self.result_folder, result_filename)
self.task_queue.put((image_path, result_path))
def worker(self):
"""
工作线程函数,从任务队列中取出任务并处理。
"""
while not self.task_queue.empty():
image_path, result_path = self.task_queue.get()
start_time = time.time()
base64_image = self._encode_image_to_base64(image_path)
response = self._send_request(base64_image)
end_time = time.time()
inference_time = end_time - start_time
print(f"正在处理 {os.path.basename(image_path)}...")
self._process_response(response, result_path, inference_time)
self.task_queue.task_done()
def process_images(self):
"""
启动两个工作线程处理图像。
"""
threads = []
for _ in range(2):
thread = threading.Thread(target=self.worker)
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
if __name__ == "__main__":
prompt_file = "prompt.txt"
images_folder = "images"
result_folder = "result"
api_url = 'http://localhost:6000/predict'
processor = ImageProcessor(prompt_file, images_folder, result_folder, api_url)
processor.process_images()