会話メモリを実装したのに、同じスレッドで「この市」が通じなかった ― クロスセッション記憶とライブ文脈は別物だった話

家庭内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-pgweb_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)

RouteRequesthistory を足し、メモリ注入の直後(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 ティアへのフォローアップにも条件付きで文脈を渡す件。どちらも次回へ。