分散AI基盤 Coordinator のシリーズ第28回。前回(第27回)は同一スレッド内の会話履歴フォワード(Path A)を実装しました。今回はその裏で長らく「半分だけ動いていた」会話メモリの書き込み側を完成させ、項目6(会話メモリ)を完全クローズします。そして今回いちばんの学びは、コードよりも「引き継ぎ資料を信じず、稼働中の実機を直接見る」という運用規律の話でした。
会話メモリの「両輪」をおさらい
Coordinator の会話メモリは2つの役割で成り立っています。
- consumer(読み出し側):
/routeの入口で、過去の記憶を Qdrant から検索し、関連するものだけをプロンプトに前置して Worker に渡す。前々回までに完成済み。 - producer(書き込み側): 会話から「覚えるべき事実・決定・方針」を軽量LLM(moon の qwen2.5:7b)で抽出し、Qdrant に保存する。
記憶には2つのスコープがあります。global(全スレッド共通)と thread(特定の会話スレッド限定)です。producer 側はこれまで夜間バッチ(PostgreSQL の完了タスクを走査して給餌)だけが動いており、これは thread_id を持たないため global 専任でした。つまり「この会話の中だけで覚えておきたいこと」をライブで thread として書く経路が欠けていた——これが残課題「Path B」でした。
着手前に実機を見たら、前提が覆った
当初の計画では、Path B は「Coordinator に新しい投函口を作り、pipe から叩き、worker のスコープ設定を切り替える」という3点の実装だと見積もっていました。ところが着手前に稼働中のソースを直接確認すると、3点のうち2点はすでに完成・稼働していたのです。
| 部品 | 実機の状態 |
|---|---|
Coordinator の投函口 POST /ingest_memory |
✅ 実装済み・稼働中(引き継ぎ資料に記載漏れ) |
worker のスコープ分岐 thread_if_available |
✅ コード実装済み |
| worker サービスのスコープ設定 | ✅ override.conf で thread_if_available に切替済み(稼働 env も確認) |
pipe → /ingest_memory の呼び出し |
❌ これだけ欠落 |
「入口・抽出器・スコープ設定」は揃っているのに、それを叩く者が誰もいなかった。だからライブの thread 記憶は、ここまで1件も書かれていませんでした。残りは pipe の1か所だけ。当初「3点の新規実装」と見積もったものが、実際は「1か所の配線」に縮みました。
変更は pipe の1ブロックだけ
OpenWebUI の Pipe Function(coordinator_pipe.py)を v6.5 に更新。タスクの応答をユーザーに返した後に、会話を投函口へ best-effort で送るだけです。
if self.valves.MEMORY_INGEST_ENABLED and chat_id:
try:
async with aiohttp.ClientSession() as session:
await session.post(f"{base_url}/ingest_memory", json={
"prompt": user_message, "response": response_text,
"thread_id": chat_id, "source_task_id": task_id, "model": model,
}, timeout=aiohttp.ClientTimeout(total=self.valves.MEMORY_INGEST_TIMEOUT))
except Exception:
pass # best-effort: 取りこぼしは夜間バッチが global で拾う
この十数行に、これまでの設計判断がそのまま効いています。
chat_idがある時だけ投函 → ライブはthread専任、globalは夜間バッチのバックストップ。役割がきれいに分かれます。- 除外(Web検索・RAG)は
modelを渡して Coordinator 側に一元化。pipe は判定ロジックを持ちません。 - 重複排除は
source_task_id。ライブで投函したものは、夜間バッチが同じタスクIDを見てスキップします。取りこぼした時だけ夜間がglobalで拾う。 - PostgreSQL は汚染しない。pipe が送るのは原文のプロンプトだけ。記憶や履歴を前置した「注入版」はキュー側にしか存在しないので、タスク記録は常に原文のまま保たれます。これは記憶が記憶を呼ぶ自己増幅ループの防止に直結します。
- 応答は投函の前に返し終えているので、投函の失敗・遅延・古い Coordinator の404は、すべて握り潰してユーザー体験に一切影響させません。
実機で4点を確認
同一スレッドで2往復して、producer のログと PostgreSQL を確認しました。
- 書き込み:
[remember] [thread/fact] thread=3b3c778c-… 'ユーザーの作業ディレクトリは~/coordinatorです'。スコープがthread、スレッドIDが実際の会話ID。同じログに並ぶ夜間由来の[global/decision] thread=-と対比すると、「ライブ=thread / 夜間=global」の二輪が回り始めたのが一目で分かります。 - 読み出し: 2往復目「私の作業ディレクトリはどこだっけ?」に対し、1往復目とは別のノード(moon → rtx3070ti)が
~/coordinatorと正答。別ノードが答えられたということは、ノード内に記憶があったのではなく、共有の記憶ストアから検索・注入された証拠です。 - 自己増幅の抑止: 質問文「どこだっけ?」は抽出器が「覚えるべき事実ではない」と判定して非記憶(
[pass] not durable)。問い返しが新たな記憶として積み上がらないことを確認。 - 非汚染: 直近のタスク記録のプロンプトはすべて原文のみ。注入版の前置は混ざっていませんでした。
これで項目6(会話メモリ)は producer / consumer の両輪、global / thread の両スコープが揃って完全クローズです。
今回の本当の学び:引き継ぎ資料は実機の影でしかない
もし引き継ぎ資料の「producer は未着手」をそのまま信じていたら、すでに完成・稼働していた投函口を、もう一度ゼロから実装しかけていました。幸い、このプロジェクトには「作業前に稼働中のソースを直接 grep して確認する」という習慣があり、それが二重実装を未然に防ぎました。
ドキュメントのステイルネス(古び)は、これまで「本番の改善を黙って巻き戻す」方向で警戒してきましたが、今回は逆向き——「完成済みの機能を取りこぼし、無駄な再実装を招く」方向にも効くのだと分かりました。資料はあくまで実機の影であって、実機そのものではない。判断の一次ソースは常に稼働中のシステムに置く、という原則を再確認した回でした(発見したドキュメントのズレ3点は、その場で資料に反映済みです)。
次にやること
会話メモリが片付いたので、次の優先候補は外部レビューでも最優先に挙がっていたソース/設定の Git 管理(自宅サーバへの self-host)と、PostgreSQL のテストリストアです。「復元したことのないバックアップは、まだ仮説でしかない」——次回はそこに手を入れます。