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で履歴を渡しているはずなのにAIが忘れる — 会話メモリが3ターンで飛ぶ3つの原因と完全な直し方

0
Posted at

「前の質問も覚えていてください」と指示したチャットアプリを動かしたら、会話が3ターン目で壊れました。「私の名前は何?」と聞いたユーザーに対し、AIは「存じ上げません」と返したのです。過去の会話はキレイにプロンプトへ渡している。原因を調べてもエラーは出ない。丸1日溶かしてようやく分かったのは、履歴を渡した「つもり」になっているだけで、実際のリクエストには1ターン分しか入っていないでした。この記事では、私が踏んだ3つの失敗原因と、コピペで動く直し方を全部載せます。

なぜ「履歴を渡したつもり」で終わるのか

LangChainのメモリ回りは抽象化が厚い層です。ChatMessageHistoryにaddした内容と、LLMへ実際に送られるmessagesは、別物である可能性があります。間にMemory・ChatPromptTemplate・RunnableWithMessageHistoryのどれか1つでも噛んでいると、「渡したはず」の履歴が黙って捨てられます。エラーは1つも出ない。だからたちが悪い。

原因はこの3つに集約される

# 失敗パターン 何が起きているか 表面に出る症状
1 historyを保存したが渡していない add_messagesしたオブジェクトと、Runnableに渡しているhistoryが別インスタンス 会話の最初から毎回ゼロの応答
2 input_variablesにhistoryを入れていない ChatPromptTemplateが履歴用の差し込み口を持たず、組み立て時に無視される 履歴のうち最新の1ターンだけ反映
3 session_idがリクエストごとに変わっている RunnableWithMessageHistoryが毎回空の履歴として読み込む ユーザーをまたいで名前・文脈が消える

以下、順番に完全形のコードで直します。冒頭が長いので、まず失敗設計そのものを見せます。

失敗した設計: historyに保存して「渡し忘れて」いた

最初に私が書いた駄目なコードです。これは失敗パターンなのでそのままコピペしないでください(正しい形は次章に完全形があります)。

# ❌ 失敗パターン: 保存と参照が別オブジェクトで、履歴がLLMに一度も渡らない
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_openai import ChatOpenAI

history_store = InMemoryChatMessageHistory()          # ←保存用の履歴
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "あなたは親切なアシスタントです。"),
        ("human", "{input}"),                          # ←履歴の差し込み口が無い
    ]
)

chain = prompt | llm

def chat(text: str) -> str:
    history_store.add_user_message(text)               # ←履歴に保存はしている
    resp = chain.invoke({"input": text})               # ←でもchainには履歴を渡していない
    history_store.add_ai_message(resp.content)
    return resp.content

print(chat("私の名前はゆきです。覚えていてね"))
print(chat("私の名前を言って"))                         # ←「存じ上げません」と返る

このコードはエラーにならず完走する点が最悪です。history_storeは律儀に育っているのに、chainへの入力は毎回 {input} の1行だけ。「保存した」事実が実感を生み、「渡した」錯覚を作ります。

直し方: RunnableWithMessageHistoryで1本化する

保存と参照を1つの機構に集約するのがLangChainの正規の直し方です。RunnableWithMessageHistoryが「履歴の読み込み→プロンプトへの差し込み→応答の追記」まで全部担うため、渡し忘れが構造的に起きなくなります。

# ✅ 直し方: 保存と参照をRunnableWithMessageHistoryの1本に集約する
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_openai import ChatOpenAI

store = {}  # session_id -> InMemoryChatMessageHistory

def get_session_history(session_id: str) -> InMemoryChatMessageHistory:
    if session_id not in store:
        store[session_id] = InMemoryChatMessageHistory()
    return store[session_id]

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

# ポイント: {chat_history} という差し込み口をpromptに必ず作る
prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "あなたは親切なアシスタントです。ユーザーの名前を覚えてください。"),
        ("placeholder", "{chat_history}"),            # ←原因2の直し: 履歴の差し込み口
        ("human", "{input}"),
    ]
)

chain = prompt | llm

chain_with_history = RunnableWithMessageHistory(
    chain,
    get_session_history,
    input_messages_key="input",                        # ←入力のどれが新規発話か
    history_messages_key="chat_history",               # ←履歴を差し込む変数名
)

def chat(text: str, session_id: str = "user-001") -> str:
    resp = chain_with_history.invoke(
        {"input": text},
        config={"configurable": {"session_id": session_id}},
    )
    return resp.content

print(chat("私の名前はゆきです。覚えていてね"))
print(chat("私の名前を言って"))                         # ←「ゆきさんです」と返る

