ollaに元気を分けてくれ!!!!!!!

https://github.com/RiTa-23/dip_distributed_llm

TypeScript

C++

React

夢はでっかく!!参加者のPCの力を借りて動かす分散LLM!!!

RiTa

tLen

無音

aster*

推しアイデア

自分のノートPCがその場でAIの一部になる、という体験そのものが本命です。QRを読んだ瞬間に計算が始まって、自分が担当した層が画面で光る。それを会場のみんなが見ている、というライブ感を大事にしました。

作った背景

1台には載らないデカいLLMも、会場にいる人のPCをちょっとずつ借りれば動くのでは、という思いつきから。モデルも計算も会場のLANから出さない構成にしています。

推し技術

制御はHonoのWebSocket、実データはWebRTCで直接P2P、と2層に分けたのが肝です。Honoは「誰がいるか」「いつ始めるか」だけ喋って、重いデータには触りません。ブラウザで動くWASM版llama.cppと繋いで、インストールなしで参加できます。

プロジェクト詳細

ollaに力を分けてくれ!!!!!!!

(ollamaじゃなくてllama.cppだけど)

llm.ritane.co

↑ここから参加できます (LAN内でサーバーが立っている時のみ)

概要

会場のLAN内で、複数台のブラウザの計算資源を集めて1つのLLMを動かそうぜ!!うおぉーーーーー!!!!! 有線で繋いだ方がいい?ブラウザで繋ぐ実装コスト?そんなの妄想だって?ロマンです!!!

  • requester: プロンプトを打って結果を受け取る側。同時に1人だけ
  • peer: 自分のPCの空きを貸す側。QRから何人でも入れる

人が増えるほど、1台では載らないサイズのモデルが動くようになります。参加はQRを読んでページを開くだけで、インストールもアカウント登録も不要!

使用技術・アーキテクチャ

技術スタック

image

equesterのRuntimeがHonoから1回だけ落として、peerには計算に必要なぶんだけWebRTCで飛びます。peerはモデルファイルを持たない。

アーキテクチャ

image

1. 星型トポロジー

requesterがpeer1人ずつと個別にWebRTCで繋がります。llama.cppのRPCがそもそも星型なので、peer同士は繋がなくて済みます。

2. 制御とデータを分ける

Honoが喋るのは「誰が来た」「いま始めていい」くらいの軽いJSONだけで、推論のテンソルは一切通しません。

サーバーが中継すると、同じデータが会場の無線を2回通ることになります。そこが詰まるのは目に見えていたので、設計の段階でここだけは譲らないと決めました。

3. 世代(generation)で区切って組み直す

会場だと推論の最中にも人が増えます。かといってpeerが来るたびに編成を組み替えると生成が壊れるので、「次の生成が始まるとき」にだけ反映する形にしました。

ここは作りながら育った部分で、メッセージを3つ増やしました。

  • requester_accepting … 生成中に来たpeerをいま取り込んでいいか、requester側が決める。断っている間は溜めておいて、OKした瞬間にまとめて1回だけ組み直す
  • generation_failed … 編成したけど繋がらなかった、をサーバーに伝える
  • generation_aborted の理由 … 「増えた」「減った」「繋がらなかった」を分けて画面に出す

最初は「増減したら組み直す」だけのつもりでした。でも実際に動かしたら、繋がらないまま「生成中」で固まる経路が見つかって、それを塞ぐために契約が増えていった感じです。

飛び入りの人に警告を出さないための証明書

技術の無駄遣い

地味ですが、ここに割とハマりました

ブラウザでWASM版llama.cppを動かすには、pthread(SharedArrayBuffer)のためにHTTPSとCOOP/COEPが要ります。ところが開発で使うmkcertの証明書は、自分のPCでしか信頼されません。つまり会場で飛び入りしてくれた人のPCには証明書の警告が出ます。せっかくQRで気軽に参加できるのに、そこでUXが最悪に。

