AIに会話の記憶を思い出させたら、その思い出を“また記憶”するループ寸前だった ― 会話メモリ consumer と、原文/注入版を分けてPGを汚さない設計

前回、家庭内AIクラスタに「会話を覚える」仕組みの書く側(producer)を載せた。会話の1ターンを軽量LLMで評価し、「覚えておくべき要約」だけを Qdrant のコレクションに貯める抽出器だ。だが、貯めただけでは回答は何も変わらない。今回はその記憶を読んで回答に効かせる側(consumer)を作る。やってみると、コードよりも設計判断のほうがずっと面白かった。とりわけ「注入した文脈を、AIが自分でまた記憶してしまう一歩手前」だったことに気づいた瞬間が。

前提:記憶は貯まっているが、まだ誰も読んでいない

このシリーズで作っているのは「最強モデル1個」ではなく、家にある複数のローカルLLMを実績ベースで使い分ける分散推論基盤だ。Coordinator(FastAPI)がプロンプトを見てモデルとWorkerを選び、Redis のキュー経由で各マシンの Ollama に投げる。

前回の producer は、専用VM(rag-tools)の Qdrant に thread_memory というコレクションを作り、そこに会話の要約を source="auto" で書き込む。たとえば「PostgreSQL に接続するときは必ず -h localhost を付ける」といった、私が過去に決めた運用ルールが文章として貯まっていく。retrieval(検索)の品質は前回すでに計測済みで、言い換えクエリでも本命が top1 に来ること、注入してよいかのしきい値が 0.51 あたりだということまで確定していた。

つまり材料は揃っている。あとは「ユーザーが質問してきたとき、関連する記憶を引いて回答に混ぜる」だけ——のはずだった。

設計判断その1:retrieval をどこで走らせるか

記憶の検索には2つの処理が要る。クエリを埋め込みベクトルに変換する(bge-m3)のと、そのベクトルで Qdrant を検索するのと。問題は、これらが全部 rag-tools VM 側のモジュール(thread_memory.py + 共通ライブラリ + qdrant-client 依存)にまとまっていることだ。一方、注入を仕込みたい /route は Coordinator 本体(AI-Core VM)にある。

rag-tools のモジュールをそのまま Coordinator に持ち込むと、AI-Core に qdrant-client などの依存が増える。そこで、requests だけで完結する薄いクライアント memory_client.py を Coordinator 側に新設した。やることは2つだけ:

  • 埋め込み:Ollama の /api/embed(bge-m3)を叩く
  • 検索:Qdrant の REST API /collections/thread_memory/points/query を叩く

この基盤にはすでに「ルーティングを通さず外部の Ollama を直接叩く」前例がいくつもある(品質採点の判事、RAG の回答合成、記憶の抽出)。今回もその系譜で、REST 直叩きに割り切った。

埋め込みホストでひと悶着あった。当初は rag-tools の Ollama(bge-m3 が載っている)を指したが、AI-Core から繋がらない。Qdrant は 0.0.0.0 で待ち受けているが、Ollama は localhost 待受だったのだ。選択肢は2つ:

  • 案A:rag-tools の Ollama を OLLAMA_HOST=0.0.0.0 で公開し、そこへ埋め込みを投げる
  • 案B:AI-Core 自身に ollama pull bge-m3 して、埋め込みはローカルで完結させる

採ったのは案B。/route は全リクエストが通るホットパスなので、ここが他VMの Ollama 生存に依存するのは避けたかった。同じ bge-m3/Cosine なので、投入時のベクトルとも整合する。rag-tools 側の 0.0.0.0 開放はフォールバック余地として残した。

設計判断その2:AIが自分の注入文を「また記憶」しかけた話

ここが今回いちばんヒヤッとした所だ。

素朴に実装するなら、「記憶を引いてプロンプトの先頭に文脈ブロックを足し、その足した版をタスクとして保存・実行する」となる。実際、最初はそう書こうとした。だが手を止めた。このプロジェクトには、producer の給餌スクリプトに「完了したタスクの prompt を走査して、覚えるべきものを記憶に追加する」夜間バッチがある。

もし「注入版プロンプト」をタスクとして保存していたら、こうなる:

  1. ユーザーが質問 → 記憶を引いて「【参考】…」を前置した注入版を保存・実行
  2. 夜間バッチがその注入版プロンプトを走査 → 「【参考】…」ごと“覚えるべき”と判断して再記憶
  3. 次に似た質問が来ると、その再記憶がまた引かれて注入される → さらに太った版が保存される
  4. 以下、記憶が自分自身を食べて肥大していく

