Skip to content
Published on

マルチターンチャットボットの会話状態管理とコンテキスト圧縮戦略 2026

Authors
  • Name
    Twitter
マルチターンチャットボットの会話状態管理とコンテキスト圧縮戦略 2026

概要

マルチターンチャットボットは、単一の質問-回答ではなく、複数ターンにわたる連続的な会話を処理する。ユーザーが「前に言ったこと」を参照したり、文脈に依存するフォローアップ質問を投げかけたりする際、チャットボットは以前の会話内容を正確に記憶し、適切なコンテキストを維持しなければならない。2026年現在、Claude 4 Sonnetは200Kトークン、GPT-5は400Kトークンのコンテキストウィンドウを提供するが、長いコンテキストが常に良いとは限らない。

コンテキストウィンドウが大きくなるほどAttention演算はO(n^2)で増加し、コストも比例して上昇する。さらに深刻な問題は「コンテキスト腐敗(context rot)」だ。入力長が伸びるほどモデルの精度と再現率が低下する現象が研究で確認されている。したがって、全会話履歴を無条件に投入するのではなく、重要な情報を選別・圧縮する戦略が不可欠である。

本記事では、マルチターンチャットボットの会話状態を管理するメモリアーキテクチャから、コンテキストウィンドウを効率的に活用する圧縮技法、LangGraphステートマシン実装、Redisベースのセッション永続化、トークン予算管理まで、実践ですぐに適用できる戦略を扱う。

マルチターン会話の課題

トークン制限とコスト問題

マルチターン会話で最初にぶつかる壁はトークン制限だ。カスタマーサポートチャットボットを例に取ると、1セッションで50ターン以上の会話が続くケースは珍しくない。ターンあたり平均200トークンとすれば、50ターン基準で10,000トークンが会話履歴だけで消費される。さらにシステムプロンプト、RAGドキュメント、関数呼び出し結果を加えると、トークン予算は急速に減少する。

重要情報の損失

スライディングウィンドウで直近N件のメッセージだけを保持すると、会話序盤で設定されたユーザーの重要な要件が消えてしまう。ユーザーが10ターン前に「予算は500万ウォン以下」と言ったのに、そのメッセージがウィンドウの外に押し出されると、チャットボットは的外れな推薦をしてしまう。

状態管理の複雑さ

単純なQ&Aを超えて、予約、注文、問題解決などのタスク指向型会話では、現在のステップ、収集された情報(スロット)、確認状態といった構造化された状態を管理する必要がある。この状態は会話履歴とは別に追跡され、特定条件下で初期化や分岐が必要となる。

同時セッションの分離

プロダクション環境では、数百から数千のユーザーが同時に会話する。各ユーザーの会話状態が混ざらないようにセッションを分離し、ユーザーがブラウザを閉じて再び開いても以前の状態を復元する必要がある。

メモリアーキテクチャの種類

LangChainとLlamaIndexは、マルチターン会話のための多様なメモリタイプを提供している。それぞれの長所と短所を理解し、状況に合った組み合わせを選択することが重要だ。

メモリタイプ比較表

メモリタイプ保存方式トークン使用量情報忠実度適合シナリオ
Buffer Memory全会話履歴を保存高い(線形増加)非常に高い短い会話、デバッグ用
Window Memory直近K件のメッセージのみ保持固定(ウィンドウサイズ)中(序盤の情報損失)一般的なカスタマーサポート
Summary MemoryLLMで要約を生成低い(要約の長さ)低い(詳細の損失)長時間の会話、コスト削減
Summary Buffer要約 + 直近バッファの混合中間高い大半のプロダクション
Vector Memoryエンベディングで関連会話を検索可変(検索結果)高い(関連性ベース)長期記憶、クロスセッション

ConversationBufferMemory vs ConversationSummaryBufferMemory

Buffer Memoryは最もシンプルだ。すべてのメッセージをそのまま保存してプロンプトに入れる。デバッグが容易で情報損失がないが、会話が長くなるとすぐにトークン制限に達する。

Summary Buffer Memoryは実践で最もよく使われるアプローチだ。直近のメッセージは原文のまま保持し、古いメッセージはLLMを呼び出して要約に圧縮する。トークン数が設定しきい値を超えると自動的に要約がトリガーされる。

