gpt-oss の Reasoning レベル変更を MLX-LM でもできるようにする魔改造

gpt-oss が出てからというもの、MLX-LM サーバの改造を続けていますが、今回は Dify や Open WebUI で Reasoning レベル (High、Middle、Low) の指定ができるようにしました。だってシステムプロンプトに入れようが Open WebUI のカスタムパラメータとして指定しようが全然反映されなかったので。

Reasoning の値を gpt-oss へ渡すのは MLX-LM の仕事であるはずなので、そういう意味ではあるべき姿に矯正したと言えなくもないです (エラそう)。ただ今回も、「ムリヤリだが動けばいい」状態なので、中の人のお手間を取らせる PR とかは恥ずかしくてできません。

今回のコードはこれまでの改造をベースにしています。過去の改造記事のリンクを以下に置いておきますので未読の方はぜひどうぞ。

↓ 回答が途中で止まる、2回目以降のターンがエラーになる、の二つに対処:

↓ 回答の前の思考部分を <details> タグで隠す (改造は主に Dify 向け):

注意事項的な

本来クライアント側で行うべき事とサーバ側で行うべき事を全てサーバで処理しています。とりあえず動くようになったコードだけをこのページに置いておきますので、どうぞ必要な方だけ、見たりパクったりそのまま使ったりしてください。他のモデルに悪影響がある場合は、ポートを指定して別サーバとして立てるなどしてください (gpt-oss 20b 以外では、Qwen3 30B A3B Thinking でのみテスト済み)。

ご自身の環境で使う場合は、必ず元のserver.pyをコピーしておいてください。

コードはこちら

★ 改造済みスクリプト server.py 全てを見るにはここをクリック ★ (1247行あります)
# 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

# --- ここから追加 ---
import re
# -- ここまで ---


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()

        # --- ここから追加 ---
        self.reasoning_effort = "medium"
        # --- ここまで ---

        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)
        # --- ここから追加 ---
        requested_effort = self.body.get("reasoning_effort", None)
        if requested_effort is not None:
            self.reasoning_effort = requested_effort
        # --- ここまで ---

        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)
        )

        # --- ここから追加 ---
        requested_effort = self.body.get("reasoning_effort", None)
        if requested_effort is not None:
            self.reasoning_effort = requested_effort

        # --- ここまで ---
        
        # 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")

        # --- ここから追加 ---
        if self.reasoning_effort is not None:
            valid_efforts = ["low", "medium", "high"]
            if not isinstance(self.reasoning_effort, str) or self.reasoning_effort.lower() not in valid_efforts:
                logging.warning(f"Invalid value '{self.reasoning_effort}' for reasoning_effort. Defaulting to 'medium'.")
                self.reasoning_effort = "medium"
            else:
                self.reasoning_effort = self.reasoning_effort.lower() # 一貫性のために小文字に変換
        # --- ここまで ---

    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"

        # --- 1. システムプロンプトから `reasoning_effort` を最初に抽出 ---
        reasoning_level_from_prompt = None
        for message in body["messages"]:
            if message.get("role") == "system":
                matches = re.findall(r"Reasoning:[\s\n]*(\w+)", message.get("content", ""), re.IGNORECASE)
                if matches:
                    # 最後にマッチした値を採用
                    reasoning_level_from_prompt = matches[-1]
                    break
        
        # 2. 抽出した値を `self.reasoning_effort` に設定し、デフォルト値を上書き
        if reasoning_level_from_prompt is not None:
            self.reasoning_effort = reasoning_level_from_prompt
        
        # --- ここから他のメッセージ処理ロジック ---
        if self.tokenizer.chat_template:
            messages = body["messages"]

            # `assistant`メッセージを処理するカスタムロジック
            # この処理はシステムメッセージに影響しないため、ここに配置します
            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}")
                            message["thinking"] = ""
            
            # メッセージコンテンツが確定した後に呼び出す
            process_message_content(messages)

            # 3. `chat_template_args`を作成し、`reasoning_effort`を渡す
            chat_template_args = self.model_provider.cli_args.chat_template_args.copy()
            if self.reasoning_effort is not None:
                chat_template_args["reasoning_effort"] = self.reasoning_effort

            prompt = self.tokenizer.apply_chat_template(
                messages,
                body.get("tools") or None,
                add_generation_prompt=True,
                **chat_template_args,
            )
        else:
            # 非チャットテンプレートモデルの既存ロジック
            prompt = convert_chat(body["messages"], body.get("role_mapping"))
            prompt = self.tokenizer.encode(prompt)

        # --- ここから追加 --- 
        # ログに Reasoning の設定を書き出す
        logging.info(
            #f"Request {self.request_id} for model '{self.model_provider.model_name}' "
            f"Reasoning Effort '{self.reasoning_effort}'"
        )
        # --- ここまで ---

        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()

改造の元にしたバージョンはこちら:

  • MLX: 0.29.0
  • MLX-LM: 0.27.0

