「先ほどお伝えした会員番号を復唱してください」と聞いたら、自社で作った社内ヘルプデスク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読み放題対象)(葉山悠希 著)