LiteLLMとLangGraphのCheckpointでスレッド単位の会話履歴を永続化してみる

LiteLLMとLangGraphのCheckpointでスレッド単位の会話履歴を永続化してみる

2026.05.10

はじめに

データ事業本部のkobayashiです。

前回まではエージェントの「振る舞い」を扱ってきましたが、ここからは会話の継続性にフォーカスします。今回は LangGraph の Checkpoint 機構を使って、エージェントの会話履歴をスレッド単位で永続化する方法を紹介します。

実用的なチャットボットを作るには「ユーザーが昨日聞いた内容を今日も覚えている」「複数ユーザーが同時に会話してもセッションが混ざらない」といった機能が必須です。LangGraphはこれを Checkpointer + thread_id という標準パターンで提供しており、MemorySaver / SqliteSaver / PostgresSaver などの実装を選ぶだけで切り替えられます。

Checkpoint とは

CheckpointはLangGraphでステート(メッセージ履歴を含む)をスナップショットとして保存する仕組みです。thread_id で識別される会話スレッドごとに状態を分離し、次回以降の呼び出しで自動的に復元します。

主な実装:

Checkpointer 永続化 用途
InMemorySaver × 開発・テスト
SqliteSaver ファイル ローカルアプリ、シングルプロセス
PostgresSaver DB 本番、マルチプロセス

環境

Python 3.13
litellm 1.83.14
langgraph 1.1.10
langgraph-checkpoint-sqlite 2.0.6
langchain-litellm 0.6.4
langchain-core 1.3.2
$ uv pip install litellm langgraph langgraph-checkpoint-sqlite langchain-litellm langchain-core

InMemorySaver: 最もシンプルなチェックポイント

create_agentcheckpointer引数に渡すだけで、thread_idごとに会話履歴が分離されます。

inmemory_checkpoint.py
"""InMemorySaver で会話履歴を保持する最小サンプル。

thread_id ごとに会話履歴が分離される。プロセスが終了すると履歴は消える。
"""

from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_litellm import ChatLiteLLM
from langgraph.checkpoint.memory import InMemorySaver

@tool
def get_weather(city: str) -> str:
    """都市の天気を返します。"""
    return f"{city}: 晴れ、24°C"

llm = ChatLiteLLM(model="openai/gpt-5-mini")
checkpointer = InMemorySaver()
agent = create_agent(model=llm, tools=[get_weather], checkpointer=checkpointer)

# thread_id で会話セッションを識別
config_a = {"configurable": {"thread_id": "user-a"}}
config_b = {"configurable": {"thread_id": "user-b"}}

# user-a が東京の天気を聞く
print("=== user-a: 東京の天気 ===")
agent.invoke(
    {"messages": [{"role": "user", "content": "東京の天気は?"}]},
    config=config_a,
)

# user-b が大阪の天気を聞く(別スレッド)
print("=== user-b: 大阪の天気 ===")
agent.invoke(
    {"messages": [{"role": "user", "content": "大阪の天気は?"}]},
    config=config_b,
)

# user-a が「さっきの都市は?」と聞く -> 東京と覚えている
print("\n=== user-a: 文脈を引き継いで質問 ===")
result = agent.invoke(
    {"messages": [{"role": "user", "content": "さっき聞いた都市の名前は?"}]},
    config=config_a,
)
print(result["messages"][-1].content)

# user-b の同じ質問 -> 大阪と覚えている
print("\n=== user-b: 文脈を引き継いで質問 ===")
result = agent.invoke(
    {"messages": [{"role": "user", "content": "さっき聞いた都市の名前は?"}]},
    config=config_b,
)
print(result["messages"][-1].content)

実行結果は以下のようになります。

$ python inmemory_checkpoint.py
=== user-a: 東京の天気 ===
=== user-b: 大阪の天気 ===

=== user-a: 文脈を引き継いで質問 ===
先ほどお尋ねした都市名は「東京」です。

