9
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

最小限で動かすWebLLM × WebGPUでブラウザ上の簡易AIチャットモデル

Last updated at Posted at 2025-12-05

初めに

WebLLM と WebGPU を使って、ブラウザだけで小さな言語モデルを動かすチャットUIを爆速で作ります。
Vite + React を例に、セットアップ・WebGPU確認・モデル読み込み・チャット実装・なぞったエラーまでを一気通貫で記載していきます。

対象読者: React or Next.js Node.js に触れたことがある人くらい
所要時間: 約30〜45分


前提・環境

想定環境

  • OS: Windows(macOS / Linuxでも動くと思います。)
  • Node.js: 20.x 以上
  • パッケージマネージャ: npm もしくは pnpm / yarn(本記事では npm)
  • ブラウザ: WebGPU 対応ブラウザ
    • Chrome 121+ (Desktop)
    • Edge 121+
    • (2025/11 時点の情報、変更の可能性ありです)

WebGPU が有効なブラウザかどうか

WebGPU はまだすべてのブラウザでデフォルトONとは限らないため、最初に対応ブラウザ&設定を確認します。

ブラウザの DevTools コンソールで以下を実行して、navigator.gpu が存在するかを確認:

navigator.gpu ? 'WebGPU OK' : 'WebGPU NOT AVAILABLE'
  • WebGPU OK → 本記事のサンプルが動作する可能性大↑
  • WebGPU NOT AVAILABLE → ブラウザのバージョン or フラグ設定を確認する

Chrome の場合:

  1. chrome://flags を開く
  2. webgpu で検索
  3. Unsafe WebGPUEnabled に変更
  4. ブラウザを再起動で大体動きます

{5FBEA605-66EA-41D7-968B-D5B92681CBC8}.png


この記事で作るもの

  • Vite + React の最小構成 SPA
  • WebLLM を使って、ブラウザ内で LLM をロード
  • シンプルなチャットUI(ユーザーの入力とモデルの応答を表示)
  • モデルは Web 上の事前ホスト版を利用(ローカルでモデルを用意しないシンプル構成)

ゴールイメージ

  • npm run dev でローカルサーバ起動
  • http://localhost:5173/ にアクセス
  • 画面上にチャット画面が表示され、プロンプトを送るとブラウザだけでLLMが応答すること
  • 何となくのAIチャット動くこと(←前提)

プロジェクト構成

Vite + React + TypeScript で構成します。

webllm-chat/
  ├─ index.html
  ├─ package.json
  ├─ vite.config.ts
  └─ src/
      ├─ main.tsx
      ├─ App.tsx
      └─ webllmClient.ts  # WebLLM 初期化周りを担当

Step 1: Vite + React プロジェクトを作成する

まずはベースとなるフロントエンド環境を作成します。

# Vite + React + TypeScript プロジェクトを作成
npm create vite@latest webllm-chat -- --template react-ts

cd webllm-chat

# 依存パッケージをインストール
npm install

期待される出力(一部):

Scaffolding project in ./webllm-chat...
Done. Now run:

  cd webllm-chat
  npm install
  npm run dev

Node.js / npm がインストールされていない場合、以下のようなエラーが出ます。
Node.js公式サイト or nvm でインストールしてください。

  • npm: command not found

Step 2: WebLLM を導入する

WebLLMのインストール

npm install @mlc-ai/web-llm

インストール後、package.json の dependencies に @mlc-ai/web-llm が追加されていることを確認します。


Step 3: WebLLM クライアントを実装する

src/webllmClient.ts を作成し、WebLLM の初期化とシンプルなチャットAPIを定義します。
実装の意図はコメントにて記載しています。ご歓談ください。

src/webllmClient.ts

import * as webllm from "@mlc-ai/web-llm";

/**
 * WebLLM エンジンのシングルトンインスタンス
 */
let engine: webllm.MLCEngineInterface | null = null;

/**
 * 初期化中のPromise(重複初期化を防ぐ)
 */
let initializing: Promise<webllm.MLCEngineInterface> | null = null;

/**
 * 利用可能なモデル名の型定義
 */
type ModelName =
  | "Llama-3-8B-Instruct-q4f16_1-MLC-1k"
  | "Llama-3.2-1B-Instruct-q4f16_1-MLC"
  | "Llama-3.2-3B-Instruct-q4f16_1-MLC";

