家庭内AIクラスタに会話メモリを載せて「機能クローズ」と書いた翌日、OpenWebUI で何気なく続けて質問したら、AI がさっきの話をまるで覚えていなかった。覚えるための機能を作ったばかりなのに、である。今回はその「覚えていなかった理由」が3つも重なっていた話と、本当に必要だった修正の記録。
症状:同じスレッドなのに「この市」が通じない
OpenWebUI の同一スレッドで、こう続けて聞いた。
ユーザー:今日の千葉県柏市の天気を教えて
AI(domain: general / model: web_search / worker: web-worker):……柏市は晴れ時々曇、最高31℃……ユーザー:この市の特産物をおしえて
AI(domain: general / model: qwen2.5:7b / worker: moon):お尋ねの「この市」について、具体的な情報がなければ特徴を特定できません。市名・位置などを教えていただければ……
1ターン目で「柏市」と言っているのに、2ターン目の「この市」が解決できていない。前日、スレッド単位の会話メモリ(producer=抽出・consumer=/route 注入)を実装し終えて、機能としてはクローズしたばかりだった。「覚えてるはずでは?」というのが出発点。
真因:覚えていない理由が3つ重なっていた
ログと設計を順に当たると、これは1つのバグではなく、性質の違う3つが折り重なっていた。
① producer は夜間バッチでしか動かない
会話メモリの「書く側」(worker_memory.py)は、feed_memory_nightly.sh が深夜 03:30 に走って、PostgreSQL の done タスクを記憶候補として給餌する設計だった。つまりターンが終わった瞬間にリアルタイムでストアへ書く経路は無い。ライブ会話の最中、直前ターンはまだ記憶ストア(Qdrant の thread_memory)に存在しない。
② そもそも直前ターンは web_search だった
仮に夜を跨いだとしても、給餌バッチ feed_memory.py --from-pg は web_search / rag タスクを除外している(SearXNG クエリやRAG検索を記憶にしても仕方ないため)。柏市の天気ターンは web_search 行きなので、今夜のバッチが走っても永遠にストアに入らない。
③ pipe が「最後の1発言」しか送っていなかった
そして本命。OpenWebUI の Pipe Function(coordinator_pipe.py)は、messages から最後のユーザー発話だけを取り出して /route に送っていた。会話履歴は一切渡していない。だから moon の qwen2.5:7b は turn1(柏市の天気)を一度も見ないまま「この市」を解決しようとして、当然できなかった。
結論として腑に落ちたこと:会話メモリ機能は「昨晩までに取り込んだ過去会話を、別のスレッドからでも思い出す」=クロスセッション想起が目的だった。今回ユーザー(=自分)が期待していたのは「同じスレッドの直前の続き」=ライブの文脈継続(指示語解決)。この2つは別物で、後者は会話メモリの守備範囲外だった。
retrieval の eval は hit@1 が 9/9 で全部 pass していた。でも eval が通ることと、ユーザーが実際にやる操作を満たすことは、別の話だった。
対策:履歴を「キューにだけ」前置する
素朴な解は「pipe が履歴を prompt に前置して送る」だが、これは過去に自分で根絶したはずの罠を2つ踏み直す。
- PG汚染:
tasks.promptに履歴入りの文字列が入ると、feed_memory --from-pgが毎ターン過去発話を再取り込みする自己増幅ループが復活する(producer 設計でわざわざ潰したやつ)。 - 分類汚染:ドメイン分類LLM(AI-Core の LFM2.5)が履歴ごと読んで誤分類しうる。
なので、consumer を作ったときとまったく同じ分離パターンを使う。pipe は履歴を別フィールド history で送り、Coordinator はRedisキューの prompt にだけ前置する。PG と分類はあくまで原文(current)のまま。
Coordinator 側(coordinator_api.py v6.6)
RouteRequest に history を足し、メモリ注入の直後(named/normal 分岐の手前)で前置するだけ。
class RouteRequest(BaseModel):
prompt: str
...
thread_id: Optional[str] = None # v6.5: 会話メモリ thread-scope 用
history: Optional[str] = None # v6.6: 直近会話履歴(キューpromptにのみ前置)
# /route 内:
queue_prompt = req.prompt
ctx = memory_client.retrieve_context(req.prompt, req.thread_id or "")
if ctx:
queue_prompt = ctx + req.prompt
# --- 会話履歴の前置(Path A・v6.6)---
# PG(tasks.prompt)・ドメイン分類は req.prompt(原文)のまま。
# web/rag はこの手前で早期returnするため対象外(memory注入と同じ除外)。
if req.history:
queue_prompt = req.history + queue_prompt
合成順は history + memory文脈 + 原文。PG への INSERT は従来どおり req.prompt、Worker へ渡る Redis キューだけが queue_prompt。Worker は無改修。
pipe 側(coordinator_pipe.py v6.4)の落とし穴
履歴を組み立てるとき、ひとつ忘れがちな点があった。OpenWebUI に保存されている自分のアシスタント発話には、pipe 自身が付けた装飾が乗っている。「📡 ルーティング情報」ヘッダ、「⏳ 処理中…」、末尾の --- domain: ... / worker: ... フッター。これをそのまま履歴に入れると、モデルに自分のルーティングメタ情報を食わせることになる。剥がしてから渡す。
def _clean_assistant(content: str) -> str:
text = content
# 末尾フッター "---*domain:..." 以降を除去
for m in ("\n\n---\n*domain:", "\n---\n*domain:", "---\n*domain:"):
i = text.find(m)
if i != -1:
text = text[:i]; break
# "⏳ 処理中..." 以前(ヘッダ・起動待ち表示)を捨てて実回答だけ残す
pos = text.rfind("⏳ 処理中...")
if pos != -1:
text = text[pos + len("⏳ 処理中..."):]
# 行頭が装飾の残骸なら落とす(保険)
return "\n".join(l for l in text.splitlines()
if not l.strip().startswith(("📡","- ドメイン:","🌙","✅ ","⚠️","❌"))).strip()
あとは直近Nターン(current は除外)を整形して route_payload["history"] に乗せるだけ。安全装置として Valves に HISTORY_ENABLED(既定ON)/ HISTORY_MAX_TURNS / HISTORY_MAX_CHARS を置き、小型モデルの文脈・レイテンシを保護する。OFF にすれば pipe が history を送らず、Coordinator 側の if req.history が偽になって従来挙動に戻る=ワンスイッチでロールバックできる。
検証:原文は汚さず、文脈は解決する
確認したいのは2つ。「PGに履歴が混ざっていないか(汚染なし)」と「Worker が文脈を解決できたか(E2E)」。
$ curl -s -X POST .../route -d '{
"prompt":"この市の特徴は何かある?",
"history":"これまでの会話:\nユーザー: 千葉県柏市の今日の天気を教えて\n..." }'
{"task_id":"f60be4c7-...","worker_id":"moon","model":"qwen2.5:7b","domain":"general"}
$ psql -c "SELECT prompt FROM tasks WHERE task_id='f60be4c7-...';"
prompt
--------------------------
この市の特徴は何かある? ← 原文だけ。履歴は混ざっていない
PG の prompt は原文のみ。汚染分離は効いている。そして本番 UI で同じ流れを再現すると、2ターン目に市名を一切書いていないのに、qwen2.5:7b が「柏市の特産品としては……」と返した。注入した履歴から「この市=柏市」を解決できている。
正直な但し書き:このとき qwen2.5:7b は「千葉レモン」「柏米」といった実在しない特産物を堂々と挙げてきた。回答内容としては幻覚だらけ。ただしこれは 7B モデルの知識の薄さの問題で、今回検証したかった「指示語を解決できるか」とは別レイヤーである。むしろこれは次の課題をはっきり示している――「文脈は解決できるが固有知識が薄い」質問こそ、本来は web 検索や RAG に寄せたい。1ターン目の天気が web に行ったように、フォローアップの実地情報もティアを選び直す余地がある。
教訓
分散システムの改善は、たいてい「直したと思った所の隣」が次のボトルネックになる。今回もそうだった。
- 「機能をクローズした」と「ユーザーの期待を満たす」は別物。retrieval eval が全部 pass しても、ユーザーが実際にやる「同一スレッドの続き」は、まったく別の経路(pipe の履歴フォワード)に依存していた。クローズ判定は実ユースケースで踏むべきだった。
- source-of-truth とキューを分ける規律が、また効いた。producer で「PGには原文・キューには注入版」を徹底していたおかげで、履歴フォワードも同じ型に流し込むだけで済み、潰したはずの自己増幅ループを再発させずに済んだ。設計の一貫性は後から効いてくる。
- 履歴に自分の出力を混ぜるときは、自分が付けた装飾を剥がす。当たり前のようで、ヘッダもフッターも全部モデルに渡してしまう事故は起きやすい。
残課題は2つ。producer 側をリアルタイム化し MEMORY_AUTO_SCOPE を thread-scope に切り替える Path B(ターン毎の記憶書き込みと読みを両輪にする)と、web / rag ティアへのフォローアップにも条件付きで文脈を渡す件。どちらも次回へ。