はじめに

「LLM = テキスト処理」という時代は終わりました。

2026年現在、主要なLLMはテキストだけでなく画像・PDF・音声・動画をネイティブに理解できます。GPT-4o、Gemini 2.0 Flash、Claude 3.5 Sonnetのいずれも、マルチモーダル入力を標準サポートしています。

しかし、「APIにファイルを渡せばなんとかなる」という感覚で実装すると、精度・コスト・信頼性の3点で失敗します。プロダクションで本当に使えるマルチモーダルAIを構築するには、各モダリティの特性を理解した上での設計が不可欠です。

この記事では、AIネイティブエンジニアとして知るべきマルチモーダルLLMの実践テクニックを、コード付きで体系的に解説します。

この記事で学べること

  • 主要モデルのマルチモーダル能力の比較と使い分け
  • 画像認識・OCR・図表解析のプロ実装パターン
  • PDF・ドキュメント処理のベストプラクティス
  • 音声入力(Whisper / GPT-4o Audio)の活用
  • 動画理解(Gemini 2.0)の実装
  • コスト最適化と品質向上のトレードオフ
  • 本番運用での注意点

マルチモーダルLLMの全体像

対応するモダリティ比較(2026年Q1時点)

モデル 画像 動画 音声入力 音声出力 ドキュメント 長コンテキスト
GPT-4o ✅(PDF) 128K tokens
GPT-4o mini ✅(PDF) 128K tokens
Gemini 2.0 Flash 1M tokens
Gemini 2.0 Pro 2M tokens
Claude 3.5 Sonnet ✅(PDF) 200K tokens
Claude 3.5 Haiku ✅(PDF) 200K tokens
Llama 3.2 Vision 128K tokens

※ 各社の機能は急速に進化中です。最新情報は公式ドキュメントで確認してください。

マルチモーダル処理のアーキテクチャ

graph TD
    subgraph 入力
        A[画像/スクリーンショット]
        B[PDF/ドキュメント]
        C[音声/動画]
    end

    subgraph 前処理
        D[画像リサイズ・エンコード]
        E[PDF→画像変換 / テキスト抽出]
        F[音声文字起こし / フレーム抽出]
    end

    subgraph LLM
        G[マルチモーダルLLM]
    end

    subgraph 後処理
        H[構造化出力]
        I[バリデーション]
        J[結果キャッシュ]
    end

    A --> D --> G
    B --> E --> G
    C --> F --> G
    G --> H --> I --> J

画像認識・解析の実践

基本的な画像入力

OpenAI APIへの画像の渡し方は、URLとBase64の2通りです。

import base64
from pathlib import Path
from openai import OpenAI

client = OpenAI()

def encode_image(image_path: str) -> str:
    """画像をBase64エンコード"""
    with open(image_path, "rb") as f:
        return base64.standard_b64encode(f.read()).decode("utf-8")

def analyze_image(image_path: str, prompt: str) -> str:
    """画像を解析してテキストを返す"""
    base64_image = encode_image(image_path)
    
    # 拡張子からMIMEタイプを判定
    ext = Path(image_path).suffix.lower()
    mime_map = {".jpg": "jpeg", ".jpeg": "jpeg", ".png": "png", 
                ".gif": "gif", ".webp": "webp"}
    mime_type = f"image/{mime_map.get(ext, 'jpeg')}"
    
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {
                "role": "user",
                "content": [
                    {
                        "type": "image_url",
                        "image_url": {
                            "url": f"data:{mime_type};base64,{base64_image}",
                            "detail": "high",  # "low" | "high" | "auto"
                        },
                    },
                    {"type": "text", "text": prompt},
                ],
            }
        ],
        max_tokens=1024,
    )
    return response.choices[0].message.content

detail パラメータの使い分け

OpenAIのビジョンAPIには detail パラメータがあります。これはコストと精度に直結します:

detail値 トークン消費 用途
low 常に85トークン 画像の大まかな内容把握、分類
high 512×512タイルに分割して計算 OCR、細部の解析、図表読み取り
auto 画像サイズに応じて自動選択 汎用(非推奨:コスト予測困難)

コスト計算例(high モード):

