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

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

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

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

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

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

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

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

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

Userscripts でできること

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

インストールと初期設定

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Image by Stable Diffusion (Mochi Diffusion)

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

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

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
guide book of an adventure game

Exclude from Image:

Seed:
699570134

Steps:
20

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & GPU

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

Mac 用 BlackHole が Apple Silicon 移行後にアンインストールできない問題を解決

Intel CPU 時代ゲームとマイクなどの複数のソースの音声を OBS で録音することができず、ボクはbrewBlackHole というオーディオループバックドライバをインストールしていました。その後 M1 Mac Mini を購入し Time Machine のバックアップから移行アシスタントを使用して環境を持ってきたのですが、どうやらこれが原因でbrewによる BlackHole のアンインストールができなくなっていたみたいです。もしくはどこかで適当に調べて実行した削除方法が悪手だったのかもしれません。ともあれ、解決方法が見つかったので共有します。インストール済みのチャネル数に応じて、2ch であればblackhole-2chと読み替えて (コマンド等は書き換えて) ください。あ、ちなみに OBS は複数ソースの取り込みに対応しているので、BlackHole は不要になりました。

症状

brew upgradeを実行するたびについでに実行されていたblackhole-16chのアンインストールが、以下のエラーで失敗していました。brew uninstall -f blackhole-16chとやっても同様です。

==> Uninstalling Cask blackhole-16ch
==> Uninstalling packages with sudo; the password may be necessary:
Password:
Could not kickstart service "com.apple.audio.coreaudiod": 1: Operation not permitted
Error: Failure while executing; `/usr/bin/sudo -E -- /bin/launchctl kickstart -kp system/com.apple.audio.coreaudiod` exited with 1. Here's the output:
Could not kickstart service "com.apple.audio.coreaudiod": 1: Operation not permitted

どうやったかは覚えていないのですが、すでに BlackHole-16ch の実体は削除済みだったので Audio MIDI 設定.app にも表示されず目に見える実害はありませんでした。ただbrew ugrade後に自動で走るbrew cleanupが長いこと実行されていなかったので、多少無駄なディスク容量は使用していたことになります。

解決方法

チャネル数やバージョン、ビルドされた日付などによって細かいところは違うと思いますが、おおよそ以下のフォーマットに近いフォルダにblackhole-*ch.rbというファイルがあるはずなので見つけてください。

/opt/homebrew/Caskroom/blackhole-16ch/.metadata/0.5.0/20230225072426.180/Casks/blackhole-16ch.rb

そのファイルの中に以下の記載があるので、エディタで編集して保存します。

編集前:

system_command "/bin/launchctl",
                   args:         [
                     "kickstart",
                     "-kp",
                     "system/com.apple.audio.coreaudiod",
                   ],
                   sudo:         true,
                   must_succeed: true

編集後:

system_command "/usr/bin/killall",
                   args:         ["coreaudiod"],
                   sudo:         true,
                   must_succeed: true

その後アンインストールすると、エラーも無く完了します。以下は実行例です。

% brew uninstall blackhole-16ch
==> Uninstalling Cask blackhole-16ch
==> Uninstalling packages with sudo; the password may be necessary:
Password:
==> Purging files for version 0.5.0 of Cask blackhole-16ch
==> Autoremoving 1 unneeded formula:
libgit2
Uninstalling /opt/homebrew/Cellar/libgit2/1.9.0... (109 files, 4.9MB)

ボクはこの後brew upgradeを実行したところ自動でbrew cleanupが走り、BlackHole-16ch もキレイに削除されたのがわかりました。

% brew update
==> Updating Homebrew...
Already up-to-date.
% brew upgrade
==> `brew cleanup` has not been run in the last 30 days, running now...
Disable this behaviour by setting HOMEBREW_NO_INSTALL_CLEANUP.
Hide these hints with HOMEBREW_NO_ENV_HINTS (see `man brew`).
...
Removing: /opt/homebrew/cache/api-source/Homebrew/homebrew-cask/2aaef0803d773e0427dea5899e5830877ff0e7d4/Cask/blackhole-16ch.rb... (924B)
Removing: /opt/homebrew/cache/api-source/Homebrew/homebrew-cask/33834b5bb4afa8aeee187913c3aa915a26da6230/Cask/blackhole-16ch.rb... (924B)
Removing: /opt/homebrew/cache/api-source/Homebrew/homebrew-cask/58c8ced139c9482c318bb6bd3bc844d54c69c164/Cask/blackhole-16ch.rb... (924B)
...

以上で完了です。

参考にしたページ

参考にした下記の GitHub Discussion では、バージョン 0.5.0 を 0.6.0 にアップグレードできないという件について書かれています。

https://github.com/ExistentialAudio/BlackHole/discussions/781

解決できてスッキリ

ブラックホールだけに抜け出せないかと思いました。やかましいですね。

Image by Stable Diffusion (Mochi Diffusion)

そのまんま「ブラックホールからの脱出」をテーマに描いてもらいました。割とうまくいかず、自分もどう細かい指示をして良いのかわからず、ただただ大量に生成して、まぁなんとか「ブラックホールから脱出した宇宙飛行士の自撮り」ができました。

Date:
2025年1月20日 0:45:54

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
selfie of an astronaut who just escaped from a blackhole

Exclude from Image:

Seed:
1463892356

Steps:
20

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & GPU

新規 NGINX サーバがホストの定義ファイル読み込まない問題を解決

