Mac でローカル LLM やるなら MLX/MLX-LM を使おう!と推し推しキャンペーン中ですが、つい先日 MXFP4 量子化に対応したバージョンがリリースされ、gpt-oss がますます速くなりました。すんばらしい!
Dify や Open WebUI で使う方法も書いています。

Contents
環境、バージョン、モデル
- Mac: M2 Max + 32GB RAM (VRAM: 24GB に変更済み。参考手順)
- MLX: 0.29.0
- MLX-Metal: 0.29.0
- MLX-LM: 0.27.0
- クライアント:
- Open WebUI: 0.6.26
- Dify: 1.8.0
- モデル: mlx-community/gpt-oss-20b-MXFP4-Q8
※ どれも本日時点で最新バージョンですが、Open WebUI と Dify はまだ gpt-oss の harmony response format に対応していないので、そのままでは動きません。ボクは MLX-LM のサーバスクリプトに変更を加えることで動かしています (やり方は以下)。LM Studio を使っている人は読む必要の無い内容となっています。
MLX-LM 環境の作り方はこちら:
いきなりですが mlx-community の Q8 は本当に Q8 なのかわからん
細かい技術的なところはよくわからないのでよくわかっている人が書いている記事を読んで欲しいのですが、ボクにとって何がよくわからんって、Q8 のサイズが Q4 とほとんど変わらないってところです。わかりやすいので LM Studio で表示されるサイズを以下にまとめました。下の表は全て mlx-community で MXFP4 量子化されたものです。
mlx-community モデル | ファイルサイズ |
---|---|
gpt-oss-120b-MXFP4-Q8 | 63.41 GB |
gpt-oss-120b-MXFP4-Q4 | 62.36 GB |
gpt-oss-20b-MXFP4-Q8 | 12.10 GB |
gpt-oss-20b-MXFP4-Q4 | 11.21 GB |
比較対象として、他のコントリビュータによるこれまでの MLX 量子化のサイズをまとめました。変換時のオプションで量子化後のサイズに多少変化があるのかも知れませんが、Q8 (8bit quant) と Q4 (4bit quant) の間には大きな差 (20b で 9GB) があるのがわかります。
モデル | ファイルサイズ |
---|---|
lmstudio-community/gpt-oss-120b-MLX-8bit | 124.20 GB |
nightmedia/gpt-oss-120b–q4-hi-mlx | 73.10 GB |
lmstudio-community/gpt-oss-20b-MLX-8bit | 22.26 GB |
nightmedia/gpt-oss-20b–q4-hi-mlx | 13.11 GB |
LM Studio の Model Directory でダウンロード済みの gpt-oss-20b-MXFP4-Q8 を見ると Quant が4bit
となっています。これが、MXFP4 を指しているのか、Hugging Face で誰かが質問しているように、mlx-community が間違って Q4 を Q8 としてアップロードしてしまったのかはわかりません。そのうちバリエーションがアップされるでしょうから、そのときに何が正しいのかがわかりそうです。いずれにせよ MXFP4 では Q8 は Q4 より 1 GB 程度しか大きくないので、動かせる環境なら Q8 でいいじゃん、という判断ができますよね。4-bit の MXFP4 をさらに 8-bit に量子化する (劣化したものを高精細化する!?) のは意味が無い、という見解もあるので、MXFP4 であれば 8-bit も 4-bit も性能・サイズともに性能に違いはほぼ無い、ということなのかなと想像しています。
性能的なところに少し触れると、2日ほど日本語で 20b-MXFP4-Q8 を使ってきた感覚では、旧 MLX の 6.5bit と比較して問題を感じません。20b-MXFP4-Q8 のサイズが 12.10 GB なのに対し、旧 MLX の 6.5bit は 17.03 GB です。
少ない性能劣化で速度も出る
同じ量子化技術を使う場合、ビット数が小さいほど (Q8 より Q4 等) 劣化し元々の性能が出なくなります。その代わりファイルサイズが抑えられるので小さな VRAM で実行でき、結果として処理速度が速くなります。一般的に Q8 は性能の劣化の少なさと処理速度向上のバランスが良いためよく使われています。
MXFP4 の場合、上記の通り Q8 で性能をほぼ維持したままファイルサイズが半分ほどになっているわけです。単純にその点だけ考えても高速で動くことがわかりますね。素の MLX 版でも速かったのに、速読スキルがないと目では追えないレベルです。
LLM の性能の良し悪しはなかなか評価が難しいですが、比較サイトなどでわずかな差で 32B の他のモデルの性能が高いと評価されていたとしましょう。しかし手持ちの VRAM 容量や使用したいコンテキスト長の絡みで 32B モデルは Q4 以下の量子化が必要ということであれば、実際の性能差は近づく可能性が高いです。ものによっては逆転の可能性もあるかもしれません。
MLX の強みのひとつ: コンテキスト長の調整が不要
MXFP4 自体のメリットではないのですが、LLM 自体のサイズが小さくなればそれだけ VRAM の空きに余裕ができるので、大きなコンテキストサイズを扱うことができます。Ollama は使用するコンテキスト長を指定してチャットを始める必要があるので、大きなサイズを扱いたいときは事前に計算したりして攻めたサイズを指定してあげる必要があります。これを間違うと部分的に CPU で処理がされることになり、大きな速度低下の原因になります。
MLX で LLM を扱う際の大きな魅力のひとつが、コンテキスト長の指定が不要 (ダイナミック) というところだと思っています。モデルが扱えるサイズの上限を入れておけばよく、もちろん VRAM サイズを超えたときには動作が遅くなりますが、そのときまでは気にせず使えます (遅くなったら新しいチャットを始めるの精神)。コンテキストが小さい間は Mac のメモリ使用量自体が抑えられているので安心感があります。
以下は Ollama のコンテキスト長関連の記事です。Ollama の速度でお悩みの方はどうぞ。
API クライアントからムリヤリ使う方法
前回の記事でも書いたのですが、gpt-oss の Harmony Response Format に対応していない LLM フロントエンド/API クライアント (Open WebUI や Dify 等) で gpt-oss を使うと、2回目のチャットでエラーが出ます。本来はクライアント側で思考部分を API サーバに送らない、というのが正解なのですが、とりあえず MLX-LM のサーバスクリプト側で受け取った思考部分を切り捨てるという方法で対処可能です。どの API クライアントでも使えるので、正式対応されるまではしのげると思います。
ただ、MLX-LM のバージョンアップと共にサーバスクリプト (server.py
) も変更されたため、前回の記事で紹介したスクリプトはそのままでは動きません (試してませんが無理でしょう)。
というわけで、以下に mlx-lm 0.27.0 に対応したコードを貼っておきます。
直下は変更前のserver.py
の対象箇所です。念のためcp server.py server.py.original
等として元のファイルを保存してから作業を進めてください。
ハイライトされている、862行目が書き換えの対象です (前後にコードを挟みます)。
if self.tokenizer.chat_template:
messages = body["messages"]
process_message_content(messages)
prompt = self.tokenizer.apply_chat_template(
messages,
body.get("tools") or None,
add_generation_prompt=True,
**self.model_provider.cli_args.chat_template_args,
)
以下に置き換えます。
# --- Changes from here ---
# Modify message based on the `mlx-lm` chat template.
for message in messages:
if message["role"] == "assistant":
content = message.get("content", "")
if "<|channel|>analysis<|message|>" in content and "<|channel|>final<|message|>" in content:
try:
analysis_start_tag = "<|channel|>analysis<|message|>"
analysis_end_tag = "<|end|>"
final_start_tag = "<|channel|>final<|message|>"
analysis_start = content.find(analysis_start_tag) + len(analysis_start_tag)
analysis_end = content.find(analysis_end_tag)
final_start = content.find(final_start_tag) + len(final_start_tag)
analysis = content[analysis_start:analysis_end].strip()
final = content[final_start:].strip()
message["content"] = final
message["thinking"] = analysis
except Exception as e:
logging.error(f"Failed to parse assistant message with analysis/final tags: {e}")
# If parsing fails, leave the content and empty thinking
message["thinking"] = ""
# --- to here ---
process_message_content(messages)
# Moved response_format before `apply_chat_template`
if body.get("response_format", {}).get("type") == "json_object":
if self.tokenizer.chat_template is None:
raise ValueError("JSON response format requested, but tokenizer has no chat template. Consider using `--use-default-chat-template`")
messages.append({"role": "user", "content": self.tokenizer.json_schema_prompt})
Diff 形式でも貼っておきます。
--- server.py.v0270.original 2025-08-31 00:48:31
+++ server.py 2025-08-31 00:54:51
@@ -861,3 +861,37 @@
messages = body["messages"]
+
+ # --- Changes from here ---
+ # Modify message based on the `mlx-lm` chat template.
+ for message in messages:
+ if message["role"] == "assistant":
+ content = message.get("content", "")
+ if "<|channel|>analysis<|message|>" in content and "<|channel|>final<|message|>" in content:
+ try:
+ analysis_start_tag = "<|channel|>analysis<|message|>"
+ analysis_end_tag = "<|end|>"
+ final_start_tag = "<|channel|>final<|message|>"
+
+ analysis_start = content.find(analysis_start_tag) + len(analysis_start_tag)
+ analysis_end = content.find(analysis_end_tag)
+ final_start = content.find(final_start_tag) + len(final_start_tag)
+
+ analysis = content[analysis_start:analysis_end].strip()
+ final = content[final_start:].strip()
+
+ message["content"] = final
+ message["thinking"] = analysis
+ except Exception as e:
+ logging.error(f"Failed to parse assistant message with analysis/final tags: {e}")
+ # If parsing fails, leave the content and empty thinking
+ message["thinking"] = ""
+ # --- to here ---
+
process_message_content(messages)
+
+ # Moved response_format before `apply_chat_template`
+ if body.get("response_format", {}).get("type") == "json_object":
+ if self.tokenizer.chat_template is None:
+ raise ValueError("JSON response format requested, but tokenizer has no chat template. Consider using `--use-default-chat-template`")
+ messages.append({"role": "user", "content": self.tokenizer.json_schema_prompt})
+
prompt = self.tokenizer.apply_chat_template(
思考部分は丸見えのままですが、これで繰り返しのチャットが可能になります。
感想
とにかくスピードがあるので魅力的ですよね。ドラクエの武道家みたいな。一発一発の攻撃力 (能力) が多少低くても手数で勝負できる感じでしょうか。
120b の Q8 でも 64GB なら、96GB のユニファイドメモリで足りるんですよね。へー、そうかー、と整備済製品ページを眺めてはため息ですよ。
Image by Stable Diffusion (Mochi Diffusion)
単純に「武道家」からイメージして生成。宮平保先生みたいな武術家のイメージだったので、素手の組み手の画像を選択。どうせボク自身が細かいビジョンを持っていないので、最小限の単語の組み合わせが良い結果を生むと理解しつつあります。
Date:
2025年9月1日 1:04:44
Model:
realisticVision-v51VAE_original_768x512_cn
Size:
768 x 512
Include in Image:
fastest martial art fighter
Exclude from Image:
Seed:
199077246
Steps:
20
Guidance Scale:
20.0
Scheduler:
DPM-Solver++
ML Compute Unit:
CPU & GPU