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 Titan | 5-10秒 | ★★★☆☆ | 高(API従量) | 低 |
| Azure DALL-E 3 | 20-30秒 | ★★★★★ | 高 | 低 |
| Together AI(Flux) | 10-20秒 | ★★★★★ | 中(API) | 低 |
うちが「自前Stable Diffusion 3.5」を選んだ理由は、データ機密性と長期コストのバランスですね。毎月数千枚の画像生成をするなら、自前のほうが結果的に安い。
ただし、もし「プロトタイピングだけ」「小規模」なら、AWS BedrockやTogether AIのようなマネージドサービスにしてた。そっちのほうが手間がぐっと減る。
まとめ
画像生成AIを本番に乗っけるのは、想像よりずっと複雑で、地味だけど重い落とし穴がいっぱいある。6ヶ月やってみて、次のアクションが重要だと気づいた:
-
推論時間とコストのバランスを最初に決める ——リアルタイムを期待するなら別の戦略(キューイング、事前生成)が必須。最初から「バッチ処理ありき」で設計したほうが後悔が少ない。
-
LoRA学習は「品質にこだわった少量」が「平均的な大量」より優勝する ——200枚より150枚選別のほうが結果良かった。データ量より質ですね。
-
プロンプトは自由度より制約 ——テンプレート化でIP問題と品質の個体差を両立できる。ユーザー満足度は落ちるけど、事故防止が優先。
-
GPU共有は優先度制御とスケジューリング必須 ——複数ワークロードがあるなら、時間帯で分離するのが現実的。メモリ管理は手作業より自動化。
-
パラメータ選択は試行錯誤の地獄、自動化推奨 ——guidance_scaleとstepsの組み合わせ検証は、むしろプログラムに任せるほうが楽。人間の判断より、スコア化して機械的に選ぶ。
正直まだ完璧じゃない部分もあるけど(品質スコアリングのアルゴリズムをもっと洗練させたいし、マルチLoRA対応も検討中)、本番で6ヶ月回してみたおかげで、何が本当の課題なのかが見えてきた。
皆さんで画像生成AI導入考えてる人いたら、このへんの落とし穴に気をつけてください。正直、ChatGPTの文字生成より、画像生成のほうがはるかに運用は大変ですよ。