会話メモリ producer のライブ給餌で項目6を完全クローズ ── そして「引き継ぎ資料より実機を見る」規律が二重実装を防いだ話

分散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.confthread_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 を確認しました。

  1. 書き込み: [remember] [thread/fact] thread=3b3c778c-… 'ユーザーの作業ディレクトリは~/coordinatorです'。スコープが thread、スレッドIDが実際の会話ID。同じログに並ぶ夜間由来の [global/decision] thread=- と対比すると、「ライブ=thread / 夜間=global」の二輪が回り始めたのが一目で分かります。
  2. 読み出し: 2往復目「私の作業ディレクトリはどこだっけ?」に対し、1往復目とは別のノード(moon → rtx3070ti)が ~/coordinator と正答。別ノードが答えられたということは、ノード内に記憶があったのではなく、共有の記憶ストアから検索・注入された証拠です。
  3. 自己増幅の抑止: 質問文「どこだっけ?」は抽出器が「覚えるべき事実ではない」と判定して非記憶([pass] not durable)。問い返しが新たな記憶として積み上がらないことを確認。
  4. 非汚染: 直近のタスク記録のプロンプトはすべて原文のみ。注入版の前置は混ざっていませんでした。

これで項目6(会話メモリ)は producer / consumer の両輪、global / thread の両スコープが揃って完全クローズです。

今回の本当の学び:引き継ぎ資料は実機の影でしかない

もし引き継ぎ資料の「producer は未着手」をそのまま信じていたら、すでに完成・稼働していた投函口を、もう一度ゼロから実装しかけていました。幸い、このプロジェクトには「作業前に稼働中のソースを直接 grep して確認する」という習慣があり、それが二重実装を未然に防ぎました。

ドキュメントのステイルネス(古び)は、これまで「本番の改善を黙って巻き戻す」方向で警戒してきましたが、今回は逆向き——「完成済みの機能を取りこぼし、無駄な再実装を招く」方向にも効くのだと分かりました。資料はあくまで実機の影であって、実機そのものではない。判断の一次ソースは常に稼働中のシステムに置く、という原則を再確認した回でした(発見したドキュメントのズレ3点は、その場で資料に反映済みです)。

次にやること

会話メモリが片付いたので、次の優先候補は外部レビューでも最優先に挙がっていたソース/設定の Git 管理(自宅サーバへの self-host)と、PostgreSQL のテストリストアです。「復元したことのないバックアップは、まだ仮説でしかない」——次回はそこに手を入れます。