実際に改造済みコードを使えるようにする手順は以前の記事にそれなりに細かく書いたので、そちらを参照してください。基本的には単純にコードをコピーして MLX-LM のserver.pyを上書きするだけです。

Dify や Open WebUI からの使い方

システムプロンプトにReasoning: High (選べるオプション: High/Middle/Low) 等と入れてあげれば指定に従って推論してくれます。指定しなければMiddleで、大文字小文字は区別しません。

Dify では、割といい感じで回答してくれる他のパラメータの設定と併せてこんな感じ ↓ で使ってください。

MLX バックエンドなので Max Tokens は最大でも当分は大丈夫

Open WebUI だと特に繰り返しの文章が頻発するので、パラメータを結構いじっています。いくつかはデフォルト値と同じで、いくつかは OpenAI の推奨設定です。

効果

確実に推論 (reasoning/thinking) に使われるトークン数は High が多く、Low だとほとんど使いません。つまり、High にしたほうが回答の出力が始まるまでじっくり考えています。

Token/sec (トークン数/秒 = 出力の速さ) ということで言えばどれもだいたい 80 T/S を超えてくるので大きな違いはありません。質問を投げてから回答が終わるまでの時間は当然 Low が一番速くなりますが、非 reasoning/thinking 系モデルのようにすぐに回答が始まるわけではなく、質問の難度によってある程度の推論は行われます (Qwen3 32B 等の/no_thinkとは違い、あくまでもLow)。

回答の質はどうかというと、試した感じでは確かに High にした場合が一番良い回答を得られています。Middle ではいくらラウンドを繰り返して説得しても「10cm」と回答してくれなかった下の質問ですが、High にすると毎回では無いものの「10cm」と回答することがあるようになりました (Temperature や Top_P の値が大きいので、回答内容には結構幅がある)。

厚さ 10cm の綿飴の上に厚さ 10cm のレンガを載せたときの合計の高さを教えてください。物理世界で最も起こり得る可能性を重要視してください

ただ、原因は不明ながら High と Low だと日本語が若干怪しくなるときがあります。

なぜそこまでして gpt-oss を使うのか

リリース当初こそ多くの gpt-oss 関連記事が見られましたが、性能が見限られたのか最近ほとんど使っているという情報を目にしなくなりました。ググってもボクのブログ記事が上位に来てしまう状況です。実際自分で使っていても、上に書いた質問にはいくら説得しても答えられないし、頻繁に同じ事を吐き続けるループに陥るし、という感じで、速いだけのじゃじゃ馬感がありました。

それでも LLM の性能比較によく見に行っているサイト ( ↓ ) では gpt-oss-20B (high) が 20-30 クラスの LLM では悪くない位置にいます。なので、諦めるにしても性能面でのポテンシャルをもう少し自分で確かめたかったというのが大きいですね。

Comparison of Models: Intelligence, Performance & Price Analysis

あとは、検閲の入っていないオープンで性能の良いモデルを使いたいというところでしょうか。ま、ここはみなさん色々と意見がありそうですが。

とまれ、これまでの改造で Mac に最適化された gpt-oss のポテンシャルを試す下地はほぼできたんじゃないでしょうか。32 GB ユニファイドメモリの Mac で動くローカル LLM はどれがちょうどいいのか、色々と使いながら試していこうと思います。

あ、今回の改造も最終的には無料版の Gemini (2.5 Flash) に手伝ってもらいました。無料版の ChatGPT はあんまりよくないですね。チャットをまたいだ記憶とか別にいらんのよな。

Image by Stable Diffusion (Mochi Diffusion)

「手綱をつけられたじゃじゃ馬」のイメージでお願いしてみました。OpenAI のロゴは白いイメージだったのですけど、調べたら緑や紫のもあるんですね。白い馬にしましたけど。

Date:
2025年9月15日 21:32:03

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
a white wild horse with reins

Exclude from Image:

Seed:
38536801

Steps:
20

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & GPU

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 に効いているのかはよくわかりません。

(2025/09/15 追記) 新たなる魔改造を持って MLX-LM でReasoning: Highを効かせることに成功しました → gpt-oss の Reasoning レベル変更を MLX-LM でもできるようにする魔改造

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: 0.27.0です。

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

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

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

スクリプト全てを見るにはここをクリック ★ (1194行あります)
# 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/09/16 追記) MLX-LM のサーバスクリプトをさらに書き換えて Dify や Open WebUI から Reasoning: High とかもできるようにしました。記事はこちら:

(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

Python の pipenv 環境で専用の alias や export を読み込む

本記事の内容をより正確に書くと「zsh (bash) が読み込まれたときに、カレントディレクトリにあるaliasexportの書かれた設定ファイルを自動で読み込ませる」方法です。Python の仮想環境pipenvは新しいシェルを読み込むので、結果としてalias等を自動的に設定することができます。pipenv環境から抜けると仮想環境内の設定が無効になるため、素や他の環境に影響を与えません。簡単に実現できますが、pipenvを使っている人が少ないのかズバリの情報が見つからなかったのでまとめました。

環境

シェル:zsh (bashでもできるらしいですが未確認です)

Python 仮想環境:pipenv

手順

macOS でpipenvを使うには、まずbrew install pipenvでインストールします。簡単に仮想環境を作る手順はこんな感じです:

mkdir my_project # プロジェクトディレクトリを作る
cd my_project # プロジェクトディレクトリに入る
pipenv --python 3.11 # Python 3.11 が入った仮想環境を作る
pipenv shell # 仮想環境に入る。出るときは exit または ctrl + D

~/.zshrc に一文追加

自分のホームディレクトリにある.zshrcに以下を追加します。コメント文は無くて構いません。

# カレントディレクトリに .zshrc.local ファイルが存在する場合は読み込む
[[ -f .zshrc.local ]] && source .zshrc.local

内容としては、&&の左の部分が条件式で、カレントディレクトリに.zshrc.localファイルが存在しているか調べています。真であれば右のsourceコマンドが実行され.zshrc.localファイルを読み込みます (試してませんが、書式としてはbashでも同じ方法でイケるらしいです)。(2025/07/02 訂正) Bash は上の書式が使えないので、以下の様にしてください。仮想環境内のファイルは.bashrc.localとしています。

if [ -f .bashrc.local ]; then
    source .bashrc.local
fi

pipenv のルートディレクトリに .zshrc.local ファイルを書く

その仮想環境内でのみ有効にしたいaliasexport、その他.zshrcに書けることはもちろん何でも書けます。とりあえず簡単なサンプルは以下の通りです:

alias t='time'
export HW="Hello, World!"

仮想環境に入り、試す

以下、実行例です:

$ pipenv shell # 仮想環境に入る
$ t # alias で登録した time コマンド
(time コマンドの実行結果が表示される)

$ echo $HW
Hello, World!
(export で登録した文字列が表示される)

仮想環境から出て、試す

以下、実行例です:

$ exit # または ctrl + D で仮想環境を抜ける
$ t
zsh: command not found: t
(t というコマンドはない)

$echo $HW

(空行が表示される)

注意点

GitHub 等に公開するプロジェクトでは.gitignore.zshrc.localを忘れずに追加しましょう。

venv でもほぼ同様のことをする

Python の標準的仮想環境ツールvenvでは新たにシェルを読み込まれません。よって、別の方法で同様の事を実現します。

bin/activateの最終行に以下を追加します。~/.zshrcに書いたものと同じです。

# カレントディレクトリに .zshrc.local ファイルが存在する場合は読み込む
[[ -f .zshrc.local ]] && source .zshrc.local

pipenvのやり方よりひと手間増えますが、これで一応同じ様な事ができます。

venv での違い、注意点

上記の方法では、シェルの再読み込みはせずに.zshrc.localを読み込んでいるので、deactivatevenv環境を抜けた後もエイリアスや環境変数が有効になっています。同一のターミナルで仮想環境を抜けた後も別の作業を続けることがよくあるという方は普段の環境変数などが上書きされている可能性があるので注意が必要です (ターミナルを閉じるのが手っ取り早い)。

なぜこんなことが必要だったのか

ボクは最近、mlx-lm.serverでサーバを立てて MLX 版 LLM を使うのですが、Ollama と違ってメモリが解放されない (メモリプレッシャーが高止まり状態になる) ことがちょいちょいあります。仕方が無いのでつど ctrl + C で止めて再度コマンドからサーバを立てるのですが、他のターミナルでコマンドを叩いていたりするとカーソルキーの上ですぐに呼び出せずストレスを感じていました。そこで、pipenvの環境内でのみ有効なaliasを作れないかと思った次第です。

ネット上では想像したほど簡単にその方法が見つからず、ローカルの QwQ や Qwen3、ChatGPT にも相談しながら最終的には自分で解決方法にたどり着きました。それぞれの LLM に評価をお願いしたところ「すばらしい!」と褒めてくれたので、うれしくてブログにまとめました。わはは!

Image by Stable Diffusion (Mochi Diffusion)

この記事にどのような画像が合うのかイメージか浮かばず、とりあえずいろんな自転車のあるショールームを描いてもらいました。依頼内容も画像もこれが正解なのかいまだにわかっていませんけど。

Date:
2025年6月14日 19:47:15

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
showroom with different types of bycicles

Exclude from Image:

Seed:
1251791658

Steps:
20

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & GPU

pipenv –python 3.x コマンドで仮想環境を作れず、エラーも無い、という時の解決方法

新しく作ったディレクトリでpipenv --python 3.13等と叩いたときに、仮想環境が作られない時の解決方法です。エラーは無く、ただ終了してしまう、という状況です。

Python のバージョンは何を指定しても結果は同じ。pipenv shellで構築済みの環境には入れる。pipenv --helppipenv --versionは問題無く動く。PC/Mac の再起動、pipenv のアップデート、どれを試しても変化無し。Pipenvでよく出喰わす問題やローカル LLM、Google 先生に聞いてもこれと言った原因が見つからない。というような状況でした (「出喰わす」は元のページの書き方に従ってます)。

原因

原因は、何かの手違いで上位のディレクトリに仮想環境が作られていたから、でした。仮想環境の中に別の仮想環境は作れないので、pipenv --pythonは失敗していたと言うことのようです。--verboseを付けてもエラーは出ず、自分のミスとはいえ、何かヒントをくれても…と思ってしまいました (pipenv は brew でインストールした version 2024.4.1)。

確認方法

pipenv --venvで、作成済み仮想環境の.venvのパスが表示されます。クリーンな状態であれば以下の様に、環境が無いよ、と表示されます。

% pipenv --venv
No virtualenv has been created for this project/Users/handsome/Documents/Python/NewDir yet!
Aborted!

逆に、構築済みの場合はパスのみが表示されます。ボクの場合は新しく作った NewDir の上のディレクトリに環境があったわけです。

% pipenv --venv
/Users/handsome/Documents/Python/.venv

解決方法

  1. 既存の pipenv 環境下に無いディレクトリに仮想環境を作る
  2. 不要な pipenv 環境を削除する

たいていの場合は 1だと思いますが、ボクのケースでは間違えて作ってしまった環境を削除する必要があったので、以下手順で解消しました。

cd .. # pipenv を削除する親環境へ移動
pipenv --rm
rm Pipfile* 

あまり pipenv の仮想環境だけを作り直すことが無かったので知りませんでしたが、Pipfile (と Pipfile.lock) はpipenv --rmでは削除されないので、手動で削除する必要がありました。

普通はあり得ないミス

ボクの場合、親ディレクトリに Python 3.6 の環境が作られていました。Pipfile のタイムスタンプから 2ヶ月前に作られた様ですが、なぜ 3.6 の環境が 2024年末に必要だったのか全く思い出せません。Python のバージョンを指定しないとエラーになるし、pipenv shell とするとボクの環境では Python 3.13 がインストールされるので、本当に謎です。

ともあれ pipenv が何らかのエラーを吐いてくれたらもっと早く解決できたのに、と思ってしまいました。というわけで、このページにたどり着く人はいないかもしれませんが、自戒の意味も込めて残しておきます。

Image by Stable Diffusion (Mochi Diffusion)

当初「家の建て方を忘れた大工さん」みたいなイメージを考えていたのですがうまく指示出しできなかったので、「住宅街の更地」にしてみました。わかりづらいですけど。

Date:
2025年2月9日 13:57:59

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
an empty lot between american style houses

Exclude from Image:

Seed:
1751847373

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

MLX-LM で QwQ-32B-Preview を API ストリーミング (Ollama より速い)

Ollama のこちらの issue に、MLX バックエンドのサポートをおねだりするコメントがたくさんあり、中には llama.cpp (GGUF) より 20~40% も高速という書き込みがあったのでお気に入りの QwQ-32B-Preview MLX 版を試してみました。OpenAI の o1 の様に試行錯誤を繰り返して回答の精度を高めるオープンなモデルです。結論から言うと、確かに少し速いです。該当のコメントを書き込んだ人は M3 ということなので、M4 を含んだ新しめチップ搭載 Mac ならもっと違いが実感できるのかもしれません。せっかくなのでやり方を残しておきます。

ところで、↓ これって Ollama 公式の X ポストなんですかね? Ollama で正式に MLX バックエンドがサポートされる匂わせとも取られています。

2025年6月22日追記: Ollama 0.9.2 でも MLX はサポートされていません。MLX と Ollama (GGUF) の簡単な比較を行った記事も書いたのでどうぞ:

MLX って?

ざっくり言うと、Apple 社純正の、Apple シリコン用機械学習フレームワークです。GPU だけではなく、CPU も使えるみたいです。性能は、100% というわけではなさそうなんですが、いろいろな実験をされた方々の記事を見てみると PyTorch で MPS を使用したときより速いというケースもあるらしいです。

MLX 公式 GitHub: https://ml-explore.github.io/mlx/build/html/index.html

詳細はブラウザの翻訳機能でも使って読んでください。Safari ならアドレスバーの右にあるこのアイコンで日本語に翻訳できます:

というわけで、「MLX 版 LLM」なんて呼んでいるのは、上記 MLX で動くように変換された、オープンな LLM のことです。

MLX-LM とは

MLX で動くように変換された LLM の実行環境です。言語モデルのみ対応しています。実行の他、Safetensor 形式 (HuggingFace にあるオリジナルのモデルはだいたいコレですね) を MLX 形式に変換したり、API サーバを実行する機能もあります。今回の記事では、API サーバとして利用する方法を紹介しています。

MLX-LM 公式 GitHub: https://github.com/ml-explore/mlx-examples/blob/main/llms/README.md

似たような実行環境に MLX-VLM というのもあり、こちらは Pixtral や Qwen2-VL 等のビジョンモデルに対応しています。

MLX-VLM 公式 GitHub: https://github.com/Blaizzy/mlx-vlm

上記二つの MLX-LM と MLX-VLM 両方の API サーバとして動作する FastMLX という Python パッケージもあり機能的には魅力的なのですが、ビジョンモデルは画像の URL かパスしか受け付けなかったり (Dify では使えない)、テキストのストリーミングがうまくいかず例外が出たりして結構工夫しないと使えなさそうなので、これまでのところくじけてます。興味がある方はどうぞ。

FastMLX 公式 GitHub: https://github.com/arcee-ai/fastmlx

LM Studio でも使えます

LM Studio は MLX モデルが使えるので、別に Dify 使わない・使っていないという方はこれ以上読み進めなくて大丈夫です。また、Dify には OpenAI API コンパチのモデルプロバイダとして LM Studio を登録して使うこともできますが、LLM からのレスポンスがなめらかにストリーミングされません。特に日本語の部分は結構な量がまとまって表示されるケースが多いです。なので、Dify から API で MLX の LLM を使うなら、MLX-ML のサーバ機能を利用するのが良いかと思います。

以下、内容はビジョンモデルですが、LM Studio の API を Dify から使う方法を別の記事にしてあるので参考にしてみてください。

MLX-LM で API サーバを立てる

インストール

MLX-LM を利用するには、まず仮想環境に MLX-LM をインストールしてください。今回使ったバージョンは、最新の0.20.4でした。

pip install mlx-lm

一度サーバを立てる

サーバを立てるにはmlx_lm.serverコマンドを使用します (実コマンドはインストール時のハイフンと違いアンダースコアなので注意)。Dify (や他の API クライアント) が別のホストで動いているとか、他のサーバがポートを使用している等という場合は、下の例のようにオプションを指定してあげます。ボクは Dify が別の Mac で動いていて、テキスト読み上げ (text-to-speech) サーバが動いていたりするので、それぞれを指定しています。オプションの詳細はmlx_lm --helpを見てください。--log-levelは付けなくても問題ありません。

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

こんな表示が出れば動いているはずです。

% mlx_lm.server --host 0.0.0.0 --port 8585 --log-level INFO
/Users/handsome/Documents/Python/FastMLX/.venv/lib/python3.11/site-packages/mlx_lm/server.py:682: UserWarning: mlx_lm.server is not recommended for production as it only implements basic security checks.
  warnings.warn(
2024-12-15 21:33:25,338 - INFO - Starting httpd at 0.0.0.0 on port 8585...

モデルをダウンロードする

ボクの場合 32GB の RAM で動いてほしいので、4bit 量子化されたもの (18.44GB) を選んでいます。

HuggingFace: https://huggingface.co/mlx-community/QwQ-32B-Preview-4bit

MLX-LM サーバが動いている状態で別のターミナルウィンドウを開き、以下の様な簡単なスクリプト書いて保存し、Python で実行してモデルをダウンロードします。

import requests

url = "http://localhost:8585/v1/models"
params = {
    "model_name": "mlx-community/QwQ-32B-Preview-4bit",
}

response = requests.post(url, params=params)
print(response.json())
python add_models.py

ダウンロードが完了したら Ctrl + C でサーバを一度終了します。あ、ちなみにこの方法でダウンロードしたモデルは、LM Studio からも読めますので、どちらも試す場合はコマンドで入れる方法が容量削減になります (ただしフォルダ名は人にきびしい)。

モデルを指定して API サーバを立ち上げる

モデルの保存場所は~/.cache/huggingface/hub/の中で、今回の例ではmodels--mlx-community--QwQ-32B-Preview-4bitというフォルダになります。サーバを立ち上げるコマンドに渡すパスはさらに深く、snapshotの中、config.jsonファイルが含まれるフォルダとなります。そちらを指定して API サーバを立ち上げるコマンドはこんな感じです:

mlx_lm.server --host 0.0.0.0 --port 8585 --model /Users/handsome/.cache/huggingface/hub/models--mlx-community--QwQ-32B-Preview-4bit/snapshots/e3bdc9322cb82a5f92c7277953f30764e8897f85

無事にサーバが上がると、ブラウザからダウンロード済みモデルが確認できます: http://localhost:8585/v1/models

{"object": "list", "data": [{"id": "mlx-community/QwQ-32B-Preview-4bit", "object": "model", "created": 1734266953}, {"id": "mlx-community/Qwen2-VL-7B-Instruct-8bit", "object": "model", "created": 1734266953}]}

Dify に登録する

OpenAI-API コンパチブルのモデルとして登録

Dify へは、OpenAI-API-compatible の LLM モデルとして登録します。モデル名は上に度々登場しているもので、URL にはポートと /v1 が必要、Delimiter には適当に \n\n を入れるくらいで大丈夫だと思います。参考にスクリーンショットを貼っておきます。

Chatbot を作る

上記で登録したモデルを指定し、Max Tokens は 4096 あたりにします。この値であれば 32GB RAM でも 100% GPU で動きます。QwQ からの回答に中国語が混じるのを防ぐには、下の System プロンプトを使ってみてください。100% ではありませんが、効果はあります。

中国語は絶対に使わないでください。日本語の質問は常に日本語で答えてください。
While answering, never ever use Chinese. Always answer in Japanese or English based on language used.
Ollama のモデルに比べて、OpenAI API コンパチは指定できるパラメータ数が少ない

さて、これで準備が整ったので、公開して実際に試してみましょう。

Dify では正確な数字での比較ができないが、体感は速い

さてこれで準備が整ったので、GGUF (ollama pull qwq:32b-preview-q4_K_M) と MLX を使った同じ条件のチャットボットを作り、同じ質問をいくつも投げて違いを見てみました。見た目は確かに MLX 版の方が文字の出力が速いです。ただ、Dify アプリのダッシュボードである「監視」での比較だと GGUF が 6トークン/秒くらいに対し、MLX が 18トークン/秒と 3倍速い結果になっていますが、実際にそこまでの違いはありません。個別のチャットのステータスが見れる「ログ&アナウンス」で確認できる経過時間とトークンの合計から算出すると、GGUF は大体 12トークン/秒くらいで、MLX は 35トークン/秒くらいとなるのですが、実際に出力されている文字数をざっと見比べてもそこまでの違いはありません。むしろ明らかに Ollama から多くの文字を受け取っているのに、MLX-LM から受け取ったトークン数の方が多くカウントされています。トークンの算出方法が違うのかもしれません。今回試しませんでしたが、英語でやると正確に調べられるかもしれません。また、単純に GGUF (llama.cpp) vs MLX-LM のガチンコ勝負をするなら、スクリプトを書いて比較するのが良いと思います。

追記: 本ブログの英訳記事を書く際に英語でテストを実施したところ、MLX の方が 30-50% 速い結果となりました。使ったプロンプトも載せてあります。

MLX-LM に乗り換えられるか?

QwQ や特定のモデル固定で構わないなら MLX-LM はアリだと思います。Mac の電源を切る習慣がある方は、別のブログ記事に書いたスクリプトエディタでアプリケーションを作る方法でログイン時に実行させれば勝手にモデルが使えるようにしてくれますし。でも、お手軽さと使いやすさでは Ollama の方が完全に上なので、複数のモデルをとっかえひっかえ使う方には向きません → MLX-LM でも LLM をとっかえひっかえできます (別記事「Alibaba 公式 MLX 版 Qwen3 を他の量子化版と比較」参照のこと)。上で触れた FastMLX はクライアント側からモデルの入れ替えができるようなので、本気で移行を考えるなら選択肢になり得るかと思います。ただ、Ollama 公式 X らしきポストを真に受けると、MLX 対応しそうな感じもあるので、どちらかと言えばそれ待ちですね。いずれにせよ、今回の GGUF vs MLX の趣旨とは外れますが、個人的に QwQ の出力速度はチャットベースであれば十分です。用途に応じて試してみてください。

Image by Stable Diffusion

「リンゴの上で走るロボット」をお願いしたのですが、big apple だと NYC になり、huge apple とすると、そんなものないとガン無視されました。というわけでこんな感じで手を打ちました。日本人の持つ「かわいいロボット」という感覚は西洋にはなさそうです

Date:
2024年12月16日 0:38:20

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
fancy illustration, comic style, smart robot running on a huge apple

Exclude from Image:

Seed:
2791567837

Steps:
26

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & GPU

Dify と Style-Bert-VITS2 でライブ配信風音声チャットを実現 (ローカル専用)

今回作ってみたのは、Dify を使った音声チャットボットです。キーボードからの入力に対し、生成 AI が音声で答えてくれます。遊んでみた感覚としては、ちょっとラグがあってから声で返事があるので、ライブ配信に参加している感じに近いですね。参加したことないですけど。あと、昔で言えば、人工無能を使った BBS シミュレータといったところでしょうか。昔で言い換える必要も無いんですけど。

ローカル専用です

Dify でチャットボットを作り、Ollama のローカル LLM に推論させ、Style-Bert-VITS2 にしゃべらせる、という構成です。もちろん自分で作った (学習させた) 音声モデルを利用できます。Style-Bert-VITS2 が走っているサーバに直接しゃべらせるので、ローカル専用です。Dify には (本記事執筆時の最新版 0.9.1-fix1 でも) 簡単に音声ファイルを再生する機能が無いため、ある意味苦肉の策です。Style-Bert-VITS2 の API サーバに物理的に音声再生能力があれば、Mac じゃなくても今回紹介する (本家を少しいじっただけの) スクリプトで実現できると思います。

環境構築

Dify と Ollama の構築はボクのブログの別記事を見てください。RAM がいっぱい (64GB 以上) あれば、1台の Mac でまかなえます。ボクの Mac は 32GB しか RAM が無いので、Dify を別の Mac (mini) で走らせています。

下の記事で紹介していますが、Style-Bert-VITS2 の CPU 学習可能版を作りました。Mac ユーザや CUDA が動かない環境の方向けです。ブログ記事 (というか GitHub) を参考に環境を作ってください。CUDA が動く環境の方は、本家の Style-Bert-VITS2 で大丈夫です。

API サーバスクリプトの説明

まず、すでに環境構築済みの方は、pydubをインストールしてください。今回新たに構築した場合は、インストールされているはずです。

pip install pydub

しゃべる API サーバの server_speech_fastapi.py は、元のスクリプト server_fastapi.py に音声再生機能を追加しただけです。追加変更箇所は以下ですが、まるごと持って行くならこちらからどうぞ

# サーバサイドで再生する為にインポート
from pydub import AudioSegment
from pydub.playback import play
            # 以下 2行追加して、音声を再生。wav のかわりに、テキストを返す
            speech = AudioSegment.from_file(wavContent, format="wav")
            play(speech)
            #return Response(content=wavContent.getvalue(), media_type="audio/wav")
            return Response(content=text, media_type="text/html")

Response は、音声合成に使用した (クライアントから送られた) テキストです。何の工夫も無くごめんなさい。ただ、音声の再生ということで言えば、音声データをクライアントに送りクライアントが再生するとか、サーバ自身で再生するにしても、一度ファイルとして書き出してから再生する、みたいな余計な処理がない分速いと思います。

設定はconfig.ymlserver:ブロックを少しいじって、ポートの変更と受け付けるテキストの上限をそれなりに変更しましょう。ちなみにdevicempsにしても、CUDA が動かなければ APIサーバスクリプト側でcpuにしちゃいます。v. 1.0.3 で MPS で動くようになりました。利用可能なデバイスを自動で判定します。CPU ならキャパの半分を使用します。 → MPS で音声合成すると声がかすれる現象が確認できています。解決できるまで、CPU で実行してください (GitHub のファイルは更新済み)。→ v.1.1.0 で解決しました (2025.06.06)。音声合成は MPS が使われます。

server:
  device: mps
  language: JP
  limit: 20000 # 文字数の上限
  origins:
  - '*'
  port: 5055 # デフォルトの 5000 から、使っていないポートに変更

API サーバは、仮想環境内から以下コマンドで実行できます。モデルとトークナイザの読み込みに時間がかかります。

python server_speech_fastapi.py

しばらく待って、こうなれば準備完了です。

10-12 19:06:26 | DEBUG  | __init__.py:130 | pyopenjtalk worker server started
10-12 19:06:27 |  INFO  | bert_models.py:92 | Loaded the Languages.JP BERT model from /Users/handsome/Documents/Python/Style-Bert-VITS2-Mac/bert/deberta-v2-large-japanese-char-wwm
10-12 19:06:27 |  INFO  | bert_models.py:154 | Loaded the Languages.JP BERT tokenizer from /Users/handsome/Documents/Python/Style-Bert-VITS2-Mac/bert/deberta-v2-large-japanese-char-wwm
10-12 19:06:27 |WARNING | tts_model.py:397 | No model files found in model_assets/.cache, so skip it
10-12 19:06:27 |  INFO  | server_speech_fastapi.py:116 | Loading models...
10-12 19:06:27 |  INFO  | server_speech_fastapi.py:123 | The maximum length of the text is 20000. If you want to change it, modify config.yml. Set limit to -1 to remove the limit.
10-12 19:06:27 |WARNING | server_speech_fastapi.py:129 | CORS allow_origins=['*']. If you don't want, modify config.yml
10-12 19:06:27 |  INFO  | server_speech_fastapi.py:338 | server listen: http://127.0.0.1:5055
10-12 19:06:27 |  INFO  | server_speech_fastapi.py:339 | API docs: http://127.0.0.1:5055/docs
10-12 19:06:27 |  INFO  | server_speech_fastapi.py:340 | Input text length limit: 20000. You can change it in server.limit in config.yml

テストはウェブブラウザから行うのが簡単です。アドレスバーにこんな感じでしゃべって欲しいテキストとモデル名 (ModelNameを差し替え) を入れてエンターキーを叩いてください (スピーカのボリュームに注意!)。

http://localhost:5055/voice?text=こんにちは!おしゃべりできて、うれしいです!&model_name=ModelName

API サーバを自分の Mac/PC で実行していれば、スピーカーなどから音声が流れた後にブラウザに「こんにちは!おしゃべりできて、うれしいです!」と表示されるはずです。ターミナルにも SUCCESS が出ていれば OK です。次に進みましょう。

Dify で AI アプリを作る

チャットボットを Chatflow で作ってください。

LLM はお好きなのをどうぞ。Ollama でローカルモデルを使用される方は、日本語が多少怪しくてもレスポンスが速い、軽めのモデルが良いと思います (参考まで、ボクは qwen2.5:14b-instruct-q8_0 を Temperature: 0.7、Top_P: 0.9、Keep Alive: 30m で使っています。普段よく使っている Llama3.1 だと内容によっては全く答えてくれないのですが、Qwen 2.5 は多少攻めた会話もできます)。

SYSTEM プロンプトにはキャラ付けの他、「簡潔に」「markdown を使用しない」「日本語のみ」「英語や中国語は絶対に使わない」という内容を入れておくのが良いでしょう。Style-Bert-VITS2 にいい感じにしゃべってもらうには、とにかく日本語であることが重要です。

次に、LLM と回答をつなぐラインをクリックし、「HTTPリクエスト」を追加します。

最低限この内容を設定すれば大丈夫です。

API:

メソッドURL
POSThttp://<IPアドレス>:<ポート>/voice
例: http://192.168.1.12:5055/voice

パラメータ:

キー
textLLM/(x) text
model_name(自分のモデル名)
例: ModelName
入力後はこんな感じ

最後の回答ブロックには、最初から入っている LLM/(x) text か、HTTPリクエストの (x) body が良いと思います。前者はしゃべり出す前に、後者はしゃべり終わってからテキストがチャットに表示されます。

どちらか気に入った方で

ここまでできたらプレビューでテキストの生成や音声の再生をテストし、仕上げに公開しましょう。

これで、基本的なライブ配信風チャットシステムの完成です。システムプロンプトでのキャラ付けをしたり、RAG で知識を入れてあげたり、ムフフな回答もしてくれる LLM を採用したり、お好きなようにお楽しみください。ただし、音声データの著作権を持っていない場合は、あくまでも個人で楽しむだけにとどめてください。

学習につかった音声データと、満足度、そして不安

今回このシステムを楽しむに当たり、5つの mp3 の音声データを合計 40秒弱用意しました。Mac の CPU で学習にかかった時間は 20分ほどです (M2 Max 12-core CPU)。で、実際に Style-Bert-VITS2 がしゃべった感じはどうかというと、かなり良いですね。日本語の発音として相当に自然です。ラグに関しても、音声合成は CPU で処理されますが、LLM による文章の生成よりかなり短いので問題ありません。→ MPS (GPU) で処理するようになったので、結構速いです。

音声データはネットで拾ってきた動画から切り出したもので、内容はファンイベントの告知です。コーパスとして適しているものなのかはわかりませんが、丁寧に視聴者に語りかける口調が素材として良かったのかもしれません。当然 Style-Bert-VITS2 やモデル、トークナイザの性能、日本語への最適化が大きいことは間違いありません。すばらしいシステムを作り上げてくださった作者様達に大感謝です。

それと、これでチャットしてるとすごく不思議な感じになりますね。LLM に、アイドルとして回答して、と指示出ししているだけでそれ以外の知識を与えているわけじゃ無いのに、レスポンスが音声なだけでかなりリアルにアイドルのライブ配信に参加している感じになります (したことないですけど)。以前、Google の AI 研究者か誰かが、AI はついに本当の知能を得た!証拠もある!と会社に報告したことで解雇された、というニュースを見ました。今回この AI アプリで遊ぶことでそのことを思い出しました。ありうるな、と。最近よく聞く「脳がバグる」状況は、かなり簡単に実現できますね。悪意のある LLM を使った場合にどうなるか、ちょっと不安になりました。

ところで Style-Bert-VITS2-Mac の学習の MPS 化はどうなったのか

PyTorch の Nightly ビルドはすでに AMP Autocast で MPS をサポートしており、学習用のコードはほぼ動くようになりました。6秒のファイルの学習は、1分20秒ほどで完了します。ただし、エラー無く処理が終わるようにコードをいじると完成したモデルは誰か知らない女性の声になり (ホラー?)、いじらずに実行するとロスの処理の部分でエラーとなり、失敗します。ボクには PyTorch や機械学習の知識や学習が足らず、不具合の特定ができないので Issue も上げられていない状況です。気になる方は、GitHub に上げているコードを見てみてください。853行目の+ loss_dur + loss_klを削除すると処理は完了し、そのままだと以下のエラーで止まります (実行には、requirements.txt の torch と torchaudio をコメントし、それぞれの nightly をインストールしてください)。

[rank0]:   File "/Users/handsome/Documents/Python/Style-Bert-VITS2-Mac/.venv/lib/python3.11/site-packages/torch/autograd/graph.py", line 825, in _engine_run_backward
[rank0]:     return Variable._execution_engine.run_backward(  # Calls into the C++ engine to run the backward pass
[rank0]:            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
[rank0]: RuntimeError: view size is not compatible with input tensor's size and stride (at least one dimension spans across two contiguous subspaces). Use .reshape(...) instead.

というわけで Mac ユーザの皆さん、ボクが MPS 化を完成させるのを待つよりも、地道に CPU で学習させた方が速いですよ。

Image by Stable Diffusion

今回のトップ絵は、かなり楽勝で作れたと思います。プロンプトを書き換えたのも 2回くらい。画像のクオリティを上げようと思ってステップ数を上げると急にモニタの向こうに人が増えたりしてゾゾーッとします。不気味の谷とかじゃなくて、普通にホラー映画のやつ。

Date:
2024年10月13日 0:07:02

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
live video chat streaming event of a popular pop singar. one girl in monitor

Exclude from Image:

Seed:
2850031306

Steps:
25

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & GPU

© Peddals.com