/**
 * 利用するプリセットモデル名(WebLLM側がホストしているモデル)
 *
 * 推奨モデル:
 * - Llama-3.2-1B-Instruct-q4f16_1-MLC: 軽量で高速(約1GB、初心者推奨)
 * - Llama-3.2-3B-Instruct-q4f16_1-MLC: バランス型(約2GB)
 * - Llama-3-8B-Instruct-q4f16_1-MLC-1k: 最高性能だが重い(約4GB、ハイスペックマシン向け)
 */
const MODEL_NAME: ModelName = "Llama-3.2-3B-Instruct-q4f16_1-MLC";

/**
 * WebLLM エンジンを初期化して返す
 * 
 * シングルトンパターンで実装されており、複数回呼び出しても
 * エンジンは一度だけ初期化されます。
 * 
 * @returns {Promise<webllm.MLCEngineInterface>} 初期化されたWebLLMエンジン
 * @throws {Error} エンジンの初期化に失敗した場合
 */
export async function getEngine(): Promise<webllm.MLCEngineInterface> {
  if (engine) {
    return engine;
  }

  if (!initializing) {
    initializing = (async () => {
      try {
        console.log(`[WebLLM] モデル "${MODEL_NAME}" の初期化を開始します...`);
        
        const eng = await webllm.CreateMLCEngine(MODEL_NAME, {
          initProgressCallback: (report: webllm.InitProgressReport) => {
            // 初期化の進捗をログ出力
            console.log("[WebLLM init]", report);
          },
        });

        console.log("[WebLLM] 初期化が完了しました");
        engine = eng;
        return eng;
      } catch (error) {
        // 初期化失敗時はPromiseをクリアして再試行可能にする
        initializing = null;
        console.error("[WebLLM] 初期化に失敗しました:", error);
        throw new Error(
          `WebLLMエンジンの初期化に失敗しました: ${error instanceof Error ? error.message : String(error)}`
        );
      }
    })();
  }

  return initializing;
}

/**
 * チャットメッセージの役割(ロール)
 */
type MessageRole = "system" | "user" | "assistant";

/**
 * チャットメッセージの型定義
 */
interface ChatMessage {
  role: MessageRole;
  content: string;
}

/**
 * 単純な1ターンチャットを行うヘルパー関数
 * 
 * ユーザーからの1つのプロンプトに対して、モデルからの応答を返します。
 * 会話履歴は保持されません。
 * 
 * @param {string} prompt - ユーザーからの入力プロンプト
 * @param {string} [systemPrompt] - システムプロンプト(省略時はデフォルト値を使用)
 * @returns {Promise<string>} モデルからの応答テキスト
 * @throws {Error} エンジンの初期化またはチャット処理に失敗した場合
 */
export async function chatOnce(
  prompt: string,
  systemPrompt: string = "You are a helpful assistant running in the browser."
): Promise<string> {
  if (!prompt.trim()) {
    throw new Error("プロンプトが空です");
  }

  try {
    const eng = await getEngine();

    const messages: ChatMessage[] = [
      { role: "system", content: systemPrompt },
      { role: "user", content: prompt },
    ];

    const reply = await eng.chat.completions.create({
      messages,
    });

    const content = reply?.choices?.[0]?.message?.content;
    
    if (content === undefined || content === null) {
      throw new Error("モデルからの応答が取得できませんでした");
    }

    return content;
  } catch (error) {
    console.error("[WebLLM] チャット処理中にエラーが発生しました:", error);
    throw new Error(
      `チャット処理に失敗しました: ${error instanceof Error ? error.message : String(error)}`
    );
  }
}

ポイント:

  • getEngine()シングルトン的にエンジンを初期化。毎回ロードしないようにする。
  • MODEL_NAME はWebLLMのプリセット名を利用。重い場合はより小さいモデルに変更するとよい。

Step 4: React でチャットUIを実装する

src/App.tsx

import React, { useState, useRef, useEffect, useCallback } from "react";
import { chatOnce } from "./webllmClient";

/**
 * メッセージの役割
 */
type MessageRole = "user" | "assistant";

/**
 * チャットメッセージの型定義
 */
interface Message {
  role: MessageRole;
  content: string;
}

/**
 * アプリケーションの状態
 */
type AppStatus = "idle" | "initializing" | "ready" | "error";

