0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

LangChainのチャットボットが3往復目から過去を忘れた — ConversationBufferMemoryの実装ミス3つと記憶の正しい保存先

0
Posted at

「先ほどお伝えした会員番号を復唱してください」と聞いたら、自社で作った社内ヘルプデスクbotが「お話しいただいた記憶はありません」と返しました。LangChainのチャットボットが3往復目から過去を忘れる事故が、リリース3日目に起きた記録です。失敗の根っこは「Memoryオブジェクトを作れば記憶してくれる」という前提でした。この記事では、事故を3つの実装ミスに分解し、RunnableWithMessageHistory+FileChatMessageHistoryで「何往復でも覚える」状態に直す完全なコードを載せます。

何が起きたか: 3往復目から過去が消える

実測のタイムラインです。

時刻 起きたこと
リリース1日目 単発の質問には正しく答える。QAリストの初期テストは全パス
リリース2日目 19:40 ユーザーが「会員番号は A-7742」と伝言。直後の返答は正しい
リリース2日目 19:42 3往復目で「さっきの会員番号を復唱して」→「お話しいただいた記憶はありません」
ログ確認 chat_history ディレクトリが空。履歴はどこにもファイルとして残っていなかった

事故を整理すると、1つの症状に対して3つの原因が積み重なっていました。

原因 症状 正しい対策
リクエストのたびにMemoryをnewしている 毎回履歴が空。1往復前のことも覚えない session_idをキーに履歴を永続化ストアから取得する
保存先がプロセス内メモリのみ worker再起動・デプロイのたびに全会話が消える 1セッション1ファイルでディスクに保存する
session_idをリクエストごとにuuid4で生成 すべて別セッション扱い。保存していても辿り着けない ユーザーIDから決定論的に生成する

最初のミスで「記憶しない」、2つ目で「保存しても消える」、3つ目で「保存しても参照できない」が起こります。どれか1つを直しても症状は残るため、3つセットで潰す必要がありました。

失敗した設計: リクエストローカルにMemoryを作るFastAPIハンドラ

リリース時点で私が書いていたのは、次のようなハンドラです。一見LangChainのMemoryを使っているように見えますが、記憶としては何も機能していません。

# 失敗した設計: この3つのミスを同時に抱えていた
import uuid
from fastapi import FastAPI
from pydantic import BaseModel
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate

app = FastAPI()
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.2)
prompt = ChatPromptTemplate.from_messages([
    ("system", "あなたは社内ヘルプデスクです。過去のやり取りを踏まえて日本語で答えてください。"),
    ("human", "{question}"),
])

class Ask(BaseModel):
    question: str

@app.post("/ask")
def ask(body: Ask) -> dict:
    session_id = str(uuid.uuid4())        # 失敗3: 毎リクエスト新しいセッションID
    memory = ConversationBufferMemory()   # 失敗1: リクエストローカル新規=必ず空
    memory.save_context(                  # 失敗2: プロンプトに履歴を組み込む設計が無い
        {"input": body.question},
        {"output": llm.invoke(prompt.invoke({"question": body.question}).content)}
    )
    return {"answer": llm.invoke(prompt.invoke({"question": body.question}).content).content}

「失敗1〜3」のうち失敗2が本質です。プロンプトに MessagesPlaceholder が存在せず、たとえMemoryに履歴が入っていても、LLMに渡るメッセージはsystem+humanの2件だけでした。Memoryクラスは「どこにも履歴を保存しない」デフォルトの受け皿で、注入する経路は自分で設計する必要があります。

記憶の持ち方を4方式で比較した

直す前に、保存先の選択肢を比較しました。セッション数で選べば迷いません。

方式 実装 破綻ポイント 向いている規模
リクエストローカルのMemory(失敗設計) handler内でnew 毎回履歴が空 動かないので選択肢外
プロセス内dictでsession_id→Memoryをキャッシュ RAMに保存 再起動・複数workerで消失 ローカル検証のみ
FileChatMessageHistory(採用) 1セッション1 JSONファイル 同一ファイルへのプロセス間同時書き込みは非推奨 数百セッション程度まで
Redis/Postgres等の外部ストア BaseChatMessageHistoryを自前実装 実装・保守コスト 数千セッション以上・マルチworker

小中規模の社内botならファイル保存で十分でした。workerを複数に増やす前に、まず1プロセスで完結する形にします。

正しい全体像: RunnableWithMessageHistoryに「注入と追記」を一任する

LangChain 0.2以降の標準は RunnableWithMessageHistory です。チェーン側は MessagesPlaceholder で履歴の挿入位置を宣言し、ラッパーがinvokeのたびに (1) session_idから履歴を取得、(2) プロンプトへ注入、(3) 応答後にhuman/AIメッセージを履歴へ追記、までを自動で行います。手動の save_context 呼び出しは不要になりました。

インストールは次の2行です。

pip install -U langchain langchain-openai langchain-community
# 動作確認バージョン: langchain 0.3.10 / langchain-openai 0.2.8

以下が修正後の完全なコードです。1ファイルそのまま保存して実行できます。

# ============================================================
# 記憶する社内ヘルプデスクbot: RunnableWithMessageHistory + ファイル永続化
# 使い方: 環境変数 OPENAI_API_KEY を設定して `python memory_chatbot.py`
# ============================================================
import os
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.chat_history import BaseChatMessageHistory
from langchain_core.runnables import RunnableWithMessageHistory
from langchain_community.chat_message_histories import FileChatMessageHistory

