MLX-LM

ローカルLLM環境でモデルの学習をする場合、nVIDIAのGPUという選択肢がすぐ浮かぶが、Macでもそこそこのことはできるので、しくみの理解に役に立ちます。

ここでは、二種類のトピックで学習したLoRAアダプタを、話題によって切り替えるデモを試してみました。

#!/usr/bin/env python3
"""
2種類のLoRAアダプターを作成するスクリプ
- cooking_adapter: 料理に関する知識
- tech_adapter: 技術に関する知識
"""

import json
import subprocess
import sys
from pathlib import Path


def create_cooking_dataset():
    """料理トピック用のデータセット"""
    data_dir = Path("data_cooking")
    data_dir.mkdir(exist_ok=True)

    train_data = [
        {
            "messages": [
                {"role": "user", "content": "カレーの作り方を教えて"},
                {"role": "assistant", "content": "カレーの基本的な作り方:1. 玉ねぎにんじんじゃがいもを切る 2. 肉を炒める 3. 野菜を加えて炒める 4. 水を入れて煮込む 5. カレールーを溶かして完成です。"},
            ]
        },
        {
            "messages": [
                {"role": "user", "content": "パスタを美味しく茹でるコツは?"},
                {"role": "assistant", "content": "パスタを美味しく茹でるコツ:1. 大量の湯を使う(1Lに対して塩10g) 2. 沸騰してから入れる 3. 表示時間の1分前に味見 4. アルデンテで仕上げる 5. 茹で汁は少し取っておくとソースに使えます。"},
            ]
        },
        {
            "messages": [
                {"role": "user", "content": "味噌汁のダシの取り方"},
                {"role": "assistant", "content": "味噌汁のダシの取り方:1. 昆布を水に30分浸す 2. 弱火でゆっくり加熱 3. 沸騰直前に昆布を取り出す 4. かつお節を入れて火を止める 5. 2分待ってから濾すこれで本格的なダシが取れます。"},
            ]
        },
        {
            "messages": [
                {"role": "user", "content": "おすすめの料理は?"},
                {"role": "assistant", "content": "私のおすすめは和食の煮物です季節の野菜を使った筑前煮や肉じゃがは日本の家庭料理の定番で栄養バランスも良く作り置きもできます。"},
            ]
        },
    ]

    valid_data = [
        {
            "messages": [
                {"role": "user", "content": "天ぷらをサクサクに揚げるには?"},
                {"role": "assistant", "content": "天ぷらをサクサクにするコツ衣は冷水で作り混ぜすぎない油の温度は180度をキープ少量ずつ揚げることが大切です。"},
            ]
        },
    ]

    test_data = [
        {
            "messages": [
                {"role": "user", "content": "簡単な朝食レシピ"},
                {"role": "assistant", "content": "簡単な朝食には卵かけご飯やトーストにバターとジャムまたはオートミールがおすすめです。"},
            ]
        },
    ]

    for path, data in [
        (data_dir / "train.jsonl", train_data),
        (data_dir / "valid.jsonl", valid_data),
        (data_dir / "test.jsonl", test_data),
    ]:
        with open(path, "w", encoding="utf-8") as f:
            for item in data:
                f.write(json.dumps(item, ensure_ascii=False) + "\n")

    return str(data_dir)


