🤖

ブラウザで動く LLM「WebLLM」を React+TypeScript に導入し、ローカル AI 機能を実装するまで

に公開

はじめに

React アプリケーションに AI 機能を組み込む際、外部 API にはコスト、プライバシー、ネットワーク遅延といった課題があります。

これらの課題を解決する手段として、ブラウザ上で直接 LLM を動作させるライブラリ「WebLLM」に注目しました。

本記事では、WebLLM の技術的な概要と詳細な比較(理論編)から、React への具体的な導入パターン、プロンプトエンジニアリング、そして導入時のハードル(実践編)までを網羅的に解説します。

本記事で学べること

  • WebLLM の基本概念と、それを支える技術 (WebGPU, Wasm)
  • サーバーサイド LLM との詳細なメリット・デメリット比較
  • React + TypeScript での WebLLM 統合パターン(汎用カスタムフック)
  • WebLLM に合わせたプロンプトエンジニアリングの実例
  • ブラウザベース LLM 導入時の重要な注意点(HTTPS、初回ロード)

【理論編】WebLLM とは何か?

このセクションでは、まず WebLLM がどのような技術で、従来の手法と何が違うのかを解説します。

WebLLM の概要とコア技術

WebLLM は、ブラウザ上で直接 LLM を動作させるための JavaScript ライブラリです。

従来、LLM の実行には高性能な GPU を備えたサーバーが必要でしたが、WebLLM は以下の技術を活用することで、クライアントサイドでの実行を可能にしました。

  • WebGPU: ブラウザから GPU に直接アクセスするための最新 API。これにより、複雑なモデル計算を高速に処理できます。
  • Wasm (WebAssembly): C++や Rust などで書かれたコードをブラウザ上で高速実行する技術。LLM のコアロジックをネイティブに近い速度で動かします。
  • キャッシュ: 一度ダウンロードしたモデル(数百 MB〜数 GB)をブラウザのキャッシュに保存し、2 回目以降の起動を高速化します。

なぜ WebLLM なのか? サーバーサイド API との徹底比較

AI 機能を実装する際、OpenAI API のようなクラウド API を利用するのが一般的です。WebLLM はそれらと何が違うのでしょうか。

特徴 WebLLM(ブラウザ内) サーバーサイド LLM(クラウド API)
コスト 開発コストのみ。推論あたりの費用はゼロ。 運用コスト。トークン単位またはサブスク課金。
データプライバシー 非常に高い。データはデバイスから出ない。 セキュリティ対策が必要。
モデル性能/サイズ モデルサイズとユーザーのハードウェアに制限される。 ほぼ無制限。最先端の最大規模モデルを利用可能。
遅延 ネットワーク遅延なし。ローカルハードウェア依存。 ネットワーク遅延あり。推論自体は高速。
オフライン機能 可能(モデルキャッシュ後)。 不可。
保守・更新 ユーザーが新しいモデルを再ダウンロードする必要がある。 サーバー側での一度のデプロイで全ユーザーに反映。

ローカルで完結させたい機能や、プライバシーを重視する機能、API コストをかけたくない機能において、WebLLM は非常に強力な選択肢となります。

パフォーマンスとブラウザの対応状況

処理速度 (token/s)

ブラウザ実行とはいえ、WebGPU のおかげでパフォーマンスは実用的です。Apple M2 Ultra でのベンチマーク例(arxiv:2412.15803)では、ネイティブ実行(Python/C++)と比較しても遜色ありません。

Model WebLLM (token/s) MLC-LLM (ネイティブ) (token/s) 性能保持率
Llama-3.1-8B 41.1 57.7 71.2%
Phi-3.5-mini (3.8B) 71.1 89.3 79.6%

ブラウザ上でありながら、ネイティブの 70-80%の速度が期待できます。

WebGPU の対応ブラウザ

WebLLM の性能は WebGPU に大きく依存します。WebGPU が利用できない環境では、Wasm にフォールバックしますが、推論速度は大幅に低下します。

ブラウザ デスクトップ モバイル
Chrome 安定版 (v113 以降) 安定版 (Android v121 以降)
Edge 安定版 (v113 以降) 不明
FireFox 安定版 (v141 以降) 実験的
Safari 安定版 (v26 以降) 安定版 (iOS 26.0 以降)

【実践編】React への導入と最適化

理論を理解したところで、次に React + TypeScript 環境へ WebLLM を導入する具体的な手順と注意点を解説します。

導入アーキテクチャ

今回は、WebLLM のロジックを汎用的なカスタムフック (useWebLLM) に分離し、UI コンポーネントはそれを呼び出すだけ、という設計を採用しました。

ステップ 1: 環境準備

まず、パッケージをインストールします。

pnpm add @mlc-ai/web-llm

ステップ 2: カスタムフック useWebLLM の実装

WebLLM を React で扱うための核心部分です。エンジンの状態(isLoading, progressなど)と操作(initializeModel, generate)をフックにまとめます。