from langchain.memory import ConversationSummaryBufferMemory
from langchain_openai import ChatOpenAI

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

# max_token_limitを超えると古いメッセージを自動要約
memory = ConversationSummaryBufferMemory(
    llm=llm,
    max_token_limit=2000,
    return_messages=True,
    memory_key="chat_history",
    human_prefix="顧客",
    ai_prefix="担当者",
)

# 会話の保存
memory.save_context(
    {"input": "ノートPC購入を検討中で予算は150万ウォンです"},
    {"output": "150万ウォンの予算で良いノートPCをお勧めいたします。主な用途は何ですか?"},
)
memory.save_context(
    {"input": "プログラミングと軽い動画編集をします"},
    {"output": "開発と動画編集用であれば、RAM 16GB以上、SSD 512GB以上のスペックをお勧めします。"},
)
memory.save_context(
    {"input": "MacBook Air M4とLenovo ThinkPadではどちらが良いですか?"},
    {"output": "どちらも優れた製品ですが、用途によって違いがあります。MacBook Air M4は..."},
)

# トークン制限超過時に自動要約 + 直近メッセージ保持
loaded = memory.load_memory_variables({})
print(loaded["chat_history"])
# SystemMessage: "顧客は150万ウォンの予算でプログラミングと動画編集用のノートPCを探しており..."
# + 直近の元のメッセージ

LlamaIndex ChatSummaryMemoryBuffer

LlamaIndexでも同様のメカニズムを提供している。ChatSummaryMemoryBufferは設定されたトークン上限を超えると古いメッセージを定期的に要約しつつ、直近のメッセージ原文はそのまま維持する。

from llama_index.core.memory import ChatSummaryMemoryBuffer
from llama_index.llms.openai import OpenAI

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

memory = ChatSummaryMemoryBuffer.from_defaults(
    llm=llm,
    token_limit=3000,
    # 要約トリガーのトークン比率(全体上限の70%超過時)
    summarize_threshold=0.7,
)

# Chat Engineにメモリを接続
from llama_index.core.chat_engine import CondensePlusContextChatEngine

chat_engine = CondensePlusContextChatEngine.from_defaults(
    retriever=index.as_retriever(similarity_top_k=3),
    memory=memory,
    llm=llm,
    system_prompt="あなたは技術サポートの専門家です。",
)

response = chat_engine.chat("先ほど説明したエラーコードについてもっと詳しく教えてください")

コンテキストウィンドウ管理

スライディングウィンドウ戦略

スライディングウィンドウは最も直感的なコンテキスト管理方法だ。直近K件のメッセージだけを保持し、残りは破棄する。実装がシンプルでトークン使用量が予測可能だが、会話序盤の情報が完全に失われるデメリットがある。

改善されたスライディングウィンドウは、単純なメッセージ数ではなくトークン数ベースでウィンドウサイズを決定する。短いメッセージ20件と長いメッセージ5件を同列に扱わないということだ。

トークンベースのウィンドウ実装

import tiktoken


def sliding_window_by_tokens(
    messages: list[dict],
    max_tokens: int = 4000,
    model: str = "gpt-4o",
    always_keep_system: bool = True,
) -> list[dict]:
    """トークン数ベースのスライディングウィンドウ。
    システムメッセージは常に保持し、直近メッセージから逆順に埋める。
    """
    enc = tiktoken.encoding_for_model(model)
    result = []
    current_tokens = 0

    # システムメッセージを優先確保
    system_messages = [m for m in messages if m["role"] == "system"]
    non_system = [m for m in messages if m["role"] != "system"]

    if always_keep_system:
        for sm in system_messages:
            sm_tokens = len(enc.encode(sm["content"]))
            result.append(sm)
            current_tokens += sm_tokens

    # 直近メッセージから逆順に追加
    selected = []
    for msg in reversed(non_system):
        msg_tokens = len(enc.encode(msg["content"]))
        if current_tokens + msg_tokens > max_tokens:
            break
        selected.append(msg)
        current_tokens += msg_tokens

    result.extend(reversed(selected))
    return result

コンテキストウィンドウ管理方法の比較

管理方法実装複雑度トークン効率情報保存性レイテンシー
メッセージ数ベースの滑動非常に低い中間低いなし
トークン数ベースの滑動低い高い低い非常に低い
要約 + 滑動中間高い高い中間(LLM呼び出し)
ベクトル検索ベース高い非常に高い高い中間(エンベディング + 検索)
ハイブリッド(要約 + ベクトル)非常に高い非常に高い非常に高い高い

