はじめに
MiroFishはマルチエージェント社会シミュレーションフレームワークで、エージェント同士が相互に情報を共有しながら集合知を形成します。
面白そうなのでローカルで動かそうとしたのですが、デフォルトの構成では OpenAI API(有料)と Zep Cloud(グラフDBサービス、Enterprise版限定)が必要でした。
本記事では以下の2点を置き換えて、完全無料・ローカルで動かすまでの手順と詰まったポイントをまとめます。
| 元の構成 | 置き換え後 |
|---|---|
| OpenAI API | Ollama(ローカルLLM) |
| Zep Cloud(有料) | SQLite + 自作互換レイヤー |
動作環境
| 項目 | 内容 |
|---|---|
| OS | Tahoe 26.4 |
| マシン | MacBook Pro (Apple M5 Pro / 48GB RAM) |
| Python | 3.12(重要:3.14は不可) |
| パッケージマネージャ | uv |
| LLM | Ollama + llama3.1 |
今回は少し古いllama 3.1を使っています。
1. 事前準備
Ollamaのインストールと起動
# インストール(公式サイトからDLするか brew で入れる)
brew install ollama
# モデルのダウンロードと起動
ollama pull llama3.3:70b
ollama serve
http://localhost:11434 でOpenAI互換のAPIが使えるようになります。
uvのインストール
MiroFishはパッケージマネージャに uv を使っています。
curl -LsSf https://astral.sh/uv/install.sh | sh
source ~/.zshrc # または ~/.bashrc
uvについてはこちら
2. リポジトリのクローンと依存関係のインストール
git clone https://github.com/666ghj/MiroFish.git
cd MiroFish
Python 3.14では動きません
uv sync 時に以下のエラーが出ました。
error: Failed to build `tiktoken==0.7.0`
MiroFishが依存する camel-ai 0.2.78 が tiktoken 0.7.0 を固定しており、これは Python 3.14でビルドできません。Python 3.12を指定して解決します。
brew install python@3.12
uv sync --python 3.12
また、tiktokenのビルドには Rust が必要です。入っていない場合は先にインストールしてください。
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source ~/.cargo/env
3. .envの設定
MiroFish/.env を作成します。
# Ollama(OpenAI互換エンドポイント)
LLM_API_KEY=ollama
LLM_BASE_URL=http://localhost:11434/v1
LLM_MODEL_NAME=llama3.1
# Zepは後述の自作レイヤーに置き換えるので適当な値でOK
ZEP_API_KEY=local
4. Zep Cloudの置き換え(最大の山場)
問題
MiroFishはグラフ型メモリDBとして Zep Cloud を使っています。アカウントを作成してAPIキーを取得しようとしたところ…
APIの利用はEnterpriseプランのみ
普通の無料アカウントでは使えません。
解決策:SQLiteで互換レイヤーを自作
Zep SDKの呼び出し箇所を調べると、すべて client.graph.node.* / client.graph.edge.* というネスト構造のインターフェースを通じていることがわかりました。この構造を同じままSQLiteで実装すれば、既存コードの変更を最小限に抑えられます。
backend/app/utils/local_graph_store.py を新規作成します。
import json, sqlite3, threading, uuid
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
DB_PATH = Path(__file__).parent.parent / "data" / "mirofish_graph.db"
class _NodeObj:
__slots__ = ("uuid_", "uuid", "name", "labels", "summary", "attributes", "created_at")
def __init__(self, uuid_, name, labels, summary, attributes, created_at=None):
self.uuid_ = self.uuid = uuid_
self.name = name
self.labels = labels
self.summary = summary
self.attributes = attributes
self.created_at = created_at
class _EdgeObj:
__slots__ = (
"uuid_", "uuid", "name", "fact",
"source_node_uuid", "target_node_uuid",
"created_at", "valid_at", "invalid_at", "expired_at",
"attributes", "episodes", "fact_type",
)
def __init__(self, uuid_, name, fact, source_node_uuid, target_node_uuid,
created_at=None, valid_at=None, invalid_at=None, expired_at=None):
self.uuid_ = self.uuid = uuid_
self.name = name
self.fact = fact
self.fact_type = name
self.source_node_uuid = source_node_uuid
self.target_node_uuid = target_node_uuid
self.created_at = created_at
self.valid_at = valid_at
self.invalid_at = invalid_at
self.expired_at = expired_at
self.attributes = {}
self.episodes = []
class _EpisodeObj:
processed = True
def __init__(self, uuid_):
self.uuid_ = self.uuid = uuid_
SQLiteのテーブル定義は以下の通りです。
def _init_schema():
with _open() as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS nodes (
uuid TEXT PRIMARY KEY,
graph_id TEXT NOT NULL,
name TEXT NOT NULL,
labels TEXT NOT NULL DEFAULT '[]',
summary TEXT NOT NULL DEFAULT '',
attributes TEXT NOT NULL DEFAULT '{}',
created_at TEXT
);
CREATE TABLE IF NOT EXISTS edges (
uuid TEXT PRIMARY KEY,
graph_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
fact TEXT NOT NULL DEFAULT '',
source_node_uuid TEXT NOT NULL,
target_node_uuid TEXT NOT NULL,
created_at TEXT,
valid_at TEXT,
invalid_at TEXT,
expired_at TEXT
);
CREATE TABLE IF NOT EXISTS episodes (
uuid TEXT PRIMARY KEY,
graph_id TEXT NOT NULL,
data TEXT NOT NULL,
ep_type TEXT NOT NULL DEFAULT 'text',
processed INTEGER NOT NULL DEFAULT 1,
created_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_n_graph ON nodes(graph_id);
CREATE INDEX IF NOT EXISTS idx_e_graph ON edges(graph_id);
CREATE INDEX IF NOT EXISTS idx_n_name ON nodes(graph_id, name);
""")
Zep SDKと同じネスト構造のAPIクラスを実装し、最後にシングルトンとして公開します。
class LocalGraphStore:
"""Zep(api_key=...) の drop-in replacement"""
_lock = threading.Lock()
_instance = None
def __new__(cls):
with cls._lock:
if cls._instance is None:
inst = object.__new__(cls)
_init_schema()
inst.graph = _GraphAPI()
cls._instance = inst
return cls._instance
既存サービスの修正
Zepを使っていた4ファイルの __init__ を書き換えるだけで済みました。
# 変更前
from zep_cloud.client import Zep
self.client = Zep(api_key=Config.ZEP_API_KEY)
# 変更後
from ..utils.local_graph_store import LocalGraphStore
self.client = LocalGraphStore()
対象ファイル:
services/graph_builder.pyservices/zep_tools.pyservices/zep_entity_reader.pyservices/zep_graph_memory_updater.py
5. ページング処理の修正
utils/zep_paging.py はZep SDKのAPIを直接呼んでいたため、LocalGraphStoreに合わせて書き直しました。
def fetch_all_nodes(client, graph_id, page_size=500, max_items=5000, **_) -> list:
nodes, cursor = [], None
while True:
batch = client.graph.node.get_by_graph_id(
graph_id, limit=page_size, uuid_cursor=cursor
)
if not batch:
break
nodes.extend(batch)
if len(nodes) >= max_items or len(batch) < page_size:
break
cursor = getattr(batch[-1], "uuid_", None) or getattr(batch[-1], "uuid", None)
if not cursor:
break
return nodes[:max_items]
6. 詰まったポイント:エンティティが0件になる競合状態
症状
シミュレーションを実行すると以下のログが出て先に進めませんでした。
グラフエンティティの読み取り: 完了、合計0エンティティ
没有找到符合条件的实体
原因
元の実装では、add_batch() でエピソードを追加した後、バックグラウンドスレッドでOllamaにエンティティ抽出を依頼していました。
# 元のコード(問題あり)
threading.Thread(
target=_extract_and_store, args=(graph_id, data), daemon=True
).start()
一方、graph_builder.py は _EpisodeObj.processed == True を確認してすぐに次の処理へ進むため、Ollamaが抽出を終える前にシミュレーションがエンティティを読みに行ってしまっていました。
修正
add_batch() 内の抽出処理を同期実行に変更しました。
# 修正後
def add_batch(self, *, graph_id: str, episodes) -> List[_EpisodeObj]:
# ... エピソードをDBに保存 ...
# 同期実行することで、呼び出し元がentityを読みに行く前に抽出が完了する
combined = "\n\n---\n\n".join(texts[:5])
_extract_and_store(graph_id, combined)
return results
Ollamaのレスポンスを待ってから処理が進むため、初回の起動は数十秒かかりますが、0件問題が解消されました。
7. 起動方法
# バックエンド(MiroFish/backend/ で実行)
uv run python run.py
# フロントエンド(MiroFish/ で実行)
npm run frontend
成果物
今回は明日からコーヒーが禁止されるとどうなる?という題材で話し合ってもらいました。
まとめ
| 課題 | 解決策 |
|---|---|
| Python 3.14でtiktokenがビルドできない |
uv sync --python 3.12 で3.12を指定 |
| Zep CloudがEnterprise限定 | SQLite + 互換インターフェースを自作 |
| エンティティ抽出の競合状態(0件問題) | バックグラウンドスレッド→同期処理に変更 |
| OpenAI APIが有料 | Ollamaのllama3.1をローカルで実行 |
ローカルLLMで動かすと、APIコストを気にせず試行錯誤できるのは大きなメリットです。