def estimate_vision_tokens(width: int, height: int) -> int:
    """high detailモードでのトークン数を概算"""
    # 短辺を768px以下にスケール
    if min(width, height) > 768:
        scale = 768 / min(width, height)
        width = int(width * scale)
        height = int(height * scale)
    
    # 長辺を2048px以下にスケール
    if max(width, height) > 2048:
        scale = 2048 / max(width, height)
        width = int(width * scale)
        height = int(height * scale)
    
    # 512x512タイル数を計算
    tiles_w = (width + 511) // 512
    tiles_h = (height + 511) // 512
    tiles = tiles_w * tiles_h
    
    return 85 + 170 * tiles  # ベーストークン + タイルあたり170トークン

# 例: 1920x1080の画像
tokens = estimate_vision_tokens(1920, 1080)
print(f"推定トークン数: {tokens}")  # 約595トークン

スクリーンショット解析(UIテスト自動化への応用)

import asyncio
from playwright.async_api import async_playwright
from openai import AsyncOpenAI
import base64

client = AsyncOpenAI()

async def analyze_ui_screenshot(url: str, question: str) -> dict:
    """Webページのスクリーンショットを撮ってUI解析"""
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page(viewport={"width": 1280, "height": 720})
        await page.goto(url)
        screenshot = await page.screenshot(type="png")
        await browser.close()
    
    b64 = base64.standard_b64encode(screenshot).decode()
    
    response = await client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {
                "role": "user",
                "content": [
                    {
                        "type": "image_url",
                        "image_url": {"url": f"data:image/png;base64,{b64}", "detail": "high"},
                    },
                    {"type": "text", "text": f"""
以下のWebページのスクリーンショットを分析してください。

質問: {question}

JSON形式で回答してください:
answer
"""},
                ],
            }
        ],
        response_format={"type": "json_object"},
        max_tokens=1024,
    )
    
    import json
    return json.loads(response.choices[0].message.content)

# 使用例
# result = asyncio.run(analyze_ui_screenshot(
#     "https://example.com",
#     "ログインボタンは存在しますか?アクセシビリティ上の問題はありますか?"
# ))

PDF・ドキュメント処理

PDFをどう扱うか:2つのアプローチ

PDFの処理方法には大きく2つのアプローチがあります:

graph LR
    PDF[PDF入力]
    
    PDF --> A[テキスト抽出アプローチ]
    PDF --> B[画像変換アプローチ]
    
    A --> A1[pdfplumber / pypdf2]
    A1 --> A2[テキストをLLMに渡す]
    A2 --> A3[メリット: 安価・高速]
    
    B --> B1[pdf2image / pymupdf]
    B1 --> B2[画像をVision LLMに渡す]
    B2 --> B3[メリット: レイアウト・図表も解析可能]

使い分けの基準:

状況 推奨アプローチ
テキスト主体のPDF(契約書、論文) テキスト抽出
図表・グラフを含む 画像変換
スキャンPDF(OCRが必要) 画像変換
レイアウトが重要(財務報告書) 画像変換
コスト優先 テキスト抽出

テキスト抽出アプローチ(高速・低コスト)

import pdfplumber
from openai import OpenAI
from typing import Generator

client = OpenAI()

def extract_pdf_text(pdf_path: str) -> str:
    """PDFからテキストを抽出(ページ番号付き)"""
    pages = []
    with pdfplumber.open(pdf_path) as pdf:
        for i, page in enumerate(pdf.pages, 1):
            text = page.extract_text()
            if text and text.strip():
                pages.append(f"=== ページ {i} ===\n{text}")
    return "\n\n".join(pages)

def analyze_pdf_with_text(pdf_path: str, question: str) -> str:
    """テキスト抽出でPDFを解析"""
    text = extract_pdf_text(pdf_path)
    
    # 長すぎる場合はチャンク処理が必要
    MAX_CHARS = 100_000  # 約25Kトークン相当
    if len(text) > MAX_CHARS:
        text = text[:MAX_CHARS] + "\n\n[以降のテキストは省略されました]"
    
    response = client.chat.completions.create(
        model="gpt-4o-mini",  # テキストのみなら安価なモデルで十分
        messages=[
            {
                "role": "system",
                "content": "あなたはドキュメント解析の専門家です。提供されたPDFテキストに基づいて正確に回答してください。",
            },
            {
                "role": "user",
                "content": f"以下のPDFの内容について回答してください。\n\n質問: {question}\n\n--- PDFテキスト ---\n{text}",
            },
        ],
        max_tokens=2048,
    )
    return response.choices[0].message.content