会話要約戦略

会話要約は、コンテキストウィンドウを節約しつつ重要な情報を維持するコア技法だ。単に「会話を要約して」と指示すると重要な詳細が抜け落ちる可能性があるため、構造化された要約プロンプトを使用すべきである。

段階的要約(Progressive Summarization)

全会話を一度に要約するのではなく、一定ターンごとに既存の要約に新しい内容をマージする方式だ。このアプローチは要約品質が高く、コストも低い。

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate

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

PROGRESSIVE_SUMMARY_PROMPT = ChatPromptTemplate.from_messages([
    ("system", """あなたはカスタマーサポート会話の要約専門家です。
既存の要約と新しい会話をマージして更新された要約を生成してください。

ルール:
1. 顧客の核心的な要件、制約条件、好みを必ず維持
2. 確認された事実(名前、注文番号、日付など)を絶対に漏らさない
3. 現在の進行段階と次に必要なアクションを明示
4. 解決済みの問題は簡潔に、未解決の問題は詳細に記録
5. 200文字以内で作成"""),
    ("human", """既存の要約:
{existing_summary}

新しい会話:
{new_messages}

更新された要約:"""),
])


class ProgressiveSummarizer:
    def __init__(self, llm, summary_interval: int = 5):
        self.llm = llm
        self.chain = PROGRESSIVE_SUMMARY_PROMPT | llm
        self.summary = ""
        self.buffer = []
        self.summary_interval = summary_interval
        self.turn_count = 0

    def add_turn(self, user_msg: str, assistant_msg: str):
        self.buffer.append(f"顧客: {user_msg}")
        self.buffer.append(f"担当者: {assistant_msg}")
        self.turn_count += 1

        if self.turn_count % self.summary_interval == 0:
            self._update_summary()

    def _update_summary(self):
        new_messages = "\n".join(self.buffer)
        result = self.chain.invoke({
            "existing_summary": self.summary or "(なし)",
            "new_messages": new_messages,
        })
        self.summary = result.content
        self.buffer = []  # バッファクリア

    def get_context(self) -> str:
        """要約 + 直近バッファを結合したコンテキストを返す"""
        parts = []
        if self.summary:
            parts.append(f"[会話要約]\n{self.summary}")
        if self.buffer:
            parts.append(f"[最近の会話]\n" + "\n".join(self.buffer))
        return "\n\n".join(parts)

構造化要約 vs 自由形式要約

要約方式長所短所推奨シナリオ
自由形式要約実装が簡単、柔軟重要情報の漏れの可能性一般的な雑談チャットボット
スロットベース構造化必須情報を保証プロンプト設計が必要予約/注文チャットボット
キーバリュー抽出検索/フィルタ可能コンテキスト損失の可能性データ収集目的
段階的マージコスト効率が高い、高品質累積エラーの可能性長時間サポート

コンテキスト圧縮技法

LLMLinguaを活用したプロンプト圧縮

MicrosoftのLLMLinguaシリーズは、プロンプトを最大20倍まで圧縮しながら性能低下を最小限に抑える技術だ。小さな言語モデルのPerplexityをベースに重要でないトークンを削除する。LLMLingua-2はGPT-4蒸留データで学習されており、ドメインに依存しない汎用圧縮が可能で、元のLLMLinguaと比較して3-6倍高速である。

from llmlingua import PromptCompressor

# LLMLingua-2の初期化
compressor = PromptCompressor(
    model_name="microsoft/llmlingua-2-xlm-roberta-large-meetingbank",
    use_llmlingua2=True,
    device_map="cpu",  # GPU使用時は"cuda"
)

# 長い会話履歴の圧縮
conversation_history = """
顧客: こんにちは、先週注文した商品について問い合わせます。
担当者: こんにちは!注文番号をお知らせいただければ確認いたします。
顧客: 注文番号はORD-2026-03-1234です。配送がまだ届いていません。
担当者: 確認いたします。少々お待ちください。
担当者: 注文番号ORD-2026-03-1234を確認しました。現在配送中で明日到着予定です。
顧客: 明日ですか?元々昨日届くはずだったのに、なぜ遅れているのですか?
担当者: 物流センターの事情で1日遅延しました。ご不便をおかけして申し訳ございません。
顧客: 送料の返金は可能ですか?
担当者: はい、配送遅延による送料返金が可能です。返金処理を進めてよろしいですか?
顧客: はい、お願いします。
担当者: 送料3,000ウォンの返金処理を完了しました。1-3日以内に元のお支払い方法に返金されます。
"""

