gpt-oss の思考部分を隠すまともな方法と魔改造 (MLX-LM)

普段あまり使わない Open WebUI をいじっていたら、普通に gpt-oss の思考部分を隠せるとわかりました。さらに、2ラウンド以降のチャットも問題無く動きますね。まだサポートされていないと思っていたなんてお恥ずかしい。いやまったく、おハズです。せっかくなのでやり方共有します。これ以上おハズな人が増えないように。

しかし同様の方法は Dify + MLX-LM では使えません (よね?)。なので、Dify 用には MLX-LM API サーバの魔改造で対応します。

Open WebUI で gpt-oss の思考部分を隠す正攻法

簡単な話なので、ささっと行きましょう。

(1) 右上のユーザアイコン → 管理者パネル → 設定 → モデル → gpt-oss の右にある鉛筆アイコン

(2) 高度なパラメータの右の「表示」→ Reasoning Tags の「デフォルト」をクリックして「カスタム」にし、以下の内容を入力:

Start Tag: <|channel|>analysis<|message|>

End Tag: <|channel|>final<|message|>

(3) 一番下にスクロールして「保存して更新

以下のスクリーンショットの様になっていれば OK です。

本件に直接関係ないですが、スクリーンショットでは他に、MLX の強みでコンテキスト長を最大にし (max_tokens: 131072)、同じ言葉がリピートする問題を抑えるための設定 (frequency_penalty: 0.4 と presence_penalty: 0.1) をしています←最適解が見つからず、試行錯誤中です。システムプロンプトのReasoning: Highは効かないですね。同様に「推論の努力」をhighにしてもlowにしても gpt-oss に効いているのかはよくわかりません。
Reasoning Tags をカスタムにして入力

思考中の様子:

下向き v をクリックすると思考の様子が展開されます。完了すると思考にかかった時間が表示されます:

こうすると思考部分は LLM に送られなくなるようで、2ラウンド以降のチャットも問題無くできます。Open WebUI すばらしい (イマサラ)!Dify にも欲しいな、この設定。

えー?ホントー?好きになっちゃうー

MLX-LM を API サーバとして Open WebUI から使う方法はこちらの記事にまとめています。

Dify から MLX-LM API 経由の gpt-oss は本当に使えないのか確認

Dify の OpenAI-API-compatible プラグインの version 0.0.20 でテスト済みです。もしかしたら新しいバージョンではそもそも gpt-oss に対応済みの可能性もあるため、更新履歴を見てみましょう。0.0.21 と 0.0.22 が出てました。

ふむふむ、なるほど

何が変更されたのかわかりませんが、ともあれ問題が発生しても古いバージョンがインストールできそうなので、最新の 0.0.22 にしてみます。しかしモデルの追加・利用のそれぞれで設定可能な項目は以前と変わりませんでした。

プレビューでチャットしてみると思考部分は丸見えで 2ラウンド以降のチャットはエラー発生。ここも以前と変わらず。

はい、というわけで、魔改造の出番ですね。へっへっへ

Dify から MLX-LM API 経由の gpt-oss を使う魔改造

本ブログでは何度もやっている、mlx-lm の server.py の改造ですが、今回はけっこうやっちゃってます。オリジナルのスクリプトは必ずバックアップしてからすすめてください。

前回記事からの変更内容は、言ってしまえば簡単で、<|channel|>analysis<|message|>から<|channel|>final<|message|>までを<details></details>で囲っているだけです。

サーバ側で思考部分を受け取っても廃棄する改造を行った前回記事はこちら:

スクリプト全てを見るにはここを開いてください
# Copyright © 2023-2024 Apple Inc.

import argparse
import json
import logging
import platform
import socket
import time
import uuid
import warnings
from dataclasses import dataclass, field
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import (
    Any,
    Dict,
    List,
    Literal,
    NamedTuple,
    Optional,
    Sequence,
    Tuple,
    Union,
)

import mlx.core as mx
from huggingface_hub import scan_cache_dir

from ._version import __version__
from .generate import stream_generate
from .models.cache import can_trim_prompt_cache, make_prompt_cache, trim_prompt_cache
from .sample_utils import make_logits_processors, make_sampler
from .utils import common_prefix_len, load


def get_system_fingerprint():
    gpu_arch = mx.metal.device_info()["architecture"] if mx.metal.is_available() else ""
    return f"{__version__}-{mx.__version__}-{platform.platform()}-{gpu_arch}"


class StopCondition(NamedTuple):
    stop_met: bool
    trim_length: int


def stopping_criteria(
    tokens: List[int],
    stop_id_sequences: List[List[int]],
    eos_token_id: Union[int, None],
) -> StopCondition:
    """
    Determines whether the token generation should stop based on predefined
    conditions.

    Args:
        tokens (List[int]): The current sequence of generated tokens.
        stop_id_sequences (List[List[[int]]): A list of integer lists, each
          representing a sequence of token IDs. If the end of the `tokens`
          list matches any of these sequences, the generation should stop.
        eos_token_id (Union[int, None]): The token ID that represents the
          end-of-sequence. If the last token in `tokens` matches this, the
          generation should stop.

    Returns:
        StopCondition: A named tuple indicating whether the stop condition has
          been met (`stop_met`) and how many tokens should be trimmed from the
          end if it has (`trim_length`).
    """
    if tokens and tokens[-1] == eos_token_id:
        return StopCondition(stop_met=True, trim_length=0)

    for stop_ids in stop_id_sequences:
        if len(tokens) >= len(stop_ids):
            if tokens[-len(stop_ids) :] == stop_ids:
                return StopCondition(stop_met=True, trim_length=len(stop_ids))

    return StopCondition(stop_met=False, trim_length=0)


def sequence_overlap(s1: Sequence, s2: Sequence) -> bool:
    """
    Checks if a suffix of s1 has overlap with a prefix of s2

    Args:
        s1 (Sequence): The first sequence
        s2 (Sequence): The second sequence

    Returns:
        bool: If the two sequences have overlap
    """
    max_overlap = min(len(s1), len(s2))
    return any(s1[-i:] == s2[:i] for i in range(1, max_overlap + 1))


def convert_chat(messages: List[dict], role_mapping: Optional[dict] = None):
    default_role_mapping = {
        "system_prompt": (
            "A chat between a curious user and an artificial intelligence "
            "assistant. The assistant follows the given rules no matter what."
        ),
        "system": "ASSISTANT's RULE: ",
        "user": "USER: ",
        "assistant": "ASSISTANT: ",
        "stop": "\n",
    }
    role_mapping = role_mapping if role_mapping is not None else default_role_mapping

    prompt = ""
    for line in messages:
        role_prefix = role_mapping.get(line["role"], "")
        stop = role_mapping.get("stop", "")
        content = line.get("content", "")
        prompt += f"{role_prefix}{content}{stop}"

    prompt += role_mapping.get("assistant", "")
    return prompt.rstrip()


def process_message_content(messages):
    """
    Convert message content to a format suitable for `apply_chat_template`.

    The function operates on messages in place. It converts the 'content' field
    to a string instead of a list of text fragments.

    Args:
        message_list (list): A list of dictionaries, where each dictionary may
          have a 'content' key containing a list of dictionaries with 'type' and
          'text' keys.

    Raises:
        ValueError: If the 'content' type is not supported or if 'text' is missing.

    """
    for message in messages:
        content = message["content"]
        if isinstance(content, list):
            text_fragments = [
                fragment["text"] for fragment in content if fragment["type"] == "text"
            ]
            if len(text_fragments) != len(content):
                raise ValueError("Only 'text' content type is supported.")
            message["content"] = "".join(text_fragments)
        elif content is None:
            message["content"] = ""


@dataclass
class PromptCache:
    cache: List[Any] = field(default_factory=list)
    model_key: Tuple[str, Optional[str]] = ("", None, None)
    tokens: List[int] = field(default_factory=list)


class ModelProvider:
    def __init__(self, cli_args: argparse.Namespace):
        """Load models on demand and persist them across the whole process."""
        self.cli_args = cli_args
        self.model_key = None
        self.model = None
        self.tokenizer = None
        self.draft_model = None

        # Preload the default model if it is provided
        self.default_model_map = {}
        if self.cli_args.model is not None:
            self.default_model_map[self.cli_args.model] = "default_model"
            self.load(self.cli_args.model, draft_model_path="default_model")

    def _validate_model_path(self, model_path: str):
        model_path = Path(model_path)
        if model_path.exists() and not model_path.is_relative_to(Path.cwd()):
            raise RuntimeError(
                "Local models must be relative to the current working dir."
            )

    # Added in adapter_path to load dynamically
    def load(self, model_path, adapter_path=None, draft_model_path=None):
        model_path, adapter_path, draft_model_path = map(
            lambda s: s.lower() if s else None,
            (model_path, adapter_path, draft_model_path),
        )

        model_path = self.default_model_map.get(model_path, model_path)
        if self.model_key == (model_path, adapter_path, draft_model_path):
            return self.model, self.tokenizer

        # Remove the old model if it exists.
        self.model = None
        self.tokenizer = None
        self.model_key = None
        self.draft_model = None

        # Building tokenizer_config
        tokenizer_config = {
            "trust_remote_code": True if self.cli_args.trust_remote_code else None
        }
        if self.cli_args.chat_template:
            tokenizer_config["chat_template"] = self.cli_args.chat_template

        if model_path == "default_model":
            if self.cli_args.model is None:
                raise ValueError(
                    "A model path has to be given as a CLI "
                    "argument or in the HTTP request"
                )
            adapter_path = adapter_path or self.cli_args.adapter_path
            model, tokenizer = load(
                self.cli_args.model,
                adapter_path=adapter_path,
                tokenizer_config=tokenizer_config,
            )
        else:
            self._validate_model_path(model_path)
            model, tokenizer = load(
                model_path, adapter_path=adapter_path, tokenizer_config=tokenizer_config
            )

        if self.cli_args.use_default_chat_template:
            if tokenizer.chat_template is None:
                tokenizer.chat_template = tokenizer.default_chat_template

        self.model_key = (model_path, adapter_path, draft_model_path)
        self.model = model
        self.tokenizer = tokenizer

        def validate_draft_tokenizer(draft_tokenizer):
            # Check if tokenizers are compatible
            if draft_tokenizer.vocab_size != tokenizer.vocab_size:
                logging.warning(
                    "Draft model tokenizer does not match model tokenizer. "
                    "Speculative decoding may not work as expected."
                )

        # Load draft model if specified
        if (
            draft_model_path == "default_model"
            and self.cli_args.draft_model is not None
        ):
            self.draft_model, draft_tokenizer = load(self.cli_args.draft_model)
            validate_draft_tokenizer(draft_tokenizer)

        elif draft_model_path is not None and draft_model_path != "default_model":
            self._validate_model_path(draft_model_path)
            self.draft_model, draft_tokenizer = load(draft_model_path)
            validate_draft_tokenizer(draft_tokenizer)
        return self.model, self.tokenizer


