著者: 著者: やまねこ タグ:
Mac mini上のmlxローカルLLMサーバーとHermes AgentがTailscaleネットワーク経由で安全に接続されている概念図

Tailscaleは繋がっているはずなのに、Hermes AgentからmlxのローカルLLMサーバーに接続できない。
ncではポートが開いているように見えるのに、curlを打つと応答が返らずタイムアウトする。

こうした経験はないでしょうか。実はこれ、ネットワークのアクセス制御と、モデル側の設定という、まったく別レイヤーの2つの罠が絡み合って起きています。

この記事では、Mac mini上のmlx_lm.serverをTailscale経由でHermes Agentに接続する手順を、実際にハマったポイントとあわせて解説します。
Hermes Agent自体の機能については、既存記事で詳しく整理しています。

本記事は、その実践編として、自分のローカルLLMをどう繋ぐかに絞って書きます。

ざっくりまとめ

  • 実際のアクセス制御を担うのはTailscale Grantsです。CORS設定はブラウザ向けの補助にすぎません。
  • Tailscale Grantsは許可ルールの加算方式です。8080ポートだけHermes機に絞るには、既存ルールを削除するのではなく、穴あけした形に置換します。
  • tool callingが動くかどうかは、モデルのtokenizer_config.jsonchat_templateで決まります。量子化(8bit/4bit)そのものでは変わりません。
  • 「TCPは通るのにHTTPが無応答」の正体は、たいていmacOSアプリケーションファイアウォールの承認待ちです。
  • 常駐運用では、Qwen3-Coder-30B-A3B-Instruct-4bit-DWQが現実的な本命です(2026-06-27実測)。
    (参考: Tailscale Grants公式ドキュメントmlx-lm SERVER.mdmlx-lm GitHubApple公式サポート文書

今すぐ速度や長いcontextを求めている方には、この記事は基本接続編であり、OptiQによる高速化やKV cache運用は次の記事に分けています。
まずは安全な基本構築から進めます。

結論から言うと — この構成で安全に繋がる

結論から言うと、次の3点セットが揃えば安全に繋がります。

  1. Tailscale Grantsで8080ポートをHermes機だけに絞る(加算方式・穴あけ形の設定)
  2. macOSアプリケーションファイアウォールの初回承認を済ませる
  3. tool parserが確実に動作するモデルを使う

構成はシンプルです。推論サーバー(Mac mini M4 Pro 64GB、mlx_lm.server)と、Hermes Agentを動かすクライアント機(Mac mini M4 16GB)を、同一のTailscaleネットワーク(tailnet)で結びます。

mlx_lm.server自体には強い認証機能がありません。
守りの主役はTailscaleのACLと、--hostオプションによるbindアドレスの限定です。通信は平文HTTPですが、tailnet内の経路はTailscale(WireGuard)で暗号化されます。
(参考: mlx-lm SERVER.md

ただし、経路の暗号化だけで安心はできません。
残存リスクとして、tailnetへの不正デバイス参加、サーバー機やクライアント機自体の侵害、同一機上の他プロセスからのポートアクセス、サーバーログへの平文プロンプト記録、ACL設定ミスが考えられます。
秘匿情報を扱う前提であれば、ACLを最小権限に絞り、ログ出力先も点検してください。

なぜCORSを疑ってしまうのか — Tailscale Grantsとの違い

接続できないとき、多くの人がまずCORS設定を疑います。しかし、CORSはブラウザ向けの制御であり、ネットワークレベルのアクセス制御ではありません。

CORSとアクセス制御は別レイヤー

mlx_lm.server--allowed-originsは、既定では"*"です。Hermes Agentはブラウザではないため、--allowed-origins ""と設定しますが、これはAccess-Control-Allow-Originヘッダーを返さなくするだけです。
(参考: mlx-lm SERVER.md

効果の範囲を正確に理解しておく必要があります。

  • ブラウザがJavaScriptから応答を読めなくなります
  • application/jsonのクロスオリジンPOSTは、preflightリクエストの段階で失敗します
  • 一方で、text/plainのsimple POSTはサーバーに届き、推論が実行される可能性があります
  • curlやHermes Agentのような非ブラウザのクライアントには、そもそも無関係です

残存リスクとして、リクエストのmodelフィールドは任意のローカルパスやHugging FaceのリポジトリIDとして扱われます。
信頼できる端末からのみアクセスできる前提であれば許容範囲ですが、意図しないモデル切り替えや大容量ダウンロードを誘発しうる点は覚えておいてください。

加算方式の罠(削除でなく置換)

Tailscaleのgrantsは、許可ルールの加算(和集合)だけで構成されます。「特定のポートだけ拒否する」という単体のdenyルールは書けません。
(参考: Tailscale Grants公式ドキュメント

これは、実際に踏んでみないと実感しにくい罠です。

初期状態の全許可ルール{"src":["*"],"dst":["*"],"ip":["*"]}をそのまま削除すると、SSH(22)を含むすべての通信がdefault denyになり、tailnet全体が壊れます。
自分のSSH管理経路まで切れてしまうため、削除ではなく、8080だけ穴あけした形に置換するのが正解です。

実際に動作する最小構成は次のような形になります。

{
  "grants": [
    { "src": ["*"], "dst": ["*"], "ip": ["tcp:1-8079", "tcp:8081-65535", "udp:*", "icmp:*"] },
    { "src": ["hermes-m4"], "dst": ["mlx-m4pro"], "ip": ["tcp:8080"] },
  ],
  "hosts": {
    "hermes-m4": "100.x.x.x",
    "mlx-m4pro": "100.y.y.y",
  },
  "tests": [
    { "src": "hermes-m4", "proto": "tcp", "accept": ["mlx-m4pro:8080"] },
    { "src": "macbook-air", "proto": "tcp", "deny": ["mlx-m4pro:8080"] },
  ],
}

1本目のルールで8080以外の全ポートを全端末間で許可し、SSHや画面共有を維持します。2本目のルールで、8080だけをHermes機からのみ許可します。

もう一つ、地味だが刺さりやすい罠があります。testsブロックでaccept/denyprotoを省略すると、tcp/udp/icmpのすべてが検証対象になります。
1本目のルールにudp:*が含まれていると、denyの検証がudp:8080の許可に引っかかり、Saveが失敗します。エラー表示はtcpと出るため紛らわしいのですが、真因はudpです。"proto": "tcp"を明示してください。

Saveボタンを押すと、testsが自動実行されます。壊れた設定はそもそも保存できない仕組みになっているので、適用後は実機でcurlによる到達性確認と、他端末からの遮断確認をあわせて行うと安心です。

最小権限の考え方は、既存記事とも共通しています。専用環境を分け、権限を絞ってから広げるという方針は、ローカルLLMのネットワーク構成でも変わりません。

tool parserは何を見て判定しているのか

Hermes Agentの生命線はtool callingです。ここが動かないと、ツールを使った作業依頼がすべて失敗します。

mlx_lm.serverは、起動フラグでtool parserを指定しません。mlx_lm/tokenizer_utils.pyが、次の優先順位でparserを決めています。
(参考: mlx-lm GitHub

  1. tokenizer_config.jsontool_parser_typeが明示されていれば、それを最優先で使う
  2. 明示が無い場合のみ、chat_templateの内容からフォールバック推論する。判定条件は、単独の<function=ではなく、<tool_call>の直後に改行を挟んで<function=が続く連続パターンです

Qwen3-Coder-30B-A3B-Instructの8bit/4bitは、tool_parser_typeの明示がなく、chat_templateのマーカーからフォールバック経由でqwen3_coderが選ばれます。実際に選択されるかどうかは、次のようなコードで確認できます。

from transformers import AutoTokenizer

MODEL = "mlx-community/Qwen3-Coder-30B-A3B-Instruct-8bit"
t = AutoTokenizer.from_pretrained(MODEL)
ct = t.chat_template or ""
ok = ("<tool_call>\n<function=" in ct) or ("<tool_call>\\n<function=" in ct)
print(MODEL, "qwen3_coder parser selectable:", ok)

Trueが返れば、フォールバック経由でparserが選ばれます。ここで注意したいのは、量子化(8bit/4bit)自体はparserの選択ロジックに影響しないという点です。
「8bitのほうが出力フォーマットが崩れにくい」という話を見かけることがありますが、これは一般論としてはあり得る仮説であって、一次情報の裏付けはありません。気になる場合は、自分の環境で8bitと4bitをA/Bで実測して判断してください。

いずれにしても、最終確認は必ず実際のtool_callsが返るかどうかと、Hermes Agent側でtool loopが完走するかどうかで行います。parserの選択ロジックを頭で理解していても、実機での確認を飛ばさないことが重要です。

ここまでのポイント

CORSとTailscale Grantsは別レイヤーの話であり、tool parserとモデル選定も別の話です。この2つを混同すると、原因不明のまま長時間ハマります。ここからは、実際の手順に進みます。

実際にやってみる — 起動から疎通確認まで

まず、サーバー機(Mac mini M4 Pro 64GB)でTailscale IPを確認します。

tailscale ip -4

以降、この値を<MLX_TAILSCALE_IP>とします。MagicDNS(<host>.<tailnet>.ts.net)も使えます。

mlx-lmは専用の仮想環境にインストールします。

python3 -m venv ~/mlx-env
source ~/mlx-env/bin/activate
pip install --upgrade pip
pip install --upgrade mlx-lm

モデルはmlx-communityの量子化済みモデルを取得します。

hf download mlx-community/Qwen3-Coder-30B-A3B-Instruct-8bit

メモリ目安は、8bitで約32GB、4bitで約17.5GBです。ただしこれは実測ではなく参考値であり、OSやKV cache、mlx-lmのオーバーヘッドで実際にはこれより多く消費します。
長いcontextほどKV cacheが増える点も踏まえ、必ず自分の環境で実測してください。

サーバーを起動します。

mlx_lm.server \
  --model mlx-community/Qwen3-Coder-30B-A3B-Instruct-8bit \
  --host <MLX_TAILSCALE_IP> \
  --port 8080 \
  --allowed-origins ""

疎通確認は、基本的なchatと、tool callingの2段階で行います。tool calling確認では、toolsパラメータを付けたリクエストを送り、choices[0].message.tool_callsに関数呼び出しが入っているかを見ます。空であれば、前のセクションのparser確認に戻ってください。

Hermes Agent側は、クライアント機(Mac mini M4 16GB)で設定します。公式リポジトリでも、hermes modelによるカスタムエンドポイントへの切り替えが説明されており、コード変更なしにプロバイダーを差し替えられる設計です。
(参考: Hermes Agent GitHub
wizardを使うのがおすすめです。

  • Provider: Custom Endpoint
  • Base URL: http://<MLX_TAILSCALE_IP>:8080/v1
  • API mode: chat_completions
  • Model: 使用するモデル名
  • Context length: 65536

Context lengthの65536は、必須の設定です。Hermes Agentは、モデルのcontext windowが64,000未満だと起動を拒否します。
空欄のままauto-detectに任せると、モデルの宣言値(Qwen3-Coderはネイティブ256K)を拾ってしまい、KV cacheが逼迫しやすくなります。

ここにも罠があります。いったんwizardで65536を設定しても、後からhermes config set model.default <model>でモデル名だけを差し替えると、context_lengthの設定が引き継がれずauto-detectに戻ってしまいます。
モデルを切り替えるときは、wizard(hermes -p <profile> model)でモデルとcontext lengthをセットで設定するのが確実です。

ハマったポイント — TCPは通るのにHTTPが無応答になる罠

ここが、実地でもっともハマりやすいポイントです。

症状の見え方はいつも同じです。

  • lsof -nP -iTCP:8080 -sTCP:LISTENでは、正しくLISTENしている
  • nc -vz <MLX_TAILSCALE_IP> 8080succeeded!と返る
  • それなのにcurlは0バイトでタイムアウトし、サーバーログにリクエストの行が一切出ない
  • --host 127.0.0.1で起動してcurl http://127.0.0.1:8080/v1/modelsを叩くと、即座に成功する

真因は、macOSのアプリケーションファイアウォールです。着信しようとしたアプリがリストに無い場合、許可を求めるアラートが表示される仕組みになっています。
(参考: Apple公式サポート文書
問題は、このアラートがGUIにしか出ないことです。SSH越しのヘッドレス環境では、この画面が見えないまま、いつまでも承認が完了しません。

なぜnc -zが成功するのにcurlが失敗するのか。アプリケーションファイアウォールは、TCPハンドシェイクは通過させたうえで、アプリがacceptしてデータを処理する段階をブロックします。
そのため、nc -zが示すのはカーネルがSYNを受け取っただけの結果であり、実際に到達性を確認したことにはなりません。到達性は、必ずcurl -vでHTTPレスポンスのバイトが返るかどうかと、サーバーログのリクエスト行で確認してください。

対処は2通りです。

  1. サーバー機の画面で、初回起動時に出る「pythonの着信を許可しますか?」のダイアログを承認する
  2. 画面に触れない場合は、CLIで事前に許可する
PYBIN="$HOME/mlx-env/bin/python3.11"
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --add "$PYBIN"
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --unblockapp "$PYBIN"

承認が済めば、--host <MLX_TAILSCALE_IP>のままで問題なく疎通します。切り分けの手段として、一時的に--host 127.0.0.1で起動してみて、localhostだけ通るなら、原因はほぼこのファイアウォール承認だと判断できます。

launchdで常駐化する

手動起動が安定したら、launchdで常駐化します。

前提として、LaunchAgentはGUIログイン中のみ稼働します。
Mac miniをヘッドレス運用したい場合は、自動ログインを有効にするか、LaunchDaemon化が必要です。ただしLaunchDaemonは、Tailscaleの接続確立より先に起動しやすく、ユーザーのHFキャッシュやvenvパスの扱いも変わるため、今回のようにログイン状態で常用する用途では、LaunchAgentのままで問題ありません。

wrapperスクリプトの役割は、Tailscale IPを動的に取得し、待機することです。これにより、launchdの起動順(Tailscale接続確立より先にスクリプトが走る場合がある)を吸収し、bind失敗によるループを防げます。

#!/bin/zsh
set -euo pipefail
export PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"

PORT="8080"
MODEL="mlx-community/Qwen3-Coder-30B-A3B-Instruct-4bit-DWQ"

TS_BIN="$(command -v tailscale || true)"
HOST=""
for i in {1..60}; do
  HOST="$("$TS_BIN" ip -4 2>/dev/null | head -n1 || true)"
  [[ -n "$HOST" ]] && break
  sleep 2
done

exec ~/mlx-env/bin/mlx_lm.server \
  --model "$MODEL" \
  --host "$HOST" \
  --port "$PORT" \
  --allowed-origins ""

LaunchAgent plistには、LabelProgramArgumentsRunAtLoadKeepAliveThrottleIntervalと、ログの出力先を指定します。管理は次のコマンドで行います。

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/local.mlx-lm-hermes.plist
launchctl enable gui/$(id -u)/local.mlx-lm-hermes
launchctl kickstart -k gui/$(id -u)/local.mlx-lm-hermes

一点、注意が必要です。非対話のsshセッションからは、gui/$(id -u)ドメインの操作が制限される場合があります。停止や状態確認は、GUIログイン中のターミナルで行ってください。

結局どのモデルを選ぶべきか — 8bit/4bit-DWQ/Next-4bit実測比較

Hermes Agent用途では、「tool parserが確実に動くか」が最優先です。そのうえでメモリ効率を比較すると、常駐運用に向くモデルが見えてきます。

以下は、2026年6月27日、M4 Pro 64GB・mlx-lm 0.31.3・Hermes context 65536という条件で、3つの候補を同一手順(parser確認・実tool_calls確認・Hermes tool loop検証)で検証した結果です。

モデル起動メモリ残余裕tool parserHermes tool loop体感速度
Qwen3-Coder-30B-A3B-Instruct-8bit約32GBフォールバック合格基準
同-4bit-DWQ21.88GB約24GB(厚い)フォールバック1回で的確に完走8bitと同等
Qwen3-Coder-Next-4bit48.63GB約8GB(薄い)明示型で確定完走(5回試行)8bitと同等

常駐の実用本命は、Qwen3-Coder-30B-A3B-Instruct-4bit-DWQです。起動メモリが21.88GBと最も軽く、残メモリを約24GB確保できるため、長いcontextでKV cacheが伸びても逼迫しにくくなっています。
DWQ(Distillation-aware)量子化により、4bitでも品質劣化が小さく、tool loopは1回の呼び出しで的確に解決しました。

Qwen3-Coder-Next-4bitは、parserが明示的に確定していて動作自体は良好ですが、64GB機では残メモリが約8GBと薄く、64Kのcontextを目一杯使う長いタスクではKV cacheの逼迫が懸念されます。
上位世代としての価値はあるものの、64GB機の常駐用途ではメモリ効率でDWQに劣ります。96GB以上のマシンであれば、有力な選択肢になります。

8bitは、品質基準のフォールバックとして温存しておくとよいでしょう。

検証の過程で、誤解しやすいポイントも見つかりました。Hermesに自己紹介をさせると、モデルそのものの人物紹介ではなく、ユーザープロフィールのような内容が返ってくることがあります。
これはHermes Agentがシステムプロンプトでプロフィール情報を注入しているためで、コンテキスト追従が正常に働いている証拠です。モデル品質の劣化ではありません。

なお、いずれの候補も、64Kのcontextを目一杯まで使った状態でのメモリと速度は、今回検証していません。短中規模のタスクでの実測値である点は明記しておきます。

まとめ:結局どうすればいい?

ここまでの内容を整理します。

  • ネットワークのアクセス制御(Tailscale Grants)と、モデル側の罠(tool parser判定)は、独立した2つの問題です
  • 実際のアクセス制御はTailscale Grantsが担います。CORS設定は補助にすぎません
  • 「TCPは通るのにHTTPが無応答」は、まずmacOSアプリケーションファイアウォールの承認を疑ってください
  • 常駐運用の本命は、2026年6月27日時点の実測でQwen3-Coder-30B-A3B-Instruct-4bit-DWQです

基本構築ができれば、次に気になるのは速度と長いcontextへの対応です。次の記事では、mlx-optiqへの置き換えによる実測ベンチマークと、KV cache運用の実務を扱います。

あわせて、最小権限構成の考え方は、既存記事「OpenClawをMac miniに安全に導入する手順」でも参照できます。

よくある質問

TailscaleのCORS設定とアクセス制御は何が違うの?

CORS設定(--allowed-origins)は、ブラウザへの応答許可を制御するだけで、リクエスト自体は拒否しません。実際のアクセス制御は、Tailscale Grantsが担います。
(参考: Tailscale Grants公式ドキュメントmlx-lm SERVER.md

TCPは通るのにHTTPが無応答なのはなぜ?

macOSアプリケーションファイアウォールが、TCPハンドシェイクは通しつつ、アプリのaccept処理の段階をブロックしていることがほとんどです。GUIでの承認、またはCLIでの事前許可で解決します。
(参考: Apple公式サポート文書

tool callingが動いたり動かなかったりするのはなぜ?

モデルのtokenizer_config.jsonchat_templateの内容でparserが選ばれます。量子化(8bit/4bit)そのものは、選択ロジックに影響しません。
(参考: mlx-lm GitHub

mlx_lm.serverにどのモデルを使えばいい?

常駐運用であれば、Qwen3-Coder-30B-A3B-Instruct-4bit-DWQが実測ベースでの本命です。品質基準のフォールバックとして8bitを温存する選択肢もあります。

Hermes Agentのcontext lengthはいくつに設定すればいい?

Hermes Agentは、64,000未満のcontext windowだと起動を拒否します。まずは65536を明示してください。

launchdで常駐化する際の注意点は?

wrapperスクリプトでTailscale IPの取得を待機させ、起動順の依存を吸収することが重要です。LaunchAgentはGUIログイン中のみ稼働する点にも注意してください。

8bitと4bit、どちらを選ぶべき?

常駐運用でメモリ効率を優先するなら4bit-DWQ、品質を最優先するフォールバックとしては8bit、というのが2026年6月27日時点の実測に基づく判断です。