=== user-b: 文脈を引き継いで質問 ===
さっき聞いた都市は「大阪」です。ほかの都市の天気も知りたいですか?

thread_id を切り替えるだけで、エージェントは別の会話履歴を参照します。マルチユーザーのチャットボットでは、ユーザーIDをそのままthread_idに使うのが定石です。

SqliteSaver: ファイルに永続化する

InMemorySaverはプロセスを再起動すると履歴が消えます。SqliteSaverはSQLiteファイルに保存するので、再起動後も同じthread_idで会話を継続できます。

sqlite_checkpoint.py
"""SqliteSaver でディスクに会話履歴を永続化する。

プロセスを再起動しても thread_id ごとの履歴が復元できる。
"""

from langchain.agents import create_agent
from langchain_core.tools import tool
from langchain_litellm import ChatLiteLLM
from langgraph.checkpoint.sqlite import SqliteSaver

@tool
def remember_fact(fact: str) -> str:
    """ユーザーに関する情報を記憶します(実際にはstateに残ることでLLMが参照する)。"""
    return f"記憶しました: {fact}"

llm = ChatLiteLLM(model="openai/gpt-5-mini")

# context manager で SQLite ファイルを開く
with SqliteSaver.from_conn_string("./checkpoints.db") as checkpointer:
    agent = create_agent(model=llm, tools=[remember_fact], checkpointer=checkpointer)
    config = {"configurable": {"thread_id": "session-001"}}

    # 1回目の起動: 名前を伝える
    print("=== 1回目: 名前を伝える ===")
    agent.invoke(
        {"messages": [{"role": "user", "content": "私の名前はkobayashiです"}]},
        config=config,
    )

# 上の with を抜けたところで一度終了したと仮定し、新たに開き直す
print("\n--- プロセス再起動を模した再オープン ---\n")

with SqliteSaver.from_conn_string("./checkpoints.db") as checkpointer:
    agent = create_agent(model=llm, tools=[remember_fact], checkpointer=checkpointer)
    config = {"configurable": {"thread_id": "session-001"}}

    print("=== 2回目: 名前を聞く ===")
    result = agent.invoke(
        {"messages": [{"role": "user", "content": "私の名前を覚えていますか?"}]},
        config=config,
    )
    print(result["messages"][-1].content)

実行結果は以下のようになります。

$ python sqlite_checkpoint.py
=== 1回目: 名前を伝える ===

--- プロセス再起動を模した再オープン ---

=== 2回目: 名前を聞く ===
はい、覚えています。kobayashiさんです。今後もその名前でお呼びしてよろしいですか?別の呼び方が良ければ教えてください。

ファイル ./checkpoints.db に履歴が保存されており、別のwithブロックで開き直してもthread_idが一致すれば履歴が復元されます。

本番環境では、PostgresSaverを使うことでマルチプロセス・マルチノードのデプロイメントでも整合性のある状態管理が可能になります(接続文字列の差し替えだけで from langgraph.checkpoint.postgres import PostgresSaver に切り替えられます)。

履歴のトリミング・要約

長期間使い続けると会話履歴がコンテキストウィンドウを圧迫します。LangGraphではRemoveMessageを使って古いメッセージを削除しつつ、削除した内容を要約として保持するパターンが一般的です。

history_management.py
"""長くなった会話履歴を要約・トリミングする戦略。

会話を続けるとメッセージが膨らみ、コンテキストウィンドウを圧迫する。
ここでは「N件を超えたら要約に置き換える」シンプルな戦略を実装する。
"""

import litellm
from langchain_core.messages import (
    AIMessage,
    BaseMessage,
    HumanMessage,
    RemoveMessage,
    SystemMessage,
)
from langchain_litellm import ChatLiteLLM
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, MessagesState, StateGraph

# Anthropic(summarizer)はツール無し呼出時に modify_params=True が必要
litellm.modify_params = True