compressed = compressor.compress_prompt(
    conversation_history,
    rate=0.5,  # 50%圧縮率
    force_tokens=["注文番号", "ORD-2026-03-1234", "返金"],  # 必ず保持するトークン
)

print(f"元のトークン: {compressed['origin_tokens']}")
print(f"圧縮後のトークン: {compressed['compressed_tokens']}")
print(f"圧縮率: {compressed['ratio']:.1f}x")
print(f"圧縮結果:\n{compressed['compressed_prompt']}")

圧縮技法の比較

圧縮技法圧縮率性能維持速度学習必要
LLMLingua最大20倍高い中間不要(推論のみ)
LLMLingua-2最大20倍非常に高い高速(3-6倍)不要
LongLLMLingua最大4倍非常に高い中間不要
LLM要約可変中間遅い(LLM呼び出し)不要
ルールベースフィルタリング2-3倍低い非常に高速不要
Selective Context最大10倍高い高速不要

LangGraphステートマシンの実装

LangGraphは、会話フローをグラフベースのステートマシンとしてモデル化できる。LangChainの従来のメモリ方式とは異なり、明示的な状態スキーマとリデューサー関数を使用して、複雑なマルチターンワークフローを安定的に管理する。チェックポインターにより状態が自動的に永続化されるため、セッション復元も自然に行える。

from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langgraph.checkpoint.memory import MemorySaver
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage


# 1. 状態スキーマの定義
class OrderSupportState(TypedDict):
    messages: Annotated[list, add_messages]  # リデューサーでメッセージを累積
    order_id: str | None
    issue_type: str | None  # "配送", "返金", "交換", "その他"
    step: str  # "greeting", "identify", "diagnose", "resolve", "close"
    collected_info: dict
    summary: str  # 以前の会話要約


# 2. ノード関数の定義
llm = ChatOpenAI(model="gpt-4o", temperature=0)


def greeting_node(state: OrderSupportState) -> dict:
    """挨拶と初期分類"""
    response = llm.invoke([
        SystemMessage(content="カスタマーサポートチャットボットです。顧客の問い合わせ種類を把握してください。"),
        *state["messages"],
    ])
    return {
        "messages": [response],
        "step": "identify",
    }


def identify_node(state: OrderSupportState) -> dict:
    """注文番号と問題タイプの特定"""
    context_parts = []
    if state.get("summary"):
        context_parts.append(f"以前の会話要約: {state['summary']}")

    system_msg = f"""顧客の注文番号と問題の種類を把握してください。
収集済み情報: {state.get('collected_info', dict())}
{chr(10).join(context_parts)}"""

    response = llm.invoke([
        SystemMessage(content=system_msg),
        *state["messages"][-10:],  # 直近10件のメッセージのみ使用
    ])

    # 応答から注文番号を抽出(実際にはより精緻なパースが必要)
    return {
        "messages": [response],
        "step": "diagnose",
    }


def resolve_node(state: OrderSupportState) -> dict:
    """問題解決の提案"""
    response = llm.invoke([
        SystemMessage(content=f"問題タイプ: {state.get('issue_type', '未確認')}。 "
                              f"注文番号: {state.get('order_id', '未確認')}。 解決策を提示してください。"),
        *state["messages"][-6:],
    ])
    return {
        "messages": [response],
        "step": "close",
    }


# 3. ルーティング関数
def route_by_step(state: OrderSupportState) -> str:
    step = state.get("step", "greeting")
    if step == "greeting":
        return "greeting"
    elif step == "identify":
        return "identify"
    elif step in ("diagnose", "resolve"):
        return "resolve"
    else:
        return END


# 4. グラフの構築
graph = StateGraph(OrderSupportState)
graph.add_node("greeting", greeting_node)
graph.add_node("identify", identify_node)
graph.add_node("resolve", resolve_node)

