PC Watchの記事で紹介されていた、外出先から自宅のGPUを叩くサービス「LM Link」。
「自前で再現してみたい」と思い立ったのが始まりでした。
本記事では、GPUを搭載したデスクトップPCをホストとし、スマホ(Pixel 8a)やノートPCから安全かつ爆速でLLMを利用できる環境を構築した記録、そして公式Web UIのカスタマイズの試行錯誤をまとめます。
1. なぜ「Ollama」ではなく「llama.cpp」なのか?
現在、手軽にLLMを動かすならOllamaやLM Studioが定番です。しかし、今回はあえて本家本元である llama.cpp (llama-server) を直接採用しました。
選定の理由
-
最新アーキテクチャへの追従スピード:
Ollamaなどのエンジンも内部的にはllama.cppです。しかし、新しいモデルが登場した際、本家のllama.cppが最初に対応し、他はそれを後から取り込む形になります。最新モデルをいち早く試したい人にはllama.cppがよいと考えています。 -
自由度の高さ(Thinkingの制御):
Ollamaは非常に便利ですが、ThinkingをModelfaileでoffにしてもバックグラウンドで動いているという記事を見て、その自由度の低さが気になりました(参照 :https://qiita.com/ntaka329/items/35f156dbe526121e66f5 )。
2. 構築プロセス:セキュアな「自前LM Link」の完成
「llama.cpp + Tailscale + クライアント端末」という、オープンでセキュアな構成を目指しました。
システム構成
- ホスト: Windows 11 / 32GB RAM / Ryzen 7 5800H / RTX 4070 Super
- クライアント: Pixel 8a / Core i5・i7搭載ノートPC
- ネットワーク: Tailscale (VPN)
2-1. Tailscaleの導入とネットワーク構築
外部から安全にアクセスするため、VPNのTailscaleを使用します。
- ホストPCとクライアント(スマホやノートPC)の両方にTailscaleをインストールし、同じアカウントでログインします。
- ホストPCのTailscale IPアドレス(
100.x.x.x)をメモしておきます。
2-2. llama.cppのインストールとモデルの準備
今回は winget を使って手軽にインストールしました。
# llama.cppのインストール
winget install llama.cpp
モデルはHugging FaceからGGUF形式のものをダウンロードしておきます。今回は Qwen3.5 9B と Gemma4 e4bを用意しました。
llama-server -hf unsloth/Qwen3.5-9B-GGUF:UD-Q4_K_XL # 例 Qwen3.5-9Bをインストール
2-3. 直面した「壁」:Windowsファイアウォール
サーバーを立ち上げてポート8080を指定しても、スマホから繋がりませんでした。原因はWindowsファイアウォールです。
管理者権限でPowerShellを開き、以下のコマンドで特定のポート(今回は8080)に穴を開けます。
# ポート8080のインバウンド通信を許可するコマンド
New-NetFirewallRule -DisplayName "llama.cpp 8080" -Direction Inbound -LocalPort 8080 -Protocol TCP -Action Allow
2-4. サーバーの起動とアクセス確認
セキュリティを考慮し、0.0.0.0 ではなくTailscaleのIPを指定して起動します。
また、あえて特定のモデルを指定せずに起動することで、UI上から動的にモデルを切り替えられるようにしました。
# 実際の起動コマンド
llama-server --host 100.xxx.xxx.xxx --port 8080 -ngl 99 --path "C:\path\to\your\custom\public\index.html"
スマホ(Pixel 8a)のブラウザから http://100.xxx.xxx.xxx:8080 にアクセスします。
無事にスマートフォンからもllama.cppのweb uiが開けました🎉
実際にスマホからアクセスし、「llama.cppとvLLMの比較をしてください」とプロンプトを入力して推論を回してみました。
結果は 68.72 t/s という爆速生成を確認!スマホからでも全くストレスなく、サクサクとローカルLLMを実行できる環境ができました😊
3. Web UI改造:Unloadボタン実装とThinking制御への挑戦
ここからが本題です。公式GUIには「VRAMからモデルを降ろす(Unload)」ボタンがなく、推論モデルの「Thinking」切替も不十分でした。無い機能は作ってしまおう、ということで改造に挑みました。
成功:アンロードボタンの実装
ビルド済みの public/index.html に直接JavaScriptを注入しました。
index.htmlに実装の要点は以下の通りです。改造元はhttps://github.com/ggml-org/llama.cpp/blob/master/tools/server/public/index.html
</div><!-- <div style="display: contents">の閉じタグ -->
<button id="floating-unload-btn" onclick="unloadModel()" style="display: none; position: fixed; bottom: 20px; right: 20px; z-index: 9999; padding: 12px 20px; background-color: #ef4444; color: white; border: none; border-radius: 8px; font-weight: bold; cursor: pointer; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
</button>
<script>
// DOM要素を関数の外で一度だけ取得(キャッシュ)
const unloadBtn = document.getElementById('floating-unload-btn');
// 処理中かどうかを判定するフラグ(連打対策)
let isProcessing = false;
// モデルのロード状態を定期監視する関数
async function checkModelStatus() {
if (isProcessing) return;
try {
const response = await fetch('/models');
const data = await response.json();
// data.data の中に status が 'loaded' のモデルが存在するかチェック
if (data && data.data) {
const isLoaded = data.data.some(model => model.status === 'loaded');
if (isLoaded) {
if (unloadBtn.style.display !== 'block') {
unloadBtn.style.display = 'block';
}
} else {
if (unloadBtn.style.display !== 'none') {
unloadBtn.style.display = 'none';
}
}
}
} catch (error) {
console.log("[Status Check] モデル状態の取得に失敗しました: " + error);
}
}
// アンロードAPIを叩く関数
async function unloadModel() {
if (isProcessing) return;
isProcessing = true;
unloadBtn.style.opacity = '0.5'; // 視覚的に無効化されていることを示す
unloadBtn.style.cursor = 'not-allowed';
try {
const response = await fetch('/models/unload', { method: 'POST' });
if (response.ok) {
// ボタンを即座に隠す
unloadBtn.style.display = 'none';
} else {
console.log("[Unload Action] エラー: サーバーがステータス " + response.status + " を返しました。");
}
} catch (error) {
console.log("[Unload Action] 通信エラーが発生しました: " + error);
} finally {
// 処理が終わったらフラグと見た目を元に戻す
isProcessing = false;
unloadBtn.style.opacity = '1';
unloadBtn.style.cursor = 'pointer';
}
}
// 5秒ごとにAPIを叩いて状態を監視 (5000ミリ秒)
setInterval(checkModelStatus, 5000);
// ページ読み込み時にも一度実行
checkModelStatus();
</script>
これにより、ボタン押下で正常にVRAMからモデルを解放できるようになりました。
失敗:Thinking OFFを巡る4つのアプローチ
推論モデル(Qwen3.5等)の思考を強制的にOFFにしようと試みましたが、llama.cpp サーバー側の安全装置(バリデーション)との戦いになりました。
1. Fetch APIフックによるテンプレ書き換え
結果: 失敗。 リクエストを横取りし、Qwen用テンプレートで <think> 終了を偽装して送信しましたが、フォーマット不一致とみなされ無視されました。
2. アシスタントメッセージの事前入力(Prefill)
結果: 失敗。 AIが思考を終えた体で事前入力を試みましたが、llama.cppの最新バリデーションに阻まれ 400 Bad Request となりました。
3. 純正テンプレートの動的取得と改変
結果: 失敗。 /props APIからテンプレを取得し改変しようとしましたが、ルーターモードではモデル名が空で返る仕様のため処理が破綻しました。
4. ロード要求保存と「再Load」
結果: 失敗。 ロード時のJSONを保存し、純粋なChatMLに書き換えて再Loadしましたが、モデル自体の構造やサーバー側の推論モードロックにより、思考プロセスはバイパスできませんでした。
4. 学んだことと次なる展望
今回の挑戦で、llama-server のAPI仕様、特に強力なバリデーション機能やルーターモード時の挙動を少し理解することができました。
「確実な思考OFF」への解
結論として、API経由のテンプレート書き換えには限界があります。
「メタデータを『思考なし』に書き換えた別名のGGUFファイルを物理的に作成し、UIからはモデルの再ロード(切り替え)として処理する」こと、またはAPIの仕様を書き換えることが、確実であるという結論に達しました。
展望
- Thinking制御: スマホで気軽にローカルLLMをテストできる環境は無事に整ったので、まずはこれであれこれ遊んでみようと思います。気が向いたら、Thinkingを作ってみたいと思います💤
- モデルインストールの自動化: 欲を言えば、UI上からHugging Faceのモデルを直接選んで落とせたら最高ですよね。これも「いつかやれたらやる」リストに入れておきます。
「無い機能はAPIを叩いて作る」というプロセスは、LLMの挙動を深く知る最高の学習機会になりました。自宅に眠っているGPUがある方、ぜひ「自前LM Link」に挑戦してみてください。
参考リンク
https://pc.watch.impress.co.jp/docs/column/nishikawa/2092683.html
https://qiita.com/ntaka329/items/35f156dbe526121e66f5