llm = ChatLiteLLM(model="openai/gpt-5-mini")
# 要約は会話文脈の品質に直結するため Claude を割当
summarizer_llm = ChatLiteLLM(model="anthropic/claude-sonnet-4-6")

# 履歴を要約に置き換えるしきい値
SUMMARY_THRESHOLD = 6

class State(MessagesState):
    summary: str

def chat_node(state: State) -> dict:
    """要約があればシステムプロンプトに含めて応答する。"""
    summary = state.get("summary") or ""
    sys = (
        f"これまでの会話の要約:\n{summary}"
        if summary
        else "通常の会話を続けてください。"
    )
    response = llm.invoke([SystemMessage(content=sys), *state["messages"]])
    return {"messages": [response]}

def summarize_node(state: State) -> dict:
    """しきい値を超えたら古いメッセージを要約に置き換える。"""
    messages: list[BaseMessage] = state["messages"]
    existing_summary = state.get("summary") or ""
    target = messages[:-2]  # 直近2件は残す
    summary_input = "\n".join(
        f"{m.type}: {m.content}" for m in target if isinstance(m.content, str)
    )
    prompt = (
        f"これまでの要約:\n{existing_summary}\n\n"
        f"新しい会話:\n{summary_input}\n\n"
        "上記を統合し、ユーザーに関する重要な情報を保持しつつ短く要約してください。"
    )
    response = summarizer_llm.invoke([HumanMessage(content=prompt)])

    # 古いメッセージは削除(直近2件のみ残す)
    delete_msgs = [RemoveMessage(id=m.id) for m in target if m.id]
    print(f"  [Summarize] {len(delete_msgs)} 件を要約に圧縮")
    return {"summary": str(response.content), "messages": delete_msgs}

def should_summarize(state: State) -> str:
    if len(state["messages"]) >= SUMMARY_THRESHOLD:
        return "summarize"
    return "end"

graph = StateGraph(State)
graph.add_node("chat", chat_node)
graph.add_node("summarize", summarize_node)

graph.add_edge(START, "chat")
graph.add_conditional_edges(
    "chat",
    should_summarize,
    {"summarize": "summarize", "end": END},
)
graph.add_edge("summarize", END)

app = graph.compile(checkpointer=InMemorySaver())

# 8 ターンの会話を投げて要約発火を観察
config = {"configurable": {"thread_id": "demo"}}
turns = [
    "こんにちは、私はkobayashiです。",
    "趣味は釣りです。",
    "好きな魚はマグロです。",
    "東京に住んでいます。",
    "猫を2匹飼っています。",
    "コーヒーが好きです。",
    "今日はとても疲れました。",
    "ここまでで覚えていること、私の趣味は何でしたか?",
]

for i, t in enumerate(turns, 1):
    print(f"\n[Turn {i}] USER: {t}")
    result = app.invoke({"messages": [HumanMessage(content=t)]}, config=config)
    last: AIMessage = result["messages"][-1]
    print(f"AI: {str(last.content)[:120]}")
    if result.get("summary"):
        print(f"(要約: {result['summary'][:120]})")

ポイントは2つです。

RemoveMessage

RemoveMessage(id=m.id)messages に追加すると、LangGraphが自動的にそのIDのメッセージを履歴から削除します。MessagesStateのリデューサーが add_messages で、これを内部で解釈します。

要約とメッセージの両立

要約をstateの別フィールド(summary)に持ち、chat_nodeがシステムプロンプトに埋め込むことで「古い情報は要約として、新しい情報は生メッセージとして」両方をLLMに渡せます。

実行結果は以下のようになります。

$ python history_management.py

[Turn 1] USER: こんにちは、私はkobayashiです。
AI: こんにちは、kobayashiさん。よろしくお願いします。お元気ですか?

[Turn 2] USER: 趣味は釣りです。
AI: いいですね、kobayashiさん。釣りがお好きなんですね!どんな釣りをよくされますか?...