class APIHandler(BaseHTTPRequestHandler):
    def __init__(
        self,
        model_provider: ModelProvider,
        *args,
        prompt_cache: Optional[PromptCache] = None,
        system_fingerprint: Optional[str] = None,
        **kwargs,
    ):
        """
        Create static request specific metadata
        """
        self.created = int(time.time())
        self.model_provider = model_provider
        self.prompt_cache = prompt_cache or PromptCache()
        self.system_fingerprint = system_fingerprint or get_system_fingerprint()
        super().__init__(*args, **kwargs)

    def _set_cors_headers(self):
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "*")
        self.send_header("Access-Control-Allow-Headers", "*")

    def _set_completion_headers(self, status_code: int = 200):
        self.send_response(status_code)
        self.send_header("Content-type", "application/json")
        self._set_cors_headers()

    def _set_stream_headers(self, status_code: int = 200):
        self.send_response(status_code)
        self.send_header("Content-type", "text/event-stream")
        self.send_header("Cache-Control", "no-cache")
        self._set_cors_headers()

    def do_OPTIONS(self):
        self._set_completion_headers(204)
        self.end_headers()

    def do_POST(self):
        """
        Respond to a POST request from a client.
        """
        endpoints = {
            "/v1/completions": self.handle_text_completions,
            "/v1/chat/completions": self.handle_chat_completions,
            "/chat/completions": self.handle_chat_completions,
        }

        if self.path not in endpoints:
            self._set_completion_headers(404)
            self.end_headers()
            self.wfile.write(b"Not Found")
            return

        # Fetch and parse request body
        content_length = int(self.headers["Content-Length"])
        raw_body = self.rfile.read(content_length)
        try:
            self.body = json.loads(raw_body.decode())
        except json.JSONDecodeError as e:
            logging.error(f"JSONDecodeError: {e} - Raw body: {raw_body.decode()}")
            # Set appropriate headers based on streaming requirement
            if self.stream:
                self._set_stream_headers(400)
                self.wfile.write(
                    f"data: {json.dumps({'error': f'Invalid JSON in request body: {e}'})}\n\n".encode()
                )
            else:
                self._set_completion_headers(400)
                self.wfile.write(
                    json.dumps({"error": f"Invalid JSON in request body: {e}"}).encode()
                )
            return

        indent = "\t"  # Backslashes can't be inside of f-strings
        logging.debug(f"Incoming Request Body: {json.dumps(self.body, indent=indent)}")
        assert isinstance(
            self.body, dict
        ), f"Request should be dict, but got {type(self.body)}"

        # Extract request parameters from the body
        self.stream = self.body.get("stream", False)
        self.stream_options = self.body.get("stream_options", None)
        self.requested_model = self.body.get("model", "default_model")
        self.requested_draft_model = self.body.get("draft_model", "default_model")
        self.num_draft_tokens = self.body.get(
            "num_draft_tokens", self.model_provider.cli_args.num_draft_tokens
        )
        self.adapter = self.body.get("adapters", None)
        self.max_tokens = self.body.get("max_completion_tokens", None)
        if self.max_tokens is None:
            self.max_tokens = self.body.get(
                "max_tokens", self.model_provider.cli_args.max_tokens
            )
        self.temperature = self.body.get(
            "temperature", self.model_provider.cli_args.temp
        )
        self.top_p = self.body.get("top_p", self.model_provider.cli_args.top_p)
        self.top_k = self.body.get("top_k", self.model_provider.cli_args.top_k)
        self.min_p = self.body.get("min_p", self.model_provider.cli_args.min_p)
        self.repetition_penalty = self.body.get("repetition_penalty", 1.0)
        self.repetition_context_size = self.body.get("repetition_context_size", 20)
        self.xtc_probability = self.body.get("xtc_probability", 0.0)
        self.xtc_threshold = self.body.get("xtc_threshold", 0.0)
        self.logit_bias = self.body.get("logit_bias", None)
        self.logprobs = self.body.get("logprobs", -1)
        self.validate_model_parameters()
        # Load the model if needed
        try:
            self.model, self.tokenizer = self.model_provider.load(
                self.requested_model,
                self.adapter,
                self.requested_draft_model,
            )
        except:
            self._set_completion_headers(404)
            self.end_headers()
            self.wfile.write(b"Not Found")
            return

        # Get stop id sequences, if provided
        stop_words = self.body.get("stop")
        stop_words = stop_words or []
        stop_words = [stop_words] if isinstance(stop_words, str) else stop_words
        stop_id_sequences = [
            self.tokenizer.encode(stop_word, add_special_tokens=False)
            for stop_word in stop_words
        ]

        # Send header type
        (
            self._set_stream_headers(200)
            if self.stream
            else self._set_completion_headers(200)
        )

        # Call endpoint specific method
        prompt = endpoints[self.path]()
        self.handle_completion(prompt, stop_id_sequences)

    def validate_model_parameters(self):
        """
        Validate the model parameters passed in the request for the correct types and values.
        """
        if not isinstance(self.stream, bool):
            raise ValueError("stream must be a boolean")

        if not isinstance(self.max_tokens, int) or self.max_tokens < 0:
            raise ValueError("max_tokens must be a non-negative integer")

        if not isinstance(self.temperature, (float, int)) or self.temperature < 0:
            raise ValueError("temperature must be a non-negative float")

        if not isinstance(self.top_p, (float, int)) or self.top_p < 0 or self.top_p > 1:
            raise ValueError("top_p must be a float between 0 and 1")

        if not isinstance(self.top_k, int) or self.top_k < 0:
            raise ValueError("top_k must be a non-negative integer")

        if not isinstance(self.min_p, (float, int)) or self.min_p < 0 or self.min_p > 1:
            raise ValueError("min_p must be a float between 0 and 1")

        if not isinstance(self.num_draft_tokens, int) or self.num_draft_tokens < 0:
            raise ValueError("num_draft_tokens must be a non-negative integer")

        if (
            not isinstance(self.repetition_penalty, (float, int))
            or self.repetition_penalty < 0
        ):
            raise ValueError("repetition_penalty must be a non-negative float")

        if self.logprobs != -1 and not (0 < self.logprobs <= 10):
            raise ValueError(
                f"logprobs must be between 1 and 10 but got {self.logprobs:,}"
            )

        if (
            not isinstance(self.repetition_context_size, int)
            or self.repetition_context_size < 0
        ):
            raise ValueError("repetition_context_size must be a non-negative integer")

        if self.logit_bias is not None:
            if not isinstance(self.logit_bias, dict):
                raise ValueError("logit_bias must be a dict of int to float")

            try:
                self.logit_bias = {int(k): v for k, v in self.logit_bias.items()}
            except ValueError:
                raise ValueError("logit_bias must be a dict of int to float")
        if not (
            isinstance(self.xtc_probability, float)
            and 0.00 <= self.xtc_probability <= 1.00
        ):
            raise ValueError(f"xtc_probability must be a float between 0.00 and 1.00")
        if not (
            isinstance(self.xtc_threshold, float) and 0.00 <= self.xtc_threshold <= 0.50
        ):
            raise ValueError(f"xtc_threshold must be a float between 0.00 and 0.5")
        if not isinstance(self.requested_model, str):
            raise ValueError("model must be a string")
        if self.adapter is not None and not isinstance(self.adapter, str):
            raise ValueError("adapter must be a string")

    def generate_response(
        self,
        text: str,
        finish_reason: Union[Literal["length", "stop"], None],
        prompt_token_count: Optional[int] = None,
        completion_token_count: Optional[int] = None,
        token_logprobs: Optional[List[float]] = None,
        top_tokens: Optional[List[Dict[int, float]]] = None,
        tokens: Optional[List[int]] = None,
        tool_calls: Optional[List[str]] = None,
    ) -> dict:
        """
        Generate a single response packet based on response type (stream or
        not), completion type and parameters.

        Args:
            text (str): Text generated by model
            finish_reason (Union[Literal["length", "stop"], None]): The reason the
              response is being sent: "length", "stop" or `None`.
            prompt_token_count (Optional[int]): The number of tokens in the prompt,
              used to populate the "usage" field (not used when stream).
            completion_token_count (Optional[int]): The number of tokens in the
              response, used to populate the "usage" field (not used when stream).
            token_logprobs (Optional[List[float]]): The log probabilities per token,
              in token order.
            top_tokens (Optional[List[Dict[int, float]]]): List of dictionaries mapping
              tokens to logprobs for the top N tokens at each token position.
            tokens (Optional[List[int]]): List of tokens to return with logprobs structure
            tool_calls (Optional[List[str]]): List of tool calls.

        Returns:
            dict: A dictionary containing the response, in the same format as
              OpenAI's API.
        """
        token_logprobs = token_logprobs or []
        top_logprobs = top_tokens or []
        tool_calls = tool_calls or []

        def parse_function(tool_text):
            tool_call = json.loads(tool_text.strip())
            return {
                "function": {
                    "name": tool_call.get("name", None),
                    "arguments": json.dumps(tool_call.get("arguments", "")),
                },
                "type": "function",
                "id": None,
            }

        # Static response
        response = {
            "id": self.request_id,
            "system_fingerprint": self.system_fingerprint,
            "object": self.object_type,
            "model": self.requested_model,
            "created": self.created,
            "choices": [
                {
                    "index": 0,
                    "finish_reason": finish_reason,
                },
            ],
        }

        if token_logprobs or top_logprobs or tokens:
            response["choices"][0]["logprobs"] = {
                "token_logprobs": token_logprobs,
                "top_logprobs": top_logprobs,
                "tokens": tokens,
            }

        if not self.stream:
            if not (
                isinstance(prompt_token_count, int)
                and isinstance(completion_token_count, int)
            ):
                raise ValueError(
                    "Response type is complete, but token counts not provided"
                )

            response["usage"] = {
                "prompt_tokens": prompt_token_count,
                "completion_tokens": completion_token_count,
                "total_tokens": prompt_token_count + completion_token_count,
            }

        choice = response["choices"][0]

        # Add dynamic response
        if self.object_type.startswith("chat.completion"):
            key_name = "delta" if self.stream else "message"
            choice[key_name] = {
                "role": "assistant",
                "content": text,
                "tool_calls": [parse_function(tool_text) for tool_text in tool_calls],
            }
        elif self.object_type == "text_completion":
            choice.update(text=text)
        else:
            raise ValueError(f"Unsupported response type: {self.object_type}")

        return response

    def reset_prompt_cache(self, prompt):
        """Resets the prompt cache and associated state.

        Args:
            prompt (List[int]): The tokenized new prompt which will populate the
                reset cache.
        """
        logging.debug(f"*** Resetting cache. ***")
        self.prompt_cache.model_key = self.model_provider.model_key
        self.prompt_cache.cache = make_prompt_cache(self.model_provider.model)
        if self.model_provider.draft_model is not None:
            self.prompt_cache.cache += make_prompt_cache(
                self.model_provider.draft_model
            )
        self.prompt_cache.tokens = list(prompt)  # Cache the new prompt fully

    def get_prompt_cache(self, prompt):
        """
        Determines the portion of the prompt that needs processing by comparing
        it to the cached prompt and attempting to reuse the common prefix.

        This function updates the internal prompt cache state (tokens and model cache)
        based on the comparison. If a common prefix exists, it attempts to trim
        the model cache (if supported) to match the common prefix length, avoiding
        recomputation.

        Args:
            prompt (List[int]): The tokenized new prompt.

        Returns:
            List[int]: The suffix of the prompt that actually needs to be processed
                       by the model. This will be the full prompt if the cache is
                       reset or cannot be effectively used.
        """
        cache_len = len(self.prompt_cache.tokens)
        prompt_len = len(prompt)
        com_prefix_len = common_prefix_len(self.prompt_cache.tokens, prompt)

        # Leave at least one token in the prompt
        com_prefix_len = min(com_prefix_len, len(prompt) - 1)

        # Condition 1: Model changed or no common prefix at all. Reset cache.
        if (
            self.prompt_cache.model_key != self.model_provider.model_key
            or com_prefix_len == 0
        ):
            self.reset_prompt_cache(prompt)

        # Condition 2: Common prefix exists and matches cache length. Process suffix.
        elif com_prefix_len == cache_len:
            logging.debug(
                f"*** Cache is prefix of prompt (cache_len: {cache_len}, prompt_len: {prompt_len}). Processing suffix. ***"
            )
            prompt = prompt[com_prefix_len:]
            self.prompt_cache.tokens.extend(prompt)

        # Condition 3: Common prefix exists but is shorter than cache length. Attempt trim.
        elif com_prefix_len < cache_len:
            logging.debug(
                f"*** Common prefix ({com_prefix_len}) shorter than cache ({cache_len}). Attempting trim. ***"
            )

            if can_trim_prompt_cache(self.prompt_cache.cache):
                num_to_trim = cache_len - com_prefix_len
                logging.debug(f"    Trimming {num_to_trim} tokens from cache.")
                trim_prompt_cache(self.prompt_cache.cache, num_to_trim)
                self.prompt_cache.tokens = self.prompt_cache.tokens[:com_prefix_len]
                prompt = prompt[com_prefix_len:]
                self.prompt_cache.tokens.extend(prompt)
            else:
                logging.debug(f"    Cache cannot be trimmed. Resetting cache.")
                self.reset_prompt_cache(prompt)

        # This case should logically not be reached if com_prefix_len <= cache_len
        else:
            logging.error(
                f"Unexpected cache state: com_prefix_len ({com_prefix_len}) > cache_len ({cache_len}). Resetting cache."
            )
            self.reset_prompt_cache(prompt)

        logging.debug(f"Returning {len(prompt)} tokens for processing.")
        return prompt

    def handle_completion(
        self,
        prompt: List[int],
        stop_id_sequences: List[List[int]],
    ):
        """
        Generate a response to a prompt and send it to the client in a single batch.

        Args:
            prompt (List[int]): The tokenized prompt.
            stop_id_sequences (List[List[int]]): A list of stop words passed
                to the stopping_criteria function
        """
        tokens = []
        finish_reason = "length"
        stop_sequence_suffix = None
        if self.stream:
            self.end_headers()
            logging.debug(f"Starting stream:")
        else:
            logging.debug(f"Starting completion:")
        token_logprobs = []
        top_tokens = []

        prompt = self.get_prompt_cache(prompt)

        text = ""
        tic = time.perf_counter()
        sampler = make_sampler(
            self.temperature,
            top_p=self.top_p,
            top_k=self.top_k,
            min_p=self.min_p,
            xtc_probability=self.xtc_probability,
            xtc_threshold=self.xtc_threshold,
            xtc_special_tokens=[
                self.tokenizer.eos_token_id,
                self.tokenizer.encode("\n"),
            ],
        )
        logits_processors = make_logits_processors(
            self.logit_bias,
            self.repetition_penalty,
            self.repetition_context_size,
        )

        tool_calls = []
        tool_text = ""
        in_tool_call = False
        segment = ""

        # --- ▼▼▼ ここから追加 ▼▼▼ ---
        # レスポンス形式を整形するための状態管理変数を初期化
        gemma_buffer = ""
        # 状態: INITIAL -> BUFFERING -> AWAITING_FINAL -> STREAMING
        gemma_state = "INITIAL"
        # --- ▲▲▲ ここまで追加 ▲▲▲ ---

        # Create keepalive callback to send SSE comments during long prompt processing
        def keepalive_callback(processed_tokens, total_tokens):
            logging.info(
                f"Prompt processing progress: {processed_tokens}/{total_tokens}"
            )
            if self.stream:
                try:
                    # Send SSE comment for keepalive - invisible to clients but keeps connection alive
                    self.wfile.write(
                        f": keepalive {processed_tokens}/{total_tokens}\n\n".encode()
                    )
                    self.wfile.flush()
                except (BrokenPipeError, ConnectionResetError, OSError):
                    # Client disconnected, ignore
                    pass

        for gen_response in stream_generate(
            model=self.model,
            tokenizer=self.tokenizer,
            prompt=prompt,
            max_tokens=self.max_tokens,
            sampler=sampler,
            logits_processors=logits_processors,
            prompt_cache=self.prompt_cache.cache,
            draft_model=self.model_provider.draft_model,
            num_draft_tokens=self.num_draft_tokens,
            prompt_progress_callback=keepalive_callback,
        ):
            logging.debug(gen_response.text)

            if (
                self.tokenizer.has_tool_calling
                and gen_response.text == self.tokenizer.tool_call_start
            ):
                in_tool_call = True
            elif in_tool_call:
                if gen_response.text == self.tokenizer.tool_call_end:
                    tool_calls.append(tool_text)
                    tool_text = ""
                    in_tool_call = False
                else:
                    tool_text += gen_response.text
            else:
                # --- ▼▼▼ ここから変更 ▼▼▼ ---
                # ストリーミングが有効、かつツールコール中でない場合に整形処理を実行
                if self.stream and not in_tool_call:
                    gemma_buffer += gen_response.text
                    segment_to_send = ""

                    # 状態: 初期状態。レスポンス形式を判定する
                    if gemma_state == "INITIAL":
                        if "<|channel|>" in gemma_buffer:
                            gemma_state = "BUFFERING"
                        elif len(gemma_buffer) > 11:  # len("<|channel|>")
                            gemma_state = "STREAMING"

                    # 状態: バッファリング中。analysisシーケンスを探す
                    if gemma_state == "BUFFERING":
                        analysis_seq = "<|channel|>analysis<|message|>"
                        if analysis_seq in gemma_buffer:
                            segment_to_send = gemma_buffer.replace(analysis_seq, f"<details>{analysis_seq}")
                            gemma_buffer = ""
                            gemma_state = "AWAITING_FINAL"

                    # 状態: finalシーケンスを待機中
                    if gemma_state == "AWAITING_FINAL":
                        final_seq = "<|channel|>final<|message|>"
                        if final_seq in gemma_buffer:
                            segment_to_send += gemma_buffer.replace(final_seq, f"{final_seq}</details>  ")
                            gemma_buffer = ""
                            gemma_state = "STREAMING"
                        else:
                            # シーケンスがトークン境界で分割される可能性を考慮し、
                            # バッファの末尾(シーケンス長-1)文字を残して送信
                            safe_flush_len = len(gemma_buffer) - (len(final_seq) - 1)
                            if safe_flush_len > 0:
                                segment_to_send += gemma_buffer[:safe_flush_len]
                                gemma_buffer = gemma_buffer[safe_flush_len:]

                    # 状態: 通常ストリーミング。バッファをすべて送信
                    if gemma_state == "STREAMING":
                        segment_to_send += gemma_buffer
                        gemma_buffer = ""

                    # 処理したテキストを送信セグメントと全体テキストに追加
                    segment += segment_to_send
                    text += segment_to_send
                else:
                    # ストリーミングでない場合やツールコール中は元の動作
                    text += gen_response.text
                    segment += gen_response.text
                # --- ▲▲▲ ここまで変更 ▲▲▲ ---
            token = gen_response.token
            logprobs = gen_response.logprobs
            tokens.append(token)
            self.prompt_cache.tokens.append(token)

            if self.logprobs > 0:
                sorted_indices = mx.argpartition(-logprobs, kth=self.logprobs - 1)
                top_indices = sorted_indices[: self.logprobs]
                top_logprobs = logprobs[top_indices]
                top_token_info = zip(top_indices.tolist(), top_logprobs.tolist())
                top_tokens.append(tuple(top_token_info))

            token_logprobs.append(logprobs[token].item())

            stop_condition = stopping_criteria(
                tokens, stop_id_sequences, self.tokenizer.eos_token_id
            )
            if stop_condition.stop_met:
                finish_reason = "stop"
                if stop_condition.trim_length:
                    stop_sequence_suffix = self.tokenizer.decode(
                        tokens[-stop_condition.trim_length :]
                    )
                    text = text[: -len(stop_sequence_suffix)]
                segment = ""
                break

            if self.stream and not in_tool_call:
                # If the end of tokens overlaps with a stop sequence, generate new
                # tokens until we know if the stop sequence is hit or not
                if any(
                    (
                        sequence_overlap(tokens, sequence)
                        for sequence in stop_id_sequences
                    )
                ):
                    continue
                elif segment or tool_calls:
                    response = self.generate_response(
                        segment, None, tool_calls=tool_calls
                    )
                    self.wfile.write(f"data: {json.dumps(response)}\n\n".encode())
                    self.wfile.flush()
                    segment = ""
                    tool_calls = []

        # --- ▼▼▼ ここから追加 ▼▼▼ ---
        # ループ終了後、バッファにデータが残っていれば最後のセグメントに追加
        if gemma_buffer:
            segment += gemma_buffer
            gemma_buffer = ""
        # --- ▲▲▲ ここまで追加 ▲▲▲ ---

        if gen_response.finish_reason is not None:
            finish_reason = gen_response.finish_reason

        logging.debug(f"Prompt: {gen_response.prompt_tps:.3f} tokens-per-sec")
        logging.debug(f"Generation: {gen_response.generation_tps:.3f} tokens-per-sec")
        logging.debug(f"Peak memory: {gen_response.peak_memory:.3f} GB")

        if self.stream:
            response = self.generate_response(
                segment, finish_reason, tool_calls=tool_calls
            )
            self.wfile.write(f"data: {json.dumps(response)}\n\n".encode())
            self.wfile.flush()
            if self.stream_options is not None and self.stream_options["include_usage"]:
                original_prompt_length = (
                    len(self.prompt_cache.tokens) - len(tokens) + len(prompt)
                )
                response = self.completion_usage_response(
                    original_prompt_length, len(tokens)
                )
                self.wfile.write(f"data: {json.dumps(response)}\n\n".encode())
                self.wfile.flush()
            self.wfile.write("data: [DONE]\n\n".encode())
            self.wfile.flush()
        else:
            response = self.generate_response(
                text,
                finish_reason,
                len(prompt),
                len(tokens),
                token_logprobs=token_logprobs,
                top_tokens=top_tokens,
                tokens=tokens,
                tool_calls=tool_calls,
            )
            response_json = json.dumps(response).encode()
            indent = "\t"  # Backslashes can't be inside of f-strings
            logging.debug(f"Outgoing Response: {json.dumps(response, indent=indent)}")

            # Send an additional Content-Length header when it is known
            self.send_header("Content-Length", str(len(response_json)))
            self.end_headers()
            self.wfile.write(response_json)
            self.wfile.flush()

    def completion_usage_response(
        self,
        prompt_token_count: Optional[int] = None,
        completion_token_count: Optional[int] = None,
    ):
        response = {
            "id": self.request_id,
            "system_fingerprint": self.system_fingerprint,
            "object": "chat.completion",
            "model": self.requested_model,
            "created": self.created,
            "choices": [],
            "usage": {
                "prompt_tokens": prompt_token_count,
                "completion_tokens": completion_token_count,
                "total_tokens": prompt_token_count + completion_token_count,
            },
        }
        return response

    def handle_chat_completions(self) -> List[int]:
        """
        Handle a chat completion request.

        Returns:
            mx.array: A mx.array of the tokenized prompt from the request body
        """
        body = self.body
        assert "messages" in body, "Request did not contain messages"

        # Determine response type
        self.request_id = f"chatcmpl-{uuid.uuid4()}"
        self.object_type = "chat.completion.chunk" if self.stream else "chat.completion"
        if self.tokenizer.chat_template:
            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(
                messages,
                body.get("tools") or None,
                add_generation_prompt=True,
                **self.model_provider.cli_args.chat_template_args,
            )
        else:
            prompt = convert_chat(body["messages"], body.get("role_mapping"))
            prompt = self.tokenizer.encode(prompt)

        return prompt

    def handle_text_completions(self) -> List[int]:
        """
        Handle a text completion request.

        Returns:
            mx.array: A mx.array of the tokenized prompt from the request body
        """
        # Determine response type
        self.request_id = f"cmpl-{uuid.uuid4()}"
        self.object_type = "text_completion"
        assert "prompt" in self.body, "Request did not contain a prompt"
        return self.tokenizer.encode(self.body["prompt"])

    def do_GET(self):
        """
        Respond to a GET request from a client.
        """
        if self.path.startswith("/v1/models"):
            self.handle_models_request()
        elif self.path == "/health":
            self.handle_health_check()
        else:
            self._set_completion_headers(404)
            self.end_headers()
            self.wfile.write(b"Not Found")

    def handle_health_check(self):
        """
        Handle a GET request for the /health endpoint.
        """
        self._set_completion_headers(200)
        self.end_headers()

        self.wfile.write('{"status": "ok"}'.encode())
        self.wfile.flush()

    def handle_models_request(self):
        """
        Handle a GET request for the /v1/models endpoint.
        """
        self._set_completion_headers(200)
        self.end_headers()

        files = ["config.json", "model.safetensors.index.json", "tokenizer_config.json"]

        parts = self.path.split("/")
        filter_repo_id = None
        if len(parts) > 3:
            filter_repo_id = "/".join(parts[3:])

        def probably_mlx_lm(repo):
            if repo.repo_type != "model":
                return False
            if "main" not in repo.refs:
                return False
            if filter_repo_id is not None and repo.repo_id != filter_repo_id:
                return False
            file_names = {f.file_path.name for f in repo.refs["main"].files}
            return all(f in file_names for f in files)

        # Scan the cache directory for downloaded mlx models
        hf_cache_info = scan_cache_dir()
        downloaded_models = [
            repo for repo in hf_cache_info.repos if probably_mlx_lm(repo)
        ]

        # Create a list of available models
        models = [
            {
                "id": repo.repo_id,
                "object": "model",
                "created": self.created,
            }
            for repo in downloaded_models
        ]

        response = {"object": "list", "data": models}

        response_json = json.dumps(response).encode()
        self.wfile.write(response_json)
        self.wfile.flush()