/**
 * ステータス表示用のラベルマップ
 */
const STATUS_LABELS: Record<AppStatus, string> = {
  idle: "未初期化",
  initializing: "モデル初期化中...(数十秒〜数分かかる場合があります)",
  ready: "✓ 利用可能",
  error: "✗ エラー発生",
};

/**
 * チャットロジックを管理するカスタムフック
 */
const useChatLogic = () => {
  const [messages, setMessages] = useState<Message[]>([]);
  const [status, setStatus] = useState<AppStatus>("idle");
  const [errorMessage, setErrorMessage] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);

  /**
   * メッセージを送信してモデルから応答を取得
   */
  const sendMessage = useCallback(async (userInput: string): Promise<void> => {
    if (!userInput.trim() || loading) return;

    setErrorMessage(null);
    setLoading(true);
    
    if (status === "idle") {
      setStatus("initializing");
    }

    const userMessage: Message = { role: "user", content: userInput.trim() };
    setMessages((prev) => [...prev, userMessage]);

    try {
      const reply = await chatOnce(userMessage.content);
      const assistantMessage: Message = { role: "assistant", content: reply };
      setMessages((prev) => [...prev, assistantMessage]);
      setStatus("ready");
    } catch (error) {
      console.error("[App] チャット送信エラー:", error);
      const message = error instanceof Error ? error.message : "不明なエラーが発生しました";
      setErrorMessage(message);
      setStatus("error");
    } finally {
      setLoading(false);
    }
  }, [loading, status]);

  /**
   * チャット履歴をクリア
   */
  const clearMessages = useCallback(() => {
    setMessages([]);
    setErrorMessage(null);
  }, []);

  return {
    messages,
    status,
    errorMessage,
    loading,
    sendMessage,
    clearMessages,
  };
};

/**
 * ステータス表示コンポーネント
 */
interface StatusDisplayProps {
  status: AppStatus;
  errorMessage: string | null;
}

const StatusDisplay: React.FC<StatusDisplayProps> = ({ status, errorMessage }) => (
  <>
    <div style={styles.status}>
      <strong>状態:</strong> {STATUS_LABELS[status]}
    </div>

    {errorMessage && (
      <div style={styles.errorBox} role="alert">
        <strong>エラー:</strong> {errorMessage}
      </div>
    )}
  </>
);

/**
 * メッセージリスト表示コンポーネント
 */
interface MessageListProps {
  messages: Message[];
}

const MessageList: React.FC<MessageListProps> = ({ messages }) => {
  const chatBoxRef = useRef<HTMLDivElement>(null);

  // 新しいメッセージが追加されたら自動スクロール
  useEffect(() => {
    if (chatBoxRef.current) {
      chatBoxRef.current.scrollTop = chatBoxRef.current.scrollHeight;
    }
  }, [messages]);

  return (
    <div style={styles.chatBox} ref={chatBoxRef}>
      {messages.length === 0 ? (
        <div style={styles.placeholder}>
          下のテキストボックスからメッセージを送信してください。
        </div>
      ) : (
        messages.map((message, index) => (
          <MessageBubble key={index} message={message} />
        ))
      )}
    </div>
  );
};

/**
 * 個別メッセージバブルコンポーネント
 */
interface MessageBubbleProps {
  message: Message;
}

const MessageBubble: React.FC<MessageBubbleProps> = ({ message }) => {
  const isUser = message.role === "user";
  const displayName = isUser ? "You" : "Assistant";
  const bubbleStyle = isUser ? styles.userMessage : styles.assistantMessage;

  return (
    <div
      style={{
        ...styles.message,
        ...bubbleStyle,
      }}
    >
      <strong>{displayName}:</strong> {message.content}
    </div>
  );
};

/**
 * 入力フォームコンポーネント
 */
interface ChatInputProps {
  value: string;
  onChange: (value: string) => void;
  onSend: () => void;
  disabled: boolean;
  loading: boolean;
}