画像変換アプローチ(高精度・図表対応)

import fitz  # pymupdf: pip install pymupdf
import base64
from openai import OpenAI
from pathlib import Path

client = OpenAI()

def pdf_to_images_base64(pdf_path: str, dpi: int = 150) -> list[str]:
    """PDFを画像に変換してBase64リストを返す"""
    doc = fitz.open(pdf_path)
    images = []
    
    for page in doc:
        # DPI設定でmatrixを計算(72dpi基準)
        zoom = dpi / 72
        mat = fitz.Matrix(zoom, zoom)
        pix = page.get_pixmap(matrix=mat)
        img_bytes = pix.tobytes("png")
        images.append(base64.standard_b64encode(img_bytes).decode())
    
    doc.close()
    return images

def analyze_pdf_with_vision(
    pdf_path: str,
    question: str,
    max_pages: int = 20,
    dpi: int = 150,
) -> str:
    """Vision LLMを使ってPDFを高精度解析"""
    images = pdf_to_images_base64(pdf_path, dpi=dpi)
    
    if len(images) > max_pages:
        print(f"警告: {len(images)}ページ中、最初の{max_pages}ページのみ処理します")
        images = images[:max_pages]
    
    # コンテンツリストを構築
    content = [{"type": "text", "text": f"以下のPDFを分析して回答してください。\n\n質問: {question}"}]
    
    for i, b64_img in enumerate(images, 1):
        content.append({
            "type": "text",
            "text": f"[ページ {i}]"
        })
        content.append({
            "type": "image_url",
            "image_url": {
                "url": f"data:image/png;base64,{b64_img}",
                "detail": "high",
            },
        })
    
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": content}],
        max_tokens=4096,
    )
    return response.choices[0].message.content

Claudeのネイティブ PDF サポートを活用

AnthropicはPDFをBase64でそのまま渡せるネイティブ対応をしており、変換不要で高精度です:

import anthropic
import base64

anthropic_client = anthropic.Anthropic()

def analyze_pdf_claude(pdf_path: str, question: str) -> str:
    """Claude のネイティブPDFサポートを活用"""
    with open(pdf_path, "rb") as f:
        pdf_data = base64.standard_b64encode(f.read()).decode()
    
    response = anthropic_client.messages.create(
        model="claude-3-5-sonnet-20241022",
        max_tokens=4096,
        messages=[
            {
                "role": "user",
                "content": [
                    {
                        "type": "document",
                        "source": {
                            "type": "base64",
                            "media_type": "application/pdf",
                            "data": pdf_data,
                        },
                    },
                    {"type": "text", "text": question},
                ],
            }
        ],
    )
    return response.content[0].text

音声処理

Whisper APIによる文字起こし

from openai import OpenAI
from pathlib import Path

client = OpenAI()

def transcribe_audio(
    audio_path: str,
    language: str = "ja",
    response_format: str = "verbose_json",
) -> dict:
    """音声ファイルを文字起こし(タイムスタンプ付き)"""
    with open(audio_path, "rb") as audio_file:
        transcript = client.audio.transcriptions.create(
            model="whisper-1",
            file=audio_file,
            language=language,
            response_format=response_format,  # verbose_jsonでタイムスタンプ取得
            timestamp_granularities=["segment", "word"],  # word単位のタイムスタンプ
        )
    
    return {
        "text": transcript.text,
        "language": transcript.language,
        "duration": transcript.duration,
        "segments": [
            {
                "start": seg.start,
                "end": seg.end,
                "text": seg.text,
            }
            for seg in (transcript.segments or [])
        ],
    }

def transcribe_and_summarize(audio_path: str) -> dict:
    """文字起こし + 要約を一括処理"""
    result = transcribe_audio(audio_path)
    
    # 文字起こしテキストをGPT-4oで要約
    summary_response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": "音声の文字起こしを受け取り、重要なポイントを箇条書きで要約してください。",
            },
            {"role": "user", "content": result["text"]},
        ],
        max_tokens=1024,
    )
    
    result["summary"] = summary_response.choices[0].message.content
    return result

