Slide 1

Slide 1 text

Local LLMと会話できるWebアプリを作ってみた 〜Web Streams API しか勝たん〜 2024/12/4 馬場一樹(kbaba1001)

Slide 2

Slide 2 text

デモムービー

Slide 3

Slide 3 text

つくったもの ● 音声でAIと話せるやつ ● 特徴:外部APIを使わずすべてサーバー内で実行している ● Backend: Deno, Hono, Kysely, PostgreSQL ● Frontend: Node, Vite, React ●

Slide 4

Slide 4 text

コンセプト ● 外部APIを使わずローカルGPUを活用してシステムを構築する ● ストリーミング処理を活用して早く動いているように見せる ● Web Socket を使わず、 Web Streams API を活用する

Slide 5

Slide 5 text

ストリーミング処理について

Slide 6

Slide 6 text

ストリーミング処理 ● データをChunkという単位で少しずつフロントに送るような処理 ● 例えばストリーミング配信: ○ 動画データを少しずつフロントエンドに送っている ● 例えばGoogleDocs: ○ 少しずつデータをサーバーに送ることで同期をとって同時編集ができる

Slide 7

Slide 7 text

ストリーミング処理でググると。。。 ● WebSocket や WebRTC の話がたくさん出てくる

Slide 8

Slide 8 text

WebSocket のメリット ● Socket io などのライブラリを使えば簡単に実装できる ● 古いブラウザでも動く ●

Slide 9

Slide 9 text

WebSocketのデメリット ● 双方向通信のため通信量が多い ● HTTP/HTTPS ではなく ws/wss というプロトコルで通信するため、HTTPハンドラと は別の設計が必要 ●

Slide 10

Slide 10 text

おまけ: WebRTC ● クライアントとクライアントの通信なので微妙 ○ 例えばミーティングツールを WebRTCで作った場合に仮想背景機能を実装しようとしたら WebGPU などを使うしかない

Slide 11

Slide 11 text

Web Streams しか勝たん

Slide 12

Slide 12 text

Web Streams API とはなにか? https://developer.mozilla.org/en-US/docs/Web/API/Streams_API

Slide 13

Slide 13 text

Readable Stream https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Concepts

Slide 14

Slide 14 text

Writable Stream https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Concepts

Slide 15

Slide 15 text

Pipe https://developer.mozilla.org/en-US/docs/Web/API/Streams_API/Concepts

Slide 16

Slide 16 text

どこで使われているのか?: fetchのResponse body

Slide 17

Slide 17 text

どこで使われているのか?: Hono の Streaming Helper

Slide 18

Slide 18 text