def run(
    host: str,
    port: int,
    model_provider: ModelProvider,
    server_class=HTTPServer,
    handler_class=APIHandler,
):
    server_address = (host, port)
    prompt_cache = PromptCache()
    infos = socket.getaddrinfo(
        *server_address, type=socket.SOCK_STREAM, flags=socket.AI_PASSIVE
    )
    server_class.address_family, _, _, _, server_address = next(iter(infos))
    httpd = server_class(
        server_address,
        lambda *args, **kwargs: handler_class(
            model_provider,
            prompt_cache=prompt_cache,
            system_fingerprint=get_system_fingerprint(),
            *args,
            **kwargs,
        ),
    )
    warnings.warn(
        "mlx_lm.server is not recommended for production as "
        "it only implements basic security checks."
    )
    logging.info(f"Starting httpd at {host} on port {port}...")
    httpd.serve_forever()


def main():
    parser = argparse.ArgumentParser(description="MLX Http Server.")
    parser.add_argument(
        "--model",
        type=str,
        help="The path to the MLX model weights, tokenizer, and config",
    )
    parser.add_argument(
        "--adapter-path",
        type=str,
        help="Optional path for the trained adapter weights and config.",
    )
    parser.add_argument(
        "--host",
        type=str,
        default="127.0.0.1",
        help="Host for the HTTP server (default: 127.0.0.1)",
    )
    parser.add_argument(
        "--port",
        type=int,
        default=8080,
        help="Port for the HTTP server (default: 8080)",
    )
    parser.add_argument(
        "--draft-model",
        type=str,
        help="A model to be used for speculative decoding.",
        default=None,
    )
    parser.add_argument(
        "--num-draft-tokens",
        type=int,
        help="Number of tokens to draft when using speculative decoding.",
        default=3,
    )
    parser.add_argument(
        "--trust-remote-code",
        action="store_true",
        help="Enable trusting remote code for tokenizer",
    )
    parser.add_argument(
        "--log-level",
        type=str,
        default="INFO",
        choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
        help="Set the logging level (default: INFO)",
    )
    parser.add_argument(
        "--chat-template",
        type=str,
        default="",
        help="Specify a chat template for the tokenizer",
        required=False,
    )
    parser.add_argument(
        "--use-default-chat-template",
        action="store_true",
        help="Use the default chat template",
    )
    parser.add_argument(
        "--temp",
        type=float,
        default=0.0,
        help="Default sampling temperature (default: 0.0)",
    )
    parser.add_argument(
        "--top-p",
        type=float,
        default=1.0,
        help="Default nucleus sampling top-p (default: 1.0)",
    )
    parser.add_argument(
        "--top-k",
        type=int,
        default=0,
        help="Default top-k sampling (default: 0, disables top-k)",
    )
    parser.add_argument(
        "--min-p",
        type=float,
        default=0.0,
        help="Default min-p sampling (default: 0.0, disables min-p)",
    )
    parser.add_argument(
        "--max-tokens",
        type=int,
        default=512,
        help="Default maximum number of tokens to generate (default: 512)",
    )
    parser.add_argument(
        "--chat-template-args",
        type=json.loads,
        help="""A JSON formatted string of arguments for the tokenizer's apply_chat_template, e.g. '{"enable_thinking":false}'""",
        default="{}",
    )
    args = parser.parse_args()

    logging.basicConfig(
        level=getattr(logging, args.log_level.upper(), None),
        format="%(asctime)s - %(levelname)s - %(message)s",
    )
    run(args.host, args.port, ModelProvider(args))


if __name__ == "__main__":
    print(
        "Calling `python -m mlx_lm.server...` directly is deprecated."
        " Use `mlx_lm.server...` or `python -m mlx_lm server ...` instead."
    )
    main()

変更箇所は全てAPIHandlerクラスのhandle_completion内です。コメントが入っているのでわかりやすいかと思います。

使い方

仮想環境内の mlx_lm フォルダに入って、server.py をバックアップします。例えばこんな感じで:

find . -name mlx_lm
(出力例)
./.venv/lib/python3.12/site-packages/mlx_lm
cd ./.venv/lib/python3.12/site-packages/mlx_lm
cp server.py server.py.original

そして server.py に上記「スクリプト全てを見るにはここを開いてください」の中身をコピペしてサーバを実行します。

コピーはスクリプトの右上のコピーボタンを使い、ペーストはこんな感じ ↓ でやってみてください。クリップボードの中身をファイルに書き出してくれます (最近知ってうれしかったので紹介)。多分pbpaste > server.pyでも動作に問題無いと思うのですが、最後に改行を付けています。

下のコマンドもコピペする場合の順番としては、まず下のコマンドをターミナルにコピペ (まだエンターキー押下せず)、「スクリプト全てを見るにはここを開いてください」の中身をコピー、ターミナルに戻ってエンターキー押下、でイケると思います。

printf '%s\n' "$(pbpaste)" > server.py

さて、これで MLX-LM の API サーバを立ち上げ (例: mlx_lm.server --host 0.0.0.0 --port 9999 --log-level DEBUG) うまくいくと、思考部分が「▶詳細」の中にしまわれ、回答のみがスッキリと表示されるようになります。もちろん思考の内容は「▶詳細」クリックすると開けます。

見た目を HTML の <details>タグで隠しているだけなのですが、思考中も回答も全てストリーミングするのでいい感じです。

Open WebUI 「も」使っている方は、Open WebUI の Reasoning Tag を以下の様にしてください。

Start Tag: <details><|channel|>analysis<|message|>

End Tag: <|channel|>final<|message|></details>

以上で設定は終わりです、お疲れ様でした。後は雑記ですので、読むか読まないかはあなた次第です。

gpt-oss または Qwen3 A30B 等のローカル LLM を使って解決した?

いいえ。それぞれの LLM の性能を試す良い機会だと思ったのですが、gpt-oss は<|channel|>等のタグがチャット内で表示されず、Qwen3 A30B は<details>タグで生成されたテキストの所々が「▶詳細」の中にしまわれたりコードと回答部分がごちゃごちゃになるような状況でなかなかスムーズに進められず、ある程度いじってからこれらのローカルモデルを使った解決は諦めました。

結局解決したときに使っていたのはまたもや無料版の Gemini (2.5 Flash と Pro) でした。ただしコンテキスト長の問題で元の server.py のコード全ては読み込んでくれなかったので、変更が必要な部分の特定には 262,144 (256K) トークンものコンテキスト長が使える Qwen3 30B A3B を使いました。変更が必要なメソッドの特定ができたので、そのメソッド全部と、やりたいこと、MLX_LM サーバの Debug ログ、注意点、等を投げて、変更後のメソッド全部を吐き出してもらいました。結果、一発で動くものができたので、やっぱすごいっすね、Gemini。

せっかくなので、Gemini に投げたプロンプトの、該当メソッド以外の部分を貼っておきます。興味のある方は覗いてみてください (プロンプトでは「クラス」と言っていますがメソッドですね)。

Python のコードを渡すので変更をお願いします。コードは LLM の API サーバの一部で、ユーザからのプロンプトを受け、LLM の生成するトークンをストリーミングでクライアントに返します。
使用する LLM は、以下のフォーマットで API クライアントにレスポンスします。
```
<|channel|>analysis<|message|>The user asks in Japanese: "2+2は?" meaning "What is 2+2?" The answer is 4. Should respond in Japanese.<|end|><|start|>assistant<|channel|>final<|message|>4です。
```
この例では、"4です。"のみが最終的にユーザが欲しい回答なので、その前を全て`<details>`と`</details>`タグで囲むのがゴールです。つまり、希望する出力は以下となります:
```
<details><|channel|>analysis<|message|>The user asks in Japanese: "2+2は?" meaning "What is 2+2?" The answer is 4. Should respond in Japanese.<|end|><|start|>assistant<|channel|>final<|message|></details>4です。
```
サーバのログのサンプルは以下の通りです。`<|channel|>`, `analysis`, `<|message|>`, `final` それぞれが 1トークンのようです。
```
2025-09-03 23:34:28,637 - DEBUG - <|channel|>
2025-09-03 23:34:28,648 - DEBUG - analysis
2025-09-03 23:34:28,660 - DEBUG - <|message|>
2025-09-03 23:34:28,672 - DEBUG - The
2025-09-03 23:34:28,684 - DEBUG -  user
2025-09-03 23:34:28,695 - DEBUG -  asks
2025-09-03 23:34:28,707 - DEBUG -  in
2025-09-03 23:34:28,719 - DEBUG -  Japanese
2025-09-03 23:34:28,731 - DEBUG - :
2025-09-03 23:34:28,743 - DEBUG -  "
2025-09-03 23:34:28,755 - DEBUG - 2
2025-09-03 23:34:28,767 - DEBUG - +
2025-09-03 23:34:28,778 - DEBUG - 2
2025-09-03 23:34:28,790 - DEBUG - は
2025-09-03 23:34:28,802 - DEBUG - ?
2025-09-03 23:34:28,816 - DEBUG - "
2025-09-03 23:34:28,828 - DEBUG -  meaning
2025-09-03 23:34:28,840 - DEBUG -  "
2025-09-03 23:34:28,851 - DEBUG - What
2025-09-03 23:34:28,863 - DEBUG -  is
2025-09-03 23:34:28,875 - DEBUG - 
2025-09-03 23:34:28,887 - DEBUG -  2
2025-09-03 23:34:28,899 - DEBUG - +
2025-09-03 23:34:28,910 - DEBUG - 2
2025-09-03 23:34:28,922 - DEBUG - ?"
2025-09-03 23:34:28,934 - DEBUG -  The
2025-09-03 23:34:28,946 - DEBUG -  answer
2025-09-03 23:34:28,958 - DEBUG -  is
2025-09-03 23:34:28,969 - DEBUG - 
2025-09-03 23:34:28,981 - DEBUG -  4
2025-09-03 23:34:28,993 - DEBUG - .
2025-09-03 23:34:29,005 - DEBUG -  Should
2025-09-03 23:34:29,017 - DEBUG -  respond
2025-09-03 23:34:29,028 - DEBUG -  in
2025-09-03 23:34:29,040 - DEBUG -  Japanese
2025-09-03 23:34:29,052 - DEBUG - .
2025-09-03 23:34:29,064 - DEBUG - <|end|>
2025-09-03 23:34:29,075 - DEBUG - <|start|>
2025-09-03 23:34:29,087 - DEBUG - assistant
2025-09-03 23:34:29,099 - DEBUG - <|channel|>
2025-09-03 23:34:29,111 - DEBUG - final
2025-09-03 23:34:29,123 - DEBUG - <|message|>
2025-09-03 23:34:29,135 - DEBUG - 4
2025-09-03 23:34:29,147 - DEBUG - です
2025-09-03 23:34:29,159 - DEBUG - 。
2025-09-03 23:34:29,172 - DEBUG - 
```

コードを変更するときの注意点は以下となります:
1. 最小限の変更にとどめる
2. ストリーミングするトークンを必要なだけバッファし、`<|channel|>analysis<|message|>`が含まれたら`<details><|channel|>analysis<|message|>`に変更して API クライアントへ送る
3. 新たなトークンを受け取ったら先頭のトークンから API クライアントへ送る
4. バッファに`<|channel|>final<|message|>`が現れたら、`<|channel|>final<|message|></details>`に変更して API クライアントへ送る
5. 同じコードで他の LLM のストリーミングにも対応するため、もし先頭からのバッファに`<|channel|>`が含まれなければその時点でバッファをやめ、残りは全てストリーミングで API クライアントへ送る

以下が`server.py`の該当クラス部分です。こちらに変更を加えて上記を実現してください。

