Stable Diffusion 3.5を本番運用して6ヶ月、地獄を見た実装記録

「簡単だ」と思って導入したら、メモリ管理から品質管理まで次々と問題が。チームで痛い目を見た工夫と失敗をぶっちゃけます。

画像生成AIを本番に入れてみたら、地獄の6ヶ月だった

先日プロジェクトで、マーケティング部からの要望で「商品画像を自動生成できないか」という話になった。最初は「ChatGPT使えばいいでしょ」くらいの軽い気持ちだったんだけど、実際に導入して6ヶ月運用してみたら、想像以上に複雑で、地味だけど重い落とし穴が次々と出てくるんですよね。

今日は、その失敗と工夫をまるっと共有しようと思う。多分、似た状況のエンジニアなら「あ、これ俺も経験した」ってなる話が結構あるはずです。

Stable Diffusion 3.5を選んだ理由と最初の誤算

うちがStable Diffusion 3.5を選んだのは、2026年時点でオンプレで動かせることが最大の理由。AWSのBedrock経由でAnthropic ClaudeのVisionを使うという選択肢もあったけど、毎回のAPI呼び出しコストと、データ機密性の問題でボツになった。

実装は簡単だった。Diffusersライブラリで基本的な推論はいけちゃう。

from diffusers import StableDiffusion3Pipeline
import torch

pipeline = StableDiffusion3Pipeline.from_pretrained(
    "stabilityai/stable-diffusion-3.5-large",
    torch_dtype=torch.float16
)
pipeline.to("cuda")

prompt = "A professional product photo of a blue ceramic mug, studio lighting, white background"
image = pipeline(
    prompt=prompt,
    num_inference_steps=28,
    guidance_scale=7.5
).images[0]

image.save("output.png")

数行でいけるわけですよ。最初は「こんなに簡単で大丈夫か」って思ったほど。ところが本番に乗っけたら、すぐに問題が出た。

メモリ地獄と推論時間の現実

最初の壁は、VRAM。うちのL40Sで約40GBメモリを食う。fp16で動かしてもそれくらい。マーケティング部が「1秒で画像を出してほしい」って言ってたんだが、実際には1枚生成に10〜15秒かかるんですよ。28ステップで余裕を見たら15秒。20ステップに削ると8秒くらいになるけど、品質がガクッと落ちる。

この時点で、うちのチームは「画像生成AIなんてリアルタイムじゃ無理」っていう現実と向き合うことになった。結果として、キューイングシステムを入れて、バッチ処理化した。

# Simple Redis-based queue implementation
import redis
import json
from celery import Celery

app = Celery('image_gen', broker='redis://localhost:6379')

@app.task
def generate_image(prompt, product_id):
    pipeline = StableDiffusion3Pipeline.from_pretrained(
        "stabilityai/stable-diffusion-3.5-large",
        torch_dtype=torch.float16
    ).to("cuda")
    
    image = pipeline(
        prompt=prompt,
        num_inference_steps=28,
        guidance_scale=7.5
    ).images[0]
    
    # S3に保存
    image_path = f"s3://our-bucket/generated/{product_id}.png"
    image.save(image_path)
    
    return {"status": "completed", "path": image_path}

# 呼び出し側
generate_image.delay("blue ceramic mug", "product_123")

「バッチ処理なら遅延しても大丈夫」ってのは、最初の想定では間違ってなかったんだけど、その後別の問題が出てくるんですよね。

LoRA学習で「なんか違う」を乗り越える地獄

マーケティング部が「うちの商品のスタイルに合わせた画像を出してほしい」と言い出したのが、運用2ヶ月目だった。一般的なプロンプトだと、どうしてもGenericな感じになっちゃうんですよ。スタイルが定まらないというか。

そこでLoRA(Low-Rank Adaptation)学習を入れることにした。自社の既存商品画像200枚を使って、カスタムLoRAモデルを作ろうと。