GPT-4o Audio Preview(リアルタイム音声会話)

import base64
from openai import OpenAI
from pathlib import Path

client = OpenAI()

def audio_chat_with_gpt4o(audio_path: str, text_prompt: str = "") -> dict:
    """GPT-4o Audio Previewで音声を直接LLMに渡す"""
    with open(audio_path, "rb") as f:
        audio_data = base64.standard_b64encode(f.read()).decode()
    
    ext = Path(audio_path).suffix.lower().lstrip(".")
    if ext == "mp3":
        audio_format = "mp3"
    elif ext in ("wav", "wave"):
        audio_format = "wav"
    else:
        audio_format = "mp3"  # フォールバック
    
    content = [
        {
            "type": "input_audio",
            "input_audio": {
                "data": audio_data,
                "format": audio_format,
            },
        }
    ]
    if text_prompt:
        content.append({"type": "text", "text": text_prompt})
    
    response = client.chat.completions.create(
        model="gpt-4o-audio-preview",
        modalities=["text"],  # テキスト応答のみ
        messages=[{"role": "user", "content": content}],
        max_tokens=1024,
    )
    
    return {
        "text": response.choices[0].message.content,
        "usage": {
            "audio_tokens": response.usage.prompt_tokens_details.audio_tokens,
            "text_tokens": response.usage.prompt_tokens_details.text_tokens,
        },
    }

動画理解(Gemini 2.0)

GeminiはYouTube URLや動画ファイルをネイティブにサポートします:

import google.generativeai as genai
import time
from pathlib import Path

genai.configure(api_key="YOUR_GEMINI_API_KEY")

def analyze_youtube_video(youtube_url: str, question: str) -> str:
    """YouTube動画をGeminiで分析"""
    model = genai.GenerativeModel("gemini-2.0-flash")
    
    response = model.generate_content([
        {
            "file_data": {
                "file_uri": youtube_url,
                "mime_type": "video/youtube",
            }
        },
        question,
    ])
    return response.text

def analyze_local_video(video_path: str, question: str) -> str:
    """ローカル動画ファイルをGeminiで分析"""
    print(f"動画をアップロード中: {video_path}")
    video_file = genai.upload_file(path=video_path)
    
    # アップロード完了を待機
    while video_file.state.name == "PROCESSING":
        time.sleep(5)
        video_file = genai.get_file(video_file.name)
    
    if video_file.state.name == "FAILED":
        raise ValueError(f"動画の処理に失敗しました: {video_file.state.name}")
    
    model = genai.GenerativeModel("gemini-2.0-flash")
    response = model.generate_content([video_file, question])
    
    # 使用後はファイルを削除(ストレージ節約)
    genai.delete_file(video_file.name)
    
    return response.text

# 使用例
# 動画の特定シーンについて質問
# result = analyze_youtube_video(
#     "https://www.youtube.com/watch?v=XXXXX",
#     "この動画で説明されている技術的な概念を3点にまとめてください"
# )

高度な実装パターン

パターン1:マルチモーダルRAG(画像をベクトル検索に組み込む)

from openai import OpenAI
import base64
import json
from pathlib import Path

client = OpenAI()

def get_image_description_for_embedding(image_path: str) -> str:
    """画像の説明テキストを生成してエンベディング用に使う"""
    b64 = base64.standard_b64encode(Path(image_path).read_bytes()).decode()
    
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "user",
                "content": [
                    {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}", "detail": "low"}},
                    {
                        "type": "text",
                        "text": """この画像について、検索インデックス用の詳細な説明を生成してください。
以下を含めてください:
1. 画像の種類(写真/図表/スクリーンショット等)
2. 主要なコンテンツの説明
3. テキストが含まれる場合はその内容
4. 重要なキーワード(タグ形式)

JSON形式で返してください: {"description": "...", "tags": [...], "text_content": "..."}""",
                    },
                ],
            }
        ],
        response_format={"type": "json_object"},
        max_tokens=512,
    )
    
    result = json.loads(response.choices[0].message.content)
    # 検索用のテキストとして結合
    return f"{result['description']} {' '.join(result['tags'])} {result.get('text_content', '')}"