Oracle Cloud Infrastructure (OCI) の Cloud Free Tier に含まれる、無期限の無料 AMD サーバインスタンスにウェブサーバを立てようと調べたところ、少ない CPU とメモリでは Apache より NGINX の方がまだなんとかなりそうな雰囲気だったので、Ubuntu 22.04 + NGINX の構成で行くことにしました。いじってみると大まかにやることは Apache 2 とほぼ同じなので安心していたところ、Let’s Encrypt の証明書のインストールでハマったので、内容を残しておきます。まぁ実際はホストの設定が読み込まれないという NGINX だけの問題だったのですけど。なのでおそらくこの記事が役に立つという人は、独学で NGINX サーバを構築し始めた人だろうと思います。

環境

NGINX は、aptコマンドでは無くNGINX 公式の手順に従って最新の Stable バージョンをインストールしています。だからハマった、という事かもしれません。とりあえずバージョン一式はこんな感じです:

# uname -a
Linux ubuntu22-04 6.8.0-1018-oracle #19~22.04.1-Ubuntu SMP Mon Dec  9 23:57:57 UTC 2024 x86_64 x86_64 x86_64 GNU/Linux
# nginx -v
nginx version: nginx/1.26.2
# certbot --version
certbot 1.21.0

不具合の内容

OCI 側の Ingress Rules と Ubuntu の ufw で TCP 80 と 443 を開け、NGINX をインストールして開始すると、デフォルトのページは IP アドレスの指定で外からでも見られるようになりました。ここまでは順調です。その後、独自ドメインを使った簡単なウェブページがみられるようにしたところでハマりました。

DNS に A レコードを追加して、ドキュメントルートに index.html 置いて、 /etc/nginx/sites-available にホストの設定ファイルを書き、sites-enabled にシンボリックリンクを作って、nginx をリスタートして、みたいな手順はほぼ Apache 2 と同じなのでささーっと進められたのですが、ローカルからアクセスしても作ったページが開きません。サーバ上でcurl -H "Host: web.peddals.com" http://localhost"を実行しても、Nginx デフォルトの HTML が表示されるだけです。

ボクが所有するドメイン peddasl.com は HSTS preload の設定がしてあり、サブドメインも対象にしています。なので、全てのウェブアクセスは自動的にセキュアな HTTPS に切り替わります。考えてみればホストの設定ファイルには非セキュアな 80版ポートの指定しか書いていなかったので、あぁそれか、と。で、調べてみると Let’s Encrypt のcertbotが自動的に設定ファイルを HTTPS に書き換えてくれるようなので、とりあえず証明書をインストールしてみました。そしてここでエラーが発生します。検索で引っかかるように、実行結果をほぼそのまま載せておきます:

# apt install certbot python3-certbot-nginx
(インストールログ省略)
# certbot --nginx -d web.peddals.com
Saving debug log to /var/log/letsencrypt/letsencrypt.log
Enter email address (used for urgent renewal and security notices)
(Enter 'c' to cancel): [email protected]

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Please read the Terms of Service at
https://letsencrypt.org/documents/LE-SA-v1.4-April-3-2024.pdf. You must agree in
order to register with the ACME server. Do you agree?
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
(Y)es/(N)o: Y

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Would you be willing, once your first certificate is successfully issued, to
share your email address with the Electronic Frontier Foundation, a founding
partner of the Let's Encrypt project and the non-profit organization that
develops Certbot? We'd like to send you email about our work encrypting the web,
EFF news, campaigns, and ways to support digital freedom.
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
(Y)es/(N)o: N
Account registered.
Requesting a certificate for web.peddals.com

Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/web.peddals.com/fullchain.pem
Key is saved at: /etc/letsencrypt/live/web.peddals.com/privkey.pem
This certificate expires on 2025-04-01.
These files will be updated when the certificate renews.
Certbot has set up a scheduled task to automatically renew this certificate in the background.

Deploying certificate
Could not install certificate

NEXT STEPS:
- The certificate was saved, but could not be installed (installer: nginx). After fixing the error shown below, try installing it again by running:
certbot install --cert-name web.peddals.com

Could not automatically find a matching server block for web.peddals.com. Set the `server_name` directive to use the Nginx installer.
Ask for help or search for solutions at https://community.letsencrypt.org. See the logfile /var/log/letsencrypt/letsencrypt.log or re-run Certbot with -v for more details.

証明書 (Certificate) の保存はできたけど、対象の web.peddals.com のサーバブロックが見つからなかったのでインストールできなかった、修正してからcertbot install --cert-name web.peddals.comを実行して、ということです。何度もホストの設定ファイルを見直したり作り直したり nginx サービスを restart したりreload したり、nginx をアンインストールして再度インストールしたりしても直らず、Qwen2.5 Coder 32B とチャットを繰り返してやっと解決できました。nginx -Tにホストの設定内容が表示されない、これがポイントです。

解決方法