def create_tech_dataset():
    """技術トピック用のデータセット"""
    data_dir = Path("data_tech")
    data_dir.mkdir(exist_ok=True)

    train_data = [
        {
            "messages": [
                {"role": "user", "content": "Pythonでリストをソートする方法"},
                {"role": "assistant", "content": "Pythonでリストをソートする方法:1. list.sort()でインプレースソート 2. sorted(list)で新しいリストを返す 3. key引数でカスタムソート可能 4. reverse=Trueで降順ソートnumbers.sort(reverse=True)"},
            ]
        },
        {
            "messages": [
                {"role": "user", "content": "Gitでブランチを作成するには?"},
                {"role": "assistant", "content": "Gitでブランチを作成:1. git branch 新ブランチ名 で作成 2. git checkout -b 新ブランチ名 で作成と切り替えを同時に 3. git switch -c 新ブランチ名 も使えます作成後は git push -u origin ブランチ名 でリモートにプッシュ。"},
            ]
        },
        {
            "messages": [
                {"role": "user", "content": "APIとは何ですか?"},
                {"role": "assistant", "content": "APIApplication Programming Interfaceソフトウェア同士がやり取りするためのインターフェースです例えば天気APIを使えばアプリから天気情報を取得できますREST APIやGraphQLが一般的な形式です。"},
            ]
        },
        {
            "messages": [
                {"role": "user", "content": "おすすめの技術は?"},
                {"role": "assistant", "content": "私のおすすめはMLXですApple Siliconに最適化された機械学習フレームワークでローカルでLLMを効率的に動かせますPythonで書けてNumPy風のAPIが使いやすいです。"},
            ]
        },
    ]

    valid_data = [
        {
            "messages": [
                {"role": "user", "content": "Dockerとは?"},
                {"role": "assistant", "content": "Dockerはコンテナ仮想化プラットフォームですアプリと依存関係をコンテナにパッケージ化しどの環境でも同じように動作させられます。"},
            ]
        },
    ]

    test_data = [
        {
            "messages": [
                {"role": "user", "content": "機械学習の始め方"},
                {"role": "assistant", "content": "機械学習を始めるにはPythonNumPyPandasを学びscikit-learnで基礎を身につけるのがおすすめです。"},
            ]
        },
    ]

    for path, data in [
        (data_dir / "train.jsonl", train_data),
        (data_dir / "valid.jsonl", valid_data),
        (data_dir / "test.jsonl", test_data),
    ]:
        with open(path, "w", encoding="utf-8") as f:
            for item in data:
                f.write(json.dumps(item, ensure_ascii=False) + "\n")

    return str(data_dir)


def train_adapter(data_dir: str, adapter_path: str, name: str):
    """LoRAアダプターを学習"""
    print(f"\n{'='*50}")
    print(f"{name} アダプターを学習中...")
    print(f"{'='*50}")

    Path(adapter_path).mkdir(exist_ok=True)

    cmd = [
        sys.executable, "-m", "mlx_lm", "lora",
        "--model", "mlx-community/Llama-3.2-1B-Instruct-4bit",
        "--train",
        "--data", data_dir,
        "--batch-size", "1",
        "--iters", "30",
        "--num-layers", "4",
        "--adapter-path", adapter_path,
        "--steps-per-report", "10",
        "--steps-per-eval", "30",
        "--save-every", "30",
    ]

    result = subprocess.run(cmd)
    if result.returncode != 0:
        print(f"エラー: {name} アダプターの学習に失敗しました")
        return False

    print(f"{name} アダプター完了: {adapter_path}/")
    return True


def main():
    print("=" * 50)
    print("2種類のLoRAアダプターを作成")
    print("=" * 50)

    # データセット作成
    cooking_data = create_cooking_dataset()
    tech_data = create_tech_dataset()

    print(f"\nデータセットを作成しました:")
    print(f"  料理: {cooking_data}/")
    print(f"  技術: {tech_data}/")

    # アダプター学習
    success1 = train_adapter(cooking_data, "adapters_cooking", "料理")
    success2 = train_adapter(tech_data, "adapters_tech", "技術")

    if success1 and success2:
        print("\n" + "=" * 50)
        print("全てのアダプターが作成されました!")
        print("=" * 50)
        print("\n作成されたアダプター:")
        print("  - adapters_cooking/ (料理トピック用)")
        print("  - adapters_tech/ (技術トピック用)")
        print("\n次のステップ:")
        print("  python demo_langgraph_lora.py")
    else:
        print("\nエラー: 一部のアダプター作成に失敗しました")


if __name__ == "__main__":
    main()
#!/usr/bin/env python3
"""
LangGraph + 別モデル構成

- 分類用: 大きいモデル(3Bで高精度な分類
- 応答用: 小さいモデル(1B)+ LoRAアダプターで専門的な応答
"""

import re
from typing import TypedDict, Literal
from pathlib import Path

from langgraph.graph import StateGraph, END
from mlx_lm import load, generate


class State(TypedDict):
    """グラフの状態"""
    user_input: str
    topic: Literal["cooking", "tech", "general"]
    classification_reason: str
    response: str
    adapter_used: str
    classifier_model: str
    response_model: str