# kohyaのsd-scriptsを使ってLoRA学習
python train_network.py \
  --pretrained_model_name_or_path="stabilityai/stable-diffusion-3.5-large" \
  --train_data_dir="./training_data" \
  --output_dir="./lora_output" \
  --max_train_steps=1000 \
  --resolution="1024,1024" \
  --train_batch_size=1 \
  --learning_rate=0.0001 \
  --network_dim=64 \
  --network_alpha=32

ここからが問題だった。学習データの準備がクソ大変なんですよ。ただの200枚じゃなくて、タグ付けが必須なんです。「blue mug」「ceramic」「studio lighting」みたいに、きちんと属性を記述する必要がある。

最初は手作業でやってたんだけど、200枚タグ付けするのに30時間かかった。その後、CLIPで自動タグ付けを試したけど、日本語商品名がうまく認識されなくて、結局半分手作業に戻った。地獄。

学習後の「なんか違う」現象

LoRA学習が完了して、パイプラインに統合したのがいい。

from diffusers import StableDiffusion3Pipeline
from peft import PeftModel

pipeline = StableDiffusion3Pipeline.from_pretrained(
    "stabilityai/stable-diffusion-3.5-large",
    torch_dtype=torch.float16
).to("cuda")

# LoRAをロード
pipeline.load_lora_weights("./lora_output/pytorch_lora_weights.safetensors")

prompt = "A <mug_style> blue ceramic product on white background"
image = pipeline(prompt=prompt).images[0]

ところがですよ。生成された画像がなんか違うんですよ。スタイルは反映されてるんだけど、微妙に品質が落ちてるんです。色がやや飽和気味になったり、ディテールが甘くなったり。微妙な違いだけど、並べると「あ、こっちおかしい」ってわかるレベル。

原因は学習データのばらつき。200枚あっても、照明条件、背景、アングルがバラバラだったから、LoRA学習時に「何を学ぶべきか」が曖昧になってた。結果、オーバーフィッティングぎみになったのかな。学習が正しい「スタイル」じゃなくて、「ノイズの共通パターン」を拾ってた可能性がある。

正直、ここで3週間ハマった。学習率を変えたり、ステップ数を調整したり、正規化手法を試したり。最終的には、学習データを厳選して150枚に削ることで改善した。

# より厳選したデータセット
# - 背景は統一(白のスタジオセット)
# - 照明は固定
# - 複数アングルから同一商品を撮影
# - わりと高品質な既存画像100枚 + 新規撮影50枚

結果として「150枚でいいから品質にこだわる」が、「200枚でも平均的」より優勝することが判明した。その後の学習は安定して、マーケティング部も満足するクオリティになった。地味な改善だけど、効果は絶大でしたね。

本番で痛い目を見た3つの落とし穴

1. プロンプトインジェクションと予期しない出力

マーケティング部が自由にプロンプトを打ち込める仕様だったんが、ある日突然、商品画像ではなく**「うちの競合他社のロゴ」が生成される**っていう事態が起きた。

原因は、営業が「Nike mug in Apple store style」みたいなプロンプトを入力してたから。Stable Diffusionはそれを素直に解釈して、Nike的なスタイルのマグを生成してた。当然、IP的にアウト。下手すると法務部から怒られる案件ですよ。

そこから、プロンプトのサニタイズを入れた。

import re
from typing import List

# ブロックリスト
BLOCKED_TERMS = [
    r'(apple|nike|coca-cola|gucci)',  # ブランド名
    r'(logo|brand|trademark)',  # 商標関連
    r'(person|face|celebrity|actor)',  # 人物
]

def sanitize_prompt(prompt: str) -> bool:
    """プロンプトが許可可能か判定"""
    lower_prompt = prompt.lower()
    for pattern in BLOCKED_TERMS:
        if re.search(pattern, lower_prompt, re.IGNORECASE):
            return False
    return True

# 使用例
if not sanitize_prompt(user_prompt):
    raise ValueError("Blocked term detected")

地味だけど重い仕様変更だった。以降、人手を介す方向に舵を切った。つまり、プロンプトテンプレートを作って、その中から選ぶ方式。完全自由度じゃなくなったけど、安全性は格段に上がった。