エラーの類いが何も無かったので苦労しましたが、結論としては、/etc/nginx/nginx.conf ファイルに /etc/nginx/sites-enabled/* が書かれていなかったため、そもそも追加したホストの設定が読み込まれていなかったというのが原因でした。なぜそうなのか、という根本原因までは深追いしていませんのであしからず。おそらく普通にapt install nginxで Ubuntu 公式リポジトリからインストールすれば発生しないんじゃないかと思います。

NGINX サーバでホストの設定が読み込まれないという不具合で困っている場合は、以下をお試しください。Let’s Encrypt のインストールも正常に完了します。

1. nginx -Tでホストの設定が表示されない事を確認

2. grep sites-enabled /etc/nginx/nginx.confで何も表示されない事を確認

3. /etc/nginx/nginx.confhttpブロックに/etc/nginx/sites-enabled/*;を書き加える (ハイライトした 32行目)


user  nginx;
worker_processes  auto;

error_log  /var/log/nginx/error.log notice;
pid        /var/run/nginx.pid;


events {
    worker_connections  1024;
}


http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    keepalive_timeout  65;

    #gzip  on;

    include /etc/nginx/conf.d/*.conf;
    include /etc/nginx/sites-enabled/*;
}

4. systemctl restart nginxでサービスを再起動

Let’s Encrypt certbot によるホスト定義ファイルの変更ビフォー&アフター

用途によって色々変更や書き加えることがあるでしょうが、HTTP を HTTPS にしてくれて、インストールログを見る限り更新も自動でやってくれるみたいなので、なんか助かりますね。本番でやる場合は、certbot を実行する前に念のためオリジナルの定義ファイルをバックアップしておくのが良いと思います。

オリジナル:

server {
    listen 80;
    server_name web.peddals.com;

    location / {
        root /var/www/web.peddals.com;
        index index.html index.htm;
    }

    error_page 404 /404.html;
    location = /40x.html {
    }

    error_page 500 502 503 504 /50x.html;
    location = /50x.html {
    }
}

certbot による書き換え後:

server {
    server_name web.peddals.com;

    location / {
        root /var/www/web.peddals.com;
        index index.html;
    }

    error_page 404 /404.html;
    location = /40x.html {
    }

    error_page 500 502 503 504 /50x.html;
    location = /50x.html {
    }

    listen 443 ssl; # managed by Certbot
    ssl_certificate /etc/letsencrypt/live/web.peddals.com/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/web.peddals.com/privkey.pem; # managed by Certbot
    include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot

}
server {
    if ($host = web.peddals.com) {
        return 301 https://$host$request_uri;
    } # managed by Certbot


    listen 80;
    server_name web.peddals.com;
    return 404; # managed by Certbot


}

そういえばこんなニュースがありましたね

Gigazine さんの ↓ の記事を読んでいたので、若干警戒 (?) していた NGINX ですが、今回ついにいじり初めました。

NGINXのコア開発者が親会社と決別、新たに「freenginx」という名前でフォーク版を作成開始 https://gigazine.net/news/20240215-freenginx

ネガティブな動きが無い限りは、当面 NGINX を使い続けてみようかと思います。

Image by Stable Diffusion (Mochi Diffusion)

ウェブで躍動するエンジン!を思い描いて、自動車なんかのエンジンぽいものを期待していたんですが、どちらかというとギアで構成された装置が多く生成されました。ま、こんなもんでしょ。

Date:
2025年1月2日 18:59:00

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
mechanical machine’s engine floating on world wide web

Exclude from Image:

Seed:
3347794983

Steps:
20

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & GPU

Dify で Mac 専用 MLX 版 Qwen2-VL を使う (たぶん、LM Studio のバグ技)

前回 Pixtral (Mistral 社のビジョンモデル) の日本語コミュニケーション能力と画像認識の性能の高さに感動しつつも、画像の中の日本語は読めないことを知り傷心したボク。Ollama のバージョンアップで正式に利用できるようになった Meta 社の画像認識モデルである Llama3.2 Vision 11B こそが本命であるはずだ!と早速試したんですが、あまりのぱっとしなさに記事にすることもありませんでした。ローカルで「使える」オープンモデル・ウェイトのビジョンモデルの登場はまだ先なのか、または画像内の日本語が認識できる LLM なんて 32GB RAM の Mac には一生やってこないのか、っていうか OCR したいなら普通に macOS 標準のプレビューでいいじゃんか、ということなのか。なんて思っていたら、普通にありました。とっくに出てました。しかもボクが最近何かとメインで使っている Alibaba 社の Qwen ファミリーに。そう、Qwen2-VL です。

(Qwen2.5-VL の登場と共に Qwen2-VL のウェブサイトが消えたようなので、Qwen2.5-VL のリンクを貼っておきます)

https://github.com/QwenLM/Qwen2.5-VL

はい、日本語正式サポートしてるぅ (上のサイトを Safari で日本語に翻訳してもらった一部のキャプチャ)
画像内の日本語認識が不要なら、Pixtral がオススメ

簡単に使うなら、やはり LM Studio 単体で

LM Studio の虫眼鏡アイコンで “qwen2-vl” と打ち込み、横のチェックボックスで MLX を選べば mlx-community にて変換された MLX 版の Qwen2-VL がずらずら出てきます。一番大きい 7B-Instruct-bf16 でも 16.60 GB なので、32GB RAM の Mac でも問題無く動きそうですが、そこはビジョンモデル、テキストオンリーのモデルと違ってメモリを喰います。BF16 はたまに CPU を使ったり頻繁にハルシネーションをおこしたりするヤンチャ坊主なので、オススメは 7B-Instruct-8bit です。というわけで、LM Studio で動けばそれでヨシ、という方はこれ以降読まなくて大丈夫です。ステキなビジョンモデルライフをエンジョイください。

bf16 のスクリーンショットですが、オススメは Qwen2-VL-7B-Instruct-8bit

Ollama では動かない

さて、普段はローカル LLM のモデルプロバイダとして使っている Ollama をなぜ使わないのかということを説明します。Ollama はバックエンドに llama.cpp を使っていますが、llama.cpp は (今のところ) ビジョンモデルに対応していません。Llama3.2 Vision は、Ollama の開発者が llama.cpp に依存せずに 頑張ってどうにか動くようにした (?) らしいです。が、Llama3.2 Vision 11B のパフォーマンスが 32GB RAM の M2 Max ではイマイチでして、ボクはすぐに使うのやめちゃいました。英語だけで OK の方は良いのかもしれないですね。

Safetensors は MPS で動かすこともできる

↓の Zenn に投稿された金のニワトリさんの記事に、MPS でサンプルコードを動かす方法が紹介されています。Llama3.2 Vision にガッカリしていたときに試したので、えらく感動しました。Mac の GPU で素のビジョンモデル Qwen2-VL が動いていたので。

https://zenn.dev/robustonian/articles/qwen2_vl_mac

さて、API で利用するにはどうするか?

macOS で Qwen2-VL を動かすには、元の Safetensors 版か、MXL 版のどちらか、ということがわかったわけですが、やっぱり Dify から使いたくなってしまいます (よね?)。本家の GitHub にあるワンライナーの API サーバ起動方法は vllm という Python のライブラリを使用しており、残念ながら Mac 未対応です。他の方法を探ると、言語モデルの MLX 用なら割といろんな API サーバライブラリがあるのですが、ビジョンモデルに対応したものとなるとかなり絞られました。さらにそこから実際に Qwen2-VL を使おうとすると、そもそも Dify に登録できなかったり、無理矢理登録できても例外しか発生しないという悲しい状況が 2週間ほど続きました。

同じように MLX のビジョンモデルである Pixtral は LM Studio が API サーバとなり Dify から使えるのに、Qwen2-VL を OpenAI コンパチモデルとして登録しようとすると、最初の ping (モデルが本当につながるかどうかのテスト) に画像が含まれないために Qwen2-VL がエラーを返してきて登録ができません。あらゆる方法を自分なりにいろいろと試しては失敗し、を繰り返していたある日、突然でたらめな組み合わせで動き始めました。詳細は以下に続きます。

成功した構成とバージョン

LM Studio または Dify の新しいバージョンではバグが潰されてこの方法が使えなくなるか、はたまた正式にサポートされて普通に動くようになるかわかりませんが、とりあえず ↓ の構成&バージョンで動作が確認できています。

  • Dify: 0.11.2 (0.11.1 でも動いた実績あり)
  • LM Studio: 0.3.5 (Build 2)
  • Pixtral: mlx-community/pixtral-12b-8bit
  • Qwen2-VL-7B: mlx-community/Qwen2-VL-7B-Instruct-8bit (FastMXL でダウンロードしたモデル)
  • Qwen2-VL-2B: mlx-community/Qwen2-VL-2B-Instruct-4bit (LM Studio でダウンロードしたモデル)
  • macOS: Sequoia 15.1.1 (上記アプリが動けば、多分何でも大丈夫)

Dify から LM Studio の Qwen2-VL を使う方法

やり方は、ほぼ前回の記事と同じです。異なっているのは、LM Studio には Qwen2-VL をロードしておくだけです。Dify のアプリで使うモデルは mlx-community/pixtral-12b-4bit のままで構いません。というか、上記バージョンでは Dify に LM Studio 上の Qwen2-VL を登録できないので、Dify は Pixtral を呼び出しているつもり、LM Studio は読み込み済みの Qwen2-VL を使って推論をする、という状況になります。本来 LM Studio は指定されたモデルを読み込むはずなので、バグだと思いますが、これで動きます。

実行サンプル

いくつか参考となりそうな画像を貼っておきます。

テキストの日本語が怪しいことは多いです
提灯の文字を読んで「雷門」と回答したわけでは無いらしい。っていうか「唐傘 (提灯)」って
文字だけなら手書きの日本語を読めます
結構がんばってる。Markdown の罠でインデックス番号が各行に振られているところと、写真一番下のテキストが斜めになってしまっているところは読めていないのが残念。ラウンドを繰り返すか、プロンプトを工夫すれば精度はあがるかも

感想

性能的には、画像認識の精度や知識は Llama3.2 Vision モデルよりは良いけど、Pixtral ほどじゃ無いです。日本語 OCR メインの用途なら macOS のクイックルックやプレビューの方が相当優れています。というわけでやはり、期待したほどでは無いな、というのが正直な感想です。大量の RAM を積んでいる方が 72B のモデルを動かすと、また違う感想になるかもしれません。ビジョンモデルの使いどころも定まっていないボクにとっては、「普通には動かせないものを動かす」というハッカー的欲求を満たせたのでとりあえず満足、という感じですかね。

ところで最近のローカル LLM はほとんど Qwen2.5 シリーズしか使ってません。日本語チャットは qwen2.5:14b-instruct-q8_0 (15GB) もしくは qwen2.5:32b-instruct-q4_K_M (19GB)、コーディングは qwen2.5-coder:14b-instruct-q8_0 (15GB) もしくは qwen2.5-coder:32b-instruct-q4_K_M (19GB) です。速度や大きなコンテキスト長を扱いたいとき (注) は 15GB サイズのモデル、精度が欲しいときは 19GB のモデルを使っています。欧米のモデルよりも日本語が得意というのもありますが、各種リーダーボード (日本語オープン、日本語総合コーディング) でも Qwen シリーズは高評価ですので、未体験の方はゼヒ。

ついでにですが、最近リリースされた推論 (reasoning) 重視モデルの QwQ-31B-Preview も普通に Ollama でダウンロードして使えます。32GB RAM の M2 Max での生成速度は速くない (7.5 TPS 程) ですが、性能というか、考え方の方向性がこれまでとかなり違い、ヤバいです。次元が違う感じ。

(注: 大きなサイズのモデルだとコンテキスト長が大きいと遅くなる場合があります。詳しくはこちらの記事をご覧ください)

最後に、ビジョンモデルの活用法を一つ思いつきました。大量の素材用写真が無造作にフォルダに入っているみたいな状況があれば、それぞれの写真の特徴を macOS の「情報を見る」のコメント欄に書き込んでくれるスクリプトなんてよさそうですね。Spotlight で検索できるようになるし。Qwen2-VL はビデオの読み込みもできるらしいので、整理に困っている大量の画像・映像を持っている人は良いかもですね。

Image by Stable Diffusion (Mochi Diffusion)

Pixtral の記事のトップ絵に対抗した内容で、登場人物を alibaba にし、場所を Alibaba Cloud 社があるらしい中国の Yu Hang にしてみました。きっと本当はこんな町並みでは無いのでしょうが、ボクの想像と概ね近いものを選びました。

Date:
2024年11月27日 23:59:11

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
masterpiece, intricate, realistic, sharp focus, photograph, alibaba walking in Yu Hang, china

Exclude from Image:

Seed:
1426778725

Steps:
20

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & GPU

マウスホイールが一瞬だけ逆にスクロールする問題を解決

この記事は、主に Mac ユーザに向けての記事になります。マウスは数千円程度の、特に特徴の無い、平均的もしくはお安いもの (4千円以下?) が対象になるかと思います。ボクが解決できたのは USB ドングルタイプです。

注意: 「Mac でマウスのホイールの回転方向とスクロールの方向を逆にする方法の説明」ではありません。その設定方法は簡単に見つかります

この記事で説明するのは、使っていると一瞬だけ上下逆に動いちゃう、という問題の解決方法です。特に、一度スクロールを止めて少ししてからもう一度同じ方向にスクロールしようとすると、一瞬逆に動いて読んでいたところを見失う、というような問題への解決方法です。この方法で解決しなければ、世の中に 100万ページくらいある他のサイトで紹介されている方法をお試しください。

解決方法

システム環境設定のマウスで、スクロールの速さを 2つくらい下げましょう。どれくらいが最適かは試しながら調整してください。これで上に書いた不具合は解決します。多分、3つ 4つ下げても体感的にスクロールの遅さを感じないと思います。なのに、きっと問題は解決します。→ボクの場合それでもまだ時々発生していたので、思い切って一番「遅い」設定にしてからは発生していません。Safari のスクロールも遅さを感じません。お試しあれ!

macOS Sequoia 15.x はここ:

真ん中あたりで試しましょう。たいていの人はスピードの差を感じないです

macOS Sonoma 14.x まで (多分) はここ、もしくは Spotlight で「スクロールの速さ (マウス)」を検索するのが早いかも:

一番左でもスクロールに不便なし。macOS 15 でアイコンやめちゃったのはカメへの配慮?

なぜ解決するか 〜多分こうなんじゃないかな〜

「ホイールの汚れを数ヶ月おきに掃除しましょう」なんて話を真に受けてマウスを分解したことがある人ならわかると思いますが、大抵のマウスのホイールの内側には細い溝がたくさん開いていて、片側から光もしくはレーザーが出され、反対側のセンサーでで受けています。ここでホイールの回転を読んでいるわけですね。お安いマウスだと、Mac 側が感度を高めた (スクロールを速くした) 時の読み込み速度に追いつかなくて、結果逆回転と判定されちゃうんじゃ無いかと思います。Mac が落ち着いてホイールの動作を読み取るようにしてあげることで、常に正しい結果が得られる、ということなんじゃないかなと思ってます。知らんけど。

試した結果とか、使っているマウスとか

ボクの場合、一つのマウスを仕事用の Windows と Mac で使っていますが、Windows では全く発生したことが無かったことから、汚れ、ハードウェアの不具合、電池の消耗等は除外していました。ふと昔から Mac はマウスの解像度が高かったということを思いだし、ホイールのスクロールの速度を下げたことが功を奏しました。スクリーンショットの位置に変更した後、タイトルの不具合は一度も発生していません。もちろん Google 先生にも相談しましたが、役立つ情報はありませんでした。

ちなみに使っているマウスは、logi の M220 (レーザー、静音タイプ、USB ドングル付き) です。ホイールのほどよい固さとクリックのしやさも気に入っているので、こんなことで解決できてよかったです。なので、皆さんへハッピーのお裾分けです。せっかくなのでお小遣い稼ぎに Amazon のリンクを貼っておきます。

Image by Stable Diffusion

マウスのヒーローがマッドサイエンティストをやっつけるイメージを作ろうとしたら、ヤバい感じの画像ばかりができてきました。最終的には、ここまでひどい完成度なら誰からも怒られないだろう、という画像を採用です。某万博マスコットキャラクターの採用理由と近いですかね。知らんけど。

Date:
2024年10月18日 0:29:23

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
comicbook cover, the super hero mouse-man versus a mad doctor

Exclude from Image:

Seed:
2438098213

Steps:
25

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
CPU & GPU

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

最近のオープンソース・オープンウェイトの LLM のパフォーマンスは本当にすごくて、コーディング補助なら DeepSeek Coder V2 Lite Instruct (16B)、日本語と英語のチャットや翻訳なら Llama 3.1 Instruct (8B) で十分です。Ollama をターミナルアプリから実行してチャットすると、その内容と回答スピードには本当に驚かされますね。インターネットが止まっても当分生きていける感じがします。

ところが、Dify や Visual Studio Code 用 LLM 拡張機能 Continue から Ollama の同じモデルを API で使用すると、使い物にならないくらい遅いという状況が発生しました。今回はその解決方法を紹介します。あなたの問題の原因は別のところにあるかもしれませんが、簡単に確認・修正できるので、まずは本記事の【結論】の内容を試してみることをオススメします。

確認できた環境

OS やアプリのバージョン

macOS: 14.5
Ollama: 0.3.8
Dify: 0.6.15
Visual Studio Code - Insiders: 1.93.0-insider
Continue: 0.8.47

LLM とサイズ

モデル名モデルサイズコンテキストサイズOllama ダウンロードコマンド
llama3.1:8b-instruct-fp1616 GB131072ollama pull llama3.1:8b-instruct-fp16
deepseek-coder-v2:16b-lite-instruct-q8_016 GB163840ollama run deepseek-coder-v2:16b-lite-instruct-q8_0
deepseek-coder-v2:16b-lite-instruct-q6_K14 GB163840ollama pull deepseek-coder-v2:16b-lite-instruct-q6_K
mac で 32GB 以上の RAM なら楽勝で動くはずのモデルサイズ

【結論】コンテキストサイズを見直そう

API 経由で Ollama のモデルを利用する側のアプリ、例えば Dify で設定する「Size of context window」を十分に小さくすることで解決します。モデル自体が対応しているから、とか、将来のためになるべく多くのトークンを処理できるキャパにしておきたいから、という理由で大きな数字を割り振るのはやめましょう。デフォルト値 (2048) または 4096 程度に変更し、短い文章のチャットでテストしてみてください。本来のスピードに近いパフォーマンスが出れば、ビンゴです。

コンテキストサイズとは: 英語では context size、他にコンテキストウィンドウ (context window)、コンテキスト長 (context length)、とも呼ばれる値で、LLM が一度のやりとりで処理できるトークン数の合計です。トークン数とは、日本語ならほぼ文字数、英語ならほぼ単語数とイコールです。上の表の Llama 3.1 を見ると 131072 となっていますので、単純に LLM への入力と生成されるテキストが同じ量であると想定すると入力に使えるのは半分なので、Llama 3.1 は約 6万5千文字の日本語の文章を入力に使用できる、そのキャパシティがあるということです。

コンテキストサイズを変更するところ

Dify

スタジオのアプリ内にある LLM ブロックを開き、モデル名をクリックすると細かい設定が行えます。下にスクロールすると Size of cont… (Size of content window) があるので、そこのチェックを外すか、「4096」を入力します。

無効化したときのデフォルト値は 2048

Continue (VS Code 用拡張機能)

コンフィグファイル config.json の LLM の設定内、contextLengthmaxTokens それぞれを 40962048 に変更します (maxTokensは LLMで生成されるトークンの最大値なので、半分にしています)。コンフィグファイルは Continue ペインのギアアイコンから開けます。

    {
      "title": "Chat: llama3.1:8b-instruct-fp16",
      "provider": "ollama",
      "model": "llama3.1:8b-instruct-fp16",
      "apiBase": "http://localhost:11434",
      "contextLength": 4096,
      "completionOptions": {
        "temperature": 0.5,
        "top_p": "0.5",
        "top_k": "40",
        "maxTokens": 2048,
        "keepAlive": 3600
      }
    }

LLM のコンテキストサイズを調べる

一番簡単なのは、Ollama のコマンドollama show <modelname>を使う方法です。context length として表示されます。実行例:

% ollama show llama3.1:8b-instruct-fp16
  Model                                          
  	arch            	llama 	                         
  	parameters      	8.0B  	                         
  	quantization    	F16   	                         
  	context length  	131072	                         
  	embedding length	4096  	                         
  	                                               
  Parameters                                     
  	stop	"<|start_header_id|>"	                      
  	stop	"<|end_header_id|>"  	                      
  	stop	"<|eot_id|>"         	                      
  	                                               
  License                                        
  	LLAMA 3.1 COMMUNITY LICENSE AGREEMENT        	  
  	Llama 3.1 Version Release Date: July 23, 2024

アプリケーションにモデルを追加する時のコンテキストサイズ指定

Dify のモデルプロバイダー Ollama

Dify に Ollama の LLM を追加する際、デフォルトで 4096 になっているところを上書きすることで、モデルのキャパシティ (Model context size) と生成されるトークンの上限 (Uper bound for max tokens) を設定できます。ただ上限をここでかけてしまうと作った AI アプリ側で不具合が出たときにデバッグしづらいので、追加の際にはどちらもモデルのコンテキストサイズ (context length の値) を入れておくのが良いと思います。そして、AI アプリ側の Size of content window で後述するほどよいコンテキストサイズを指定しましょう。

Continue の “models”

Continue の場合、設定内容はモデルを選択したときに使われるので、titleにコンテキストサイズに関する説明 (Fastest Max Size とか 4096 とか) を入れて、同じモデルで複数の異なったコンテキストサイズの設定を用意しておいても良いかもしれません。以下は、ボクが実際に 32GB RAM の M2 Max で試して Llama 3.1 (8B) の高速動作が確認できた値を入れてあります。Dify とは異なり、maxTokenscontextLengthと同じ値だとエラーになるため、半分にします。

    {
      "title": "Chat: llama3.1:8b-instruct-fp16 (Fastest Max Size)",
      "provider": "ollama",
      "model": "llama3.1:8b-instruct-fp16",
      "apiBase": "http://localhost:11434",
      "contextLength": 24576,
      "completionOptions": {
        "temperature": 0.5,
        "top_p": "0.5",
        "top_k": "40",
        "maxTokens": 12288,
        "keepAlive": 3600
      }
    }

LLM の処理が重いとき、何が起こっているか (状況からの想定)

ollama runで使用すると速いのに他のアプリから Ollama サーバ経由で使用すると重いのは、上記の通りコンテキストサイズが大きいことが原因のひとつです。実際に LLM が動作しているときに、ollama psコマンドを叩いてみましょう。以下は実行例ですが、上がモデルのコンテキストサイズ最大値を設定して反応が重い時、下がサイズを小さくして反応が速い時の出力です。SIZEPROCESSORの下に書かれている内容に注目してください。

% ollama ps
NAME                     	ID          	SIZE 	PROCESSOR      	UNTIL               
llama3.1:8b-instruct-fp16	a8f4d8643bb2	49 GB	54%/46% CPU/GPU	59 minutes from now	

% ollama ps
NAME                     	ID          	SIZE 	PROCESSOR	UNTIL              
llama3.1:8b-instruct-fp16	a8f4d8643bb2	17 GB	100% GPU 	4 minutes from now

重い時のSIZEは実モデルのサイズ (16 GB) よりもかなり大きい 49 GB となり、処理は CPU で 54%、GPU で 46% 行っています。ウラを取っていませんが、Ollama は実際に処理しているトークン数にかかわらず API で大きなコンテキストサイズを受け取ると、LLM のサイズ自体を大きく処理するようです。そのため、GPU の VRAM サイズを超えたモデルを動かしていると認識されるので (ユニファイドメモリの Mac ではほぼ意味がないですが) CPU とその配下の RAM も動員し、場合によってスワップも使用して処理するのでとてつもなく遅くなる、のであろうと考えています。そういう仕様なのだろうと。

ほどよいコンテキストサイズの値を見つける

さて、状況証拠からおおよその理由がわかったので、対策を取ります。4096トークンでまかなえるのであればそれで構いませんが、可能な限り大きなトークンを処理したいですよね。Ollama の仕様を見つけられれば良かったのですが諦め、手作業コンテキストサイズを 4096 の倍数で増減させながらチャットを繰り返し、PROCESSOR 100% GPUになる値を見つけ出しました。それが、24576 (4096*6) です。Llama 3.1 8B の F16 と DeepSeek-Corder-V2-Lite-Instruct の Q6_K なら 100% GPU で動きます。32 GB 以外のユニファイドメモリの方は、同様の方法で見つけ出してください。使った感じ、CPU 10%、GPU 90% くらいでも十分な速度が得られましたが、4096 の倍数以外の数字を使うと文字化けが発生したので、そこはご注意ください (DeepSeek-Corder-V2-Lite-Instruct の Q8_0 が該当)。また、Dify で同じコンテキストサイズを使った場合、Continue よりもSIZEが小さくなります。欲張ればもう少し増やせるかもしれませんので、必要に応じて試す価値はありそうです。時間はかかっても良いので長文を処理したい、原文を分割したくない、なんてケースでは、LLM の持つキャパを最大限使うという選択肢もアリだと思います。

(追記) 後日書いた記事 ↓ で、より多くの RAM 容量を GPU に使わせる方法を説明しています。上の方法で 100% GPU で動くコンテキスト値がわかったら、下の方法で VRAM を増やし、1024 トークンずつ増やすことで極限まで大きいトークン数を扱うことができるようになります。場合によっては大きいモデル、量子化モデルも使えますのでお試しください。

Ollama、疑ってごめんね (読まなくて良い話)

Ollama 自体で動かしたら速いのに、API 経由で使うととてつもなく遅くなるんだから、Ollama のバグに違いない!サーバの処理がおかしいんだ!と決めつけて調べていたのですが、Windows 版で GPU を使ってくれないという Issue のやりとりにあった「context size を 4096 にして試したまえ」というアドバイスにハッとし、実際に試してみるとウソのように解決しました。Ollama さん、盛大に疑ってごめんなさい。

一番大きいモデルサイズは DeepSeek-Corder-V2236BLlama 3.1 にもなると 405B と完全なる貴族仕様で、利用可能なコンテキストサイズはそのまま小さな庶民サイズのモデルにも適用されています。もしかしたら将来的に Ollama サーバでは違う処理がされるのかもしれませんが、 少なくとも 2024年の晩夏現在、一般庶民 (レベルの RAM 容量) で快適に LLM を使うには、コンテキストサイズを自分で調整する必要がある、ということを学びました。

Image by Stable Diffusion (Mochi Diffusion)

小さなバイクが、ゴージャスなバンだかキャンピングカーだかピックアップトラックだかを抜き去る画像が欲しかったんですけど、バイク vs バイクだったり、逆車線で単純にすれ違っただけだったり、バンが見切れてたり、ただただバイクがかっこよく走ってるだけだったり、と非常に苦戦!疾走感が無いですが、小さい方が速い感を出せてるこれにキメタ!

Date:
2024年9月1日 2:57:00

Model:
realisticVision-v51VAE_original_768x512_cn

Size:
768 x 512

Include in Image:
A high-speed motorcycle overtaking a luxurious van

Exclude from Image:

Seed:
2448773039

Steps:
20

Guidance Scale:
20.0

Scheduler:
DPM-Solver++

ML Compute Unit:
All

Oracle Cloud の MFA リセット方法

しばらくぶりに OCI (Oracle Cloud Infrastructure) にログインしようとしたところ、MFA のパスコードで入れない。何度か試すとアカウントがロックされる。パスワードの再設定とアカウントのロック解除はできるが、MFA のコードだけはトップ画像の通り「無効なパスコード」といって受け付けてくれない。という状況に陥り、サポートの方々のご協力で無事 MFA のリセットができました。公式・非公式ともにあまり役立つ情報がみつからなかったので、まとめておきます。スマホの機種変更をした、バイパスコードは手元にない、他にアドミンユーザはいない、というときに必要な手順になります。ちなみにボクは Free Tier の利用ですので、有償サービスを契約していなくても使えます。

必要なもの

手元に準備しておいてください。

  • ウェブブラウザのウィンドウかタブ 2枚 (チャット用とログインテスト用)
  • 何らかの Authenticator アプリをインストールしたスマホ (ボクは Microsoft Authenticator)
  • Tenancy name (= クラウド・アカウント名)
  • Email address (登録に使用したメールアドレス)
  • Phone number (登録に使用した電話番号)
  • Last 4 digits of credit card (登録に使用したクレジットカードの下 4桁)
  • Expiery date (登録に使用したクレジットカードの有効期限 (月/年))
  • 多少の英語力
  • (サポート ID (CSI番号) は不要でした)

製品別サポート窓口

一応。金曜日の 21:30 頃フリーダイヤルへかけたら、日本人の方が対応くださいました。が、解決できなかったので、今回のケースではすっ飛ばしてかまわないでしょう。

https://www.oracle.com/jp/support/support-services-list

まず自力で試してみること

ボクのケースでは解決に至りませんでしたが、ChatBot で紹介されたのでまずはこちらを試してみても良いかもしれません。でもこれができたら結構脆弱な感じがしちゃうけどどうなんでしょう。

1) 以下 URL にアクセス

https://www.oracle.com/jp/cloud/sign-in.html

2) クラウド・アカウント名を入力して次に進む

3) ユーザ名 (メールアドレス) とパスワードを入力するページの URL を以下例のように編集して開く (太字部分を書き換え)

元の URL 例:

https://idcs-1234abcd5678efgh1234abcd5678efgh.identity.oraclecloud.com/ui/v1/signin

変更後例:

https://idcs-1234abcd5678efgh1234abcd5678efgh.identity.oraclecloud.com/ui/v1/myconsole?root=my-info&my-info=my_profile_security

4) うまくいくと、バイパスコードを表示できたりするらしいので、それを使用して MFA をパスする

【解決方法】チャットでサポートに MFA のリセットをお願いする

解決方法です。英語でサポートの方と直接チャットをして MFA をリセットしてもらいます。

1) 以下 URL へアクセス

https://www.oracle.com/cloud

2) 右下の Talk to sales をクリックして Chat now をクリック

3) Oracle Chatbot が開くので、Get Support → Cloud Infrastructure (OCI) including Free Trial をクリック

4) 下の方の Cloud Support Chat をクリック

5) Live Chat が開くので、国選択、メアド入力、最初の問い合わせ内容を選び、privacy term にチェックを入れたら Start chat をクリック

6) おそらく順番待ちのキューに入るので、担当の方が現れたら MFA のリセットをお願いしましょう。「please reset my MFA. I just replaced my mobile.」とでも書いて、後は要求に応える形で上に書いた「必要なもの」をつど提供します

7) MFA のリセットが行われたら、チャットを終了する前に Authenticator アプリに登録し、ログインできるか試しましょう。目の細かい 2D コードは Oracle Authenticator アプリ以外では多分使えないので、下にあるチェックボックス「オフライン・モードまたは別のオーセンティケータ」で他のアプリ用の QR コードを表示し、アプリから読み込んで登録します

OCI ログインURL: https://www.oracle.com/jp/cloud/sign-in.html

8) 無事 Authenticator が登録でき、MFA でログインできたらチャットにお礼を書き、アンケートに答えましょう

【重要・追加作業】バイパスコードの取得

今回のような不測の状況を避けるには、バイパスコードの取得が必要です。以下、その手順となります。バイパスコードは機種変したとき等に MFA のパスコードの代わりに入力するものです。一度使用すると使えなくなりますので、使った後は同じ手順で作り直して安全な方法で保管しておきましょう。

1) ログイン後、右上のアイコンから My profile をクリック

2) 自分の名前の下にある Security をクリック

3) Bypass codes の枠の中の Generate をクリックして生成し、コピーする (下の例はすでに 1つ生成した状態)。生成時にコピーを忘れた場合は、目のアイコンクリックで再表示できます

バイパスコードでログインする方法

パスワードを入れた後、パスコードを入力する画面で一番下の「かわりのログイン方法を表示」をクリックすると、バイパス・コードボタンが表示されます。そちらをクリックすると表示される「2ステップ検証」でバイパスコードの入力ができます。

おまけ (怖い話)

今回のトップ画像は、MFA のリセット後、無事 Authenticator アプリでログインできる状態になってから撮ったスクリーンショットになります。画像を加工しているときに気がついたのですが、おかしな部分があったので矢印を付けました。ボク以外の人にはわからないんですけど、今回 MFA の設定は 2回目なのに、Phone のサフィックスが -3 になっています。MFA が失敗していたとき、そこは Phone-1 でした。つまり、ボクの知らないスマホで MFA の登録が行われていた。。。?

この記事をここまで読んだあなた、Phone のサフィックスは正しいですか?

© Peddals.com