class DualModelManager:
    """分類用と応答用で別々のモデルを管理"""

    def __init__(
        self,
        classifier_model_name: str,
        response_model_name: str,
    ):
        # 分類用大きいモデル
        self.classifier_model_name = classifier_model_name
        self.classifier_model = None
        self.classifier_tokenizer = None

        # 応答用小さいモデル + アダプター
        self.response_model_name = response_model_name
        self.response_model = None
        self.response_tokenizer = None
        self.current_adapter = None

        # アダプターパス
        self.adapters = {
            "cooking": "adapters_cooking",
            "tech": "adapters_tech",
            "general": None,
        }

    def load_classifier(self):
        """分類用モデルをロード(大きいモデル)"""
        if self.classifier_model is None:
            print(f"\n分類用モデルをロード中: {self.classifier_model_name}")
            print("(大きいモデルのため少し時間がかかります...)")
            self.classifier_model, self.classifier_tokenizer = load(
                self.classifier_model_name
            )
            print("分類用モデル準備完了")

    def load_response_model(self, topic: str):
        """応答用モデルをロード(小さいモデル + アダプター)"""
        adapter_path = self.adapters.get(topic)

        # 同じアダプターなら再ロード不要
        if self.current_adapter == adapter_path and self.response_model is not None:
            return

        # アダプターが存在するか確認
        if adapter_path and not Path(adapter_path).exists():
            print(f"警告: {adapter_path} が見つかりません。ベースモデルを使用します。")
            adapter_path = None

        print(f"\n応答用モデルをロード中: {self.response_model_name}")
        print(f"アダプター: {adapter_path or 'なし'}")

        if adapter_path:
            self.response_model, self.response_tokenizer = load(
                self.response_model_name,
                adapter_path=adapter_path
            )
        else:
            self.response_model, self.response_tokenizer = load(
                self.response_model_name
            )

        self.current_adapter = adapter_path

    def classify_topic(self, user_input: str) -> tuple[str, str]:
        """大きいLLMでトピックを分類"""
        self.load_classifier()

        classification_prompt = f"""あなたはトピック分類の専門家です
ユーザーの質問を以下の3つのカテゴリのいずれかに分類してください

カテゴリ:
- cooking: 料理食べ物レシピ調理方法食材味付けに関する質問
- tech: プログラミング技術コンピュータソフトウェア開発ITに関する質問
- general: 上記以外の一般的な質問天気挨拶雑談など

分類のポイント:
- 質問の主題が何かを考えてください
- 曖昧な場合はより適切と思われるカテゴリを選んでください

必ず以下の形式で回答してください:
TOPIC: [cooking/tech/general]
REASON: [なぜそのカテゴリに分類したかを簡潔に説明]

ユーザーの質問: {user_input}"""

        messages = [
            {"role": "user", "content": classification_prompt},
        ]

        formatted = self.classifier_tokenizer.apply_chat_template(
            messages, tokenize=False, add_generation_prompt=True
        )

        result = generate(
            self.classifier_model,
            self.classifier_tokenizer,
            prompt=formatted,
            max_tokens=150,
            verbose=False,
        )

        # 結果をパース
        topic = "general"
        reason = "分類できませんでした"

        topic_match = re.search(r'TOPIC:\s*(cooking|tech|general)', result, re.IGNORECASE)
        if topic_match:
            topic = topic_match.group(1).lower()

        reason_match = re.search(r'REASON:\s*(.+?)(?:\n|$)', result)
        if reason_match:
            reason = reason_match.group(1).strip()

        return topic, reason

    def generate_response(self, user_input: str, topic: str) -> str:
        """小さいモデル + アダプターで応答を生成"""
        self.load_response_model(topic)

        messages = [
            {"role": "system", "content": "あなたは親切なAIアシスタントです日本語で回答してください。"},
            {"role": "user", "content": user_input},
        ]

        formatted = self.response_tokenizer.apply_chat_template(
            messages, tokenize=False, add_generation_prompt=True
        )

        response = generate(
            self.response_model,
            self.response_tokenizer,
            prompt=formatted,
            max_tokens=256,
            verbose=False,
        )

        return response


# グローバルなモデルマネージャー
model_manager = None

# モデル設定
CLASSIFIER_MODEL = "mlx-community/Llama-3.2-3B-Instruct-4bit"  # 大きい分類用
RESPONSE_MODEL = "mlx-community/Llama-3.2-1B-Instruct-4bit"    # 小さい応答用


def init_model_manager():
    """モデルマネージャーを初期化"""
    global model_manager
    if model_manager is None:
        model_manager = DualModelManager(
            classifier_model_name=CLASSIFIER_MODEL,
            response_model_name=RESPONSE_MODEL,
        )