def create_image_embedding(image_path: str) -> list[float]:
    """画像の検索用エンベディングを生成"""
    description = get_image_description_for_embedding(image_path)
    
    embedding_response = client.embeddings.create(
        model="text-embedding-3-small",
        input=description,
    )
    return embedding_response.data[0].embedding

パターン2:構造化データ抽出(レシート・名刺・フォーム)

from pydantic import BaseModel, Field
from openai import OpenAI
import base64
import json
from typing import Optional
from datetime import date

client = OpenAI()

class ReceiptItem(BaseModel):
    name: str = Field(description="商品名")
    quantity: int = Field(description="数量", default=1)
    unit_price: float = Field(description="単価(円)")
    total_price: float = Field(description="合計金額(円)")

class Receipt(BaseModel):
    store_name: str = Field(description="店舗名")
    date: Optional[str] = Field(description="日付 (YYYY-MM-DD形式)", default=None)
    items: list[ReceiptItem] = Field(description="購入品目リスト")
    subtotal: Optional[float] = Field(description="小計(税抜)", default=None)
    tax: Optional[float] = Field(description="消費税額", default=None)
    total: float = Field(description="合計金額(税込)")
    payment_method: Optional[str] = Field(description="支払い方法", default=None)

def extract_receipt_data(image_path: str) -> Receipt:
    """レシート画像から構造化データを抽出"""
    b64 = base64.standard_b64encode(open(image_path, "rb").read()).decode()
    
    schema = Receipt.model_json_schema()
    
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {
                "role": "system",
                "content": f"""レシート画像から情報を抽出し、以下のJSONスキーマに従って出力してください。
読み取れない項目はnullにしてください。金額は数値(円)で記録してください。

スキーマ: {json.dumps(schema, ensure_ascii=False)}""",
            },
            {
                "role": "user",
                "content": [
                    {
                        "type": "image_url",
                        "image_url": {"url": f"data:image/jpeg;base64,{b64}", "detail": "high"},
                    },
                    {"type": "text", "text": "このレシートの情報を抽出してください。"},
                ],
            },
        ],
        response_format={"type": "json_object"},
        max_tokens=1024,
    )
    
    data = json.loads(response.choices[0].message.content)
    return Receipt(**data)

パターン3:複数画像の比較・差分検出

from openai import OpenAI
import base64

client = OpenAI()

def compare_images(image_path_1: str, image_path_2: str, context: str = "") -> dict:
    """2つの画像を比較して差分・変化点を検出"""
    
    def load(path):
        return base64.standard_b64encode(open(path, "rb").read()).decode()
    
    b64_1 = load(image_path_1)
    b64_2 = load(image_path_2)
    
    prompt = f"""2つの画像を比較して、以下のJSON形式で違いを報告してください。
{f'コンテキスト: {context}' if context else ''}

summary
  ],
  "unchanged_elements": ["変化していない主要な要素"],
  "overall_similarity": 0.0  // 0.0〜1.0の類似度スコア
}}"""

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {
                "role": "user",
                "content": [
                    {"type": "text", "text": "画像1(変更前):"},
                    {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64_1}", "detail": "high"}},
                    {"type": "text", "text": "画像2(変更後):"},
                    {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64_2}", "detail": "high"}},
                    {"type": "text", "text": prompt},
                ],
            }
        ],
        response_format={"type": "json_object"},
        max_tokens=2048,
    )
    
    import json
    return json.loads(response.choices[0].message.content)

コスト最適化戦略

画像前処理によるトークン削減

from PIL import Image
import io
import base64
from pathlib import Path

def optimize_image_for_llm(
    image_path: str,
    max_short_side: int = 768,
    max_long_side: int = 1568,
    quality: int = 85,
    format: str = "JPEG",
) -> str:
    """LLMに渡す前に画像を最適化してコストを削減"""
    img = Image.open(image_path)
    
    # RGBAをRGBに変換(JPEGはアルファチャンネル非対応)
    if img.mode in ("RGBA", "P") and format == "JPEG":
        background = Image.new("RGB", img.size, (255, 255, 255))
        if img.mode == "P":
            img = img.convert("RGBA")
        background.paste(img, mask=img.split()[3] if img.mode == "RGBA" else None)
        img = background
    elif img.mode != "RGB":
        img = img.convert("RGB")
    
    # アスペクト比を保ちながらリサイズ
    w, h = img.size
    short_side = min(w, h)
    long_side = max(w, h)
    
    if short_side > max_short_side:
        scale = max_short_side / short_side
        w, h = int(w * scale), int(h * scale)
    
    if max(w, h) > max_long_side:
        scale = max_long_side / max(w, h)
        w, h = int(w * scale), int(h * scale)
    
    img = img.resize((w, h), Image.LANCZOS)
    
    # 圧縮して出力
    buffer = io.BytesIO()
    img.save(buffer, format=format, quality=quality, optimize=True)
    buffer.seek(0)
    
    return base64.standard_b64encode(buffer.read()).decode()

