Ncatbotでマルチモーダル対話プラグインを構築し、OpenAIとOllamaを連携する実装
0. はじめに
プロジェクトはGithubでオープンソース化されています。ここでは基礎的な技術解説を行います。
https://github.com/ouyangyanhuo/ModelChat
1. 構造設計:プラグイン化エンジニアリング
このプロジェクトでは、プラグインアーキテクチャを採用してシステムを構築しています。Ncatbotのプラグインモードを利用することで、メインプログラムと機能をうまく分離できます。
プラグインアーキテクチャの利点は、モジュール境界が明確で、依存関係の分離度が高く、システムの拡張性に優れていることです。標準的なプラグイン化により、コード間の結合が大幅に緩和され、開発・デプロイの両方にとって非常に親しみやすいものになります。
プロジェクトのシステムディレクトリ構造は以下の通りです:
ModuleChat/
├── main.py # プラグインのメインエントリ。コマンド登録とディスパッチロジックを担当
├── chat.py # モデル適応層。ローカル・クラウドモデルの呼び出しをカプセル化
├── config.yml # 設定ファイル。モデルパラメータと有効化オプションを一元管理
├── requirements.txt # 依存ライブラリ
└── cache/
└── history.json # チャット履歴の記憶ファイル
chat.py はシステムの中核モジュールであり、メインプログラム main.py はコマンドの受信・解析、およびモデルモジュールへのメッセージルーティングを担当します。開発においてこれは非常に親しみやすい構造であり、単一の機能モジュールの開発とデバッグに集中でき、保守の複雑さを大幅に軽減します。
設定項目は config.yml に集約されており、柔軟性と環境適応能力がさらに向上しています。
一時的なJSONファイル方式を採用し、コマンドの呼び出しと応答を記録してAPIに渡すことで、大規模モデルがある程度の短期記憶機能を得られるようにしています。しかし、これはシステムにとって必ずしも好ましいとは言えず、より良い解決策はデータベースを使用することだと考えます。ただし、データベースを使用するとシステムの複雑さが大幅に増すため、JSONファイルを使用することは良い代替選択です。
2. プラグインメインプログラム:コマンド分離とルーティングハブ
main.py はプラグインのメインエントリであり、register_user_func メソッドを使用して /chat と /clear chat_history の2つのコマンドを登録します。それぞれチャット機能と履歴クリア機能に対応します。
さらに、メインプログラムは画像メッセージの自動認識をサポートし、画像URLを抽出して chat_model_instance.recognize_image メソッドに渡し、視覚的な説明を自動的に取得します。
if image_url and self.chat_model.get('enable_vision', True) and not self.chat_model.get('use_local_model'):
# 画像認識機能を使用
image_description = await chat_model_instance.recognize_image(image_url)
user_input = f"ユーザーが画像を送信しました。画像の説明:{image_description}。ユーザーの発言:{user_input}"
elif image_url and not self.chat_model.get('enable_vision', True):
# 画像認識機能が無効、ただしローカルモデルかどうかを確認
if self.chat_model.get('use_local_model'):
user_input = f"ユーザーが画像を送信しましたが、ローカルモデルを使用しているため画像認識ができません。ユーザーの発言:{user_input}"
else:
user_input = f"ユーザーが画像を送信しましたが、画像認識機能が無効です。ユーザーの発言:{user_input}"
視覚的な説明を取得した後、言語大規模モデルに渡して出力を生成します。これは非常に優れた解決策です。現在の使用シナリオでは、画像そのものを処理するのではなく、画像を認識して内容を分析・処理することが求められることが多いため、API呼び出しを大幅に削減し、キャッシュヒット率を向上させ、TOKEN使用量を減らし、API呼び出しコストを削減できます。さらに、クラウドの大規模モデルで認識を行い、その結果をローカルの大規模モデルに渡して回答を生成することで、コストをさらに圧縮できます。
エラー処理に関しては、try...except でチャットロジック全体をラップし、画像デコードの失敗やAPIの例外によるメインフローのクラッシュを防ぎ、プラグインの堅牢性を維持しています。
総合的に見ると、main.py は典型的な「軽量コントローラー」パターンであり、各コンポーネントを調整するだけで、ビジネスロジックの詳細は担わないため、プラグイン全体のエンジニアリング的可読性が高くなっています。
3. モデル適応モジュール:マルチモデルのカプセル化と意味的一貫性
chat.py はプラグインの中核ロジックです。モデル呼び出し、チャット履歴の記憶、画像認識などのタスクを処理します。複数のモデルインターフェース(OpenAI APIやOllamaローカルサービスなど)と互換性を持たせるため、統一カプセル化インターフェース戦略を採用し、外部呼び出し側はモデルの詳細を意識せず、useCloudModel() または useLocalModel() の2つのメソッドを使用するだけで対話を完了できます。
async def useLocalModel(self, msg: BaseMessage, user_input: str):
"""ローカルモデルを使用してメッセージを処理"""
try:
# 履歴を含むメッセージリストを構築
messages = self._build_messages(user_input, msg.user_id if hasattr(msg, 'user_id') else None)
response: ChatResponse = chat(
model=self.config['model'],
messages=messages
)
reply = response.message.content.strip()
# 現在の対話を履歴に保存
if hasattr(msg, 'user_id'):
self._update_user_history(msg.user_id, {"role": "user", "content": user_input})
self._update_user_history(msg.user_id, {"role": "assistant", "content": reply})
except Exception as e:
reply = f"リクエストエラー:{str(e)}"
return reply
注目すべき点として、OpenAIインターフェースの呼び出しとOllamaの呼び出しには若干の違いがあり、一部のモデルのパラメータが完全ではないため、OpenAIインターフェースを使用してクラウドモデルを呼び出す方が、より良い体験を得られることがあります。例えば、モデルのtemperatureを制御して、より想像力豊かにしたり、より現実的にしたり、幻覚を減らしたりできます。
すべてのユーザー履歴は cache/history.json ファイルに保存されます。これは永続化保存の方法であり、ある程度の追跡可能性も備えています。履歴は _update_user_history メソッドで動的に更新され、設定ファイルで指定された最大ターン数内に制御されます。この方法により、コンテキストが大きくなりすぎることで発生するパフォーマンス問題を防ぎ、モデルが連続したコンテキストを理解できるようにして回答品質を向上させ、インターフェースを接続しているだけでも近似の記憶能力を持つことができます。
クラスにはOpenAIの画像認識モデルも統合されており、_build_vision_messages を使用してマルチモーダルメッセージ構造を構築します。設計では、画像処理、メッセージ構築、例外処理、モデル呼び出しなどの関数を分離しているため、開発中に問題を迅速に特定でき、オープンソース化後も他の開発者が読みやすくなっています。
4. クラウドモデル連携(OpenAI):標準化カプセル化
クラウドモデルの呼び出しは主に openai 公式ライブラリを介してカプセル化され、chat.completions.create メソッドを使用してコンテキスト構築と応答生成を行います。毎回の呼び出しで _build_messages() メソッドを使用して完全な対話コンテキストを構築し、システムプロンプトを追加し、cache/history.json に保存された履歴を使用して、マルチターンの記憶型対話を実現します。
def _build_messages(self, user_input: str, user_id: str = None):
"""メッセージリストを構築"""
messages = []
# システムプロンプトを追加
system_prompt = self.config.get('system_prompt', "あなたはチャット相手のロボットです")
messages.append({"role": "system", "content": system_prompt})
if user_id:
history = self._get_user_history(user_id)
messages.extend(history)
# 現在のユーザー入力を追加
messages.append({"role": "user", "content": user_input})
return messages
呼び出しロジックでは temperature パラメータをカプセル化し、設定ファイルを通じてモデル出力のランダム性を柔軟に制御できます。
バグレポートが発生した場合、エラーをユーザー側に返すために return を統一して使用します。これにより、エラー処理の開発量を大幅に削減し、設定ミスによる一般的な問題をより明確にフィードバックできます。つまり、統一モデル+ルール処理の2つの方法で実行時の問題をフィードバックします。
if "401" in str(fallback_error) or "Unauthorized" in str(fallback_error):
raise Exception("モデルAPI認証に失敗しました。設定ファイルを確認してください")
raise Exception(f"画像認識エラー: {str(e)}, 代替方法も失敗: {str(fallback_error)}")
結果が返された後、このターンのQ&Aをユーザー履歴キャッシュに同期し、ローカルファイルに保存して、次のターンでコンテキストを確実に取得できるようにします。これにより、メモリ依存を減らし、キャッシュヒット率を向上させ、その後のデバッグや動作再現の根拠を提供します。
5. ローカルモデル呼び出し(Ollama):軽量推論とインターフェース統一
ローカルモデルの呼び出しは ollama.chat() で行われ、_build_messages() のコンテキスト構築ロジックを再利用して、クラウドと一貫した呼び出しロジックを確保し、インターフェースの一貫性を維持します。
このローカル推論メカニズムの利点は明らかです。ネットワークがない環境やプライベートデプロイ環境でもスマート対話機能を使用でき、プラグインのデプロイ柔軟性とセキュリティが大幅に向上します。プライバシーに敏感なシナリオでも、ローカルでのデプロイと実行が可能です。
設計では、ローカルとクラウドの呼び出しインターフェースを統一し(どちらも use*Model() としてカプセル化)、外部呼び出し側がモデルの出所を判断する必要がないようにして、複雑さを低減しています。さらに、履歴更新や例外キャッチ機構も同様に実装されており、ローカルモデルはクラウドと同等の機能完全性と安定性を備えています。
6. 画像認識処理ロジック:マルチモーダル入力の意味的強化戦略
画像認識機能はこのプラグインの大きなハイライトです。画像メッセージを認識し、OpenAIビジョンモデルで処理することをサポートしています。全体のフローは以下の通りです:
画像メッセージからURLを抽出;
for segment in msg.message: if isinstance(segment, dict) and segment.get("type") == "image": image_url = segment.get("data", {}).get("url") break
- HTTPリクエストで画像コンテンツを取得し、Base64エンコード;
- ビジョン入力形式を構築(
image_urlとtext promptを含む);
- ビジョン入力形式を構築(
response = requests.get(image_url)
response.raise_for_status()
return base64.b64encode(response.content).decode('utf-8')
- ビジョンモデルを呼び出して画像説明を生成;
# 画像を取得してエンコード
image_data = self._encode_image_from_url(image_url)
# メッセージを構築
messages = self._build_vision_messages(image_data, prompt)
# ビジョンモデルを呼び出し
response = self.vision_client.chat.completions.create(
model=self.config.get('vision_model'),
messages=messages,
temperature=self.config.get('model_temperature', 0.6),
stream=False,
max_tokens=2048
)
- 画像説明をユーザー入力に連結し、コンテキストの意味的完全性を向上。
このメカニズムは、画像とテキストが混在する入力シナリオにおける情報非対称性の問題を効果的に解決し、段階的な呼び出し調整により、複雑な問題はクラウドの高い計算能力を利用し、単純化された問題はローカルで処理することで、TOKEN使用率を大幅に削減します。
例外処理に関しては、2段階のフォールバック戦略を設計しています。メインの呼び出しが失敗した場合はプレーンテキストのフォールバックプロンプトを試み、それも失敗した場合はAPIキーやモデル状態の確認をユーザーに促します。このようなフォールトトレラント設計により、プラグインは部分的な失敗時でもサービスを中断せずに維持できます。
7. チャット履歴システム:メモリウィンドウ制御
チャット履歴は cache/history.json ファイルに保存され、ユーザー単位で管理されます。この設計により、システムは複数のユーザーに同時にサービスを提供でき、各ユーザーに独立したコンテキストを維持できます。_get_user_history と _update_user_history メソッドを通じて、プラグインは各ターンの対話に履歴情報を自動的に注入し、擬似的な「記憶型」Q&A体験を実現します。
履歴の長さにはウィンドウ制限(デフォルト10ターン)を設定し、コンテキストサイズを制御して、モデルへの処理負荷やTOKEN消費の過大を防いでいます。キャッシュ更新は同期書き込み操作であり、システムクラッシュや停電などの異常時でも情報が失われないようにしています。
async def clear_user_history(self, user_id: str):
"""指定ユーザーの履歴をクリア"""
user_id = str(user_id)
if user_id in self.history:
del self.history[user_id]
self._save_history()
reply = "チャット履歴をクリアしました"
else:
reply = "ユーザーのチャット履歴が見つかりません"
return reply
さらに、/clear chat_history コマンドでユーザー履歴を能動的にクリアすることもでき、プライバシー保護や対話のリセットに便利です。このメカニズムにより、プラグインは永続性を備えつつ、ユーザーが主体的に制御する余地も残しています。
8. DEBUG & LOG
デバッグ時にブレークポイントを設定したり、printでマーカーを出力したりするのは良いテスト習慣です。私はWeChat開発からも一招学びました。それは print("FUCK") です。長期運用中に時々クラッシュが発生することがありますが、その際にログに特定の文字列を出力しておけば、ログを確認して問題を特定するときに、その文字列を直接検索して迅速に位置を特定できます。FUCKは間違いなく面白い方法です。