def classify_with_large_llm(state: State) -> State:
    """大きいLLMでトピックを分類"""
    init_model_manager()

    print(f"\n{'='*60}")
    print(f"入力: {state['user_input']}")
    print(f"{'='*60}")
    print(f"\n分類モデル: {CLASSIFIER_MODEL} (3B)")

    topic, reason = model_manager.classify_topic(state["user_input"])

    print(f"分類結果: {topic}")
    print(f"理由: {reason}")

    return {
        **state,
        "topic": topic,
        "classification_reason": reason,
        "classifier_model": CLASSIFIER_MODEL,
    }


def route_by_topic(state: State) -> str:
    """トピックに基づいてルーティング"""
    return state["topic"]


def generate_cooking_response(state: State) -> State:
    """料理アダプターで応答生成"""
    init_model_manager()
    response = model_manager.generate_response(state["user_input"], "cooking")
    return {
        **state,
        "response": response,
        "adapter_used": "adapters_cooking (料理専門)",
        "response_model": RESPONSE_MODEL,
    }


def generate_tech_response(state: State) -> State:
    """技術アダプターで応答生成"""
    init_model_manager()
    response = model_manager.generate_response(state["user_input"], "tech")
    return {
        **state,
        "response": response,
        "adapter_used": "adapters_tech (技術専門)",
        "response_model": RESPONSE_MODEL,
    }


def generate_general_response(state: State) -> State:
    """ベースモデルで応答生成"""
    init_model_manager()
    response = model_manager.generate_response(state["user_input"], "general")
    return {
        **state,
        "response": response,
        "adapter_used": "なし (ベースモデル)",
        "response_model": RESPONSE_MODEL,
    }


def build_graph() -> StateGraph:
    """LangGraphのグラフを構築"""
    graph = StateGraph(State)

    # ノードを追加
    graph.add_node("classify", classify_with_large_llm)
    graph.add_node("cooking", generate_cooking_response)
    graph.add_node("tech", generate_tech_response)
    graph.add_node("general", generate_general_response)

    # エントリーポイント
    graph.set_entry_point("classify")

    # 条件付きエッジ
    graph.add_conditional_edges(
        "classify",
        route_by_topic,
        {
            "cooking": "cooking",
            "tech": "tech",
            "general": "general",
        }
    )

    # 終了エッジ
    graph.add_edge("cooking", END)
    graph.add_edge("tech", END)
    graph.add_edge("general", END)

    return graph


def main():
    print("=" * 60)
    print("LangGraph + 別モデル構成版 v3")
    print("=" * 60)
    print("\n【モデル構成】")
    print(f"  分類用: {CLASSIFIER_MODEL} (3B - 高精度)")
    print(f"  応答用: {RESPONSE_MODEL} (1B - 高速) + LoRA")
    print("\n【フロー】")
    print("  1. 大きいモデル(3B)で高精度なトピック分類")
    print("  2. 分類結果に応じてLoRAアダプターを選択")
    print("  3. 小さいモデル(1B)+アダプターで専門的な応答")
    print("\n'quit' で終了\n")

    # グラフを構築してコンパイル
    graph = build_graph()
    app = graph.compile()

    # サンプル質問
    sample_questions = [
        "鯖の味噌煮を作りたいんだけど",
        "ReactとVueどっちがいい?",
        "最近読んだ本でおすすめはある?",
    ]

    print("サンプル質問でテスト:")
    print("-" * 60)

    for question in sample_questions:
        result = app.invoke({
            "user_input": question,
            "topic": "general",
            "classification_reason": "",
            "response": "",
            "adapter_used": "",
            "classifier_model": "",
            "response_model": "",
        })

        print(f"\n【モデル情報】")
        print(f"  分類: {result['classifier_model']}")
        print(f"  応答: {result['response_model']} + {result['adapter_used']}")
        print(f"\n回答: {result['response']}")
        print("-" * 60)

    # インタラクティブモード
    print("\n\nインタラクティブモード開始")
    print("=" * 60)

    while True:
        user_input = input("\nあなた: ").strip()

        if user_input.lower() in ["quit", "exit", "終了"]:
            print("終了します。")
            break

        if not user_input:
            continue

        result = app.invoke({
            "user_input": user_input,
            "topic": "general",
            "classification_reason": "",
            "response": "",
            "adapter_used": "",
            "classifier_model": "",
            "response_model": "",
        })

        print(f"\n[分類({result['topic']}): {result['classification_reason']}]")
        print(f"[応答モデル: {result['response_model']} + {result['adapter_used']}]")
        print(f"\nアシスタント: {result['response']}")


if __name__ == "__main__":
    main()

実行結果(アダプタの準備とクエリー)

ClaudeCodeで勉強しながらコードを生成して、それを理解する、というルーチンを最近よくやります。