CHAT_DIR = "./chat_history"   # 1セッション = 1 JSONファイルで永続化

PROMPT = ChatPromptTemplate.from_messages([
    ("system", "あなたは社内ヘルプデスクです。会話履歴の事実だけを根拠に、日本語で簡潔に答えてください。"),
    MessagesPlaceholder(variable_name="history"),
    ("human", "{question}"),
])

def get_session_history(session_id: str) -> BaseChatMessageHistory:
    os.makedirs(CHAT_DIR, exist_ok=True)
    return FileChatMessageHistory(file_path=os.path.join(CHAT_DIR, f"{session_id}.json"))

def build_app():
    llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.2)
    chain = PROMPT | llm
    return RunnableWithMessageHistory(
        chain,
        get_session_history,
        input_messages_key="question",    # invoke入力のうち質問に当たるキー
        history_messages_key="history",   # プロンプトのMessagesPlaceholder名と一致させる
    )

def main():
    app = build_app()
    # session_idはログイン済みユーザーIDから決定論的に作る。uuid4は使わない
    config = {"configurable": {"session_id": "helpdesk-user-1001"}}
    demo_inputs = [
        "会員番号は A-7742 です。今日からこの番号で問い合わせます。",
        "さっきお伝えした会員番号を復唱してください。",
        "その番号で、来月の請求書の送付先を変更したいです。手順を教えてください。",
    ]
    for question in demo_inputs:
        answer = app.invoke({"question": question}, config=config)
        print("USER:", question)
        print("BOT :", answer.content)
        print("-" * 60)

if __name__ == "__main__":
    main()

実行すると、3往復目でも過去を参照した答えが返ります(出力例。モデルにより文言は変わります)。

USER: 会員番号は A-7742 です。今日からこの番号で問い合わせます。
BOT : 承知しました。会員番号は A-7742 ですね。ご用件をお聞かせください。
------------------------------------------------------------
USER: さっきお伝えした会員番号を復唱してください。
BOT : あなたの会員番号は A-7742 です。
------------------------------------------------------------
USER: その番号で、来月の請求書の送付先を変更したいです。手順を教えてください。
BOT : 会員番号 A-7742 の送付先変更は、アカウントページの「お支払い情報」から変更できます。

chat_history/helpdesk-user-1001.json を開くと、human/AIメッセージが往復ごとに追記されていることが確認できます。workerを再起動してもこのファイルが残るため、デプロイで会話が消えなくなりました。

失敗パターン: RunnableWithMessageHistoryのうえでsave_contextを手動で呼んだ夜

最初の修正で、ラッパー導入後もこれまでの習慣で memory.save_context(...) を手動で追加してしまいました。この失敗の症状は明快で、履歴ファイルに同じhumanメッセージが2回入りました。ラッパーは追記済みなのに、自分でもう1回書いていたのです。実測では20往復のセッションで履歴トークンが約1.8倍に膨らみ、gpt-4o-miniの1リクエストあたりコストが約1,900token→約3,400tokenになりました。

書き方 履歴への追記 症状
RunnableWithMessageHistoryのみ(正しい) ラッパーが自動で1回 なし
ラッパー+save_context手動呼び(失敗) 2回(自動+手動) 同一発言が二重記録されトークンが約1.8倍
save_contextのみ手動 自分の設計次第 注入経路を自前で書く必要があり忘れやすい

原則は「追記も注入もラッパーに一任し、手動の書き込みをゼロにする」です。自前のコードが1行でも履歴に書いていたら、二重書き込みを疑います。

コストと運用の早見表

項目 値 この設計での扱い
gpt-4o-mini 1往復(履歴込み) 約900〜1,200 token 20往復で約2万token/セッション
FileChatMessageHistory 1ファイル 数KB〜約100KB 数百セッションでもディスクは問題なし
同一ファイルへの同時書き込み ファイルロックなし 同一session_idを複数プロセスで並行書きしない
履歴の上限 無制限に膨らむ 40往復を超えたら要約圧縮を検討

特に最後の行が重要です。ファイル保存は履歴を削らないため、長期セッションではコンテキスト上限に向かって単調に増えます。「何往復でも覚える」は「何tokenでも使う」ではない、という点は設計に明示しておきます。

まとめ

  • Memoryの失敗は「作る場所」(リクエストローカルnew)、「注入する場所」(MessagesPlaceholderなし)、「保存する場所」(RAMのみ)の3点で起きる。1つ直しても症状が残るため3点セットで潰す
  • session_idはユーザーIDからの決定論的生成。uuid4の乱発は「毎回新しい他人」を作っているのと同じ
  • 注入と追記は RunnableWithMessageHistory に一任する。save_contextのような手動書き込みは二重記録を生む
  • session_idをキーにした比較表を自作すると、dictキャッシュと外部ストアの移行時期が判断できる
  • 次のアクション: 自分のbotで「3往復目に前の話を復唱させる」テストを1回実行する。失敗したら、この記事の構成(MessagesPlaceholder+session_id永続化)へ一段で置き換えるのが最短です。公式の記法は MemoryのHow-to にも整理されています

🎁 読者特典を受け取る(無料・メール登録)

関連記事(同テーマ)

参考書籍: LangChain × AI 実践入門 — Pythonで作る記憶するAIアプリケーション(Kindle・Kindle Unlimited読み放題対象)(葉山悠希 著)

著者: 葉山悠希 — 書籍シリーズは Zenn / Amazon で公開中

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?