なので、リタ所有の実在ドメインに対してLet's Encryptの証明書をDNS-01で取って、そのドメインのAレコードが会場LANのプライベートIPを指すようにしました。DNS-01はTXTレコードを一時的に置けるかしか見ないので、サーバーが外から見える必要がありません。

この構成で外に出るのは「ドメイン名 → 会場のLAN IP」を引くところだけです。モデルもテンソルも制御メッセージも、会場のLANから外に出ません。

元ネタとの差分

WASM版llama.cpp + WebRTCという土台は、llmletという先行プロジェクトを参考にしています。ここからの差分がプロジェクトの中身そのものなので、少し詳しく書きます。

image

一番効いているのは2行目。llmlet自身が「TURNが無いので繋がらない環境がある」と書いていますが、同一LANならその心配はなし。ICEの候補もローカルのものだけで済むので、接続が速くて安定します。

同じ部屋にいるから成立していること

「LANに限定した」のは制約に見えて、実際はこのプロジェクトの前提そのもの

  • レイテンシ。層分割は1トークン生成するたびに、requesterが各peerを順に呼んでテンソルを往復させます。つまり1トークンあたりの通信時間が 台数 × RTT に比例します。LANのRTTは1ms以下なので4台でも4ms程度で、無視できます。これがインターネット越しだとRTTは50ms前後になるので、同じ4台で200ms以上。参加者が増えるほど遅くなるという逆転が起きます。この設計が成立するのはLANだからこそ!
  • 帯域。モデル本体は12GB以上あります。LANなら普通に配れますが、TURN中継が挟まると現実的ではありません
  • セキュリティを正当に省ける。llama.cppの公式ドキュメントは「RPCサーバーを開放されたネットワークで動かすな」と明記しています。llmletは不特定多数を想定するので対策が要りますが、閉じた会場LANで数時間動けばいい
  • QR1枚で人が増えていく演出。同じ部屋にいるからこそできる見せ方で、離れた場所を繋ぐことが本質のllmletには作れない画です

LANゆえの実装

同一LANだからこそ塞がないといけなかった穴

  • 送信バッファが溢れる。モデルの重みをDataChannelに一気に流すと、Chromeは bufferedAmount が16MiBに達した時点で例外を投げます。8MiBで一度待ち合わせる水位を入れて、溢れる前に止めるようにしました
  • 黙って消えるPCを検知できない。ノートを閉じたりWi-Fiが切れたりすると、TCPが死ぬまで切断イベントが飛んできません。その間ロスターに幽霊が残って「全員ready」の判定が狂います。30秒ごとにpingを撃って、返事が来ない相手を落とすようにしました
  • APアイソレーション。会場のWi-Fiが端末同士の通信を遮断していると、そもそもP2Pが張れません。外部サービスには頼りたくないので、会場LANの中に自前でTURNを立てて使う設定を用意してあります

技術選定の理由

  • Bun: TypeScriptがそのまま動くのと、workspacesでモノレポ(フロント・バックエンド・共通型)管理できるのが大きかったです
  • Hono: フロントと同じTypeScriptで書けて、静的配信もWebSocketも1つで済むので。最初は「重いデータを中継するならGo」という案も出ましたが、データをWebRTCで直接流す設計にしたらHonoが触るのは軽いJSONだけになったので、言語の速度差はほぼ関係なし。
  • WebRTC: ブラウザ同士を直接繋げたかったので。同じLANならSTUNもTURNも要りませんが、会場のWi-Fiが端末同士の通信を切っている場合に備えて、会場のLAN内に自前で立てたTURNを使う設定も用意してあります(外部のサービスには頼りません)
  • WASM:「URLを開くだけで計算資源を貸せる」を実現するため。ネイティブアプリを各PCへインストールするのではなく、llama.cpp自体をブラウザで動かしています。

Runtime詳しい話

llama.cppをコンパイラ(Emscripten)でWASMにしてブラウザ上で動かし、ネットワーク通信部分をWebRTC DataChannelにつなぎ替えています。

つまり、参加者は専用ソフトをインストールしなくても、URLを開くだけでブラウザをllama.cppのRPC計算ノードとして参加させられます。