const ChatInput: React.FC<ChatInputProps> = ({
  value,
  onChange,
  onSend,
  disabled,
  loading,
}) => {
  const handleKeyDown: React.KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      onSend();
    }
  };

  return (
    <div style={styles.inputArea}>
      <textarea
        style={styles.textarea}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        onKeyDown={handleKeyDown}
        placeholder="メッセージを入力して Enter で送信(Shift + Enter で改行)"
        rows={3}
        disabled={disabled}
        aria-label="チャットメッセージ入力"
      />
      <button
        style={{
          ...styles.button,
          ...(disabled ? styles.buttonDisabled : {}),
        }}
        onClick={onSend}
        disabled={disabled}
        aria-label="メッセージを送信"
      >
        {loading ? "送信中..." : "送信"}
      </button>
    </div>
  );
};

/**
 * メインアプリケーションコンポーネント
 */
const App: React.FC = () => {
  const {
    messages,
    status,
    errorMessage,
    loading,
    sendMessage,
    clearMessages,
  } = useChatLogic();

  const [input, setInput] = useState("");

  const handleSend = useCallback(() => {
    if (input.trim()) {
      sendMessage(input);
      setInput("");
    }
  }, [input, sendMessage]);

  const isInputDisabled = loading;

  return (
    <div style={styles.container}>
      <header style={styles.header}>
        <h1 style={styles.title}>WebLLM × WebGPU Chat</h1>
        <p style={styles.desc}>
          ブラウザ内で LLM を動かす簡易チャットデモです(初回ロードに時間がかかる場合があります)。
        </p>
      </header>

      <StatusDisplay status={status} errorMessage={errorMessage} />

      {messages.length > 0 && (
        <button
          style={styles.clearButton}
          onClick={clearMessages}
          aria-label="チャット履歴をクリア"
        >
          履歴をクリア
        </button>
      )}

      <MessageList messages={messages} />

      <ChatInput
        value={input}
        onChange={setInput}
        onSend={handleSend}
        disabled={isInputDisabled}
        loading={loading}
      />
    </div>
  );
};

/**
 * スタイル定義
 */
const styles = {
  container: {
    maxWidth: 800,
    margin: "0 auto",
    padding: 16,
    fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
  },
  header: {
    marginBottom: 16,
  },
  title: {
    fontSize: 28,
    fontWeight: 700,
    marginBottom: 4,
  },
  desc: {
    marginBottom: 0,
    color: "#555",
    fontSize: 14,
  },
  status: {
    fontSize: 14,
    marginBottom: 8,
    padding: 8,
    backgroundColor: "#f5f5f5",
    borderRadius: 4,
  },
  errorBox: {
    backgroundColor: "#ffe6e6",
    border: "1px solid #ff9999",
    padding: 12,
    borderRadius: 4,
    marginBottom: 8,
    color: "#b30000",
    fontSize: 13,
  },
  clearButton: {
    fontSize: 12,
    padding: "4px 12px",
    marginBottom: 8,
    borderRadius: 4,
    border: "1px solid #ddd",
    backgroundColor: "#fff",
    color: "#666",
    cursor: "pointer",
    transition: "background-color 0.2s",
  } as React.CSSProperties,
  chatBox: {
    height: 400,
    border: "1px solid #ddd",
    borderRadius: 8,
    padding: 12,
    overflowY: "auto" as const,
    marginBottom: 12,
    backgroundColor: "#fafafa",
  },
  placeholder: {
    color: "#999",
    fontSize: 14,
    textAlign: "center" as const,
    marginTop: 180,
  },
  message: {
    padding: 10,
    borderRadius: 8,
    marginBottom: 10,
    fontSize: 14,
    lineHeight: 1.6,
    maxWidth: "85%",
    wordWrap: "break-word" as const,
  },
  userMessage: {
    backgroundColor: "#e3f2fd",
    marginLeft: "auto",
    borderBottomRightRadius: 2,
  },
  assistantMessage: {
    backgroundColor: "#f1f8e9",
    marginRight: "auto",
    borderBottomLeftRadius: 2,
  },
  inputArea: {
    display: "flex",
    flexDirection: "column" as const,
    gap: 8,
  },
  textarea: {
    fontSize: 14,
    padding: 10,
    borderRadius: 6,
    border: "1px solid #ccc",
    fontFamily: "inherit",
    resize: "vertical" as const,
    transition: "border-color 0.2s",
  } as React.CSSProperties,
  button: {
    alignSelf: "flex-end",
    padding: "10px 20px",
    fontSize: 14,
    fontWeight: 600,
    borderRadius: 6,
    border: "none",
    background: "#1976d2",
    color: "#fff",
    cursor: "pointer",
    transition: "background-color 0.2s",
  } as React.CSSProperties,
  buttonDisabled: {
    opacity: 0.6,
    cursor: "not-allowed",
  },
} as const satisfies Record<string, React.CSSProperties>;