// src/hooks/useWebLLM.ts
import * as webllm from "@mlc-ai/web-llm";
import { useCallback, useEffect, useRef, useState } from "react";

const DEFAULT_MODEL_ID = "Llama-3.2-1B-Instruct-q4f16_1-MLC";
const DEFAULT_TEMPERATURE = 0.7;
const DEFAULT_MAX_TOKENS = 512;

export interface WebLLMState {
    isInitialized: boolean;
    isLoading: boolean;
    progress: number;
    error: string | null;
}

const getErrorMessage = (error: unknown): string =>
    error instanceof Error ? error.message : "Unknown error";

export const useWebLLM = () => {
    const engineRef = useRef<webllm.MLCEngine | null>(null);
    const [state, setState] = useState<WebLLMState>({
        isInitialized: false,
        isLoading: false,
        progress: 0,
        error: null,
    });

    // モデルの初期化(初回のみダウンロード)
    const initializeModel = useCallback(async (modelId = DEFAULT_MODEL_ID) => {
        if (engineRef.current) {
            return; // 既に初期化済み
        }

        setState((prev) => ({ ...prev, isLoading: true, error: null }));

        try {
            const engine = await webllm.CreateMLCEngine(modelId, {
                initProgressCallback: (progress) => {
                    setState((prev) => ({
                        ...prev,
                        progress: Math.round(progress.progress * 100),
                    }));
                },
            });

            engineRef.current = engine;
            setState({
                isInitialized: true,
                isLoading: false,
                progress: 100,
                error: null,
            });
        } catch (error) {
            console.error("WebLLM initialization failed:", error);
            setState((prev) => ({
                ...prev,
                isLoading: false,
                error: getErrorMessage(error),
            }));
        }
    }, []);

    // プロンプトを送信して応答を取得
    const generate = useCallback(async (prompt: string): Promise<string> => {
        if (!engineRef.current) {
            throw new Error(
                "WebLLM is not initialized. Call initializeModel first.",
            );
        }

        const messages: webllm.ChatCompletionMessageParam[] = [
            { role: "user", content: prompt },
        ];

        const reply = await engineRef.current.chat.completions.create({
            messages,
            temperature: DEFAULT_TEMPERATURE,
            max_tokens: DEFAULT_MAX_TOKENS,
        });

        return reply.choices[0]?.message?.content || "";
    }, []);

    // クリーンアップ
    useEffect(() => {
        return () => {
            if (engineRef.current) {
                engineRef.current.unload();
                engineRef.current = null;
            }
        };
    }, []);

    return {
        state,
        initializeModel,
        generate,
    };
};

ステップ 3: プロンプトエンジニアリング

useWebLLMフックはあくまで LLM を「動かす」部分です。「何をやらせるか」はプロンプトエンジニアリングにかかっています。

今回の実装例(経済指標の相関分析)では、ユーザーの言語設定(日英)と分析対象のデータに基づき、動的にプロンプトを生成するユーティリティ関数を作成しました。

// src/utils/correlationAnalysis.ts
export interface CorrelationInsightRequest {
    mainIndicator: {
        id: string;
        name: string;
    };
    comparisonIndicator: {
        id: string;
        name: string;
    };
    correlation: number;
    period: {
        start: string;
        end: string;
    };
    language?: string;
}

type CorrelationStrength = "weak" | "moderate" | "strong";

const getCorrelationStrength = (correlation: number): CorrelationStrength => {
    const absCorrelation = Math.abs(correlation);
    if (absCorrelation < 0.3) return "weak";
    if (absCorrelation < 0.7) return "moderate";
    return "strong";
};

const getCorrelationType = (correlation: number) =>
    correlation > 0 ? "positive" : "negative";

const buildJapanesePrompt = (
    mainName: string,
    comparisonName: string,
    correlation: number,
    periodStart: string,
    periodEnd: string,
): string => {
    const strengthMap: Record<CorrelationStrength, string> = {
        weak: "弱い",
        moderate: "中程度",
        strong: "強い",
    };

    const strength = strengthMap[getCorrelationStrength(correlation)];
    const type = correlation > 0 ? "正" : "負";

    return `あなたは経済アナリストです。「${mainName}」と「${comparisonName}」の相関が${strength}(相関係数: ${correlation.toFixed(
        3,
    )})である理由を、${periodStart}から${periodEnd}の期間について説明してください。

以下の点について説明してください:
1. この${strength}${type}の相関が生じた経済的な理由
2. この関係性を説明する主要な要因
3. これが経済動向について示唆すること

一般読者にも分かりやすく、3~4文で簡潔に説明してください。`;
};