モデルの計算はどう分散される?

image

大まかな流れは、

① URLからpeerが参加 ② requesterがGGUFモデルを取得 ③ llama.cppがpeerをRPC backendとして利用 ④ 担当するモデルデータを各peerへ転送 ⑤ 複数peerで分担して推論

Honoは全ブラウザへWebアプリとWASM Runtimeを配信し、requesterへGGUFモデルをHTTPで配信します。

一方、モデル配置や推論時のRPC通信はHonoを経由せず、requesterとpeerのWebRTC DataChannel上でやり取りします。

peer同士は直接接続せず、requesterを中心とした星型構成です。

llama.cppから見ると「普通のRPC」

ブラウザ側では、

llama.cpp WASM RPC PeerManager WebRTC DataChannel 別PCのブラウザ

という形になっています。

llama.cppから見るとpeerはRPC backendですが、そのRPC通信をブラウザではWebRTC DataChannelに載せています。

そのため、推論エンジンそのものをWeb用に作り直すのではなく、llama.cppの仕組みをそのままブラウザ間の分散計算へ持ってくることができます。

一番詰まったのはLLMより「境界」

実際に大変だったのは、モデルそのものより

C++ / WASM / JavaScript / WebRTC / LAN

の境界部分でした。

同じLANなのにWebRTCが繋がらない

同一LANなら必ずP2Pできると思っていましたが、物理PCで試すとhost candidateの交換までは成功してもICE接続が成立しない環境がありました。

原因を決め打ちせず、direct接続を基本にしつつ、必要なら会場LAN内のTURNサーバーを使えるようにしています。TURNは必須ではなく、direct接続できない環境向けのfallbackです。

大きなモデルになると通信が詰まる

軽量モデルでは複数peerを使った分散実行まで確認できましたが、大きなモデルではモデルデータの転送量が増えると通信が失敗する問題が出ました。

そこで、

  • peerごとの転送量
  • 送信キュー
  • 受信キュー
  • どこまでモデルデータが送られたか

を計測できるようにして、「分散されていないのか」「送信が詰まったのか」「受信側が追いついていないのか」を切り分けています。

開発の進め方の工夫

手を動かす前に土台を作った

  • モノレポ: apps/web(React)・apps/server(Hono)・packages/shared-types(共通の型)を1つのリポジトリにまとめました。言語もパッケージマネージャーも揃うので、担当じゃないところを読むのが楽です
  • CIを最初に入れた: lint / format / test / typecheck をGitHub Actionsで回しています。モノレポなので変更された場所に応じてジョブを分岐させて、無駄に全部走らないようにしました。いまフロント207件・バックエンド169件のテストが通っています
  • AI向けのドキュメントを整備: AGENTS.mdにアーキテクチャの制約やコーディング規約をまとめて、AIに書かせるときも設計がブレないようにしました。「ドキュメントとコードが食い違っていないか機械的にチェックする」skillも仕込んでおきました
  • NotionとDiscordの使い分け: 議事録や検討の途中経過みたいな重い情報はNotionへ。DiscordはPR通知を流すチャンネル、リンク置き場、記事を投げる場所、と用途で分けました

契約を先に決めて、あとは各自で進める

  • メッセージの型を最初に固めた: 実装に入る前に、やり取りするメッセージをpackages/shared-typesに判別可能ユニオンで定義して、ドキュメントにも書きました。おかげで担当がバラバラでも、契約だけ見れば独立して進められました
  • モックを先に作る: フロントは本物のWebSocketと同じ形を返すモックを最初に用意して、バックエンドの完成を待たずに画面を作り込めるようにしました
  • 状態は1つのreducerに集約: どちらの画面も「7つの状態のうち1つ」で管理しています。「繋がってないのに貢献中」みたいなありえない状態を、型の時点で作れないようにしました

開発メンバー紹介(各担当について)

  • リタ:PM/hono周り担当
  • 無音:llama.cpp周り担当
  • ざゆ:フロント(リソース側UI)
  • とるえん:フロント(チャットUI)

RiTa

@rita