AIつまみアイコンを支える技術 (Webアプリ実装編)

フロントエンド・バックエンドの実装の話

投稿日
更新日
読了予想時間
25
tag emoji技術

バックエンドでの処理

バックエンドはフロントエンドから呼び出され、複雑なロジックや処理を担う重要な役割を果たします。 例えば、Webサイトへのログイン認証や、ECサイトでの決済処理などもバックエンドで行われています。

クライアントサイドにすべての処理を記述すると、APIキーの漏洩などセキュリティリスクが生じる可能性があります。これを防ぐため、データベースや外部APIへのアクセスコードはバックエンドに隠蔽する必要があります。

また、重い処理をクライアントサイドで行うと、デバイスに負荷がかかりパフォーマンスが低下します。バックエンドは効率的なリクエスト処理や負荷分散を行い、複数ユーザーへの対応やシステム全体の拡張性を確保します。さらに、ビジネスロジックを集約することでコードの一貫性とメンテナンス性を向上させます。

最新画像のメタデータ取得 (GET /icongen/current/metadata)

トップページから呼び出しているエンドポイントです。 最新画像のメタデータを取得します。

Loading diagram...

キャッシュ

メタデータの取得はキャッシュから取ってくるようにしています。 トップページを開くたびに SELECT * FROM images ORDER BY created_at DESC LIMIT 1 とやると DB に負荷がかかるはずなので、Cloudflare Workers KV (高速にデータの読み書きができる一時保存場所) にキャッシュしています。まあ負荷がかかるほどのアクセス数はないのですが……。

2024-12-23 EDIT: あずきバーさんからツッコまれたのですが、インデックス効くので高速で動くらしいです。キャッシュ要らなかった。 → このPRで削除しました!: https://github.com/trpfrog/trpfrog.net/pull/97

TypeScript
// キャッシュから取得
const key = 'latestImageMetadata'
await c.env.KV.put(key, JSON.stringify(metadata))

// キャッシュから取得
const rawData = await c.env.KV.get(key)
const metadata = myValidateFn(JSON.parse(rawData))

メタデータの検索 (GET /icongen/query)

ここでは画像のメタデータを検索します。 現時点では「プロンプトに含まれる文字列」のみで検索できるようにしています。

Loading diagram...

Cloudflare D1 (データベース) に SQL クエリを投げるだけなので特に複雑なこと/面白いことはやっていません。 強いて書くなら今回初めて Drizzle ORM を使いました。

基本的な API は SQL に近い感じなのと、drizzle.config.ts 以外の独自の設定ファイルなしで使えるのがよかったです。 あんまりコードジェネレータが好きではないので、型計算だけでほぼ全てが完結するところが美しいなあって思います。 欠点を挙げるとすると細かいクエリを書く部分が tree-shakable な感じの API (伝われ) なのでいまいち補完が効きにくくてつらかったです。

実装はこんな感じです。そんなに難しいことせず素直に書けました。 そういえば今回バックエンドではクリーンアーキテクチャ (もどき) を採用したのですが、hono/context-storage が大活躍しました。ここで説明すると長くなるのであとで書きます。

TypeScript
import { desc, eq, or, and, like, count } from 'drizzle-orm'
import { drizzle } from 'drizzle-orm/d1'
import { getContext } from 'hono/context-storage'

function createWhereQuery(query: ImageMetadataQuery['where']) {
  const { prompt } = query
  const tokens = prompt ? prompt.split(/\s+/) : []
  return and(
    ...tokens.map(token =>
      or(like(images.promptText, `%${token}%`), like(images.promptTranslated, `%${token}%`)),
    ),
  )
}