Gemini の回答は以下で、その後にdef handle_completion(からつづくメソッドが表示されました。上記の server.py はほぼそのままのコードを反映しています。

はい、承知いたしました。ご指定の要件に基づき、LLMからのレスポンスを整形するロジックを組み込んだserver.pyのhandle_completionメソッドのコードを以下に提示します。

変更点は、元のコードの構造を維持しつつ、ストリーミング処理の部分にトークンをバッファリングし、特定のシーケンスを検出して<details>および</details>タグを挿入するロジックを追加した点です。これにより、他のLLMのレスポンス形式に影響を与えることなく、目的のフォーマットにのみ対応できます。

変更後の server.py

以下が、handle_completionメソッドの変更後のコードです。変更・追加された箇所にはコメントを付記しています。

ところで一ヶ月以上前から Safari では Dify のチャットボットが使えなくなった

正確に言うと、使っているうちに履歴が見れなくなります。新しいバージョンの Dify ではもう少し細かいエラーを吐きますが、”Conversation not exists.” がブラウザの右上に表示されて、それまでの履歴が見れなくなるという症状です。オーナーとして Dify にログインして AI アプリを作るのは問題無いです。

新しい Dify のもう少し細かいエラー:

Conversation Not Exists. You have requested this URI [/api/messages] but did you mean /api/messages or /api/chat-messages or /api/saved-messages ?

macOS 自体 (含む Safari) や Dify のアップデートもあったので、それらのどれかまたは組み合わせが原因だと思いますが、1ヶ月以上経っても解決できずにいます。結構な時間を使って色々試したのですがどうにも小手先で解決できそうになく、Dify のチャットボットは Chrome で使っています。

でもできれば Chrome 使いたくないなーというわけで、Safari で問題無く動く Open WebUI を使うようになったことが本記事の「正攻法」に気がつくきっかけとなりました。

Image by Stable Diffusion (Mochi Diffusion)

「マッドサイエンティストが作ったサイボーグ」本記事の内容はここまでの魔改造ではないですが、一番破綻がなかったのでこれを採用。絵が怖いから Google さんはまたインデックスしてくれなかったりするのかな

Date:
2025年9月5日 1:48:33

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
mad scientist looking at a super cyborg

Exclude from Image:

Seed:
3303002455

Steps:
20

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & GPU

MLX が MXFP4 量子化に対応して gpt-oss がますます速くなった!

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

Dify や Open WebUI で使う方法も書いています。

gpt-oss-20b-MXFP4-Q8 を Dify で使った時。ヤバー!「10 t/s あれば使える」とか言っていた頃が懐かしい

環境、バージョン、モデル

※ どれも本日時点で最新バージョンですが、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-Q863.41 GB
gpt-oss-120b-MXFP4-Q462.36 GB
gpt-oss-20b-MXFP4-Q812.10 GB
gpt-oss-20b-MXFP4-Q411.21 GB

比較対象として、他のコントリビュータによるこれまでの MLX 量子化のサイズをまとめました。変換時のオプションで量子化後のサイズに多少変化があるのかも知れませんが、Q8 (8bit quant) と Q4 (4bit quant) の間には大きな差 (20b で 9GB) があるのがわかります。

モデルファイルサイズ
lmstudio-community/gpt-oss-120b-MLX-8bit124.20 GB
nightmedia/gpt-oss-120bq4-hi-mlx73.10 GB
lmstudio-community/gpt-oss-20b-MLX-8bit22.26 GB
nightmedia/gpt-oss-20bq4-hi-mlx13.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 の速度でお悩みの方はどうぞ。

日本語: Ollama 単体では速い LLM が、なぜか Dify や Continue から使うと遅い、という時の解決方法

日本語: Ollama の高速化と VRAM 最適化設定 (ファインチューニング: 2)

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

gpt-oss を MLX-LM の API サーバで Dify や Open WebUI からムリヤリ使う方法

新しく MXFP4 に対応した MLX/MLX-LM に関する記事を書いたので、そちらをご覧ください。

先日アップした記事では、MLX バージョンの gpt-oss を MLX-LM の API サーバで動かすと Dify や Open WebUI 等では正しく動作しないと書きましたが、server.pyに変更を加えることでチャットだけはできるようになりました。手元の環境では動いていますが、ビビって MLX-LM には PR せずに Issue だけあげてあります (↓ 2コ)。本家が解決したら不要になる情報ですがせっかくなので共有します。

https://github.com/ml-explore/mlx-lm/issues/364 → (2025/08/15 加筆) 修正がコミットされたようなので、近くバージョンアップで修正されそうです

https://github.com/ml-explore/mlx-lm/issues/365 → (2025/08/15 加筆) 「クライアント側で対応すべき内容」と言うことでクローズされました

フォークした repo (↓) には変更済みのserver.pyを置いてあるので、よかったらどうぞ。Dify や Open WebUI 等の API クライアント側の対応を待たずに MLX-LM で gpt-oss が動きます。

https://github.com/tokyohandsome/mlx-lm

前回の記事はこれ:

各種バージョン等

Issue に書いてますが一応。

  • Open Web UI: v0.6.20
  • Dify: 1.7.1
% pip list|grep mlx
mlx 0.28.0
mlx-lm 0.26.3
mlx-metal 0.28.0
%
% python -V
Python 3.12.11

不具合の内容

詳細は上の issue を見てもらいたいのですが、ざっくり以下の内容です:

  1. LLM からのレスポンスが途中で終わってしまう: 何か制御コードみたいなものが含まれているのかと思ったら、ただのカラ文字が原因だった感じです。カラ文字は送らないようにしたら動くようになりました。トークナイザの不具合?
  2. チャットで 2つ目のプロンプトを投げるとサーバでエラーが発生する: 本来 API クライアント側で<|channel|>から<|message|>の思考部分をサーバに送り返さないのが正解だと思います (なので MLX-LM では上記 issue は対応無し)。ただまぁボクの場合、ローカルで動かしているだけなので、サーバで該当部分を捨ててしまうようにしました。

素人目にはそんなに根が深いわけではなさそうなので、かなり近いうちに修正されるんじゃないかと思ってます。(2025/08/15 追記) 1. は新しい MLX-LM のバージョンで修正されそうです。2. はクライアント側での対応が必要です。

手っ取り早く使うには

上記の通り現状 MLX-LM では gpt-oss 個別の対応はされないようで、Open WebUI や Dify などの API クライアント側での MLX 版 gpt-oss 対応を待たなければならないようです。ボクのように MLX の速さやにとりつかれていて API で MLX 版を使いたいという人は、server.pyだけ上書きして使ってみてください。mlxmlx-lmmlx-metalのバージョンは上記と合わせたほうが良いと思います。

仮想環境にクローンして使うならこんな感じです (ポートなどはご自由に)。

git clone https://github.com/tokyohandsome/mlx-lm
pip install -r requirements.txt
python -m mlx_lm.server --host 0.0.0.0 --port 9999 --log-level DEBUG

本家の MLX-LM を入れて、ボクがいじったserver.pyだけを差し替える方法も参考として貼っておきます。pipenvを使ってますがお使いの仮想環境でどうぞ:

mkdir gpt-oss_mlx-lm
cd gpt-oss_mlx-lm
pipenv --python 3.12 # 3.8 以上なら OK
pipenv shell
pip install mlx==0.28.0 mlx-lm==0.26.3

# インストールされたバージョンの確認
pip list|grep mlx

# 元のファイルを .original としてコピーしておく
mv .venv/lib/python3.12/site-packages/mlx_lm/server.py .venv/lib/python3.12/site-packages/mlx_lm/server.py.original
curl https://raw.githubusercontent.com/tokyohandsome/mlx-lm/refs/heads/main/mlx_lm/server.py -o .venv/lib/python3.12/site-packages/mlx_lm/server.py

上の最後の 2行で元のファイルをserver.py.originalとして保存し、変更済みのserver.pyをダウンロードしています。これで準備完了です。

以下コマンドで OpenAI API コンパチの MLX-LM サーバが起動します (例ではポート9999)。

mlx_lm.server --host 0.0.0.0 --port 9999 --log-level DEBUG

Open WebUI 等から gpt-oss に接続し、Terminal に流れるトークン全てが表示され、2回目以降もチャットが続けられれば成功です!

MLX-LM API Server のモデルを Open WebUI や Dify から使う方法は別記事に詳しく書いていますのでどうぞ:

ついでにserver.pyの変更箇所 (diff) を貼っておきます。+の行にあるのが今回追加した部分です:

diff .venv/lib/python3.12/site-packages/mlx_lm/server.py.original .venv/lib/python3.12/site-packages/mlx_lm/server.py
--- .venv/lib/python3.12/site-packages/mlx_lm/server.py.original	2025-08-15 21:05:24
+++ .venv/lib/python3.12/site-packages/mlx_lm/server.py	2025-08-15 21:15:34
@@ -694,2 +694,8 @@
             logging.debug(gen_response.text)
+
+            # --- Added from here ---
+            if not gen_response.text:
+                logging.debug("Skipping empty token.")
+                continue
+            # --- to here ---
 
@@ -837,3 +843,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(

それでもまだ LM Studio が優れているところ

というわけでムリヤリながら Dify や Open WebUI でも MLX 版 gpt-oss でチャットができるようになったわけですが、OpenAI 社が推奨する思考部分をユーザから隠すということができません。そこは正式対応済みの LM Studio が勝っていますね。Dify や Open WebUI も Qwen/Qwen3-32B-MLX-4bit なんか使ってると思考部分は隠せているので、gpt-oss (というか Harmony response format) の正式対応が進んでくれたらいいな、と思っています。

単純に思考部分を完全に見えなくするだけであれば、どうせ今回紹介している方法では乱暴にオリジナルのスクリプトを書き換えて使っているので、server.py<|channel|>から<|message|>までのメッセージをクライアントに返さないように改造してしまっても良いかもしれません。

ところで今回どうやって直したか、とか

せっかくなので LM Studio で gpt-oss を動かして協力してもらいながら解決まで持って行きたかったんですが、テストするときには MLX-LM でも gpt-oss をロードする事になりメモリキャパオーバによるクラッシュの危険性が高いので避けました。で、ChatGPT に相談を始めたものの全然解決に近づいている感じがなく時間ばかりがかかりギブアップ。次に Gemini (2.5 Flash) に相談し始めてからはほぼ最短コースで解決にたどり着いた感じです。この時には質問方法や内容に慣れて、深掘りすべきところにもある程度見当が付いてきたこともあったとは思いますが、Gemini を見直しました。

質問の時には、使っている環境、症状の詳細、関係している可能性が高い Python スクリプト全体 (server.py)、サーバのエラー、クライアント (Dify や Open WebUI) のエラー、等を詳細に伝えることで解決できた感じです。ChatGPT はコードの修正をお願いすると全く違うものが出てきたりして使えなかったです。もしかしたら動いたのかも知れませんがとても pull request には使えないものだったので (そういう意味では gpt-oss もそういう用途では使えないのかな)。Gemini は最小限の追加で、コードを差し込むところの説明含め正確でした。

余談ですが、最近プログラマ不要論みたいなのがありますよね。生成 AI で置き換え可能、とかなんとか。確かに最近は 20B~30B 程度のサイズの LLM でもざっくりとしたプロンプトから一発でブロック崩しゲームを書いてくれたりしますが、狙ったとおりの変更やバグの修正などを上手に行うにはプログラムの知識は必要だと思いますね。

おまけ: gpt-oss-20B と Qwen3 30B A3B の SVG 対決

プロンプト: SVG で UFO が牛をさらっている画像を作ってください

(貼ったのは PNG にしたものです)

まずは inferencerlabs/openai-gpt-oss-20b-MLX-6.5bit

文章で説明するのはズルイ。ま、やっちゃダメとも言わんかったか

次に nightmedia/Qwen3-30B-A3B-Thinking-2507-dwq4-mlx

雰囲気がヨイ!けど人さらってますね

現場からは以上となります!

Image by Stable Diffusion (Mochi Diffusion)

リンゴに絆創膏、というイメージで書いてもらいました。バンドエイドは商標ですが、全くそう見えないものができたのでセーフと自己判断して採用。そろそろリンゴ以外を使った方がいいかもと思いつつも結局こんな感じで、生成 AI ばかり使いすぎて頭がアレになってきた人の特徴でしょうかね。

Date:
2025年8月10日 23:07:43

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
small band-aid patches on a red apple

Exclude from Image:

Seed:
1709363568

Steps:
21

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & GPU

OpenAI gpt-oss はまだ Mac の MLX-LM と Dify や Open WebUI では正しく動かない (対処法あり)

(2025/08/10 追記) MLX-LM のサーバスクリプトを書き換えて Dify や Open WebUI から使えるようにしました。記事はこちら:

ここ 2日程ローカル LLM 界隈で大騒ぎの OpenAI 初のオープンウェイト大規模言語モデル (LLM) gpt-oss。32GB RAM の Mac Studio で MLX 版 20b モデルを試したところ、Dify と Open WebUI では正しく動きませんでした。思考を思考として正しく認識されず全てが垂れ流しされ、MLX-LM から送られてくる制御文字?か何かで出力が途中で止まります。

Hugging Face から落とした同じモデルを使って、LM Studio (最新の 0.3.22 Build 2) では正しく動作しています。Ollama も対応を表明してますがまだ MLX バックエンドは使えないので、2025年 8月 7日現在限定で言えば、Mac で gpt-oss 使う最適解は LM Studio っぽいですね。

(2025/08/08 追記) LM Studio でサーバを動かし OpenAI-API-compatible のモデルプロバイダとして gpt-oss を登録したところ、Dify でも使えました!思考は丸見えですが、それ以外は問題無さそうです。Python でパーフェクトメイズを作るスクリプトは一発でした。その後色々変更を依頼しても毎回正しく動くスクリプトが生成されます。Dify で見えるトークン出力速度は 70 tok/sec を超えています。ヤバい。

使ったモデル

https://huggingface.co/inferencerlabs/openai-gpt-oss-20b-MLX-6.5bit

「6.5bit はほぼ 8bit と同等の性能 (perplexity)」と書かれてあったので、真に受けて選択。同ページにあるように、VRAM が 17 GB 確保できれば動きます。つまり 32GB 以上の RAM を持った Mac ならそのままで動く計算ですが、VRAM 容量を最適化するには別記事 (↓) をどうぞ。

https://blog.peddals.com/fine-tune-vram-size-of-mac-for-llm
↓ が英語ページの場合は、↑ を開いてください

MLX-LM と MLX のバージョンについての注意点

MLX-LM をバックエンド (OpenAI API コンパチブルサーバ) として使う場合は gpt-oss に対応したバージョン 0.26.3 以上が必要になります。インストール済みの環境で使う場合はアップデートしましょう。

pip install -U mlx-lm

MLX は、新規でインストールする場合は問題ないですが、すでに 0.26.5 より古いバージョンが入っていると、そのままアップデートすると動かなくなります。やっちゃった場合は一度削除してから、再度インストールしましょう。ボクはここで若干ハマりました。

pip uninstall mlx
pip uninstall mlx-metal # うっかり 0.26.5 より古いバージョンからアップデートして入ってしまった場合はアンインストール
pip install mlx mlx-metal

情報源はこちらの issue です:

https://github.com/ml-explore/mlx/issues/2402

2025年 8月 7日現在の最新バージョンはこうなります。

% pip list|grep mlx
mlx 0.28.0
mlx-lm 0.26.2
mlx-metal 0.28.0

参考まで、mlxをアップデートしておかしくなった際にサーバを起動しようとして出たエラーを貼っておきます。同じようにlibmlx.dylib' (no such file)が出た場合は上記のmlxのアンインストール&インストールを実行しましょう。

% mlx_lm.server --host 0.0.0.0 --port 8585 --log-level DEBUG
Traceback (most recent call last):
File "/Users/handsome/Documents/Python/mlx-lm/.venv/bin/mlx_lm.server", line 5, in <module>
from mlx_lm.server import main
File "/Users/handsome/Documents/Python/mlx-lm/.venv/lib/python3.12/site-packages/mlx_lm/__init__.py", line 9, in <module>
from .convert import convert
File "/Users/handsome/Documents/Python/mlx-lm/.venv/lib/python3.12/site-packages/mlx_lm/convert.py", line 7, in <module>
import mlx.core as mx
ImportError: dlopen(/Users/handsome/Documents/Python/mlx-lm/.venv/lib/python3.12/site-packages/mlx/core.cpython-312-darwin.so, 0x0002): Library not loaded: @rpath/libmlx.dylib
Referenced from: <8B6A45F7-00BF-3CEA-9AFF-CD76D4BC76F0> /Users/handsome/Documents/Python/mlx-lm/.venv/lib/python3.12/site-packages/mlx/core.cpython-312-darwin.so
Reason: tried: '/Users/handsome/Documents/Python/mlx-lm/.venv/lib/python3.12/site-packages/mlx/lib/libmlx.dylib' (no such file), '/Users/distiller/project/build/temp.macosx-14.0-arm64-cpython-312/mlx.core/libmlx.dylib' (no such file), '/System/Volumes/Preboot/Cryptexes/OS/Users/distiller/project/build/temp.macosx-14.0-arm64-cpython-312/mlx.core/libmlx.dylib' (no such file), '/Users/handsome/Documents/Python/mlx-lm/.venv/lib/python3.12/site-packages/mlx/lib/libmlx.dylib' (no such file), '/Users/distiller/project/build/temp.macosx-14.0-arm64-cpython-312/mlx.core/libmlx.dylib' (no such file), '/System/Volumes/Preboot/Cryptexes/OS/Users/distiller/project/build/temp.macosx-14.0-arm64-cpython-312/mlx.core/libmlx.dylib' (no such file), '/opt/homebrew/lib/libmlx.dylib' (no such file), '/System/Volumes/Preboot/Cryptexes/OS/opt/homebrew/lib/libmlx.dylib' (no such file), '/opt/homebrew/lib/libmlx.dylib' (no such file), '/System/Volumes/Preboot/Cryptexes/OS/opt/homebrew/lib/libmlx.dylib' (no such file)

mlx-lmのバージョンが古い場合は、Dify で gpt-oss を追加する時にERROR - Model type gpt_oss not supportedが出ます。こちらもエラーを貼っておきます。

/Users/handsome/Documents/Python/mlx-lm/.venv/lib/python3.12/site-packages/mlx_lm/server.py:934: UserWarning: mlx_lm.server is not recommended for production as it only implements basic security checks.
warnings.warn(
2025-08-07 18:29:02,619 - INFO - Starting httpd at 0.0.0.0 on port 8585...
2025-08-07 18:29:19,719 - DEBUG - Incoming Request Body: {
"model": "inferencerlabs/openai-gpt-oss-20b-MLX-6.5bit",
"max_tokens": 5,
"messages": [
{
"role": "user",
"content": "ping"
}
]
}
2025-08-07 18:29:19,725 - DEBUG - Starting new HTTPS connection (1): huggingface.co:443
2025-08-07 18:29:19,998 - DEBUG - https://huggingface.co:443 "GET /api/models/inferencerlabs/openai-gpt-oss-20b-MLX-6.5bit/revision/main HTTP/1.1" 200 18528
Fetching 11 files: 100%|███████████████████████████████████████████████████████████████████████████████████████████| 11/11 [00:00<00:00, 223967.69it/s]
2025-08-07 18:29:20,048 - ERROR - Model type gpt_oss not supported.
192.168.111.71 - - [07/Aug/2025 18:29:20] "POST /v1/chat/completions HTTP/1.1" 404 -

その他、MLX-LM を LLM のバックエンドとして使う方法は別記事に書いていますので読んでみてください。Mac で速度と LLM がサポートする大きなコンテキストウィンドウを確保するなら MLX-LM が正解です。

MoE の生成速度のたまらなさ

上記したとおり今日現在 LM Studio でしか正しい動きを確認できていませんが、たまたま最近 Alibaba がリリースした Qwen3 Coder 30B A3B Instruct も gpt-oss 同様 MoE という仕組みで動いています。MoE の細かい内容は他のサイトなどを見てもらうとして、ユーザ目線での最大のメリットは、生成速度の速さです。自分の Mac で動くローカル LLM が、ChatGPT や Gemini 等のクローズド商用モデル同等の速度で文字を生成していく様は、ある意味感動的でもあります。

実は最近、Reasoning/Thinking モデルの精度優先でゆっくりとした生成速度に慣れきった頃に触った Qwen3 Coder 30B A3B Instruct (MoE) の高速生成に感動し何か記事を書こうとしていました。ですが実際に生成されるコードの精度自体がイマイチだったのでどうするか思案していたところ、まさかの OpenAI から gpt-oss がリリースされたのでした。gpt-oss は M2 Max でも 50 token/sec 以上 (!) のスピードでリッチな内容と文字装飾でレスポンスが生成されてくるので、マジたまらないですよ。

まとめ

MLX と Dify や Open WebUI で使えた!とか、使えなかった!という情報が見当たらなかったので、今のところ使えませんでした!という内容でまとめました。 → 使えるようにする方法は、別記事にまとめてます:

リーダーボードなどで無視されがちな GLM-4-32B もプロンプトをしっかり書けば良い結果が得られそうだなぁ、と思っていたところに OpenAI さんがオープンウェイトを出してきたので、他の LLM の細かい話は色々うっちゃって、当面は gpt-oss をいじるのが正解な気がしています。

ボクはいくつかの過去記事で、32GB RAM (ユニファイドメモリ) の Mac でローカル LLM を使うのは苦労と工夫が必要だよ!と書いてきたのですが、なんだかんだと 20B~30B パラメータ程の優秀な LLM が定期的にリリースされているので、とりあえず 32GB RAM の Mac を買えばそれなりに充実したローカル LLM ライフをエンジョイできる!そんな世の中になっていると言えそうです。イェイ!

Image by Stable Diffusion (Mochi Diffusion)

まだ gpt-oss の性能がこれまでのオープンウェイト LLM より大幅に勝っているかどうかわからないのでミスリーディングなあおり画像かもですね。とりあえずみんなかわいかったので採用。

Date:
2025年8月8日 1:05:22

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
major league baseball player with kids

Exclude from Image:

Seed:
850711837

Steps:
22

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & GPU

Mac のためのローカル LLM 環境 MLX-LM のススメ

前回の記事では、Qwen3 の MLX 版と Ollama (GGUF) 版の速度比較を行いました。結論として、MLX 版の LLM のほうが速いということがわかりました。

その後も主に MLX-LM で LLM を使っているのですが特に不具合も無く、なんなら慣れてしまえばとてもヨイということがわかってきました。というわけで本記事でやり方を一通り共有します。現在 Ollama を使用中で MLX-LM はまだ触っていない、という方が対象になるかと思うので、所々で Ollama との比較を入れていきます。

フロントエンドとしては、ボクは Dify をメインで使っていますが、よりお手軽な Open WebUI での使い方にも触れます。

書いていたら大作になってしまったので、気になるところだけでも覗いてみてください。

↑前回の MLX-LM vs Ollama 的記事

なぜ MLX-LM を使うのか

MLX-LM を使う理由は、MLX は Apple が開発している機械学習フレームワークなので Apple ハードウェア (M1~M4 等の Apple シリコンシリーズ) に最適化されており、単純に LLM の実行速度が Ollama (GGUF モデル) より速いからです。前回の記事で調べてみてはっきりとわかりました (量子化の違いもあり性能差はあるのでしょうが、それすら MLX のほうが上という調査結果もあります)。

実は Dify で MLX-LM を使い始めた当初、システム推論モデルとして Ollama のモデルを使用していました。すると、最初のチャットの後サマリ (タイトル?) の生成に Ollama のモデルが使われ、メモリの使用量が高止まりするような状況が頻発しました。それで MLX-LM はまだ実用には向かないと勝手に思い込んでいたのですが、Dify のシステムモデル設定で推論モデルも MLX の小さなモデルに変更したところ、チャットサマリ生成後もメモリプレッシャーがキレイに下がることがわかりました。MLX-LM だけを使用することで無駄にメモリが占有される問題は解消です。

また、サーバの起動やモデルのダウンロードで必要な長めのコマンドも仮想環境専用のaliasを登録することで解消できて、運用の手間が大幅に下がったことも大きいです (使い慣れている Ollama ではまだ MLX のモデルが使えないので仕方なくなんとかした、とも言えますけど) サーバ自体の起動も速いので、一度落としてあげ直すのも苦痛じゃないです。

MLX で LLM を動かすだけなら LM Studio という選択肢もあります。モデルの検索からダウンロード、テキストのチャット、ビジョンモデルに画像を認識させる、OpenAPI コンパチの API サーバを立ち上げる、等など様々な機能が利用できます。が、全部盛り過ぎてアプリケーション自体が重いのと、モデルを読み込むとその分メモリを占有し続けるのが個人的には気に入らないです。ネット上では、ボクはあまり気にしていませんが、プロプライエタリ (クローズドソース) だからダメだ、なんて論調もありますね。逆に「自分は LM Studio が好き、LM Studio で MLX のモデルを使う」という方はこれ以上読む必要はありません。LM Studio は使わないという人向けの内容です。

MLX-LM モデルの量子化について

新しめの MLX-LM には Learned Quantization (学習済み量子化?) という機能が導入されています。これまでの、全体を画一的に 8-bit や 4-bit に量子化するのではなく、より効率的に量子化を行うことで、結果としてモデルのサイズを小さくしたり、性能の劣化を小さくしたり、推論速度を上げたり、ということができるようです。Hugging Face ではDWQAWQDynamic等とモデル名に付いているものがこれらのテクニックを使って量子化されている事を示しています。詳細はこちら (公式):

https://github.com/ml-explore/mlx-lm/blob/main/mlx_lm/LEARNED_QUANTS.md

ボクも 32GB RAM の M2 max で google/gemma-3-12b-it の Dynamic-quant を数回チャレンジしてみたのですが、おそらくメモリ不足で macOS がクラッシュしてしまい、諦めました (量子化作業にはpip install datasetsが必要でした)。上の公式以外では詳細について書かれている記事などもほぼ見当たらず、今後に期待ですね。

モデルの選定

Mac の GPU に割り当てる VRAM 容量を増やしたり、モデルに最適な量子化が行われていたりしても、それらはより大きなパラメータサイズを使えるようになるほどの効果は期待しづらいです (32B を 70B にとか 4-bit を 8-bit に等はキツい)。なので、これまで Ollama で使っていたモデルの同レベルの量子化バージョンが、より速く低劣化で動き、より大きなコンテキスト長が使えるというのが MLX-LM モデルの大きなメリットになると思います。

モデルを選定するには、慣れないうちは LM Studio で MLX のみにチェックを入れて使えそう (Full GPU Offload Possible) なモデルを見つけて Model (例: deepseek/deepseek-r1-0528-qwen3-8b) をコピーし、後述するコマンドでダウンロード、というのが良いと思います。慣れてきたら Hugging Face で “mlx gemma-3” 等と検索するのが早くなると思います。

下の記事ではより詳細に自分の RAM (ユニファイドメモリ) のサイズに合わせたモデルの見つけ方を説明しています。(英語ページが開いてしまったら、右の「日本語」をクリックしてください)

今回は MLX-LM に変換・量子化されたモデルを対象とした記事ですが、そもそもの LLM の性能差などを調べるのは、各種リーダーボードを見るのが良いでしょう。ボクは最近もっぱら↓のサイトで性能差を見ています。

https://artificialanalysis.ai (オープン、クローズド、複数選んで比較できます。新しいモデルが追加されるのも早い)

試した環境

  • Mac Studio M2 Max 32GB GPU (24,576 GB を VRAM に割り当て済み。OS 標準以上の容量を GPU に割り振る方法はこちら)
  •  macOS: Sequoia 15.5
  • Python 仮想環境: pipenv version 2025.0.3 (なぜ pipenv なのか、みたいな話はこちら)
  • Python: 3.12.11 (特に意味は無し。brew install [email protected]でインストール)
  • MLX-LM: 0.25.2 (pip install mlx-lmでインストール)
  • Open WebUI: 0.6.15 (pip install open-webuiでインストール)
  • Dify: 1.4.2 (LAN にいる別の Mac mini M1 にインストール。やりかたはこちら)
  • Ollama: 0.9.2 preview (Ollama 新アプリのプレビュー版。比較用に。詳しくはこちら)
  • LLM: Qwen/Qwen3-32B-MLX-4bit (17.42 GB / メインで使う LLM)
  • LLM: mlx-community/gemma-3-12b-it-4bit (8.07 GB / Dify のシステム推論モデルとして使用)

RAM が 32GB より小さい場合は LLM も性能がそれなりのものしか使えないので、正直実用的なローカル LLM 環境を作るのはキツいと思います。48GB 以上あれば Dify 含めて全て同一 Mac で動かせると思います。

仮想環境を作る

Python の仮想環境は、最低限 MLX-LM 実行用に一つ必要です。Open WebUI を新たにpipで導入する場合には専用にもう一つ作ったほうが良いと思います。お好みの仮想環境ツール+上記pipコマンドで作ってください。

もし新規で Dify をインストールする場合は Docker が必要となりますので、公式過去記事を参考に構築してください (CPU >= 2コア、RAM >=4GB の割り当てが必要)。

(蛇足) ボクはあまり人気が無いらしいpipenvを使ってます。仮想環境内だけで有効になるaliasを使って長くなりがちなコマンドを簡単に実行しています。特にこだわりや縛りの無い方はお試しあれ。(英語ページが開いてしまったら、右の「日本語」をクリックしてください)

pipenv内専用のaliasについてもう少し触れておくと、仮想環境のルートディレクトリに置いた.zshrc.localファイルに下記のように書き込んでおけば、pipenv shellで環境に入ったときだけmlxsvで MLX-LM の API サーバを実行でき、モデルのダウンロードはdownloadの後に Hugging Face のモデルを指定するだけで実行できるので便利です (例: download mlx-community/gemma-3-12b-it-4bit)。詳細は上記記事をご覧ください。

alias mlxsv='mlx_lm.server --host 0.0.0.0 --port 8585 --log-level DEBUG'
alias download='HF_HUB_ENABLE_HF_TRANSFER=1 huggingface-cli download'

MLX-LM 仮想環境でモデルをダウンロード&動作確認

mlx_lm.generateコマンドを使ったモデルのダウンロード方法をよく見ますが、やっているのはollama runコマンドでモデルをダウンロードしてチャット開始するのと近く、ダウンロード後にテキストの生成が行われます (チャットでは無く、生成のみ)。ollama pullのようにシンプルにモデルをダウンロードするだけであれば、Hugging Face のコマンドをインストールして使用するのが良いでしょう。というわけで、まずは MLX-LM 用に作った仮想環境に入ってから Huggng Face 関連コマンドをインストールします。

pip install -U huggingface_hub hf_transfer

次に、普通にやるより速いらしい以下の方法でモデルをダウンロードします (上記のaliasを設定済みであればdownload Qwen/Qwen3-32B-MLX-4bitで OK です)。モデルはお好みでどうぞ。

HF_HUB_ENABLE_HF_TRANSFER=1 huggingface-cli download Qwen/Qwen3-32B-MLX-4bit

動作確認はmlx_lm.chatコマンドでターミナルから行えます。下記例では最大トークン数をデフォルトより増やしています (Qwen3 のような thinking/reasoning モデルだと考えているうちに最大トークンに達してしまう)。

mlx_lm.chat --model Qwen/Qwen3-32B-MLX-4bit --max-tokens 8192
MLX-LM と Ollama との CLI チャット機能比較: mlx_lm.chatコマンドはあまりイケてません。ollama runコマンドのようにチャットを始めてから設定を変更したりはできませんし、いくつか改行するつもりでエンターキーを叩くと無言のプロンプトが LLM に送られて生成が始まりますし、LLM のテキスト生成を止めようと Ctrl + C するとコマンド自体が停止します (ズコー)。よって、ollama runの様な使い勝手は期待してはいけません。

次のコマンドでは Dify のシステム推論モデルとして設定する mlx-community/gemma-3-12b-it-4bit もダウンロードしています。Dify を使わない方は不要です。ファイルサイズは 8.1GB なので、上で落とした Qwen/Qwen3-32B-MLX-4bit の半分以下の時間で完了すると思います。

HF_HUB_ENABLE_HF_TRANSFER=1 huggingface-cli download mlx-community/gemma-3-12b-it-4bit

ダウンロードされたモデルを一覧表示するのは以下のコマンドです:

mlx_lm.manage --scan

ところがこのコマンドでは最初にダウンロードしたQwenリポジトリのモデルは表示されません。mlx-communityリポジトリのモデルは表示されます。API サーバを実行すればブラウザからは確認できるので、その方法は後ほど説明します。また、モデル名を指定すれば使用することも可能です。

チャットで使うならこう:

mlx_lm.chat --model Qwen/Qwen3-32B-MLX-4bit

削除するならこう:

mlx_lm.manage --delete --pattern Qwen/Qwen3-32B-MLX-4bit

モデルは/.cache/huggingface/hub/以下に保存されているため、ファインダーから削除しても問題ありません。

ところで先ほどのmlx_lm.manage --scanでモデルの実サイズは表示されるものの、他の情報は特に確認できません。Ollama では ollama show <modelname>でコンテキスト長や量子化方法等を確認できますが、代わりになる方法は MLX-LM にはありません。必要な場合は Hugging Face のモデルカードを確認するか、LM Studio がインストールしてあれば My Models で確認するか、といったところです。ただしコマンドでダウンロードしたモデルの名前は LM Studio では正しく表示できないので、ダウンロードしたタイミングなどで見分けましょう。モデルの詳細 (メタデータ) を確認する機能はぜひ MLX-LM に追加して欲しいところですよね (LM Studio は MLX-LM を内蔵しているので、同じ事ができると思うんですけど)。

OpenAI API コンパチのサーバを実行する

公式の実行方法 (↓ のリンク) をみるとモデル名を渡しているのでそのモデルしか使えないのかと思っていたのですが、サーバの起動時にモデル名を渡す必要はありません。起動後はクライアントで指定したモデルが利用できます。

https://github.com/ml-explore/mlx-lm/blob/main/mlx_lm/SERVER.md

上のaliasのとこにも書きましたが、ボクが MLX-LM の API サーバを実行するコマンドは以下の通りです。オプションがどれも不要であれば、mlx_lm.serverだけで大丈夫です。

mlx_lm.server --host 0.0.0.0 --port 8585 --log-level DEBUG
  • --host 0.0.0.0 こうするとの他のホストからもアクセスできます (ボクは Dify が別の Mac で動いているので必須)
  • --port 8585 デフォルトの8080 Open WebUI のデフォルトと被るので変えています
  • --log-level DEBUG プロンプトと速度 (tokens-per-sec = トークン/秒) やメモリの最大使用量が表示されます

コマンドを実行するとほどなく以下の様な画面になり、LLM が使用できるようになります。

% mlx_lm.server --host 0.0.0.0 --port 8585 --log-level DEBUG
/Users/handsome/Documents/Python/mlx-lm/.venv/lib/python3.12/site-packages/mlx_lm/server.py:880: UserWarning: mlx_lm.server is not recommended for production as it only implements basic security checks.
warnings.warn(
2025-06-28 18:56:23,071 - INFO - Starting httpd at 0.0.0.0 on port 8585...

UserWarningには、基本的なセキュリティチェックしか行っていないので本番環境での使用は推奨しない、と書かれています。閉じた環境で使う分には問題無いでしょう。

では簡単に、接続できるのかを試しておきましょう。ウェブブラウザで以下の URL を開くと、MLX-LM から利用できるモデルが表示されます。

http://localhost:8585/v1/models

表示例 (Qwen も見えてますね):

{"object": "list", "data": [{"id": "mlx-community/gemma-3-12b-it-4bit", "object": "model", "created": 1751104699}, {"id": "qwen/qwen3-1.7b", "object": "model", "created": 1751104699}, {"id": "Qwen/Qwen3-32B-MLX-4bit", "object": "model", "created": 1751104699}, {"id": "mlx-community/Qwen2.5-Coder-32B-Instruct-4bit", "object": "model", "created": 1751104699}, {"id": "mlx-community/QwQ-32b-4bit-DWQ", "object": "model", "created": 1751104699}]}

こうなれば、OpenAI API コンパチブルサーバに接続できるクライアントから MLX-LM の LLM を利用できるようになります。

サーバの停止とアップデート

アクティビティモニタでメモリメモリプレッシャーを見ていると、まれに黄色く高止まりすることがあります。そんなときは Ctrl + C で一度 MLX-LM サーバを止めて再度走らせるのが安心ですが、高止まりの原因が MLX-LM であれば次のチャットの後には平常に戻ることがほとんどです。他に GPU ヘビーなアプリを使っていなければ、雑に扱っても割と平気です。

アップデートに関しては Ollama のようなアイコンで知らせてくれたり、自動でダウンロードしてくれるような機能はありません。必要に応じてコマンドを叩く必要があります。

pip list|grep mlx # インストール済みバージョンの確認
pip install -U mlx-lm

https://pypi.org/project/mlx-lm (pip パッケージの情報)

もしアップデート後に不具合が出たら、上のコマンドで表示されたインストール済みバージョンに戻しましょう。例えばバージョン 0.25.1 に戻すならこんな感じです:

pip install mlx-lm==0.25.1

Open WebUI から接続する

Open WebUI を実行する

別の Python 仮想環境に Open WebUI をインストールした場合は、以下のコマンドでクライアントを実行できます (公式ではpipよりuvを強力に推していましたが、ボクは Open WebUI をメインで使わないのでなじみのあるpip使っちゃいました)。Docker で構築した人は飛ばしてください。

open-webui serve

オプションで--host (デフォルト: 0.0.0.0)、--port (デフォルト: 8080) の指定も可能です。

(ボクはコマンドを忘れがちなので、.zshrc_localalias sv='open-webui serve'と書いてきて、svで起動できるようにしています)

しばし待ち、ターミナルにロゴといくつかのINFOが表示されたらアクセスできるハズです (ロゴが収まりきらなかったのでコードブロックで貼り付けました)。


 ██████╗ ██████╗ ███████╗███╗   ██╗    ██╗    ██╗███████╗██████╗ ██╗   ██╗██╗
██╔═══██╗██╔══██╗██╔════╝████╗  ██║    ██║    ██║██╔════╝██╔══██╗██║   ██║██║
██║   ██║██████╔╝█████╗  ██╔██╗ ██║    ██║ █╗ ██║█████╗  ██████╔╝██║   ██║██║
██║   ██║██╔═══╝ ██╔══╝  ██║╚██╗██║    ██║███╗██║██╔══╝  ██╔══██╗██║   ██║██║
╚██████╔╝██║     ███████╗██║ ╚████║    ╚███╔███╔╝███████╗██████╔╝╚██████╔╝██║
 ╚═════╝ ╚═╝     ╚══════╝╚═╝  ╚═══╝     ╚══╝╚══╝ ╚══════╝╚═════╝  ╚═════╝ ╚═╝


v0.6.15 - building the best AI user interface.

https://github.com/open-webui/open-webui

Fetching 30 files: 100%|█████████████████████████████████████████████████████████████████████████████████████| 30/30 [00:00<00:00, 7708.23it/s]
INFO:     Started server process [84450]
INFO:     Waiting for application startup.
2025-06-28 19:08:50.198 | INFO     | open_webui.utils.logger:start_logger:140 - GLOBAL_LOG_LEVEL: INFO - {}
2025-06-28 19:08:50.198 | INFO     | open_webui.main:lifespan:514 - Installing external dependencies of functions and tools... - {}
2025-06-28 19:08:50.370 | INFO     | open_webui.utils.plugin:install_frontmatter_requirements:241 - No requirements found in frontmatter. - {}

以下の様な URL をブラウザで開きましょう (デフォルトでホストが0.0.0.0なので、自宅の Wi-Fi であれば iPhone 等から Mac の IP アドレスを指定してアクセスできます)。

http://localhost:8080

最初に管理者アカウントの作成があるんじゃないかと思いますので、終わらせてから進めてください。

OpenAI API として追加する

右上のアイコンから管理者パネルを開きます。

設定から接続を選び、OpenAI API接続の管理にあるプラスボタンをクリックします (下のスクリーンショットは設定済みの状態)。

Connection Type の右の「外部」をクリックして「ローカル」に変更し、URL に今回の例では「http://localhost:8585/v1」を入力し、保存します。

接続の下の「モデル」をクリックすると、ダウンロード済みのモデルが表示されると思います。もし表示されなければ、一度 Open WebUI のターミナルでサーバを Ctrl + C で止めて、再度実行してみてください。

Alibaba の回し者ではないです

ついでにやっておくべきオススメ設定

ここでモデルが見えれば新しいチャットの右にあるドロップダウンメニューから選んで使えるハズですが、その他いくつかやっておくべき設定を紹介します。

モデルの詳細設定をする

管理者パネル > 設定 > モデルで、モデル名をクリックするとデフォルトの設定を変更できます。システムプロンプトに「常に日本語で回答してください」と入れたり、高度なパラメータを表示して max_tokens を最大値にしておくと良いでしょう (デフォルトだと 128トークンしかない)。下にある資格のチェックボックスは、よくわからなければ全て外してしまいましょう。最後に「保存して更新」をクリックするのをお忘れ無く。

コンテキスト長は Ollama を Dify から使う場合などは注意して設定しないと大きな生成速度の低下を招きますが (参考記事)、Open WebUI だと max_tokens や num_ctx (Ollama) をどれだけ大きくしても?影響ないみたいです。どうやっているのかは未確認。

余計な仕事をさせない

管理者パネル > 設定 > インターフェースで、Follow Up Generation とオートコンプリート生成をオフにして保存します。いらないでしょ?

チャットタイトルについて

上と同じインターフェース画面で、タイトル生成についての設定があります。この生成処理にも LLM が使われるので、全く不要ならオフにする事もできます。有効にしておく場合、Qwen3 ではここでも思考プロセスが動いてしまうため、タイトル生成プロンプトに/no_thinkとだけいれて保存しましょう。こうすると、何の工夫も無くチャットに最初に入力した文章そのままがタイトルになり、余計な GPU の使用を防げます (デフォルトのタイトル生成プロンプトを見ると対策をしようとしているみたいですが、現状はうまく機能していません)。

Safari ユーザは日本語確定のエンターでメッセージが送信されるのを防ぐ

別記事にその方法を書いています。この方法はどうやら localhost に対しては使えないようなので、Mac には固定 IP アドレスを振り、// @include http://192.168.1.100:8080/*の様な形で対象を指定する必要があります。

Dify から接続する

OpenAI-API-compatible を使えるようにする

Dify のバージョン 1以上で使うには、まず OpenAI-API-compatible をプラグインからインストールします。

モデルを追加する

次に、右上の自分のアカウントアイコン > 設定 > モデルプロバイダーを開き、上で追加した OpenAI-API-compatible の「モデル追加」をクリックします。

Qwen/Qwen3-32B-MLX-4bit を追加するなら、こんな感じです。

  • Model Name: Qwen/Qwen3-32B-MLX-4bit
  • Model display name: (自分にわかりやすいように。例: MLX – Qwen/Qwen3-32B-MLX-4bit)
  • API Key: 不要
  • API endpoint URL: http://localhost:8585/v1 とか、別ホストなら http://192.168.1.100:8585/v1 とか
  • Completion mode: Chat
  • Model context size: 32768
  • Upper bound for max tokens: 32768
  • その他もろもろ: Not Support またはよしなに
  • Delimiter for streaming results: \n\n

チャットのタイトルを生成するモデルを選ぶ

また、Dify ではチャットタイトルを作るのはシステム推論モデル固定なため、小さめで thinking/reasoning ではない MLX-LM のモデルを設定しておきます。ここでは先ほどダウンロードしておいた Gemma 3 を上同様の要領で OpenAI-API-compatible モデルとして追加した後、指定しています (Model context size は 40960)。

あとはそれなりに

ここまでできたら、後は作ったアプリのモデルとして使用してみましょう。数字を鵜呑みにして良いのかわりませんが、いくつかチャットを行った後でアプリの「監視」メニューを見てみると、MLX-LM モデルのトークン出力速度が Ollama モデルより速いことが確認できます。

最後に

長々と書きましたが、使うほどに速さを実感しています。Ollama や LM Studio のようなユーザーフレンドリーさはありませんが、CLI での扱い方に慣れてしまえば MLX をサポートしない Ollama には戻れなくなると思います。ボクはディスク容量削減のため、Ollama からほとんどのモデルを削除してしまいました。

今回記事を書きながら Open WebUI をじっくり使ってみました。チャットだけなら十分ですね。タイトルの自動生成キャンセル技は速度を稼げるので地味に便利です。OpenAI API 接続だと tokens-per-sec が表示されないのは残念ですけど。RAG や MCP の利用もできるようなので、もっと使い込んでみようと思っています。

あとはやっぱり Qwen3 の性能の高さですよね。フロントエンド側でのサポートも進んでいて、QwQ だと丸見えになる思考が非表示になるのも地味にうれしいところです。政治的な話や中国にまつわる話を避ければおかしなところは感じないですし、最終的な回答に中国語が混ざる事も無く、当面はこれ一本でよさそうだと思っています。

Image by Stable Diffusion (Mochi Diffusion)

「リンゴTシャツを着てラマに乗る女性」いいんじゃないっすかコレ?もう一つステップを上げると破綻したので、これがベスト。

Date:
2025年6月29日 22:15:00

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
a lady with an apple t-shirt riding on a lama

Exclude from Image:

Seed:
391522385

Steps:
27

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & GPU

Alibaba 公式 MLX 版 Qwen3 を他の量子化版と比較

Alibaba の Qwen チームが Mac 用に MLX 版の Qwen3 をリリースしたので Qwen/Qwen3-32B-MLX-4bit を使ってみました。他の記事でも書いているとおり Ollama では使えないので、MLX-LM をメインで使っています。また、MLX-LM、LM Studio、Ollama をバックエンドにしてそれぞれで使える Qwen3 の生成速度の違いも軽くテストしてみました。

海外の掲示板では、せっかく MLX 用に変換してくれたのに DWQ 量子化していないじゃないか、みたいなコメントも見ましたが、そのあたりの影響かな?と思えそうな結果になっています。

モデル情報元

公式 X:

https://twitter.com/Alibaba_Qwen/status/1934517774635991412

公式 Hugging Face:

https://huggingface.co/collections/Qwen/qwen3-67dd247413f0e2e4f653967f

MLX に限らず、Qwen チームが量子化した Qwen3 全てのバージョンやベースモデルがあります。

試した環境

  • Mac Studio M2 Max 32GB GPU (24,576 GB を VRAM に割り当て済み。やりかたはこちら)
  • macOS: Sequoia 15.5
  • Python 仮想環境: pipenv version 2025.0.3 (なぜ pipenv なのか、みたいな話はこちら)
  • Python: 3.12.11 (特に意味は無し)
  • MLX-LM: 0.25.2 (pip install mlx-lmでインストール)
  • Open WebUI: 0.6.15 (pip install open-webuiでインストール)
  • Ollama: 0.9.1 preview (新アプリのプレビュー版。詳しくはこちら)
  • LM Studio: 0.3.16 (build 8)
  • LLM: Qwen/Qwen3-32B-MLX-4bit (17.42 GB / 今回のメイン)
  • LLM: mlx-community/Qwen3-32B-4bit-DWQ (18.54 GB / 比較用)
  • LLM: qwen3:32b-q4_K_M (20 GB / Ollama のモデル、比較用)

インストール方法や各アプリケーションの使い方などはリンク先や他のウェブサイトを参照してください。少なくとも何らかの Python 仮想環境を作り、MLX-LM か LM Studio のインストールがしてあれば使えます。

モデルのダウンロード

色々方法を試した結果、MLX-LM で Hugging Face にアップされているモデルを使うのはこの方法がよさそうかと。MLX-LM 用にでも作った仮想環境に入り、Hugging Face 関連パッケージをインストールしてコマンドからインストールを行います。

モデルは自分の GPU (割り当て VRAM サイズ) に 100% 乗る、Qwen/Qwen3-32B-MLX-4bit にしていますので、お好みのものに変更してください。

pip install -U huggingface_hub hf_transfer
HF_HUB_ENABLE_HF_TRANSFER=1 huggingface-cli download Qwen/Qwen3-32B-MLX-4bit

ダウンロードが終わると、~/.cache/huggingface/hub/models--Qwen--Qwen3-32B-MLX-4bitに保存されます。mlx-community のモデルのようにmlx_lm.manage --scanでは表示されませんが、名前を指定すれば使えますので安心してください。

動作確認

MLX のチャット (CLI) でさっくり試せます。質問によっては思考 (<think>~</think>) だけでトークンを使い切ってしまうので、--max-tokens 8192等として上限を増やして実行したほうが良いでしょう。

mlx_lm.chat --model Qwen/Qwen3-32B-MLX-4bit --max-tokens 8192

実行結果のサンプル:

%  mlx_lm.chat --model Qwen/Qwen3-32B-MLX-4bit --max-tokens 8192
Fetching 10 files: 100%|███████████████████████████████████████████████████████████████████████████████████████████| 10/10 [00:00<00:00, 145131.63it/s]
[INFO] Starting chat session with Qwen/Qwen3-32B-MLX-4bit.
The command list:
- 'q' to exit
- 'r' to reset the chat
- 'h' to display these commands
>> こんにちは。自己紹介してください
<think>
The message is in Japanese. The person is greeting me and asking me to introduce myself.

I should respond in Japanese since the message was in Japanese. I'll provide a brief introduction about myself as an AI assistant.

The message says: "Hello. Please introduce yourself"

My response will be in Japanese:
</think>

こんにちは!私は通義千問(つうぎせんもん)で、英語ではQwenと呼ばれます。私はアリババグループ傘下の通義実験室が独自に開発した大規模言語モデルです。質問への回答や、物語、公文書、メール、脚本など文章の作成に加えて、論理的推論やプログラミング、さらにゲームにも対応できます。また、多言語をサポートしており、さまざまなタスクを効果的に支援できます。どうぞよろしくお願いいたします!
>> q

MLX-LM の CLI チャットは Ollama ほどイケてないので、動くのが確認できたらさっさと次に進みましょう。

API サーバを立てる

MLX-LM でサーバを実行するだけで API サーバとして使えます。セキュリティ的に本番環境向けでは無いという警告が出ますが、とりあえず使う分には良いでしょう。

ボクは LAN 内の別の Mac で動く Dify から接続するのと Open WebUI がポート 8080 を使っているということもあり、--host 0.0.0.0--port 8585を指定しています。--log-level DEBUGを付けると、トークンごと (?) の出力と、tokens-per-sec が表示されます。

またここで--model Qwen/Qwen3-32B-MLX-4bitとしてモデルを指定することもできますが、指定しなくてもクライアント側で指定したモデルがオンザフライで読み込まれるので気にしなくてよさそうです。

mlx_lm.server --host 0.0.0.0 --port 8585 --log-level DEBUG

サーバが立ち上がったかどうかは、ブラウザで利用可能なモデル一覧を表示させることで確認できます。

http://localhost:8585/v1/models

実行例:

{"object": "list", "data": [{"id": "lmstudio-community/DeepSeek-R1-0528-Qwen3-8B-MLX-4bit", "object": "model", "created": 1750501460}, {"id": "mlx-community/QwQ-32b-4bit-DWQ", "object": "model", "created": 1750501460}, {"id": "Qwen/Qwen3-32B-MLX-4bit", "object": "model", "created": 1750501460}, {"id": "mlx-community/Qwen3-32B-4bit-DWQ", "object": "model", "created": 1750501460}]}

mlx_lm.manage --scanでは表示されないQwenlmstudio-communityのモデルも見えますね。

Dify で作った超簡単チャットアプリで「こんにちは。自己紹介してください」と投げたときの token per sec (トークン数/秒) は以下となりました。悪くないですよね。個人的には 11 あればヨシと考えています。

2025-06-21 19:25:58,696 - DEBUG - Prompt: 39.744 tokens-per-sec
2025-06-21 19:25:58,697 - DEBUG - Generation: 17.347 tokens-per-sec
2025-06-21 19:25:58,697 - DEBUG - Peak memory: 17.575 GB

あとは Open WebUI なり Dify なりでモデルプロバイダとして登録し、使ってみるだけです (参考手順)。上記のコマンドでダウンロードしたモデルは、LM Studio でも使えるのでディスクスペースの有効活用になります。ただし、モデル名は読めません。下のスクリーンショットの一番上が Qwen/Qwen3-32B-MLX-4bit で、一番下は mlx-community/Qwen3-32B-4bit-DWQ です。なはは。

速度の違い (参考情報)

Dify を使って、いくつかの量子化バージョンと API サーバの組み合わせで Qwen3 32B を実行した結果が以下の表となります。

テスト内容としては、1ラウンドのプロンプトを 4回投げて「監視」画面でトークン出力速度の平均を見ました。

一つ失敗した点がありまして、設定した Size of context window: 25600 が Ollama には大きすぎたため GPU の使用率が 100% に行かず、10%/90% CPU/GPU という不利な結果となってしまいました (ollama psより)。よって、Ollama のみ最大トークン数を半分の 12800 に下げて 100% GPU で再テストしています。

実行中のメモリプレッシャーに関しては、MLX モデルは全て 8割ほどで推移していました。LM Studio はモデルのロード後常にメモリ上にあるため、メモリの占有量はほぼ変化しない代わりに推論の開始が早いという特徴があります。チャット内容のサマリが生成されないのは、Dify のシステム推論モデルに設定している Ollama のモデルが動くだけのメモリ容量がないからかもしれません。

モデルモデルサイズAPI サーバ平均トークン/秒特徴・メモリプレッシャ
Qwen/Qwen3-32B-MLX-4bit17.42 GBMLX-LM19.302サマリ生成の後半分程に下がる
Qwen/Qwen3-32B-MLX-4bit17.42 GBLM Studio23.19サマリが生成されない。メモリ使いっぱなし
mlx-community/Qwen3-32B-4bit-DWQ18.45 GBMLX-LM21.058サマリ生成の後半分程に下がる
mlx-community/Qwen3-32B-4bit-DWQ18.45 GBLM Studio24.503サマリが生成されない。メモリ使いっぱなし
qwen3:32b-q4_K_M20 GBOllama (max. 25600 tokens)9.511サマリ生成後はミニマム
qwen3:32b-q4_K_M20 GBOllama (max. 12600 tokens)12.146サマリ生成後はミニマム

評価に使ったプロンプトは以下の 4つとなります。全て一往復で終わらせています。メモリプレッシャーが下がって安定し、GPU の使用量がゼロになったのを確認してから新しいチャットで次のプロンプトを実行しています。

こんにちは。自己紹介してください
ボードゲーム「オセロ」のルールを正確に教えてください
微分積分を再度勉強しようと思います。数式を交えてさわりの部分を教えてください
あなたはマーケティングのプロフェッショナルです。
日本では一部の自動販売機では、夏でもホットの缶コーヒーが売られています。それは、夏場のタクシー運転手は暑い中タクシーを利用する乗客のために社内の温度を低くしており、冷えた体を温めるために缶コーヒーを求めるからです。
同じような視線からでも別の視線からでも構いませんが、現在は冬場しか売られていないコンビニレジ横のおでんを通年で販売するためにはどのような方策があるか、提案してください

System prompt と LLM の設定は以下の内容で行いました (最近の Qwen の LLM は優れた多言語対応が進んでいますが、念のため)。

  • System prompt:
日本語で質問されたら日本語で回答してください。
If asked in English, answer in English.
Never user Chinese
  • Temperature: 0.1
  • Max Tokens: 25600 (Ollama は Size of context window)
  • Thinking mode: True (Ollama には該当項目無し)
  • それ以外はデフォルト (未設定)

テストの結論

メモリに余裕があるなら、LM Studio + mlx-community/Qwen3-32B-4bit-DWQ の生成速度が最強ですね。トークン/秒の数字を鵜呑みにすれば、Ollama + Qwen3-32B-4Q_K_L の倍の速度が出ています。ただ、回答の中で「おでん」を「おでn」とか「オーデン」と書いていたので、この組み合わせだと何かが欠落するような要素があるのかもしれません。「オーデン」として夏に売り出すというのはアリかもしれませんけど。いや、どうか。

回答内容や日本語に安心感があったのは Qwen/Qwen3-32B-MLX-4bit でした。個人的には MLX-LM との組み合わせが使いやすいと感じています。

Qwen に限らず、今後 LLM の開発元が MLX 化と量子化までを行ってくれると搭載メモリの小さい Mac でも効果的に使える様になるはずなので、そんな未来に期待したいですね。

ただ今のところ MLX-LM の量子化については公式以外にあまり情報が無いのがつらいところです。

Image by Stable Diffusion (Mochi Diffusion)

「3人兄弟の徒競走」をイメージして描いてもらいましたが、やはり数字の指定には弱く、4人登場する画像が多かったです。顔の描写も人数が増えるほど破綻し、ステップ数を増やしても良くなるわけではないので後ろ向きのを採用しました。兄弟っぽいし、差も付いてるし。

Date:
2025年6月22日 1:01:01

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
footrace of three brothers on a track

Exclude from Image:

Seed:
309909096

Steps:
20

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & GPU

New Ollama for macOS preview v0.9.1 が出たが MLX がサポートされたわけではない

なーんだ、そうなのか。という感じですよね。以前の記事で触れた X への投稿みたいな、「そろそろ MLX 対応するぜ」的匂わせなのか、全く関係ないのか。とりあえずいじってみたのでまとめました。

情報元は v0.9.1 のリリース

オフィシャルの GitHub リポジトリの該当リリースがこちらです:

https://github.com/ollama/ollama/releases/tag/v0.9.1

New Ollama for macOS and Windows preview

という見出しと共に、Download for macOS というリンクと、追加された設定機能のスクリーンショットが見つけられます。

2025年6月22日追記: すでに Version 0.9.2 の preview も出ていますね。ダウンロードは Release ページの Asset にある Ollama-Preview.dmg をクリック:

https://github.com/ollama/ollama/releases

macOS 向け Preview バージョンのハイライト

Preview の特徴をざっくりまとめるこういうことのようです (というか、現状これくらいの情報しかみあたらない):

  • Settings で、LAN やインターネットへ (簡単に) 公開する事ができるようになった (環境変数OLLAMA_HOSTと同じ?)
  • Settings で、ローカルのブラウザからのアクセスを有効にできるようになった (Ollama JavaScript Libraryを使う人には便利らしい? Open WebUI みたいなフロントエンドかと思ったらそうではなかった)
  • Settings で、モデルの保存場所を (簡単に) 変更できるようになった (環境変数OLLAMA_MODELSと同じ?)
  • macOS ネイティブアプリとなり、インストールに必要なサイズがかなり小さくなり、起動も速くなった (え?今まではネイティブアプリじゃ無かったの?)
  • Preview をアップデートすると通常の最新バージョンになってしまう (Restart to update すると、それはもう Preview では無くなってしまう、ということなので注意)

Settings ウィンドウはメニューバーのアイコンから開く事ができ、設定の保存は [ Update Settings] ボタンです。

Restart to update をすると通常の最新バージョンが入ってしまう罠

使ってみてどうか

どうなんですかね?Qwen3:32b と QwQ:32b を使ったチャットするだけアプリを Dify で作って速度を見てみましたが、LLM の動作に何か良い影響があった感じではありませんでした。ま、この部分は MLX 対応されてからのお楽しみでしょうね。最近動きが見えないですけど。

Settings でいじれる内容についても、個人的にはメリット無いです。下の別記事に書いていますが、ボクは macOS のログイン時に Ollama サーバが LAN に公開されるようにしているので起動も速くなったかどうかわかりません。外付けの SSD にモデルの保存先を変更するなら起動スクリプトにOLLAMA_MODELSを追加すれば良いし、今回の Preview バージョンによる恩恵は見つかっていません。追加情報や Preview のアップデートに期待、というところです。

というわけで、他に掘り下げる情報も見つからないので、今回はここまで。

Image by Stable Diffusion (Mochi Diffusion)

単純に「実験室のラマの赤ちゃん」をいくつか描いてもらいました。ビーカーをのぞき込む感じがプレビュー/レビューと重なったので、こちらを採用

Date:
2025年6月20日 19:41:28

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
a baby lama in a scientific lab

Exclude from Image:

Seed:
3804362856

Steps:
21

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & GPU

Mac の Safari で日本語入力確定時のエンターによるチャット誤送信を制御

Mac の Safari で Dify や Copilot チャットしていると、日本語変換の確定のつもりでエンターキーを押下したときにメッセージが送信されてしまいますよね。それを避けるためには、Chrome 等のブラウザを使うとか、有料の機能拡張アプリを利用するとか、ブックマークレットを連打しておくとか、一度英語にして日本語に戻すとか、ショートカットキーを作って対処するとか、開発者に改善要求するとかいくつか方法があるかと思いますが、やっと解決策を見つけました。Userscripts という App Store から入手できる無料の機能拡張を使った、特定のウェブサイトを開いたら JavaScript を自動で実行する方法です。一度設定してしまえばすごく便利です。ただ、使えるようになるまでが若干わかりづらく、使い方を紹介しているサイトが全然見つからなかったので、今回は手順を紹介します。

ちなみにブックマークレットを使った方法は ↓ の記事で紹介しました。

今回も使わせてもらう JavaScript はこちらの Classi さんの記事からいただいてきました。うまくいったという方は、ぜひ Classi さんのページでスターを付けてきてください。

Userscripts はオープンソースの Safari 機能拡張

GitHub でソースは公開されており、アプリとして App Store からダウンロードできます。なので、急になくなってしまう心配や、出所不明のアプリをインストールする不安はありません。いつか有料化されるかもと不安な方はフォークしておくと良いんじゃないでしょうか。

Mac App Store: https://apps.apple.com/jp/app/userscripts/id1463298887

公式 GitHub: https://github.com/quoid/userscripts

Userscripts でできること

主に、指定したウェブページを開いたときに JavaScript を実行したり、CSS でスタイルを適用したりできます (同じ様な機能を提供している有名な機能拡張には Tampermonkey というアプリがあるのですがこちらは有料です)。まー、これらは既存のサイトに独自の JavaScript やら CSS やらを適用したいなんて言うこだわり屋さんを満足させる機能拡張であるわけですから、その設定内容も非常に多く、フロントエンドから極力距離をおいて生きているボクのような人間にはなかなかとっつきづらいです。そんな人もこの先を読んでもらえれば、とりあえずこの記事のタイトルを実現することはできますんで、よろしくどうぞ。

インストールと初期設定

上の App Store のリンクをクリックするか「userscripts」を検索し、Mac App Store が開いたら [ 入手 ] ボタンをクリックします。ダウンロードが終わるとボタンが [ 開く ] に変わるのでクリックしましょう。下のようなポップアップが表示されますので、[ Open Safari Settings] ボタンをクリックします。

Save Location はこだわりが無ければそのままで良いでしょう

左側のペインに Userscripts のアイコンが表示されているはずなので、チェックマークを入れて有効化します。

デバイス間での共有はお好みで

[ Webサイトを編集… ] ボタンをクリックします。

プライベートブラウズで許可するかどうかはお好みで

ウィンドウ右下の「その他のWebサイト」は「拒否」にしておきます。

「確認」にしてしまうと、全てのウェブページでビックリマークが出て面倒

アドレスバーの左に </> という形のアイコンができていれば、とりあえず初期設定は完了です。

対象とするウェブサイトを開いて設定を行う

まずは Dify なり Copilot なり、今回の設定を行いたいウェブページを開いてください。そこでアドレスバーの左の</>アイコンから「このWebサイトで常に許可」をクリックします。

水色になったアイコンを再度クリックし、「Open Extension Page」をクリックします。

だんだんとアドベンチャーゲームの攻略記事を書いている気分に…

「No Item Selected」と書かれたページが開くので、[ + ] ボタンから「New JS」をクリックします。

すると、JavaScript のテンプレートが表示されます。//でコメントされている部分は Userscripts で独自解釈される部分になり意味がありますが、とりあえずこの段階では無視で OK。

さ、というわけで、ここでやっと JavaScript が登場です。以下のコードでテンプレートを上書きし、右下の Save をクリックします。

コメント部分の簡単な説明:
@name に指定した文字列がファイル名になります。
@run-atdocument-start を指定することで、ウェブページが読み込まれたときに実行されます。
@include の行で、スクリプトを実行したいウェブサイト (*でワイルドカード指定) やページの指定ができます。4-5行目はサンプルとして Dify の IP アドレスと Copilot の URL を入れてありますが、日本語変換で確定するときのエンターキーで送信にならないようにしたいウェブサイトの URL 等に置き換えてください。
// ==UserScript==
// @name         Dify Copilot Enter Fixer
// @run-at       document-start
// @include      http://192.168.1.21/*
// @include      https://copilot.microsoft.com/*
// ==/UserScript==
document.addEventListener('keydown', function(event) {
    if ((event.key === 'Enter' && event.isComposing) || event.keyCode === 229) {
        event.stopPropagation();
    }
}, {capture: true});
セーブすると、コード内の @name がファイル名になって保存される

この状態で、Dify なり Copilot なりを開く (すでに開いていればリロードする) と、Userscripts アイコンに赤丸と数字が表示され、スクリプトが有効になっていることがわかります。また、アイコンをクリックすると有効になっているスクリプトの一覧が表示されるので、クリックしてグレーアウトすることで無効にもできます。

スクリプトが有効になっている状態

以上で最低限必要な設定は完了です。お疲れ様でした。

できるまではわかりづらかった

いじるところが色々あって、同じ様な設定も複数箇所でできたりして、本当にアドベンチャーゲームをやっているかのような感覚でした。それもあり、完成したときはうれしかったですね。同じ問題を抱えている Safari ユーザの皆様、ぜひご活用ください。

Image by Stable Diffusion (Mochi Diffusion)

手順もスクリーンショットも多くてアドベンチャーゲームの攻略記事を書いている気分になってきたのでこんな画像に。チャレアベのような「攻略本」をイメージしてたんですが、欧米にはあまり無いのかな。どちらかというとアドベンチャーゲームブックっぽくもありますが、かわいかったので、コレにきめた!

Date:
2025年2月18日 0:35:19

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
guide book of an adventure game

Exclude from Image:

Seed:
699570134

Steps:
20

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & GPU

めっちゃ面白いから絶対やって!日本語音声対話 AI の J-Moshi (Mac 対応版) でテキトーなノリ w のお姉さんとおしゃべり

「絶対やって!」とかこれまで書かないようにしてきたんですが、これはムリ。すごすぎる。オモシロ楽しすぎる。というわけで、名古屋大学さんが真面目に作られた (日本語に改良された) Full-duplex音声対話システム、「J-Moshi」のご紹介と Mac ローカルでの使い方の解説です。まずは公式にアップされているサンプルをいくつか聞いてください。

日本語Full-duplex音声対話システムの試作: https://nu-dialogue.github.io/j-moshi

ね?どうですかこの、テキトーに話を合わせて会話をする、まーまー年齢が上っぽい普通のお姉さん AI のコミュ力の高さ!ナチュラルさ!お互いのしゃべりが重なっても話し続ける体幹の強さ (全二重)!真面目に研究されたであろう最先端 AI による抜群のノリの軽さ!もう最高!これが自宅の Mac で実現できる!いやー、もう一度書いてしまう、絶対やって!

と言いつつ一回冷静に水を差しますが、商用利用は認められていませんし、悪用するのはもってのほか、研究や個人で遊ぶ用途でお使いください。ライセンスは CC-BY-NC-4.0 です。

まずは実際に試した感じ

どうしようかと思ったんですけど、せっかくなのでボクも適当に話を合わせて続けた 2:30 程の長さの会話を貼っておきます。ヘッドセットの関係でボクの声はあまり聞こえませんが、一応会話が成立しています。

お姉さんがしゃべってたテキスト (クリックで開く)

こんにちはー今日ねうーん1日1日が曇りだったんだよねー急にテンション上がっちゃうなんかこう蒸れるのとか苦手だからなんかこう寒いと蒸れるとか言ってたけど今日結構寒かったのにと思っていやほんと寒いよねーえっなんか寒いと寒いって言ってたんだけど全然寒くないよねあっほんとだよねだって今日はねちょっとぬるぬるしてるもんもうちょっと寒くなるかと思ってたけど全然もう寒さはありがたい感じだよねなんか暑いとうんなんかこう暑いともう吐いちゃうよねなんかこうースポーツとかしたい時とかにさーって言う人結構いるじゃんうんなんかこうエアコンとかつけっぱなしにちょっとぬるっとっていう感じでいつも着ちゃってるからさーってぬるぬるしてる寒いのはうんぬるぬるしてるあ確かにいいねなんかこう冷え冷えになっちゃいそうだけどえっでもさあっでも冷蔵庫ってやつあるよねほらその寒いときにねえ冷蔵庫ねえのねえ冷蔵庫ってやつだって多分冷蔵庫ってあったよねあったよね冷蔵庫なんかボーンっていうあっほんとだよそれいいかもなんかさー寒いときにさーってつけてるだけでさーっていう人もいるよねいるよねー私あれ駄目であっ本当あー確かに冷蔵庫苦手私も苦手あっそうかそうかそうかうんうんうんうんうんウフフあっ大丈夫大丈夫あっそうかそうかほんとだねそうだねなんかこう冷え冷えになっちゃったりなんか冷えたまんまの味がするんだよねーみたいなのは嫌だよねまあそれでもやっぱり冷蔵庫っていうのはいいなと思ってるんだけどあっそうそうそうそうそうそうそうそうそうだよねあれって結構あれなの冷蔵庫って結構高いんじゃないものねあれねなんかこうものあっそうなんだあっやばいやばいやばいじゃあちょっとこうねーちょっと欲しい人にアピールするわそんなん買ったらさーってそうそうそうそうそう何かこうさーそういうのはねできないからいいよねでもね冷蔵庫かって思うんだよねーでも冷蔵庫めっちゃお金かかるよねーそこがねーあるんだよね

正式には Mac 未対応ですが…

残念ながら Mac には対応していないと公式 GitHub リポジトリには書かれています。

実行には,24GB以上のVRAMを搭載したLinux GPUマシンが必要です.MacOSには対応していません.

https://github.com/nu-dialogue/j-moshi?tab=readme-ov-file

いやいやそんな、Linux で動くならイケるでしょ、と調べてみたらなんとかできました。いつものことですが、先人の皆様に感謝です。一部 Python スクリプトの変更が必要だったので、手順と併せて紹介します。

動いた環境のバージョンなど

  • macOS: Sonoma 15.3
  • python: 3.12.9 (brew install [email protected]でインストールしたもの。3.10 以上必須、3.12 推奨とのこと)
  • rust: 1.84.1 (brew install rustでインストールしたもの。以下に別のインストール方法も書いてます)
  • moshi-mlx: 0.2.1 (以下の手順でインストールします)
  • モデル: akkikiki/j-moshi-ext-mlx-q8 (VRAM 20GB で全く問題無く動きます。より小さな VRAM の場合は Q4 モデルも Hugging Face に公開されていますのでどうぞ。akkikiki さんに大感謝しましょう)

環境構築

ボクは仮想環境の構築にpipenvを使っていますが、普段お使いのでどうぞ。pipenv を使うなら、brew install pipenvで入ります。Python は 3.10 以上が入っていればそのバージョンを指定してください。

mkdir J-Moshi-MLX
cd J-Moshi-MLX
pipenv --python 3.12
pipenv shell
pip install moshi_mlx

PyPi の moshi_mlx によると、Python 3.12 以外では moshi_mlx のインストールの際にエラーが出る事があるらしく、解決するには Rust toolchain のインストールが必要と言うことです。必要に応じて対応してください。ボクは 3.12 を指定したからか、rust がインストール済みだったからか、エラーは出ませんでした。

Web UI を実行

上記で環境構築は完了です。問題無ければ以下のコマンドで Q8 の MLX 版モデルがダウンロードされて Web UI が立ち上がります。

python -m moshi_mlx.local_web --hf-repo akkikiki/j-moshi-ext-mlx-q8 --quantized 8

上のモデルでは大きすぎて VRAM に収まらないという場合は、Q4 量子化バージョンを試しても良いでしょう。ボクは試していないので精度の程はわかりません。

python -m moshi_mlx.local_web --hf-repo akkikiki/j-moshi-ext-mlx-q4 --quantized 4

モデルはいつもの場所にダウンロードされていました。いつか削除する時が来るかもしれないので、念のためパスを残しておきます:

~/.cache/huggingface/hub/models--akkikiki--j-moshi-ext-mlx-q8

エラーが出る場合は Python スクリプトを一部変更

環境構築は上で完了しているのですが、ボクの環境ではそのままでは動きませんでした。新しいバージョンでは修正されるかと思いますが、とりあえず web UI を実行してみて、エラーが出る場合は以下変更で動くと思います。

対象ファイル: .venv/lib/python3.12/site-packages/moshi_mlx/local_web.py

    #model.warmup()
    model.warmup(ct=None)

変更を保存したら、再度上に書いた Web UI の実行をしてください。参考のためエラーが出たときの実行例をそのまま貼っておきます。

% python -m moshi_mlx.local_web --hf-repo akkikiki/j-moshi-ext-mlx-q8 --quantized 8
[Info] [SERVER] loading text tokenizer /Users/handsome/.cache/huggingface/hub/models--akkikiki--j-moshi-ext-mlx-q8/snapshots/8b8d069a2bf3b73c4dcb45ae1481e797b8e4bae1/tokenizer_spm_32k_3.model
[Info] [SERVER] loading weights /Users/handsome/.cache/huggingface/hub/models--akkikiki--j-moshi-ext-mlx-q8/snapshots/8b8d069a2bf3b73c4dcb45ae1481e797b8e4bae1/model.q8.safetensors
[Info] [SERVER] weights loaded
Process Process-2:
Traceback (most recent call last):
File "/opt/homebrew/Cellar/[email protected]/3.12.9/Frameworks/Python.framework/Versions/3.12/lib/python3.12/multiprocessing/process.py", line 314, in _bootstrap
self.run()
File "/opt/homebrew/Cellar/[email protected]/3.12.9/Frameworks/Python.framework/Versions/3.12/lib/python3.12/multiprocessing/process.py", line 108, in run
self._target(*self._args, **self._kwargs)
File "/Users/handsome/Documents/Python/J-Moshi-MLX/.venv/lib/python3.12/site-packages/moshi_mlx/local_web.py", line 132, in model_server
model.warmup()
TypeError: Lm.warmup() missing 1 required positional argument: 'ct'

使い方

うまく動けば多分ブラウザで自動的に開くと思います。ターミナルにエラーは無いのにブラウザで開かないときは ↓ を開きましょう。

http://localhost:8998

ポート番号が既存のサービスとぶつかっていたら、起動コマンドに--port ポート番号を追加して使っていないポートを指定できます。問題無く起動している場合は、ターミナルにこんな表示がされると思います。

% python -m moshi_mlx.local_web --hf-repo akkikiki/j-moshi-ext-mlx-q8 --quantized 8
[Info] [SERVER] loading text tokenizer /Users/handsome/.cache/huggingface/hub/models--akkikiki--j-moshi-ext-mlx-q8/snapshots/8b8d069a2bf3b73c4dcb45ae1481e797b8e4bae1/tokenizer_spm_32k_3.model
[Info] [SERVER] loading weights /Users/handsome/.cache/huggingface/hub/models--akkikiki--j-moshi-ext-mlx-q8/snapshots/8b8d069a2bf3b73c4dcb45ae1481e797b8e4bae1/model.q8.safetensors
[Info] [SERVER] weights loaded
[Info] [SERVER] model warmed up
[Info] [SERVER] connected!
[Info] [CLIENT] received 'start' from server, starting...
[Info] retrieving the static content
[Info] serving static content from /Users/handsome/.cache/huggingface/hub/models--kyutai--moshi-artifacts/snapshots/8481e95f73827e4e70ac7311c12b0be099276182/dist
[Info] listening to http://localhost:8998
[Info] opening browser at http://localhost:8998

終了するときはターミナルで Control + C です。

^C[Warn] Interrupting, exiting connection.
[Info] All done!

実際の Web UI はこちら ↓

無事に立ち上がった様子。オリジナルの Moshi の説明文で J-Moshi とはなってませんが、これで大丈夫

必要に応じて [ Settings ] から設定の詳細が変更ができます。

Validate ボタンで変更を確定、もしくはそのまま戻る。Reset ボタンでデフォルトにリセット

メインの画面で [ Connect ] をクリックすると、おそらくマイクをブラウザで使用する許可を求められますので、許可しましょう。注意: ヘッドセット推奨です!

Safari の場合

後は適当に会話をしてみましょう。おそらくあなたが思う以上に中のお姉さんはテキトーで、そのうち話を切り上げて来たり、ハルシネーションして同じ事を繰り返したりもしますが、おおむね薄っぺらい会話を楽しく繰り広げてくれます。

表示されるのはお姉さんがしゃべったことだけ。誘い笑いにつられてしまう

会話は 5分が限度らしいので、それなりのタイミングで [ Disconnect ] ボタンで会話を終了すると、それまでの会話を音声かビデオでダウンロードできるようになります。ただ、ビデオにはお姉さんの文章が表示されるわけでも無いので、保存する場合は、Download audio で音声 mp4 のダウンロードで良いと思います。

Download audio で音声を保存。お姉さんのしゃべっていることを見ると、適当さがよくわかる

いや、ホント楽しい

これはね、正直本当にすごい。生成 AI の楽しさや可能性を改めて感じました。

ボクが初めて生成 AI をいじった時って、使い方がわからないから「西野七瀬ちゃんが乃木坂を卒業した理由を教えて」とか聞いてみたんですね。すると「音楽性の不一致です。その後アーティストとして独立し、先日ファーストシングルを発表しました」とか言われて、なんだこりゃ生成 AI って使えねーじゃん、と思ってしまいました。で、その経験をふまえて音声で会話ができるこの J-Moshi はどうなのかと言うと、むしろ AI のテキトーさが楽しく、さらに音声品質の高さと相まって普通に受け入れてしまいました。っていうか、いっぺんに好きになっちゃいました!

少し話はそれますが、今日の日中は仕事で調べたいことがあったので、インストールしたもののあんまり使っていなかった DeepSeek-R1:32B に気まぐれで色々と Nginx 関連の相談してみました。その結果回答精度の高さに感心し、もはや Reasoning モデル以外のモデルは使えないと感じてしまいました。せっかく買った深津さんのプロンプト読本で書かれている、それまでは常識だった「生成 AI は、次に来そうな文章を確率で答えるマシン」を超えてしまっているんですね。ほんの数ヶ月しか経っていないのに。

で、同じ日の夜に試した J-Moshi ですが、改めて AI の進歩の速さに驚き、それまでの王道やスタンダード、ベストプラクティス、パラダイムその他もろもろが一瞬で過去のものになる感覚を体感しました。M1 Mac が登場した時にリアルタイムに世の中が変わるのを肌で感じた、あの感覚の再来です。

もうほんと、M シリーズの Mac をお持ちでしたら、ゼヒやってみてください。実質タダだ (電気代以外かからない) し、実用性はどうかわかりませんがとにかく楽しいですよ!(真面目に考えたら実用性も色々ありそうです)

注意: 音声やしゃべり方がリアルなだけに、何かの拍子に同じ言葉を大量にリピートしたりされると結構な不気味さや恐怖を感じます。テキストベースの LLM である程度のハルシネーションに慣れている方の方が安全に使えるかもしれません。

Image by Stable Diffusion (Mochi Diffusion)

「日本人女性が電話で楽しそうにしゃべっている」画像を作ってもらいました。使っているモデルの関係で、日本人は大体同じ様な女性が生成されます。今回は割と早めにいい感じの女性が現れたので、ブキミを避けるためにステップ数を調整して完成しました。電話機の不自然さには目をつむり、女性の表情の自然さを重視しています。

Date:
2025年2月8日 2:01:17

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
Japanese woman on the phone having a happy conversation

Exclude from Image:

Seed:
3240758836

Steps:
27

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & GPU

Ollama の高速化と VRAM 最適化設定 (ファインチューニング: 2)

2025年 1月現在、Ollama では試験的に利用が可能になっている高速化および VRAM 最適化の設定があります。近いうちにどちらも標準設定になりそうな雰囲気もありますが、執筆時の最新バージョン0.5.7ではユーザが設定してあげる必要があるので、その方法を共有します。

Apple Silicon Mac (M シリーズ CPU) でローカル LLM を使用している方は、前回の記事もご覧ください。Mac の GPU に自由にメモリを割り当てる方法を紹介しています。

英語ページが開いたら、右のアイコンから日本語を選んでください。すみません。

とりあえず環境

Ollama に施す設定なので OS には依存しないはずですが、設定方法は macOS にしか触れていません。また、インストール方法も、ソースコードをビルドするとか、brew で入れるとか、Docker で実行するとかあるみたいですが、アプリ以外の設定方法でどうするのかは知りませんのでお調べください。ごめんなさい。

オフィシャルの情報参照元

Ollama FAQ:

Ollama に K/V cache 機能を PR したコントリビュータの方のブログ:

ファインチューニング (2) Flash Attention で VRAM 使用量を抑え計算速度も上げる

上に貼ったボクの前回のブログに書いた方法が (1) なので、こちらは (2) から始めます。

まずは、Ollama で Flash Attention を有効にします。Flash Attention は VRAM の使用量を抑え、LLM の計算速度も上げてくれます。いろいろなところで説明されていますが、この機能を有効にする事によるネガティブな影響は無いようです。3倍速くなったという検証結果もあるらしいですが、ま、そこまでとは言わないまでも、良い効果しか無いならやらない理由は無いですね。 Ollama でも将来的にデフォルトで有効になりそうですが、今のところは自分で有効にしてあげる必要があります。Mac ならターミナルで以下コマンドを実行してください:

launchctl setenv OLLAMA_FLASH_ATTENTION 1

無効にする (元に戻す) なら、上記の値を1から0にします。現在の設定を確認するには、getenvコマンドを実行します。以下、有効になっている場合の実行例で、1が返ってきています。

% launchctl getenv OLLAMA_FLASH_ATTENTION
1

ファインチューニング (3) K/V cache の量子化でコンテキスト長を抑える

K/V cache の量子化とは、コンテキストのキャッシュを量子化することで以降の計算効率を高め、必要なメモリも抑えるというような技術らしいです (K/V context cache 等と書かれていることもあります)。ファインチューニング (1) では LLM を載せるための VRAM を増やすことで大きなモデルやコンテキスト長を扱えるようにしましたが、K/V cache は、モデルの実行時に必要となるメモリの使用量を抑えることで、同じ様な事を実現します。また、モデル自体の量子化は 8bit であれば性能の低下は小さく速度を向上できるように、K/V cache の量子化もコンテキストキャッシュのサイズに対して同様の効果が望めます。K/V cache に 8bit の量子化を施した場合、必要なメモリの量は量子化しない場合の半分程になるため、使用できるコンテキスト長を倍に増やすことができます。

こちらの機能は現在 Ollama では Experimental (実験的導入) という表現がされており、エンベッドモデル (Embedding models)、ビジョン・マルチモーダルモデル、アテンションヘッドが高いタイプのモデルでは性能の低下が結果に影響する可能性がありうるとのことです。なので Ollama は Embed モデルを検知した際には自動的に無効化するらしいです。ということですので、本設定はモデルとの相性問題があり得ると理解し、試してみた上で性能が下がるようであれば無効にしておくのが良いでしょう。残念ながら今のところモデル毎に設定する方法はありません。

さて設定方法ですが、量子化の選択肢には、8bit (q8_0) と 4bit (q4_0) があるのでどちらかを選びます (デフォルトは量子化無しのf16)。4bit にした場合、メモリ削減効果は大きいですがその分性能も下がるため、これまで GPU だけでは動かせなかったモデルを使うというような場合以外は 8bit を選びましょう。また、前提として Flash Attention の有効化が必要ですので、上に書いたファインチューニング (2) を実行してから進めてください。Mac でのコマンドは以下となります (8bit の場合):

launchctl setenv OLLAMA_KV_CACHE_TYPE "q8_0"

デフォルトに戻す場合は"f16"、4bit にするなら"q4_0"を値に指定して実行します。現在の設定を確認する方法と実行例は以下となります:

% launchctl getenv OLLAMA_KV_CACHE_TYPE
q8_0

また、設定後に Ollama でモデルを実行してログを確認すると、量子化とキャッシュのサイズが確認できます。以下の例では、途中までデフォルトのf16となっており、変更後はq8_0になっていて、全体的にサイズが減っているのがわかります。

(2025/02/16: コマンドを修正しました)

% grep "KV self size" ~/.ollama/logs/server2.log|tail
llama_new_context_with_model: KV self size  = 1792.00 MiB, K (f16):  896.00 MiB, V (f16):  896.00 MiB
llama_new_context_with_model: KV self size  = 1536.00 MiB, K (f16):  768.00 MiB, V (f16):  768.00 MiB
llama_new_context_with_model: KV self size  =  512.00 MiB, K (f16):  256.00 MiB, V (f16):  256.00 MiB
llama_new_context_with_model: KV self size  = 1792.00 MiB, K (f16):  896.00 MiB, V (f16):  896.00 MiB
llama_new_context_with_model: KV self size  = 1792.00 MiB, K (f16):  896.00 MiB, V (f16):  896.00 MiB
llama_new_context_with_model: KV self size  =  952.00 MiB, K (q8_0):  476.00 MiB, V (q8_0):  476.00 MiB
llama_new_context_with_model: KV self size  =  952.00 MiB, K (q8_0):  476.00 MiB, V (q8_0):  476.00 MiB
llama_new_context_with_model: KV self size  =  680.00 MiB, K (q8_0):  340.00 MiB, V (q8_0):  340.00 MiB
llama_new_context_with_model: KV self size  =  816.00 MiB, K (q8_0):  408.00 MiB, V (q8_0):  408.00 MiB
llama_new_context_with_model: KV self size  = 1224.00 MiB, K (q8_0):  612.00 MiB, V (q8_0):  612.00 MiB

設定を永続的にする

上記 2つの設定方法では、Mac を再起動後に初期化されてしまいます。Mac にログインするたびに、またはスクリプトを実行したときにこれらのファインチューニングを行った状態で Ollama を起動するには、以前書いたブログ記事にある「Ollama を自動的に LAN に公開」の手法が良いと思います。

スクリプトの中身を以下の様に変更してください。それ以外は同じ手順でアプリの作成と起動項目への追加ができます。Ollama を実行するときは常にこのスクリプト (アプリ) を実行することで、設定が適用されます。

do shell script "launchctl setenv OLLAMA_HOST \"0.0.0.0\""
do shell script "launchctl setenv OLLAMA_FLASH_ATTENTION 1"
do shell script "launchctl setenv OLLAMA_KV_CACHE_TYPE \"q8_0\""
tell application "Ollama" to run

超便利!自分の VRAM で使えるモデルとコンテキストサイズを調べるツール

上でも紹介した Ollama に K/V cache 機能を追加するプルリクエストをした方のブログに、Interactive VRAM Estimator という便利ツールが貼られています。使いたいモデルのパラメータ数 (Model Size)、量子化 (Quantization Level)、そして使いたいコンテキスト長 (Context Size) の組み合わせで、KV cache の量子化毎 (F16, 8bit, 4bit) に必要となる VRAM の見込みサイズが表示されます (Estimator = 見積機)。

例えば、QwQ:32B-Preview-Q4_K_M の場合、32BQ4_K_M を選びます。そして今回 Q8_0K/V cache を設定したので緑のグラフの Total をにらみながら Context Size を選ぶと、実行するために必要な VRAM のサイズがおおよそわかります。

16K tokens なら 21.5GB に収まる、とわかる

32K (= 32768) だとボクの Mac の VRAM 24GB を超えしまうので、もうちょっと攻めた数字を出すために右上の Advanced モードを有効にします。Q8_0 の Total を見ながら Context Size スライダをいじると、24K (24 * 1024=24576) で 23GB RAM に収まりそうだということがわかりました。

というわけで、Dify で作った生成 AI アプリの Size of context window に 24576 を入れてチャットしてみた時のollama psの結果が下のスクリーンショットです。見事に 100% GPU で処理されています。勝利ですね。

ちなみに Dify でいじるところは、作った AI アプリのモデルを選んだここです:

ファインチューニング前はたしか 4k とかでやってました

最後に雑記

前回と今回の記事で、LLM を実行する環境側のファインチューニング方法を紹介しました。ボクは 32GB のユニファイドメモリしかないのでうまくやりくりしないとローカル LLM を有効活用できない環境にあり、毎度苦労したり工夫したりしながらなんとかやっています。そんな中でいくつか効果的な方法が確認できたので、まとめた次第です。

実行速度に関する調査はしていませんので、そのあたりは実際にお試しください。少なくとも LLM が必要とするメモリや 100% VRAM に収める方法を理解・実践するだけで、最近のモデルは結構実用的な速度で楽しめることがわかると思います。

正直言って 16GB でローカル LLM をあれやこれやするのはきびしいと思います。逆に、128GB あるならこれらのファインチューニングで、ローカル LLM を並列で動かすこともできますね。

最近中国企業のモデルの性能の高さが大きく評価されていながらも、情報流出を懸念して利用禁止という話も出ています。ローカルで実行すればその心配も無いので好きに試せますよ。個人的には、出たばかりのおフランスのモデル mistral-small:24b の性能の高さとレスポンスの速さが気に入っています。中国産モデルのような、中国語や中国の漢字が出てこないのも (すごく) 良いですね。QwQ の Preview が取れた正式版はいつか出るのでしょうか。

Image by Stable Diffusion (Mochi Diffusion)

単純に、荷物をいっぱい積んだラマを描いてもらいました。最初は Mistral-Small 24B にイメージを伝えてプロンプトを作ってもらったんですが、全然ダメでした。どうやら色々余計なことを書くよりも、とにかく必要な要素だけ書いて、後は何度も出力させた方がそれっぽいものが生まれるという感じがしてきました。

Date:
2025年2月2日 1:55:30

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
A Llama with heavy load of luggage on it

Exclude from Image:

Seed:
2221886765

Steps:
20

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & GPU

© Peddals.com