export default App;

src/main.tsx

Vite テンプレの main.tsx があるので、基本そのままで OK です。

// src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./index.css";

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

Step 5: 開発サーバを起動して動作確認

npm run dev

期待される出力例:

  VITE v5.x  ready in 500 ms

  ➜  Local:   http://localhost:5173/

ブラウザで http://localhost:5173/ にアクセス。

できた!
image.png

試してみます。
image.png

草(しかも今日木曜日)

おぉ…
{B5CD7CBD-B81E-459F-BEDF-9CD725D6586D}.png

動作時の挙動

  • 初アクセス時: モデルのダウンロード&初期化で数十秒〜数分かかる場合有りです(ネットワーク&GPU性能依存)
  • コンソールに [WebLLM init] のログが出ていれば初期化が進んでいる
  • 「モデル初期化中...」と表示されたまましばらく待つと、チャットが可能になる

はまった落とし穴と抜け出し方

1. WebGPU が有効でない

症状

  • コンソールに navigator.gpu is undefined / WebGPU is not supported 系エラー
  • WebLLM 初期化時に例外が発生

対処

  • navigator.gpu を DevTools コンソールで確認
  • Chrome / Edge のバージョンを上げる
  • chrome://flags / edge://flagsUnsafe WebGPUEnabled にして再起動

2. モデルのダウンロードが遅すぎる or 失敗する

症状

  • 初回アクセス時にいつまでも「初期化中」のまま
  • ネットワークエラー (Failed to fetch ...) がコンソールに表示

対処

  • モデルサイズの小さいプリセットに変更する
    • 例: Llama-3-8B ではなく、より軽量なモデルが提供されていればそちらを採用

2-1. Cache.add() encountered a network error

症状

  • コンソールに NetworkError: Failed to execute 'add' on 'Cache': Cache.add() encountered a network error が表示される
  • モデルのダウンロードが開始されない、または途中で止まる

原因

WebLLMはWebAssemblyとSharedArrayBufferを使用するため、特定のセキュリティヘッダー(Cross-Origin-Embedder-PolicyCross-Origin-Opener-Policy)が必要です。
これらが設定されていないと、Cache APIでネットワークエラーが発生します。

対処

vite.config.ts に以下の設定を追加します:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    headers: {
      'Cross-Origin-Embedder-Policy': 'require-corp',
      'Cross-Origin-Opener-Policy': 'same-origin',
    },
  },
  optimizeDeps: {
    exclude: ['@mlc-ai/web-llm'],
  },
})

設定後、開発サーバーを再起動してください:

npm run dev

ポイント:

  • Cross-Origin-Embedder-Policy: require-corpCross-Origin-Opener-Policy: same-origin は WebLLM で SharedArrayBuffer を使うために必須
  • optimizeDeps.exclude で WebLLM を Vite の依存関係最適化から除外することで、ビルドエラーを回避
  • ブラウザのキャッシュをクリアしてから再度アクセスすることをおすすめします

3. メモリ不足・ブラウザがフリーズする

症状

  • モデルロード中にブラウザが止まる
  • タブがクラッシュする

対処

  • もっと小さいモデルに切り替える
  • 他の重いアプリを閉じてから試す

(やりませんが…)やるならこんなこともしていいかもメモ

  • 会話履歴を保存し、 session.chat に渡す。文脈を維持した対話にする
  • システムプロンプトをUIから編集可能にし、キャラ・トーンを変えられるようにする
  • IndexedDB などにモデルをキャッシュして、二回目以降のロード時間を短縮
  • Next.js (App Router) と組み合わせて、SSR/ISRと併用したり、認証付きページでのみWebLLMを出す

まとめ

  • WebLLM × WebGPU を使うことで、ブラウザだけで完結するLLMチャットを実現できる
  • Vite + React なら、数十行のコードで最小のチャットUIを構築可能
  • 一方で、WebGPU対応状況・モデルサイズ・メモリ使用量など、フロントエンドだけで完結するがゆえの制約もある
    • 私の環境では低性能のモデルしか動きませんでした。改めてChatGPTってすごい

興味があれば、是非触ってみてください!


参考リンク

9
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?