const buildEnglishPrompt = (
    mainName: string,
    comparisonName: string,
    correlation: number,
    periodStart: string,
    periodEnd: string,
): string => {
    const strength = getCorrelationStrength(correlation);
    const type = getCorrelationType(correlation);

    return `As an economic analyst, explain why the correlation between "${mainName}" and "${comparisonName}" is ${strength} (correlation coefficient: ${correlation.toFixed(
        3,
    )}) during the period from ${periodStart} to ${periodEnd}.

Please provide:
1. Possible economic reasons for this ${strength} ${type} correlation
2. Key factors that might explain this relationship
3. What this suggests about the economic dynamics

Keep the explanation concise (3-4 sentences) and accessible to general readers.`;
};

export const buildCorrelationPrompt = (
    request: CorrelationInsightRequest,
): string => {
    const {
        mainIndicator,
        comparisonIndicator,
        correlation,
        period,
        language,
    } = request;

    return language === "ja"
        ? buildJapanesePrompt(
              mainIndicator.name,
              comparisonIndicator.name,
              correlation,
              period.start,
              period.end,
          )
        : buildEnglishPrompt(
              mainIndicator.name,
              comparisonIndicator.name,
              correlation,
              period.start,
              period.end,
          );
};

プロンプトエンジニアリングのポイント

  • ペルソナ設定: 「あなたは経済アナリストです」といった役割を与え、回答のトーンと専門性を指定します。
  • 文脈の提供: 分析対象のデータ(指標名、期間、相関係数)を動的にプロンプトに埋め込みます。
  • 構造化出力: 「以下の点について」「3 ~ 4 文で簡潔に」といった制約により、軽量なモデルでも期待する回答形式に誘導します。
  • 多言語対応: i18nの言語設定(language)を受け取り、プロンプト自体を切り替えることで、LLM に翻訳させる負荷をかけずに直接多言語対応しています。

ステップ 4: UI コンポーネントでの利用

useWebLLM フックと buildCorrelationPrompt 関数を使って UI コンポーネントを構築します。詳細なコードは省略しますが、実装のポイントは以下の通りです。

  • 状態の活用: useWebLLM から返される state.isLoading と state.progress を使い、「AI モデルをダウンロード中 45%」といった進捗を UI に表示します。
  • エラーハンドリング: state.error が存在する場合、エラーメッセージを Alert などで表示します。
  • 実行: ボタンクリック時に buildCorrelationPrompt でプロンプトを生成し、それを generate 関数に渡して実行。返ってきた結果を React のステートで保持し、UI に描画します。

導入時のハードルと解決策

WebLLM の導入は簡単でしたが、特有のハードルが 2 点ありました。

ハードル 1: 開発環境での制約

原因:
WebLLM はモデル(数百 MB)をブラウザに保存するため Cache API を使用します。Cache API は HTTPS または localhost という安全なコンテキストでのみ利用可能です。

Vite 開発サーバーなどで http://192.168.x.x:5173 のようなローカルネットワーク IP でアクセスすると、以下のエラーが発生します。

ReferenceError: caches is not defined

解決策:
以下のいずれかの方法で、セキュアコンテキストでアクセスします。

  1. localhost でアクセスする(例: http://localhost:5173)

  2. @vitejs/plugin-basic-ssl などを導入して開発サーバーを HTTPS 化する

  3. (最終確認として)プロダクションビルドでテストする

ハードル 2: 初回ロードの UX

課題:
最大の UX 課題は、初回アクセス時のモデルダウンロードです。今回使用した Llama-3.2-1B-Instruct でも約 700MB あります。
ユーザーがボタンをクリックした時に初めてダウンロードを開始すると、数十秒〜数分待たせてしまいます。

解決策: アイドル時にプリロード
ユーザーがボタンをクリックするのを待つのではなく、アプリのメインページを読み込んだ後、ブラウザがアイドル状態の時にバックグラウンドでダウンロードを開始させます。

// アプリのルートコンポーネント (App.tsxなど)
useEffect(() => {
    // initializeModelをどこかから取得(Contextやグローバルなインスタンス)

    if ("requestIdleCallback" in window) {
        // ブラウザが暇な時にダウンロードを開始
        requestIdleCallback(() => {
            initializeModel();
        });
    } else {
        // フォールバック
        setTimeout(() => initializeModel(), 1000);
    }
}, []);

これにより、ユーザーが AI 機能を必要とする頃には、ダウンロードが完了している(または進行している)可能性が高まります。


まとめ

WebLLM は、サーバーコストゼロ・高プライバシーな AI 機能を React アプリに組み込むための強力な選択肢です。

理論編で見たように、WebGPU の活用によりネイティブに近いパフォーマンスを発揮し、サーバーサイド LLM とは明確なトレードオフ(特にコストとプライバシー)があります。

実践編で示したように、useWebLLM カスタムフックでロジックをカプセル化し、軽量モデルを制御するための明確なプロンプトエンジニアリングを適用し、初回ロードのプリロード戦略という 2 つのハードルを越えれば、実用的なローカル AI 機能を実装できます。

プライバシーが重視されるドキュメント要約や、オフラインでの利用が想定されるテキスト支援ツールなど、WebLLM のユースケースは今後ますます広がっていくと感じました。

参考リンク

Emoba Tech Blog

Discussion