graph.add_conditional_edges(START, route_by_step)
graph.add_conditional_edges("greeting", route_by_step)
graph.add_conditional_edges("identify", route_by_step)
graph.add_conditional_edges("resolve", route_by_step)

# 5. チェックポインターで状態を永続化
checkpointer = MemorySaver()
app = graph.compile(checkpointer=checkpointer)

# 6. 実行(thread_idでセッションを区別)
config = {"configurable": {"thread_id": "user-session-abc123"}}
result = app.invoke(
    {
        "messages": [HumanMessage(content="注文した商品がまだ届きません")],
        "step": "greeting",
        "collected_info": {},
        "summary": "",
    },
    config=config,
)

LangGraphのチェックポインターは各ノード実行後に自動的に状態を保存する。MemorySaverはインメモリストレージのため開発/テストに適しており、プロダクションではSqliteSaverPostgresSaver、またはMongoDB Storeを使用すべきだ。thread_idでセッションを区別するため、同時に複数ユーザーの会話を分離できる。

セッション管理と永続性

Redisベースのセッションストア

プロダクション環境で会話状態を永続化する際、Redisは最も一般的な選択肢だ。低レイテンシーの読み書き、TTLベースの自動期限切れ、Pub/Subによるリアルタイム通知をすべてサポートする。

import json
import time
import redis
import tiktoken


class ChatSessionManager:
    """Redisベースのマルチターン会話セッションマネージャー"""

    def __init__(
        self,
        redis_url: str = "redis://localhost:6379",
        session_ttl: int = 3600,  # 1時間
        max_history_tokens: int = 4000,
    ):
        self.redis = redis.from_url(redis_url, decode_responses=True)
        self.session_ttl = session_ttl
        self.max_history_tokens = max_history_tokens
        self.encoder = tiktoken.encoding_for_model("gpt-4o")

    def _key(self, session_id: str, suffix: str) -> str:
        return f"chat:session:{session_id}:{suffix}"

    def create_session(self, session_id: str, metadata: dict | None = None) -> dict:
        """新しいセッションの作成"""
        session_data = {
            "session_id": session_id,
            "created_at": time.time(),
            "updated_at": time.time(),
            "turn_count": 0,
            "total_tokens": 0,
            "metadata": json.dumps(metadata or {}),
            "summary": "",
        }
        self.redis.hset(self._key(session_id, "meta"), mapping=session_data)
        self.redis.expire(self._key(session_id, "meta"), self.session_ttl)
        return session_data

    def add_message(self, session_id: str, role: str, content: str) -> None:
        """メッセージの追加とトークン管理"""
        msg = json.dumps({
            "role": role,
            "content": content,
            "timestamp": time.time(),
            "tokens": len(self.encoder.encode(content)),
        })
        history_key = self._key(session_id, "history")
        self.redis.rpush(history_key, msg)
        self.redis.expire(history_key, self.session_ttl)

        # メタデータの更新
        self.redis.hincrby(self._key(session_id, "meta"), "turn_count", 1)
        self.redis.hset(
            self._key(session_id, "meta"), "updated_at", str(time.time())
        )
        # TTL更新
        self.redis.expire(self._key(session_id, "meta"), self.session_ttl)

    def get_context_messages(self, session_id: str) -> list[dict]:
        """トークン予算内でコンテキストメッセージを返す"""
        history_key = self._key(session_id, "history")
        all_messages = self.redis.lrange(history_key, 0, -1)

        if not all_messages:
            return []

        parsed = [json.loads(m) for m in all_messages]
        result = []
        token_count = 0

        # 要約があれば先に追加
        summary = self.redis.hget(self._key(session_id, "meta"), "summary")
        if summary:
            summary_tokens = len(self.encoder.encode(summary))
            token_count += summary_tokens
            result.append({"role": "system", "content": f"以前の会話要約: {summary}"})

        # 直近メッセージから逆順にトークン予算内で追加
        selected = []
        for msg in reversed(parsed):
            msg_tokens = msg.get("tokens", 0)
            if token_count + msg_tokens > self.max_history_tokens:
                break
            selected.append({"role": msg["role"], "content": msg["content"]})
            token_count += msg_tokens

        result.extend(reversed(selected))
        return result

    def update_summary(self, session_id: str, summary: str) -> None:
        """会話要約の更新"""
        self.redis.hset(self._key(session_id, "meta"), "summary", summary)

    def delete_session(self, session_id: str) -> None:
        """セッションの削除"""
        for suffix in ("meta", "history"):
            self.redis.delete(self._key(session_id, suffix))