# コスト比較
# 元の4K画像(3840x2160): ~2,805トークン (high detail)
# 最適化後(1568x882):    ~1,275トークン → 約55%削減

モデル選択とコストのトレードオフ

from enum import Enum
from dataclasses import dataclass

class TaskComplexity(Enum):
    SIMPLE = "simple"    # 画像分類、Yes/No判定
    MEDIUM = "medium"    # テキスト抽出、基本解析
    COMPLEX = "complex"  # 詳細解析、図表理解、多画像比較

@dataclass
class ModelConfig:
    model: str
    max_tokens: int
    detail: str

def select_vision_model(task: TaskComplexity, provider: str = "openai") -> ModelConfig:
    """タスク複雑度に応じてモデルを選択"""
    if provider == "openai":
        configs = {
            TaskComplexity.SIMPLE: ModelConfig("gpt-4o-mini", 256, "low"),
            TaskComplexity.MEDIUM: ModelConfig("gpt-4o-mini", 1024, "high"),
            TaskComplexity.COMPLEX: ModelConfig("gpt-4o", 4096, "high"),
        }
    elif provider == "anthropic":
        configs = {
            TaskComplexity.SIMPLE: ModelConfig("claude-3-5-haiku-20241022", 256, ""),
            TaskComplexity.MEDIUM: ModelConfig("claude-3-5-haiku-20241022", 1024, ""),
            TaskComplexity.COMPLEX: ModelConfig("claude-3-5-sonnet-20241022", 4096, ""),
        }
    else:
        raise ValueError(f"未対応のプロバイダー: {provider}")
    
    return configs[task]

# コスト目安(OpenAI、2026年Q1)
# SIMPLE(low detail): 画像1枚あたり約$0.001
# MEDIUM(high detail、1080p): 約$0.006
# COMPLEX(high detail、4K): 約$0.025

本番運用での注意点

1. ファイルサイズ制限に対処する

from pathlib import Path

def check_file_constraints(file_path: str, provider: str = "openai") -> dict:
    """ファイルの制約チェックと推奨事項"""
    size_mb = Path(file_path).stat().st_size / (1024 * 1024)
    ext = Path(file_path).suffix.lower()
    
    limits = {
        "openai": {"image_mb": 20, "formats": [".jpg", ".jpeg", ".png", ".gif", ".webp"]},
        "anthropic": {"image_mb": 5, "formats": [".jpg", ".jpeg", ".png", ".gif", ".webp"]},
        "gemini": {"image_mb": 20, "formats": [".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"]},
    }
    
    limit = limits.get(provider, limits["openai"])
    issues = []
    
    if size_mb > limit["image_mb"]:
        issues.append(f"ファイルサイズが上限({limit['image_mb']}MB)を超えています: {size_mb:.1f}MB")
    
    if ext not in limit["formats"]:
        issues.append(f"未サポートの形式: {ext}。サポート形式: {limit['formats']}")
    
    return {
        "ok": len(issues) == 0,
        "size_mb": round(size_mb, 2),
        "issues": issues,
        "recommendation": "画像を圧縮・リサイズしてください" if issues else "問題なし",
    }

2. レート制限とリトライ

import asyncio
import time
from openai import AsyncOpenAI, RateLimitError, APIError
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type

client = AsyncOpenAI()

@retry(
    retry=retry_if_exception_type((RateLimitError, APIError)),
    wait=wait_exponential(multiplier=1, min=4, max=60),
    stop=stop_after_attempt(5),
)
async def analyze_image_with_retry(image_b64: str, prompt: str) -> str:
    """リトライ付き画像解析"""
    response = await client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {
                "role": "user",
                "content": [
                    {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_b64}"}},
                    {"type": "text", "text": prompt},
                ],
            }
        ],
        max_tokens=1024,
    )
    return response.choices[0].message.content