これで3つの原因が同時に潰せます。履歴オブジェクトはget_session_history経由で一元化され(原因1)、{chat_history}の差し込み口がpromptに明示され(原因2)、session_idがconfigで固定され(原因3)、保存と送信が同じ1本のパイプを流れます。

検証: LLMを叩かずに履歴が渡っているか確認する

「AIの応答で確認する」は低速で外的要因も混ざるので、私はモックLLMで届いたmessagesをそのまま印字する検証を先に書くようにしています。履歴のバグはこの方法が最速です。

# ✅ 検証コード: LLMをモックに差し替え、実際に届くmessagesを1行で確認する
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.chat_history import InMemoryChatMessageHistory
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
from langchain_core.messages import AIMessage
from langchain_core.runnables.history import RunnableWithMessageHistory

store = {}

def get_session_history(session_id: str) -> InMemoryChatMessageHistory:
    if session_id not in store:
        store[session_id] = InMemoryChatMessageHistory()
    return store[session_id]

# 常に「あい」と返すだけのモック。messagesの内容を1行で観察する
llm = FakeMessagesListChatModel(responses=[AIMessage(content="あい")])

prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "あなたは親切なアシスタントです。"),
        ("placeholder", "{chat_history}"),
        ("human", "{input}"),
    ]
)

chain = prompt | llm
chain_with_history = RunnableWithMessageHistory(
    chain,
    get_session_history,
    input_messages_key="input",
    history_messages_key="chat_history",
)

chain_with_history.invoke(
    {"input": "私の名前はゆきです"},
    config={"configurable": {"session_id": "user-001"}},
)
chain_with_history.invoke(
    {"input": "私の名前を言って"},
    config={"configurable": {"session_id": "user-001"}},
)

# 実際にLLMへ送られた最後のmessagesを確認する(APIキー不要・無料)
last = get_session_history("user-001").messages
for m in last:
    print(f"{m.type:8} | {m.content}")

この検証コードはAPIキー不要で動きます。出力が2行ともhumanで届けば履歴は機能していますし、1行しか出なければ原因1〜3のどれかで履歴が捨てられています。丸1日を溶かした私への戒めとして、メモリ系の実装を触るときは必ずこのモック検証を最初に回すようにしました。

やってはいけない: 原因3のsession_id設計

原因3は実装というより設計の失敗です。私がやらかしたのは、セッションIDにuuid4()を使ったこと。

# ❌ やってはいけない: 呼ぶたびにsession_idが生まれ変わる
import uuid

def chat(text: str) -> str:
    session_id = str(uuid.uuid4())                    # ←毎回空の履歴になる
    resp = chain_with_history.invoke(
        {"input": text},
        config={"configurable": {"session_id": session_id}},
    )
    return resp.content

uuid4()は呼び出しごとに別のIDを返すため、get_session_historyは毎回空の履歴を返します。ログには何の異常も出ないので「AIが忘れている」ようにしか見えませんが、実際は「誰も覚えてくれと頼んでいない」状態です。セッションIDはユーザーIDやスレッドIDなど、会話をまたいで一意に定まる値を明示的に渡す設計にします。

比較: メモリ実装の3方式

履歴を渡す方法は3つありますが、用途が明確に分かれます。

方式 コード量 履歴の捨て忘れリスク 向いているケース
手組み(history → messages変換) 多い 高い(私が踏んだ失敗そのもの) 履歴の形式を細かく制御したい場合
RunnableWithMessageHistory 少ない 低い(保存と送信が1本化) 通常のチャットアプリ・エージェント
RunnableWithMessageHistory + Redis等のストア 少ない+α 低い・プロセス再起動にも耐える 本番環境・マルチプロセス運用

今回の「直し方」は2段階目です。さらに本番では、InMemoryChatMessageHistoryをRedisChatMessageHistory(pip install langchain-redis)などに差し替えるだけで3段階目へ移行できます。get_session_historyの戻り値を変えるだけなので、chain側は1行も書き換え不要です。

まとめ

  • 履歴は「保存した」だけでは届かない。LLMへのmessagesに入って初めて「渡した」(原因1: 別インスタンス渡し忘れ)
  • {chat_history}の差し込み口が無いpromptは、履歴を黙って捨てる(原因2)
  • session_idは会話をまたいで一意に定まる値を明示指定する。uuid4()は履歴殺し(原因3)
  • 検証はAPIキー不要のモックLLM(FakeMessagesListChatModel)で先にやる。messagesを1行印字するだけで履歴の生死が分かる

AIが「名前を忘れた」日、私は履歴を疑う前にコードの錯覚を疑うべきでした。保存と参照が別オブジェクトで並走している状態は、モックLLMでmessagesを印字すれば1分で露見します。メモリを実装するときは、ぜひこの検証を最初に1回回してみてください。

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

参考書籍: 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?