典型的なフィードバックループだ。retrieval の品質がどれだけ良くても、これは静かに記憶を汚染していく。

幸い、回避は構造的にきれいに収まった。Worker はタスク保存先(PostgreSQL)の prompt を読み直さない。Redis キューに積まれたペイロードの prompt をそのまま Ollama に渡すだけ、という既存挙動だった。だから:

  • PostgreSQL の tasks.prompt には 原文を保存する(夜間バッチも品質採点も比較機能も、ここを見るので汚れない)
  • Redis キューには 注入版を積む(Worker はこれを Ollama に渡すので、回答には記憶が効く)

この一点の分離で、Worker は1行も触らずに「回答には効くが、記憶は汚さない」が両立した。設計判断が実装の単純さに直結する、気持ちのいいパターンだった。

設計判断その3:コアルーターに手を入れる怖さ

/route は全リクエストが通る心臓部だ。このシリーズでは過去に、OpenWebUI の隠れた内部タスクが英文テンプレートを /route に流し込み、ルーティングを誤爆させた事故がある。だから今回も最大限に臆病に作った。

  • 完全 fail-open:埋め込み失敗・Qdrant 失敗・タイムアウト・パース失敗——どれが起きても retrieve_context() は空文字を返す。注入が無ければ /route は従来どおり動くだけ。記憶機能が落ちてもルーターの可用性は1ミリも下げない。
  • フラグゲートMEMORY_INJECT_ENABLED が既定オフ。コードを配備してもフラグを立てるまで注入は一切走らない。本番投入と機能有効化を分離できる。
  • しきい値ゲート:スコア 0.51 未満は注入しない。関連の薄い質問にまで文脈を足さない。

注入の対象は通常推論と worker 指名だけにして、Web検索とRAGは除外した。SearXNG に投げる検索クエリや、RAG自前の検索文に余計な文脈を混ぜたくないからだ。

配線:会話の識別子を thread_id として運ぶ

最後に、OpenWebUI と Coordinator をつなぐ pipe を一点だけ直した。pipe が受け取る __metadata__ には chat_id(会話=スレッドの識別子)が入っているので、それを /route の body に thread_id として乗せる。取れなければ付けない(その場合はグローバル記憶だけで動く)。consumer 側の検索は「グローバル記憶 OR その会話の記憶」を両取りするので、thread_id があってもグローバルの記憶は引き続きヒットする。

実機で確かめる

「PostgreSQL に接続するときの注意点は?」と聞いてみる。retrieval 単体では、-h localhost を付けるという記憶が 0.714 でトップに来た。以下 0.497、0.461……と続くので、0.51 のゲートで本命だけが通る。

/route 経由でもログに注入が記録された:

[memory] injected (thread=da8901b5-... top=0.714 hits<=3)
[route] task_id=... worker=... model=... domain=general

thread= が実際の chat_id になっている=pipe の配線も効いている。そして PostgreSQL を覗くと、tasks.prompt は原文のまま(「【参考】」を含まない)で、回答のほうには -h localhost の知識がちゃんと反映されていた。狙いどおり「回答に効くが、保存は汚れない」。

逆に、別のスレッドで記憶に関係ない質問を投げたときは、注入ログが一切出なかった。これは失敗ではなく、しきい値ゲートが「関係ない記憶を無理に足さない」判断をした正常動作だ。誤注入しないことの確認のほうが、注入できることの確認より安心できる。

残したものと、学び

これで会話メモリは「書く側」と「読む側」が揃い、過去に決めた運用ルールが回答に効くようになった。ただし producer は当面、記憶をグローバル固定で書いている(その会話だけの記憶を書き分けるのは次回)。consumer の配線で thread_id は流れ始めたので、producer 側のスコープ判定を切り替えれば、スレッド単位の読み書きが両輪になる。しきい値 0.51 も11件という小さな母数での暫定値で、記憶が増えたら計測ハーネスで取り直す前提だ。

今回いちばんの学びは、「抽出器を完璧にする」ことに賭けない設計だった。producer の抽出が多少取りこぼしても・余計に拾っても、consumer 側のしきい値ゲートfail-openが二段目の防波堤になる。そして何より、注入版を保存しないという小さな分離が、記憶が自分を食べるループを未然に断った。派手な機能追加より、こういう「やらないことを正しく決める」判断のほうが、長く使う基盤では効いてくる。


このブログは家庭内に組んだ分散ローカルLLM基盤の構築記録のシリーズです。全記事の目次はこちら