export const imageMetadataRepoCloudflareD1WithKV: ImageMetadataRepo = {
  async query(query) {
    const c = getContext<Env>()
    const db = drizzle(c.env.DATABASE)
    const res = await db  
      .select()  
      .from(images)  
      .where(createWhereQuery(query.where))  
      .orderBy(desc(images.createdAtMillis))  
      .offset(query.offset)  
      .limit(query.limit)  

画像生成 (POST /icongen/update)

画像生成リクエストを受け取り、画像生成を行います。 ここが一番複雑なことをやっています。簡単に説明すると次のような流れです。

  • 最後の生成から3時間以上経過していたら画像生成を行う
  • ランダムな単語 (お題の単語) を取得する
  • LLM にお題の単語を入力してプロンプトを生成する
  • 画像生成モデルにプロンプトを入力して画像生成を行う
  • 生成した画像をオブジェクトストレージ (R2) に保存する
  • メタデータをデータベース (D1) に保存する
  • 最新データを KV Store (Workers KV) に保存する
  • 生成した画像の URL とメタデータを返す

Loading diagram...

ランダムなプロンプトの生成

人間が介在しない完全なるランダムな画像を生成をするために GPT-4o を使ってプロンプトを生成をすることにしました。プロンプトとは画像生成 AI に渡すお題のことです。

GPT-4 に直接「なんかプロンプト作って!」とお願いしても良いのですが、「なんかプロンプト作って!」だけだといつか同じことを言う可能性があるので、もうちょっと明確にランダム性が欲しいです。そこで、ランダムに単語を10個取ってきて「これを使ってプロンプトを作って!」と言ってみることにしました。

ランダムにならないって本当?

感覚的に……おそらく温度パラメータ (次単語選択の確率に関係するパラメータ) を高めに設定してあげれば十分にランダムなプロンプトが得られるとは思います。とはいえ、言語モデルはランダムな値を出すことは構造的に苦手だと思うので、外部からランダム性を与えてあげるのが良いかと思っています。すみません、詳しく実験していないのでわかりません……。(シンプルな乱数に関しては偏りがあることが報告されています: https://llmrandom.straive.app/)

ランダムな単語は Rando というサービスから取れます。GET /api?words=10 で英単語をランダムに10個取れます。ありがとうございます。

次に「この単語を使って画像生成のためのプロンプトを作って!」というプロンプトを GPT-4o に突っ込みます。

GPT-4o に与えるプロンプト (和訳)

あなたのタスクは、Stable Diffusion向けの視覚的に魅力的で、創造的かつ一貫性のある画像生成プロンプトを作成することです。以下のステップに従って進めてください。

  • フェーズ 1: 入力語の理解
    • 提供された入力語を注意深く確認してください。
    • すべての単語を使用する必要はありません。 最も魅力的で一貫性のあるシーンを作成するために単語を選びましょう。
    • コンテンツガイドライン(例:健全であること、包括性)を遵守しつつ、ユーモアや視覚的な魅力を保ちましょう。
  • フェーズ 2: 「基本」プロンプトの作成
    • 目的: 入力語に基づいてシンプルでわかりやすい説明を作成します。
    • ガイドライン: プロンプトは「an icon of trpfrog」で始め、入力語をいくつか取り入れてください。
    • アイデア出し: この段階ではアイデアを探るため、長めのプロンプトでも問題ありません。
  • フェーズ 3: 「創造的」プロンプトの展開
    • 目的: ユニークで面白い、または予想外の要素を取り入れて、シーンを視覚的または概念的に魅力的にします。
    • ガイドライン: プロンプトは「an icon of trpfrog」で始め、詳細を追加してシーンを広げます。
    • 焦点: スタイル、修飾語、および追加の詳細を使用してシーンを強化しつつ、適切性と一貫性を保ちます。
  • フェーズ 4: 「洗練された」プロンプトの作成
    • 目的: 創造的な草案を20語程度の簡潔で視覚的に一貫性のある説明に仕上げます。
    • ガイドライン: プロンプトは「an icon of trpfrog」で始めます。
    • 洗練: 不要な詳細を削減し、明確性を確保しつつ、ユーモアや視覚的な魅力を維持します。この段階の編集でシーンがどう改善されたかを説明してください。
  • フェーズ 5: 「最終」プロンプトの作成
    • 目的: 「洗練された」説明を15語以内のプロンプトに凝縮します。
    • ガイドライン: 最終プロンプトは「an icon of trpfrog」で始めます。
    • 焦点: 明確性、視覚的インパクト、簡潔さを優先します。このバージョンが最も効果的である理由を説明してください。
  • フェーズ 6: プロンプトの日本語訳
    • 最終プロンプトを日本語に翻訳します。
    • 「trpfrog」は「つまみさん」と翻訳してください。
    • 翻訳において同じトーン、ユーモア、視覚的な意図を維持してください。

画像生成プロンプト作成のヒント

  • 詳細に記述する: Stable Diffusionは、物体、環境、スタイルを具体的かつ詳細に記述すると効果的です。
  • 重要な要素を強調する: シーンの最も重要な部分に焦点を当て、モデルを効果的に導きます。
  • 修飾語を試す: 「フォトリアリスティック」、「鮮やかな色彩」、「劇的なライティング」、「ファンタジースタイル」などの言葉を使用して出力を調整します。
  • 詳細と簡潔さのバランス: プロンプトに過剰な詳細を詰め込みすぎないようにし、一貫性と明確さを優先します。
  • 光とムードを活用する: 「柔らかな光」、「ゴールデンアワー」、「幻想的な輝き」などの言葉を使用して深みや雰囲気を追加します。
  • リアル感を出すためのコンテキスト: 信頼性のあるアクション、相互作用、環境を含め、シーンを現実味のあるものにします。

追加の注意点

  • 柔軟性: すべての入力語を使用する必要はありません。代わりに、最も良い視覚的出力に寄与する単語を選びましょう。
  • アイデア出し: 「基本」および「創造的」フェーズでは、アイデアを探るために長いプロンプトも推奨されます。
  • 批判的推論:
    • 各フェーズで何がうまくいき、何がうまくいかなかったかを指摘してください。
    • 決定や編集の理由を明確に説明してください。
    • プロンプトを視覚的に印象的で、魅力的、かつユーモラスに保ちましょう。

出力フォーマット

以下のJSON形式で回答を提供してください:

{
  "basic": {
    "reasoning": "このフェーズの説明",
    "prompt": "長めのアイデア出し用プロンプト。必ず「an icon of trpfrog」で始めること。"
  },
  "creative": {
    "reasoning": "このフェーズの説明",
    "prompt": "より創造的で長めのバージョン。「an icon of trpfrog」で始めること。"
  },
  "polished": {
    "reasoning": "このフェーズの説明",
    "prompt": "約15語の洗練されたバージョン。「an icon of trpfrog」で始めること。"
  },
  "final": {
    "reasoning": "このバージョンが最適である理由",
    "prompt": "10語以内の簡潔で魅力的なバージョン。「an icon of trpfrog」で始めること。"
  },
  "translated": "最終プロンプトの日本語訳。「an icon of trpfrog」は「つまみさんの画像」とすること。"
}

プロンプトの内容を簡単に説明すると、「入力された単語を使って次のような JSON を生成してね」という内容になっています。

JSON
{
  "basic": {
    "reasoning": "このフェーズの説明",
    "prompt": "長めのアイデア出し用プロンプト。必ず「an icon of trpfrog」で始めること。"
  },
  "creative": {
    "reasoning": "このフェーズの説明",
    "prompt": "より創造的で長めのバージョン。「an icon of trpfrog」で始めること。"
  },
  "polished": {
    "reasoning": "このフェーズの説明",
    "prompt": "約15語の洗練されたバージョン。「an icon of trpfrog」で始めること。"
  },
  "final": {
    "reasoning": "このバージョンが最適である理由",
    "prompt": "10語以内の簡潔で魅力的なバージョン。「an icon of trpfrog」で始めること。"
  },
  "translated": "最終プロンプトの日本語訳。「an icon of trpfrog」は「つまみさんの画像」とすること。"
}

このように複数のステップで画像生成プロンプトを作成することで、よりクリエイティブで魅力的な画像を生成することを目指しています。これが研究だったら意味のあることなのかしっかり調査する必要がありますが、今回は趣味なのでおまじない程度ということで……。でも OpenAI o1 や "Let's think step by step" で知られているように、LLM には思考する余裕を与えてあげることで、より良い結果が得られることが報告されています。

また、出力を JSON としたのは結果をプログラム上で扱いやすくするためです。

さて、このプロンプトを AI に与えて得られる JSON をパースして、画像生成のためのプロンプトとします。このとき、

TypeScript
const finalPromptSchema = z.object({
  final: z.object({
    prompt: z.string(),
  }),
  translated: z.string(),
})
return finalPromptSchema.parse(JSON.parse(reply))

のように AI の応答を Zod でバリデーションしています。 AI は確率的に振る舞うので、必ずしも正しい JSON を吐かないことに注意が必要です。とはいえ GPT-4o は十分に賢いので、few-shot learning だけでほぼ 100% 正しい JSON を吐いてくれます。ありがたいですね。 あと OpenAI API だと出力として JSON を吐くことを強制するオプションがあります。

画像を生成する

プロンプトさえできてしまえば画像は簡単に生成できます。 HuggingFace のライブラリ @huggingface/inference を使います。HuggingFace は多数の AI モデルをホストしているサービスです。AI 界の GitHub 的な存在です。

HuggingFace では推論をやってくれる API (Inference API) があり、@huggingface/inference はそのラッパーライブラリとなっています。API で推論できることの嬉しい点としては

  • 簡単
  • デカいメモリが必要ない (重要)

ことがあげられます。通常、AI の推論には巨大な RAM か VRAM が必要になる上、モデル自体のサイズも大きいのでそれなりのディスク容量が必要になります。 これをクラウドでやると莫大なお金がかかってしまいます。これを API にまとめて向こうで推論をやってくれる HuggingFace、感謝……

次のように推論を書きました。

TypeScript
import { HfInference } from '@huggingface/inference'

const hf = new HfInference(process.env.HUGGINGFACE_TOKEN)

export async function generateTrpFrogImage(prompt: string) {
  const responseBlob = await hf.textToImage({
    model: 'Prgckwb/trpfrog-sd3.5-large-lora',
    inputs: prompt,
  })
  const arrayBuffer = await responseBlob.arrayBuffer()
  const base64 = Buffer.from(arrayBuffer).toString('base64')

雑にいえば hf.textToImageモデル名とプロンプトを渡せば終了です。簡単! モデル名にはちくわぶさんの作ってくださった Prgckwb/trpfrog-sd3.5-large-lora を指定します。

ところで、HuggingFace ではセンシティブな画像が生成されてしまったとき、エラーを吐いたり再生成したりすることなく、無言で真っ黒な画像を返すという最悪な仕様があります。

真っ黒な画像の base64 には ooooAKKKKACiiigA が繰り返し出現するので、これを利用してセンシティブチェックを行います。(これ本当にどうにかならないんですかね)

TypeScript
  const invalidImagePattern = /(ooooAKKKKACiiigA){10,}/
  return {
    base64,
    success: !invalidImagePattern.test(base64),
  }
}

これで、ランダムな画像の生成ができるようになりました!

Next.js の API Routes を使わずに Cloudflare Workers を使った理由

建前としては「ロジックが複雑なので別のサービスとして trpfrog.net 本体から分離した」ということにしていますが、実は分けた本当の理由は別にあります。これは HuggingFace Inference API の仕様によるものです。

HuggingFace Inference API は画像生成時に、画像の生成が完了するまでレスポンスが返ってきません。つまり、リクエストを送ってから画像が生成されるまでの間、レスポンスを待ち続ける必要があります。

Loading diagram...

しかし、画像生成には時間が何十秒とかかるため、Next.js の API Routes でこれを使うとリクエストがタイムアウトしてしまいます。正確には Vercel の提供する Edge Functions (無料版) ではタイムアウトしてしまいます。そのため、別のプラットフォーム上にバックエンドを作る必要がありました。

そこで今回は Cloudflare Workers を使ってバックエンドを作りました。Cloudflare Workers には 10ms の CPU Time Limit が設けられているので一見すると使えないように見えますが、CPU Time ですのでリクエストの待ち時間は含まれません。そのため、HuggingFace Inference API の長いレスポンスを待つことができます。

本当は HuggingFace Inference API 側に Callback URL を設定できれば良いのですが、現状はないようです。Callback URL を設定できれば次のような処理が可能になるので対応して欲しいところです。

Loading diagram...

記事一覧ツイート訂正リクエスト
タグ「技術」の新着記事