どこで使われているのか?: Deno の Request Body app.post("/:id/stream", async (c) => { const stream = c.req.raw.body as ReadableStream;

Slide 19

Slide 19 text

Denoの良さ ● Web 標準の機能がサーバーで動くこと ● つまりブラウザと同じものがサーバーで動くので楽 ● Nodeだと Web Streams まわりがぐちゃぐちゃでよくわからないことになっている

Slide 20

Slide 20 text

Web Streams のメリット ● fetch 関数の中で自然と使える ● つまりHTTPハンドラでStream処理ができる ● (バックエンドがDenoの場合)フロントと同じようにWeb Streams を扱える ● 通信が単方向なので軽め

Slide 21

Slide 21 text

Web Streams のデメリット ● 知名度が低いのかドキュメントが少ない ○ ほとんどMDN読むしかない ○ ただし、ChatGPTなどに聞くと詳しく教えてくれる ●

Slide 22

Slide 22 text

AIとの会話システムの実装

Slide 23

Slide 23 text

概要 何か話す ブラウザで音声デー タにする WhisperStreamingで 音声をテキスト化 音声バイナリを WebStreamsで サーバーに送る フロントエンド バックエンド 会話テキストを WebStreamsでポー リング PostgreSQL から データ取得 テキスト表示 Notify 「AIを呼び出す」 ボタンを押下 Ollamaに会話データを 入力して出力をNotifyし テーブルにも保存する Notify Listen Post Request

Slide 24

Slide 24 text

概要 何か話す ブラウザで音声デー タにする WhisperStreamingで 音声をテキスト化 音声バイナリを WebStreamsで サーバーに送る フロントエンド バックエンド 会話テキストを WebStreamsでポー リング PostgreSQL から データ取得 テキスト表示 Notify 「AIを呼び出す」 ボタンを押下 Ollamaに会話データを 入力して出力をNotifyし テーブルにも保存する Notify Listen Post Request まずここの話

Slide 25

Slide 25 text

音声データの取得 const mediaRecorderRef = useRef(null); const streamControllerRef = useRef | null>(null);

Slide 26

Slide 26 text

音声データの取得 const startRecording = async () => { // マイクへのアクセスをリクエスト const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); // MediaRecorderの作成 const options = { mimeType: "audio/webm; codecs=opus" , // サポートされているmimeTypeを指定 }; const mediaRecorder = new MediaRecorder(stream, options); mediaRecorderRef.current = mediaRecorder; // ReadableStreamの作成 const audioStream = new ReadableStream({ start(controller) { // コントローラを保存して、後でデータをエンキューする streamControllerRef.current = controller; }, });

Slide 27

Slide 27 text

音声データの取得 // dataavailableイベントのハンドリング mediaRecorder.addEventListener("dataavailable", async (event) => { if (event.data && event.data.size > 0) { // BlobをArrayBufferに変換 const arrayBuffer = await event.data.arrayBuffer(); // ArrayBufferをUint8Arrayに変換 const chunk = new Uint8Array(arrayBuffer); // ReadableStreamにチャンクをエンキュー streamControllerRef .current?.enqueue(chunk); } }); // stopイベントのハンドリング mediaRecorder.addEventListener("stop", () => { // ReadableStreamをクローズ streamControllerRef .current?.close(); }); // 録音開始(100msごとにデータを収集) mediaRecorder.start(100);

Slide 28

Slide 28 text

音声のバイナリデータをサーバーにStreaming送信 // 先程の関数の中で次を実行する mutate(audioStream) // mutate の中身 const { mutate, isPending } = useMutation({ mutationFn: async (audioStream: ReadableStream) => { return await httpClient({ jwtToken }) // ky のラッパー .post(`talks/${talkId}/stream`, { body: audioStream, // ReadableStream をそのままBodyに入れてリクエストする timeout: false, }) .text(); } });

Slide 29

Slide 29 text

バックエンドでの処理 ● 音声のバイナリデータをReadableStreamで取得 ● ffmpeg に pipe で渡してサンプリングレートなどを調整 ● Whisper Streaming に pipe で渡してテキスト化

Slide 30

Slide 30 text

Whisper Streaming ● Faster Whisper などをバックにして Stream 処理でテキスト化してくれる ● whisper_online_server.py を使えばサーバー 化できる ● これはTCPで動作するので Deno.connect で 接続できる ● 入力できる音声データのサンプリングレートな どに指定がある(のでffmpegでの変換が必要)

Slide 31

Slide 31 text

バックエンド:ReadableStreamの受け取り app.post("/:id/stream", permissionChecker("talks"), async (c) => { const stream = c.req.raw.body as ReadableStream; const talkId = Number(c.req.param("id")); const user = c.get("currentUser");

Slide 32

Slide 32 text

ffmpeg に pipe で音声データを渡す // FFmpegコマンドを設定 const ffmpeg = new Deno.Command("ffmpeg", { // Deno.Command も Stream を返す args: [ "-i", "pipe:0", "-ar", "16000", "-ac", "1", "-sample_fmt", "s16", "-f", "wav", "pipe:1", ], stdin: "piped", stdout: "piped", stderr: "piped", }); const ffmpegProcess = ffmpeg.spawn(); stream.pipeTo(ffmpegProcess.stdin); // Web Streams の Pipe で音声データをそのままffmpeg にわたす

Slide 33

Slide 33 text

Whisper Streaming に pipe でデータを渡す const convertedAudioStream = ffmpegProcess.stdout; // Deno.connect も Stream を返す const whisper = await Deno.connect({ hostname: Deno.env.get("WHISPER_HOST") || "localhost", port: Number(Deno.env.get("WHISPER_PORT")) || 43001, }); convertedAudioStream.pipeTo(whisper.writable);

Slide 34

Slide 34 text

Deno の場合、以下がすべてWeb Streams で扱える ● HTTP の Request Body ● ファイルの読み書き ● コマンドの実行 ● Deno.connect や fetch による他サーバーとの通信 便利!

Slide 35

Slide 35 text

Streamからテキストデータの取得 const reader = whisper.readable.getReader(); const decoder = new TextDecoder("utf-8"); while (true) { const { done, value } = await reader.read(); if (done) { break; } const text = decoder.decode(value); // text を DB に保存したりクライアントに返送したりすればいい }

Slide 36

Slide 36 text

概要 何か話す ブラウザで音声デー タにする WhisperStreamingで 音声をテキスト化 音声バイナリを WebStreamsで サーバーに送る フロントエンド バックエンド 会話テキストを WebStreamsでポー リング PostgreSQL から データ取得 テキスト表示 Notify 「AIを呼び出す」 ボタンを押下 Ollamaに会話データを 入力して出力をNotifyし テーブルにも保存する Notify Listen Post Request ここの話おわり

Slide 37

Slide 37 text

概要 何か話す ブラウザで音声デー タにする WhisperStreamingで 音声をテキスト化 音声バイナリを WebStreamsで サーバーに送る フロントエンド バックエンド 会話テキストを WebStreamsでポー リング PostgreSQL から データ取得 テキスト表示 Notify 「AIを呼び出す」 ボタンを押下 Ollamaに会話データを 入力して出力をNotifyし テーブルにも保存する Notify Listen Post Request 次はここの話

Slide 38

Slide 38 text

つまりこの部分を作る

Slide 39

Slide 39 text

テキスト化したデータのクライアントでの表示 ● Whisperでテキスト化した文字データをクライアントでどうやって取得するか? ● 先ほどのPOSTハンドラのレスポンスをStreamにする手もあるが、もう少し汎用的 にしたい

Slide 40

Slide 40 text

GET通信でStreamデータを読み込み続ける GET talks/${talkId}/stream クライアント (ブラウザ) Streamでテキスト を送る PostgreSQL DBのtalks テーブルに新しいデータが入ったら、それをHTTP ハンドラからクライアントに送りつけてやればいい ロングポーリング

Slide 41

Slide 41 text

GET通信でStreamデータを読み込み続ける GET talks/${talkId}/stream クライアント (ブラウザ) Streamでテキスト を送る PostgreSQL DBのtalks テーブルに新しいデータが入ったら、それをHTTP ハンドラからクライアントに送りつけてやればいい ロングポーリング どうやってこれを検知すればいいか?

Slide 42

Slide 42 text

方針 ● Whisperのレスポンスのテキストデータを新規作成したら、それをChannel (Queue) のようなものに入れて通知してやれば楽そう

Slide 43

Slide 43 text

DenoにおけるChannel ● MessageChannel ● node:diagnostics_channel ● など

Slide 44

Slide 44 text

DenoでのChannelのデメリット ● 当然ながらDenoプロセス内で実行されるものなので、サーバーが複数台になった 場合などの対応が面倒くさそう ● Channelに相当する部分を外部に出す ○ Redis の Pub/Sub や AWS SQS みたいなものを使う ○ いや、僕らには PostgreSQL がいる!

Slide 45

Slide 45 text

PostgreSQL の Channel ● Notify/Listen という機能がある ● LISTEN foo_channel; ● ● NOTIFY foo_channel, 'Hello!'; ● ● ; ● -- Asynchronous notification "foo_channel" with payload "Hello!" received from server process with PID 5963.

Slide 46

Slide 46 text

ブラウザ システムへの応用 GET /talks/${talkId}/stream DB中で Listen talks_1; して おく。 通知を受け取ったら Stream でフロントにデータを送る POST /talks/${talkId}/stream Notify talks_1 ‘{“a”: 1}’ 通知を送る

Slide 47

Slide 47 text

DenoにおけるNotify/Listenの実装 export async function notify(channel: string, obj: object) { return await sql`select pg_notify(${channel}, ${JSON.stringify(obj)})`.execute( db, ); } export async function listen( listenChannel: string, callback: (msg: { channel: string; payload: string }) => void, ) { const pgClient = await pool.connect(); pgClient.on("notification", callback); return await pgClient.query(listenChannel); }

Slide 48

Slide 48 text

概要 何か話す ブラウザで音声デー タにする WhisperStreamingで 音声をテキスト化 音声バイナリを WebStreamsで サーバーに送る フロントエンド バックエンド 会話テキストを WebStreamsでポー リング PostgreSQL から データ取得 テキスト表示 Notify 「AIを呼び出す」 ボタンを押下 Ollamaに会話データを 入力して出力をNotifyし テーブルにも保存する Notify Listen Post Request ここの話おわり

Slide 49

Slide 49 text

概要 何か話す ブラウザで音声デー タにする WhisperStreamingで 音声をテキスト化 音声バイナリを WebStreamsで サーバーに送る フロントエンド バックエンド 会話テキストを WebStreamsでポー リング PostgreSQL から データ取得 テキスト表示 Notify 「AIを呼び出す」 ボタンを押下 Ollamaに会話データを 入力して出力をNotifyし テーブルにも保存する Notify Listen Post Request 次はここの話

Slide 50

Slide 50 text

AIを呼び出すトリガー 現状ボタンにしている。 (将来的にはユーザーの声が無音になったときを トリガーにしたい)

Slide 51

Slide 51 text

「AIを呼び出す」ボタンの挙動 ● サーバー側にPOSTリクエストを投げる ● サーバー側で今までの会話の最新3件をDBから取得(a) ● AIのキャラクター設定をDBから取得(b) ● (a)(b) をもとにしてLLMのプロンプトを作成 ● ローカルLLMにプロンプトを送信 ● ローカルLLMからのレスポンスをStreamで受け取る ● 上記をフロント側にStreamで返す

Slide 52

Slide 52 text

ローカルLLMサーバー ● Ollama を使用 ○ Ollama はローカルLLMを実行しやすくするツール ○ ChatGPT互換APIなどを提供 ● モデルは Llama-3-ELYZA-JP-8B を使用 ○ Meta社の llama-3.0 を Elyza 社が日本語化したもの

Slide 53

Slide 53 text

「AIを呼び出す」ボタンを押したときの挙動 バックエンド Ollamaサーバー フロントエンド AIを呼び出す DBから必要な データを取得 POST LLM プロンプトを 入力 Notify Streamでテキストを返 す メッセージの表 示 読み上げ (Text to speech) GET Listen

Slide 54

Slide 54 text

Text to Speech について ● 現状ブラウザ(OS) の機能で読み上げてるだけ function speak(text: string) { // SpeechSynthesisUtteranceのインスタンスを作成 const utterance = new SpeechSynthesisUtterance(); utterance.text = text; utterance.lang = "ja-JP"; // 音声を再生 window.speechSynthesis.speak(utterance); }

Slide 55

Slide 55 text

ブラウザでの Text to Speech の課題 ● 棒読み ● 声を選ぶなど自由度が少ない ● フロントの環境依存 ○ フロント側で日本語の読み上げ機能が有効になっている必要がある ● リアルタイム性に欠ける ○ 現状は10文字ずつ読み上げてるだけ

Slide 56

Slide 56 text

今後の展望 ● TextをStreamで渡したら合成音声をStreamで返してくれるサーバーを作って、音 声データをフロントに送るようにしたい ● RealtimeTTS が良さそうだと思って試したけど、イマイチだったので自作することに した。 ●

Slide 57

Slide 57 text

まとめ

Slide 58

Slide 58 text

まとめ ● Web Streams API を活用することで同期処理で音声→テキスト、テキスト→音声 の変換処理を行えるようにした ● DenoはWeb標準を大切にしているので Web Streams API と相性が良い ● PostgreSQL の Notify/Listen を活用することでサーバーのプロセスから分離され た Channel を扱うことができる ● 音声の読み上げはブラウザだけでも可能