ローカルマルチモーダルを簡単に使えるAPIを公開。LLaVA-Next(旧1.6)でAPIサーバを構築
始めに
OpenAIやGoogleのAIサービスはマルチモーダル対応が当たり前のようにできます。ローカルLLMでもいくつかマルチモーダルに対応したモデルがありました。めぐチャンネルでも過去にMinigpt-4によるAPIサーバの構築を試しましたが、実用的に使えるかと言うと若干の疑問があったのも事実です。今回は1月末に公開されたLLaVA-NEXT(旧-1.6)で実用に耐えるローカルマルチモーダルLLMのAPIを独自に開発しました。LLaVAは以前から高い評価を得ていましたし、日本語も使えて便利ですが非商用でした。v1.6(NEXTに名称変更)からは商用利用も可能になりました。APIサーバはOllamaやllama.cppもLLaVA対応ができるとされているので、あえて独自APIを開発する必要は無いのですが、ドキュメントを見ながらAPIを解析してテストをする手間と独自に開発する手間では大して差は無いですし、今回は時間もなかったことから慣れている手法で画像アップロードとチャットエンドポイントを実装してAPI化しました。
GitHubのデモは面倒
いくつもサーバ機能を立ち上げながら動かします。注意深く行えばそれほど困難ではありません。以下の記事にデモの立ち上げ方を記載しています。
簡単に使えるAPIサーバがほしい
前述のように、時間も無いということで、画像アップロードとチャット機能だけに縛った簡単なAPIサーバを実装しています。LLaVAのオリジナルコードにはChatの過去ログ機能もあるので有効に活用します。
LLaVA-NEXTの導入
GiyHubからクローンします。
git clone https://github.com/haotian-liu/LLaVA.git
cd LLaVA
環境に合わせて構築
Install Packageに従えば簡単に環境は構築できるはずです。トレーニングはしないのでadditional packagesは不要です。
conda create -n llava python=3.10 -y
conda activate llava
pip install --upgrade pip # enable PEP 660 support
pip install -e .
モデルのダウンロード
最新のLLaVA-NEXTは以下のモデルが準備されています。
liuhaotian/llava-v1.6-vicuna-7b
liuhaotian/llava-v1.6-vicuna-13b
liuhaotian/llava-v1.6-mistral-7b
liuhaotian/llava-v1.6-34b
v1.5のモデルも動きます。
liuhaotian/llava-v1.5-7b
liuhaotian/llava-v1.5-13b
liuhaotian/llava-v1.5-7b-lora
liuhaotian/llava-v1.5-13b-lora
LLaVa-1.5の性能
cliのテスト
デモを動かすのは大変ですが、cli版は簡単です。
python -m llava.serve.cli \
--model-path liuhaotian/llava-v1.5-7b \
--image-file "https://llava-vl.github.io/static/images/view.jpg" \
--load-4bit
このコードは4bit量子化もオプションでつけているのでGPUは小さくても動きます。7Bで8GByte以下です。
cli.pyを改造してAPIサーバ化
cli.pyはシンプルな構造をしています。主要な部分は以下の通り
・コマンドラインの引数を処理する
・モデルをロードする(タイプごとにやり方が違います)
・イメージをアップロードする
・推論(チャット)を行う
cli.pyはクローンしたリポジトリの
LLaVA/llava/serveディレクトリにあります。
cli.pyを改造
なるべく簡単なコードにしたかったので以下を省きました。
・コマンドラインの引数処理
・モデルを固定、ハードコード
cli.pyをコピーしてファイル名をapi_server.pyに変更しFastAPIでラッピングしています。
エンドポンとは2種類のみ
画像アップロード
@app.post("/api/upload_file")
推論(チャット)
@app.post("/api/chatx")
コード
user_dicで複数のクライアントの要求にも答えられるよう、画像とチャット履歴を管理しています。一度登録すると消去の機能は無いので異なるuser_idで何度も使ったあとは再起動でクリアしてください。
モデルはコード内でモデル名を指定しています。変更する場合はコードを修正してください。
import argparse
import torch
import pprint
from llava.constants import IMAGE_TOKEN_INDEX, DEFAULT_IMAGE_TOKEN, DEFAULT_IM_START_TOKEN, DEFAULT_IM_END_TOKEN
from llava.conversation import conv_templates, SeparatorStyle
from llava.model.builder import load_pretrained_model
from llava.utils import disable_torch_init
from llava.mm_utils import process_images, tokenizer_image_token, get_model_name_from_path
from PIL import Image
import requests
from PIL import Image
from io import BytesIO
from transformers import TextStreamer
from fastapi import FastAPI,Form,File, UploadFile
from fastapi.responses import HTMLResponse,JSONResponse
from pydantic import BaseModel
app = FastAPI()
user_name="test"
user_dic={user_name:{
"cov":"",
"image":"",
}}
model_path="liuhaotian/llava-v1.5-7b"
#model_path= "SakanaAI/EvoVLM-JP-v1-7B"
#model_path="liuhaotian/llava-v1.6-mistral-7b"
model_base=None
load_8bit=False
load_4bit=True
device="cuda"
disable_torch_init()
model_name = get_model_name_from_path(model_path)
tokenizer, model, image_processor, context_len = load_pretrained_model(model_path, model_base, model_name, load_8bit=False, load_4bit=True, device=device)
conv_mode = "chatml_direct" #v1.6-34b
if "llama-2" in model_name.lower():
conv_mode = "llava_llama_2"
elif "mistral" in model_name.lower():
conv_mode = "mistral_instruct"
elif "v1" in model_name.lower():
conv_mode = "llava_v1"
elif "v1.6-34b" in model_name.lower():
conv_mode = "chatml_direct"
else:
conv_mode = "llava_v0"
#conv_mode = "llava_v1" #v1
image_file="pose2.png"
temperature=0.2
max_new_tokens=512
conv = conv_templates[conv_mode].copy()
roles = conv.roles
def load_image(image_file):
if image_file.startswith('http://') or image_file.startswith('https://'):
response = requests.get(image_file)
image = Image.open(BytesIO(response.content)).convert('RGB')
else:
image = Image.open(image_file).convert('RGB')
user_dic[user_name]["image"]=image
return image
async def generate_response(inp: str,conv,image,image_size,image_tensor):
print("user_dic1=",user_dic)
if image is not None:
# first message
if model.config.mm_use_im_start_end:
inp = DEFAULT_IM_START_TOKEN + DEFAULT_IMAGE_TOKEN + DEFAULT_IM_END_TOKEN + '\n' + inp
else:
inp = DEFAULT_IMAGE_TOKEN + '\n' + inp
conv.append_message(conv.roles[0], inp)
image = None
else:
# later messages
conv.append_message(conv.roles[0], inp)
conv.append_message(conv.roles[1], None)
user_dic[user_name]["conv"]=conv
prompt = conv.get_prompt()
print("prompt =",prompt)
# トークナイザーでpromptをトークン化
input_ids = tokenizer_image_token(prompt, tokenizer, IMAGE_TOKEN_INDEX, return_tensors='pt').unsqueeze(0).to(model.device)
# モデルで応答を生成
with torch.inference_mode():
output_ids = model.generate(
input_ids,
images=image_tensor, # 画像処理の結果
image_sizes=image_size, # 画像サイズ
do_sample=True if temperature > 0 else False,
temperature=temperature,
max_new_tokens=max_new_tokens,
use_cache=True
)
# 生成されたトークンをデコードしてテキストに変換
outputs = tokenizer.decode(output_ids[0]).strip()
# 応答をconvオブジェクトに追加(任意)
conv.messages[-1][-1] = outputs
user_dic[user_name]["conv"]=conv
print("conv.messages=",conv.messages)
return outputs
@app.post("/api/upload_file")
async def upload_file(image: UploadFile = File(...), user_name: str = Form(...)):
image_data =image.file.read()
image = Image.open(BytesIO(image_data)) # バイナリデータをPIL形式に変換
image = image.convert("RGB")
user_dic[user_name]["image"]=image
image.show()
return JSONResponse(content={'message': "OK"})
@app.post("/api/chatx")
async def chatx(prompt:str = Form(...), mode: str= Form(...), user_name: str = Form(...)):
# 画像を読み込む処理(適宜実装)
user_input=prompt
image=user_dic[user_name]["image"]
image_size = image.size
image_tensor = process_images([image], image_processor, model.config)
if type(image_tensor) is list:
image_tensor = [image.to(model.device, dtype=torch.float16) for image in image_tensor]
else:
image_tensor = image_tensor.to(model.device, dtype=torch.float16)
if mode=="new":
conv = conv_templates[conv_mode].copy()
user_dic[user_name]["conv"]=conv
else:
conv =user_dic[user_name]["conv"]
image=None
# 応答を生成 2回目はimage=Noneにすればいいはず
assistant_response = await generate_response(user_input,conv,image,image_size,image_tensor)
# 応答をJSON形式で返す
return {"assistant_response": assistant_response}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8011)
サーバの起動
簡略化したので簡単です。引数はありません。LLaVAディレクトリで以下のコマンドで起動できます。
python -m llava.serve.api_server
クライアント側
テスト用に簡単なテスト用クライアントアプリも作成しました。
コード
LLaVAディレクトリにあるpose2.pngを使用する例です。
前半で画像をアップロード
後半で質問を投げかけて答えを得ています。
"mode":"new"で過去ログがクリアされます。
import requests
from io import BytesIO
print("+++++i2i TEST")
image_file_path="pose2.png"
file_data = open(image_file_path, "rb").read()
files={"image": ("img.png", BytesIO(file_data), "image/png"),}
data= {"user_name":"test",}
# POSTリクエストを送信
url = 'http://0.0.0.0:8011/api/upload_file'
response = requests.post(url, data=data ,files=files)
# レスポンスを表示
if response.status_code == 200:
result = response.json()
print("サーバーからの応答message:", result.get("message"))
else:
print("リクエストが失敗しました。ステータスコード:", response.status_code)
url = "http://0.0.0.0:8011/api/chatx"
prompt="植木鉢の数はいくつ?"
data= {"prompt":prompt,
"mode":"new",
"user_name":"test",}
response = requests.post(url, data=data)
if response.status_code == 200:
result = response.json()
print("assistant_response:", result.get("assistant_response"))
prompt="空は何色?"
data= {"prompt":prompt,
"mode":"continue",
"user_name":"test",}
response = requests.post(url, data=data)
if response.status_code == 200:
result = response.json()
print("assistant_response:", result.get("assistant_response"))
pose2.png
まとめ
日本語で普通に会話できるマルチモーダルLLMは用途も広くて便利です。応答速度も申し分なく、精度も上々です。ローカルLLMでマルチモーダルがも利用ができる実用段階になったな、と感じます。