[Turn 3] USER: 好きな魚はマグロです。
  [Summarize] 4 件を要約に圧縮
AI: いいですね、マグロが好きなんですね!...
(要約: **ユーザー情報まとめ:**
- 名前:kobayashi
- 趣味:釣り(詳細な種類・スタイルは未回答))

[Turn 4] USER: 東京に住んでいます。
AI: 東京にお住まいですね。情報ありがとうございます。マグロ狙い(釣る/食べる)どちらが目的かでおすすめが変わりますが、まず釣り前提で東京からアクセスしやすい選択肢をまとめます。
....

[Turn 5] USER: 猫を2匹飼っています。
  [Summarize] 4 件を要約に圧縮
AI: 猫ちゃんが2匹いらっしゃるんですね...
(要約: **ユーザー情報まとめ:**
- 名前:kobayashi
- 居住地:東京
- 趣味:釣り
- 好きな魚:マグロ
- 現在の関心:マグロ釣り・食べ方どちらに興味があるか未回答)

[Turn 6] USER: コーヒーが好きです。
AI: コーヒーがお好きなんですね!情報をプロフィールに追加しました。
...

[Turn 7] USER: 今日はとても疲れました。
  [Summarize] 4 件を要約に圧縮
AI: それはお疲れさまでした...

[Turn 8] USER: ここまでで覚えていること、私の趣味は何でしたか?
AI: 覚えていることを手短に言うと、趣味は「釣り」です。
(補足で覚えている点:好きな魚はマグロ、好きな飲み物はコーヒー、東京在住で猫が2匹います。)

ターン3・5・7で messagesSUMMARY_THRESHOLD=6 を超え、その都度Summarizerが発火しています。Turn 8では生の会話メッセージは直近2件のみですが、Summarizerが保持している要約のおかげで「趣味は釣り」「好きな魚はマグロ」などの過去の情報を正しく回答できています。

LLM呼出回数は chat(GPT-5-mini)×8 + summarize(Claude)×3 = 計11回summarize → END で必ず閉じる構造のため、Summarizerが連続的にループすることはありません。要約は判断ノードを介さず単発で発火するので、しきい値だけで暴走を防げる安全な構造です。

LiteLLMでのモデル使い分け

ロール モデル例 重視する点
通常チャット (llm) openai/gpt-5-mini 速度・コスト(多数回呼ばれる)
Summarizer anthropic/claude-sonnet-4-6 要約品質(プロバイダーを切替)

ユーザーとの応答は安価なGPTで捌きつつ、ターン毎の要約だけClaudeに任せることで「コンテキストの圧縮品質」を担保できます。LiteLLMでChatLiteLLMに渡すモデル名を変えるだけで、こうした使い分けがコードロジック変更なしで実現できます。

まとめ

LangGraphの Checkpoint 機構と、長期会話のための要約戦略を紹介しました。

InMemorySaver / SqliteSaver / PostgresSaver のいずれも create_agentcheckpointer 引数に渡すだけで thread_id ごとに会話履歴を分離・永続化でき、開発用・ローカル本番・分散本番という用途に応じて実装を選び替えられます。履歴がコンテキストウィンドウを圧迫する場面では、RemoveMessage で古いメッセージを削除しつつ summary フィールドに要約を集約することで、長期会話でも安定して文脈を保てます。LiteLLMで通常チャットには安価なGPT、要約だけClaudeに切り替えることで、応答コストを抑えつつ要約品質を担保できる点もこのパターンの大きな利点です。

最後まで読んでいただきありがとうございました。


生成AI活用はクラスメソッドにお任せ

過去に支援してきた生成AIの支援実績100+を元にホワイトペーパーを作成しました。御社が抱えている課題のうち、どれが解決できて、どのようなサービスが受けられるのか?4つのフェーズに分けてまとめています。どうぞお気軽にご覧ください。

生成AI資料イメージ

無料でダウンロードする

この記事をシェアする

関連記事