async def batch_process_images(image_paths: list[str], prompt: str, concurrency: int = 5) -> list[str]:
    """複数画像を並列処理(同時実行数を制限)"""
    semaphore = asyncio.Semaphore(concurrency)
    
    async def process_one(path: str) -> str:
        async with semaphore:
            b64 = base64.standard_b64encode(open(path, "rb").read()).decode()
            return await analyze_image_with_retry(b64, prompt)
    
    tasks = [process_one(p) for p in image_paths]
    return await asyncio.gather(*tasks, return_exceptions=True)

3. プライバシーとデータガバナンス

マルチモーダルLLMを扱う際の重要な考慮事項:

import hashlib
from datetime import datetime

class MultimodalAuditLog:
    """マルチモーダルAPI呼び出しの監査ログ"""
    
    def __init__(self, db_path: str = "audit.db"):
        import sqlite3
        self.conn = sqlite3.connect(db_path)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS audit_log (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                timestamp TEXT NOT NULL,
                modality TEXT NOT NULL,       -- image/audio/video/document
                file_hash TEXT NOT NULL,      -- ファイルのSHA256ハッシュ(内容は保存しない)
                model TEXT NOT NULL,
                prompt_summary TEXT,          -- プロンプトの要約(機密情報は含めない)
                purpose TEXT,                 -- 処理目的
                data_classification TEXT      -- public/internal/confidential/restricted
            )
        """)
    
    def log(self, modality: str, file_content: bytes, model: str, 
            prompt: str, purpose: str, classification: str):
        file_hash = hashlib.sha256(file_content).hexdigest()
        # プロンプトは先頭100文字のみ記録
        prompt_summary = prompt[:100] + "..." if len(prompt) > 100 else prompt
        
        self.conn.execute(
            "INSERT INTO audit_log VALUES (NULL, ?, ?, ?, ?, ?, ?, ?)",
            (datetime.utcnow().isoformat(), modality, file_hash, model, 
             prompt_summary, purpose, classification)
        )
        self.conn.commit()

ユースケース別クイックリファレンス

ユースケース 推奨モデル アプローチ 月コスト目安(1000件/月)
レシートOCR GPT-4o mini 画像→構造化JSON ~$3
契約書PDF解析 Claude 3.5 Sonnet ネイティブPDF ~$15
UIテスト自動化 GPT-4o スクリーンショット ~$30
会議録文字起こし Whisper + GPT-4o mini 音声→テキスト→要約 ~$8
製品画像分類 GPT-4o mini (low detail) 分類タスク ~$1
図表・グラフ解析 GPT-4o high detail ~$25
動画コンテンツ分析 Gemini 2.0 Flash 動画直接入力 ~$10
マルチモーダルRAG GPT-4o + text-embedding-3-small 説明文生成→エンベディング ~$20

まとめ

マルチモーダルLLMを本番で活用するための要点をまとめます:

モデル選択

  • 動画が必要ならGemini 2.0(唯一の実用レベル)
  • PDF処理の精度ならClaude 3.5(ネイティブPDFサポート)
  • コスパと汎用性ならGPT-4o / GPT-4o mini
  • プライバシー重視ならLlama 3.2 Vision(ローカル実行)

コスト管理

  • detail: "low" で約85トークン(分類・大まかな解析に十分)
  • 画像を前処理(リサイズ・圧縮)してからAPIに渡す
  • タスク複雑度に応じてモデルを自動選択する仕組みを作る

品質向上

  • 構造化出力(JSON)で後処理を簡単に
  • 複数画像を渡す場合は順序と文脈を明示する
  • マルチモーダルRAGで大量ドキュメントも検索可能に

本番運用

  • リトライ+セマフォで安定したバッチ処理
  • 監査ログでデータガバナンスを確保
  • 機密データはローカルモデルかプライベートデプロイを検討

テキスト処理だけで使っているLLMを、今日からマルチモーダルに拡張してみてください。スキャンPDFの自動処理、UIテストの自動化、会議録の構造化など、エンジニアの日常業務を大きく効率化できるユースケースが身近にあるはずです。


参考資料