PROMPT_TEMPLATES = {
    "mug_minimal": "A {color} ceramic mug, minimalist design, studio lighting, white background, product photography",
    "mug_luxury": "A premium {color} mug, luxury aesthetic, golden rim detail, dramatic lighting, 8k quality",
    "bottle_sport": "A {color} sport water bottle, modern design, sleek form, on neutral background, professional product photo",
}

def generate_with_template(template_key: str, color: str) -> str:
    if template_key not in PROMPT_TEMPLATES:
        raise ValueError(f"Unknown template: {template_key}")
    return PROMPT_TEMPLATES[template_key].format(color=color)

これにしたら、IP問題はぐんと減った。ユーザーの使い勝手は落ちるけど、事故は防げる。実務的には、これが正解ですね。

2. 品質の個体差と検証の地獄

Stable Diffusion 3.5は「シード値を固定するとほぼ同じ画像が生成される」という特性がある。ところが、プロンプトをちょっと変えたり、guidance_scaleを変えたりすると、品質がバラバラになるんですよ。正直ここが一番面倒だった。

「guidance_scale=7.5」だと滑らかだけど、「guidance_scale=10」だと不自然な色合いになったり、ディテールが乱れたり。同じプロンプトなのに結果がコロコロ変わる。この「ハイパーパラメータ依存性」は、正直しんどい。

ここは試行錯誤しかないと開き直った。うちは、色々なパラメータで生成してそれぞれの品質をスコア化することにした。

import torch
from torchvision import transforms
from PIL import ImageOps, Image

def assess_image_quality(image: Image.Image) -> dict:
    """
    生成画像の品質を多次元で評価
    実際には BRISQUE や LPIPS を使うのがベター
    """
    # ここは簡易版。本番ではもっと複雑
    img_array = transforms.ToTensor()(image)
    
    # 色の飽和度(高すぎるのはNG)
    saturation = (img_array.std(dim=0).mean()).item()
    
    # エッジの鮮鋭度(高いほどOK)
    edges = transforms.GaussianBlur(kernel_size=3)(image)
    sharpness = (img_array - transforms.ToTensor()(edges)).abs().mean().item()
    
    return {
        "saturation": saturation,
        "sharpness": sharpness,
        "overall_score": (saturation * 0.4 + sharpness * 0.6),
    }

# 複数のハイパーパラメータで試す
results = []
for guidance_scale in [5, 7.5, 10, 12]:
    for steps in [24, 28, 32]:
        image = pipeline(
            prompt=prompt,
            num_inference_steps=steps,
            guidance_scale=guidance_scale
        ).images[0]
        
        quality = assess_image_quality(image)
        results.append({
            "guidance": guidance_scale,
            "steps": steps,
            "quality": quality
        })

# 最高スコアの組み合わせを採用
best = max(results, key=lambda x: x["quality"]["overall_score"])
print(f"Best params: guidance={best['guidance']}, steps={best['steps']}")

実際には、ここら辺のチューニングはめっちゃ時間がかかるんですよ。「商品によって最適なパラメータが違う」なんてことも判明して、マグならguidance=7.5がいいけど、ボトルならguidance=10がいい、みたいなルールを手作業で蓄積してった。個別最適化の沼。

3. VRAM管理とGPU共有の地獄

うちは、Stable Diffusion以外にも、別のML推論ワークロード(データ分析チームがLLMで予測やってる)があったんですよ。

L40Sは1枚だけ。それを両者で共有しようとした。Celeryでキューイングしてるんだから大丈夫だろ、って思ってた。

甘かった。これが一番痛かった。

ある日、夕方に「画像生成の待機が200件溜まってて、LLM推論も詰まってる」っていう悪夢の状況になった。原因は、キューイングの優先度が統一されてなくて、どっちが先に走るか不定だったから。VRAMの解放タイミングがズレて、予期しないOOMエラーが頻発。その日は本当に大変だった。

そこから、GPU時間帯の分離優先度制御を入れた。

# Before: 優先度なし、ランダム実行
@app.task
def generate_image(prompt, product_id):
    # ...