セッションストアの比較

ストアレイテンシー永続性スケーラビリティTTLサポート適合シナリオ
インメモリ(dict)ナノ秒なし単一プロセス手動実装開発/テスト
Redisミリ秒条件付き(AOF/RDB)クラスタ対応内蔵プロダクションリアルタイム
PostgreSQL数ミリ秒完全高いトリガー実装監査ログが必要な場合
MongoDB数ミリ秒完全シャーディング対応TTLインデックス非構造化状態
DynamoDB数ミリ秒完全無制限TTL内蔵AWSベースのサービス

プロダクションでは、Redisをメインのセッションストアとして使用しながら、PostgreSQLやMongoDBに非同期でフラッシュするハイブリッドパターンが一般的だ。Redisから現在の会話状態を高速に読み取り、会話終了時にリレーショナルDBに完全な履歴を保存すれば、パフォーマンスと永続性の両方を確保できる。

トークン予算管理

プロダクションチャットボットでは、トークン予算管理はコスト制御と応答品質の要だ。モデルのコンテキストウィンドウをシステムプロンプト、会話履歴、RAGドキュメント、応答予約などに分割配分する戦略が必要である。

import tiktoken
from dataclasses import dataclass


@dataclass
class TokenBudget:
    """トークン予算配分計算機"""
    model: str = "gpt-4o"
    max_context: int = 128000  # gpt-4o context window
    system_prompt_tokens: int = 500
    response_reserve: int = 4000  # 応答用予約
    rag_budget: int = 3000  # RAGドキュメント用
    tool_result_budget: int = 2000  # ツール実行結果用

    def __post_init__(self):
        self.encoder = tiktoken.encoding_for_model(self.model)

    @property
    def conversation_budget(self) -> int:
        """会話履歴に割り当て可能なトークン数"""
        reserved = (
            self.system_prompt_tokens
            + self.response_reserve
            + self.rag_budget
            + self.tool_result_budget
        )
        return self.max_context - reserved

    def count_tokens(self, text: str) -> int:
        return len(self.encoder.encode(text))

    def allocate(self, messages: list[dict]) -> dict:
        """現在のメッセージに対するトークン使用状況レポート"""
        msg_tokens = sum(
            self.count_tokens(m.get("content", "")) for m in messages
        )
        budget = self.conversation_budget
        return {
            "total_context": self.max_context,
            "system_prompt": self.system_prompt_tokens,
            "response_reserve": self.response_reserve,
            "rag_budget": self.rag_budget,
            "tool_result_budget": self.tool_result_budget,
            "conversation_budget": budget,
            "conversation_used": msg_tokens,
            "conversation_remaining": budget - msg_tokens,
            "utilization_pct": round(msg_tokens / budget * 100, 1),
            "needs_compression": msg_tokens > budget * 0.8,
        }


# 使用例
budget = TokenBudget(model="gpt-4o", max_context=128000)
print(f"会話履歴の利用可能トークン: {budget.conversation_budget:,}")

report = budget.allocate([
    {"role": "user", "content": "以前の注文状況を確認してください"},
    {"role": "assistant", "content": "注文番号を教えていただけますか?"},
])
print(f"使用率: {report['utilization_pct']}%")
print(f"圧縮が必要: {report['needs_compression']}")

トークン予算の80%を超えた時点で自動的に圧縮をトリガーするのがベストプラクティスだ。このしきい値はサービス特性に応じて調整する。正確性が重要なカスタマーサポートでは70%に下げ、カジュアルな会話では90%まで許容できる。

トラブルシューティング

コンテキスト喪失による繰り返し質問

症状: チャットボットがすでに収集した情報を再度尋ねる。

原因診断の順序:

  1. スライディングウィンドウサイズが小さすぎないか確認する。ウィンドウの外に押し出されたメッセージに重要情報が含まれている場合がある。
  2. 要約が適切に動作しているか確認する。要約プロンプトが重要なスロット情報(名前、注文番号など)を漏らしている可能性がある。
  3. 状態管理ロジックでcollected_infoが正しく更新されているか確認する。