@app.task
def run_llm_inference(data, model_id):
    # ...
# After: 時間帯と優先度で分離
from celery import current_app
from datetime import datetime

@app.task(bind=True, priority=9)  # 高優先度
def generate_image(self, prompt, product_id):
    # オンデマンド画像生成(高優先度)
    return _generate(prompt, product_id)

@app.task(bind=True, priority=5)
def batch_generate_images(self, prompts, job_id):
    # バッチ生成(中優先度、深夜実行推奨)
    return _batch_generate(prompts, job_id)

@app.task(bind=True, priority=7)
def run_llm_inference(self, data, model_id):
    # LLM推論(中優先度)
    return _llm_infer(data, model_id)

# スケジューラ:深夜(23時〜6時)はバッチ画像生成、昼間はオンデマンド
from celery.schedules import crontab

app.conf.beat_schedule = {
    'batch-images-at-midnight': {
        'task': 'tasks.batch_generate_images',
        'schedule': crontab(hour=23),  # 夜23時
    },
}

これで、昼間は画像生成リクエストはすぐに応答(ただしキュー待ち)、深夜にバッチ処理で大量生成、っていう流れになった。LLM推論とのメモリバトルもなくなった。結果として安定稼働になりましたね。

2026年の画像生成AIスタック選定

6ヶ月運用してて気づいたのは、「何を選ぶか」より「どう運用するか」が全てってことですね。

選択肢推論速度品質コスト運用難度
Stable Diffusion 3.5(自前)8-15秒/枚★★★★☆中(GPU購入)
Stable Diffusion 3.5 Turbo(自前)3-5秒/枚★★★☆☆
AWS Bedrock Titan5-10秒★★★☆☆高(API従量)
Azure DALL-E 320-30秒★★★★★
Together AI(Flux)10-20秒★★★★★中(API)

うちが「自前Stable Diffusion 3.5」を選んだ理由は、データ機密性と長期コストのバランスですね。毎月数千枚の画像生成をするなら、自前のほうが結果的に安い。

ただし、もし「プロトタイピングだけ」「小規模」なら、AWS BedrockやTogether AIのようなマネージドサービスにしてた。そっちのほうが手間がぐっと減る。

まとめ

画像生成AIを本番に乗っけるのは、想像よりずっと複雑で、地味だけど重い落とし穴がいっぱいある。6ヶ月やってみて、次のアクションが重要だと気づいた:

  1. 推論時間とコストのバランスを最初に決める ——リアルタイムを期待するなら別の戦略(キューイング、事前生成)が必須。最初から「バッチ処理ありき」で設計したほうが後悔が少ない。

  2. LoRA学習は「品質にこだわった少量」が「平均的な大量」より優勝する ——200枚より150枚選別のほうが結果良かった。データ量より質ですね。

  3. プロンプトは自由度より制約 ——テンプレート化でIP問題と品質の個体差を両立できる。ユーザー満足度は落ちるけど、事故防止が優先。

  4. GPU共有は優先度制御とスケジューリング必須 ——複数ワークロードがあるなら、時間帯で分離するのが現実的。メモリ管理は手作業より自動化。

  5. パラメータ選択は試行錯誤の地獄、自動化推奨 ——guidance_scaleとstepsの組み合わせ検証は、むしろプログラムに任せるほうが楽。人間の判断より、スコア化して機械的に選ぶ。

正直まだ完璧じゃない部分もあるけど(品質スコアリングのアルゴリズムをもっと洗練させたいし、マルチLoRA対応も検討中)、本番で6ヶ月回してみたおかげで、何が本当の課題なのかが見えてきた。

皆さんで画像生成AI導入考えてる人いたら、このへんの落とし穴に気をつけてください。正直、ChatGPTの文字生成より、画像生成のほうがはるかに運用は大変ですよ。

U

Untanbaby

ソフトウェアエンジニア|AWS / クラウドアーキテクチャ / DevOps

10年以上のIT実務経験をもとに、現場で使える技術情報を発信しています。 記事の誤りや改善点があればお問い合わせからお気軽にご連絡ください。

関連記事