解決: 構造化要約プロンプトに「必ず維持すべきフィールド」リストを明示する。スロット情報は別途の状態ディクショナリで管理する。

Redisセッション期限切れによる会話断絶

症状: ユーザーが少し席を外して戻ると、チャットボットが最初からやり直す。

原因: TTLが短すぎる設定になっている。

解決: メッセージが追加されるたびにTTLを更新し、ビジネス要件に合ったTTLを設定する。カスタマーサポートは2時間、ショッピングアシスタントは24時間が適切だ。期限切れ前に警告メッセージを送ることも推奨される。

要約の累積エラー(Summary Drift)

症状: 段階的要約を繰り返すうちに事実が歪められたり、幻覚が含まれたりする。

原因: 要約の要約を繰り返すことで情報損失と歪曲が蓄積される。

解決: 5-10回の要約サイクルごとに元のメッセージベースで要約を再生成する。数値、日付、固有名詞などの事実情報は要約とは別に抽出して状態に保存する。

同時リクエストによる状態競合

症状: ユーザーが素早く連続メッセージを送ると、応答が混ざったり状態が壊れたりする。

原因: 同時実行される2つのリクエストが同じセッション状態を読み書きし、レースコンディションが発生する。

解決: RedisのWATCH/MULTI/EXECトランザクションまたは分散ロックを使用する。LangGraphの場合、チェックポインターが順次実行を保証するため、この問題は自然に解決される。

運用チェックリスト

プロダクションのマルチターンチャットボットをデプロイする前に確認すべき項目。

メモリとコンテキスト管理:

  • メモリタイプの選定完了(Buffer、Summary Buffer、Vectorなど)
  • トークン予算配分の定義(システムプロンプト、会話履歴、RAG、応答予約)
  • コンテキスト圧縮しきい値の設定(80%以上でトリガー)
  • 要約プロンプトに必須保持フィールドを明示

セッション管理:

  • Redisまたは永続ストアの接続確認
  • セッションTTLの設定(サービスタイプに応じて)
  • 同時リクエスト処理戦略の策定(ロック、キュー、順次実行)
  • セッション期限切れ時のユーザー通知ロジックの実装

モニタリング:

  • ターンあたりの平均トークン使用量の追跡
  • 要約呼び出し頻度とコストのモニタリング
  • コンテキスト喪失による繰り返し質問率の測定
  • セッション平均持続時間とターン数の追跡

障害対応:

  • Redis障害時のインメモリフォールバックロジック
  • 要約LLM呼び出し失敗時の元メッセージ維持戦略
  • 状態復旧手順の文書化

失敗事例

事例1: 無限コンテキスト拡張

あるカスタマーサポートチャットボットプロジェクトで、「情報損失を絶対に許容しない」という方針の下、Buffer Memoryを使用しコンテキスト管理を行わなかった。当初は問題なかったが、平均会話ターン数が30を超えるとAPI費用が月500万ウォンから2,000万ウォンに急増した。応答レイテンシーも平均2秒から8秒に増加した。

教訓: すべてのメッセージを保持することが最善ではない。Summary Buffer Memoryに切り替えてトークン予算を4,000に設定したところ、コストが70%削減され、顧客満足度には有意な変化がなかった。

事例2: 自由形式要約の落とし穴

旅行予約チャットボットで自由形式要約を使用したところ、要約過程で出発日と到着日が入れ替わったり、人数が漏れたりするインシデントが繰り返し発生した。顧客が「2名です」と言った情報が要約から抜け落ち、1人分の料金で予約が進められた。

教訓: タスク指向型会話では必ずスロットベースの構造化要約を使用すべきである。出発地、目的地、日程、人数、座席クラスなどの必須スロットを明示し、要約にそれらの値が含まれているか検証ロジックを追加した。

事例3: セッション分離の失敗

マルチテナントSaaSチャットボットで、セッションキーを単純にuser_idのみで構成した。同一ユーザーが複数のブラウザタブで異なるトピックの会話を試みたところ、2つの会話の状態が混ざり、的外れな応答が発生した。

教訓: セッションキーはuser_id + session_idの複合キーで構成すべきである。各ブラウザタブに固有のsession_idを発行し、ユーザーダッシュボードでアクティブセッション一覧を管理できるようにした。

参考資料