Amazon Bedrock RAGチャットボットで複数問い合わせの回答が混ざる問題をワーキングバッファ+アーカイブパターンで解決した

Amazon Bedrock RAGチャットボットで複数問い合わせの回答が混ざる問題をワーキングバッファ+アーカイブパターンで解決した

RAGチャットボットで2件目の問い合わせに1件目の回答が混入する問題を、ワーキングバッファ+アーカイブパターンで解決しました。messagesを最終回答後にリセットしつつ、archivedMessagesで過去の文脈を参照可能に保つ実装を紹介します。
2026.05.29

サービスデスク向けのRAGチャットボットを開発していたところ、「1件目の問い合わせに回答をもらった後、クリアボタンを押さずに2件目の問い合わせを送ると、1件目の回答内容やメール文案が2件目の回答に混ざってしまう」という報告を受けました。

この記事では、問題の根本原因を分析し、ワーキングバッファ+アーカイブパターンを使って解決した話を書きます。

システムの概要

開発しているのは、社内サービスデスク担当者向けのAIアシスタントです。担当者が「Salesforceにログインできない」などの問い合わせを入力すると、Amazon Bedrock Knowledge Basesから過去チケットを検索し、類似事例・対応手順・メール文案をAIが生成して提示します。

技術スタックはこんな感じです。

  • フロントエンド: Next.js + Server-Sent Events (SSE)
  • バックエンド: Python / FastAPI
  • LLM: Amazon Bedrock (Claude Sonnet)
  • ナレッジベース: Amazon Bedrock Knowledge Bases + OpenSearch Serverless

会話は多段対話(明確化フロー)に対応しており、AIが「どのような症状ですか?」と確認質問を返し、担当者が選択肢を選ぶと最終回答が生成される仕組みです。

問題の再現

報告を受けて実際に試してみました。

  1. Q1: 「プリンターの設置依頼をしたい」→ AIがプリンター関連の手順・メール文案を返す
  2. Q2: (クリアせずにそのまま)「Salesforceにログインできない」→ 送信

するとQ2の回答の中に「プリンターの設置については〜」という文言が混入し、メール文案もプリンターとSalesforceの内容が混在していました。

根本原因

フロントエンドのコードを確認すると、問題はシンプルでした。

const [messages, setMessages] = useState<ChatMessage[]>([]);

// Q1送信
setMessages([{ role: "user", content: "プリンターの設置依頼をしたい" }]);

// Q1回答受信後、messagesに追加
setMessages(prev => [...prev, { role: "assistant", content: q1Answer }]);

// Q2送信 — messagesにはQ1の全やり取りが残っている
setMessages(prev => [...prev, { role: "user", content: "Salesforceにログインできない" }]);

バックエンドへのリクエストには messages 配列をそのまま送っているため、LLMはQ1の会話履歴を全部見た状態でQ2の回答を生成します。Bedrockが受け取るメッセージ列は次のようになっています。

user:      プリンターの設置依頼をしたい
assistant: [プリンター関連の詳細回答 + メール文案]
user:      Salesforceにログインできない  ← Q2

LLMの立場から見れば、これは「プリンター担当者との会話の続き」です。Q2のKBチャンクにSalesforce関連の情報が入っていても、直前のアシスタントメッセージにプリンター情報が含まれているため、それを引きずった回答が生成されてしまいます。

解決策の検討

いくつかのアプローチを検討しました。

案A: クリアボタンを毎回押す運用

技術的には解決しますが、「クリアしなくても独立した問い合わせとして扱ってほしい」というクライアント要件を満たしません。

案B: 最終回答後に messages を自動リセット

// 最終回答受信時
setMessages([]);

Q1完了後に messages = [] にリセットすれば、Q2送信時には履歴が空になります。しかし、これでは「さっきのチケット#123について詳しく教えて」という明示的な参照ができなくなります。クライアントからは「関連する過去のやり取りは参照できてほしい」という要件もあったので、この案は要件を満たしません。

案C: ワーキングバッファ+アーカイブパターン(採用)

LangChainの ConversationSummaryBufferMemory に類似した業界標準パターンです。

  • messages[](ワーキングバッファ): 現在進行中の問い合わせのみ保持。最終回答確定後にリセット。
  • archivedMessages[](アーカイブ): 完了した問い合わせをテキストとして蓄積。次の問い合わせ時に参照用として渡す。

「現在の問い合わせに集中しつつ、必要なら過去を参照できる」という要件を両立できます。

実装

フロントエンド側

const [messages, setMessages] = useState<ChatMessage[]>([]);
const [archivedMessages, setArchivedMessages] = useState<ChatMessage[]>([]);

SSEイベントハンドラの中で、response_type によって挙動を分岐させます。

if (chatResponse.response_type === "results") {
  // 最終回答: 現在のメッセージをアーカイブに移動してワーキングバッファをリセット
  setMessages((currentMessages) => {
    const finalAssistantMsg: ChatMessage = {
      role: "assistant",
      content: JSON.stringify({
        response_type: chatResponse.response_type,
        general_advice: chatResponse.general_advice,
      }),
    };
    setArchivedMessages((arch) => [...arch, ...currentMessages, finalAssistantMsg]);
    return []; // ワーキングバッファをリセット
  });
} else {
  // 確認質問(clarification): 引き続きワーキングバッファに追加(文脈を維持)
  setMessages((prev) => [
    ...prev,
    {
      role: "assistant",
      content: JSON.stringify({
        response_type: chatResponse.response_type,
        question: chatResponse.question,
        options: chatResponse.options,
      }),
    },
  ]);
}

setMessages の関数型更新内で setArchivedMessages を呼んでいるのがポイントです。こうすることで、明確化フロー中に蓄積された currentMessages(Q+確認質問+選択肢のやり取り)を正確にアーカイブに移せます。

バックエンドへのリクエストには archived_messages を追加します。

body: JSON.stringify({
  messages: nextMessages,
  previous_ticket_ids: previousTicketIds,
  archived_messages: archivedMessages, // アーカイブを追加
}),

hasHistory(クリアボタンの表示判定)は messages.length > 0 から bubbles.length > 0 に変更しました。messages がリセットされても画面に表示されているバブルが残っている間はクリアボタンが表示されるようにするためです。

バックエンド側: モデル

ChatRequest にフィールドを追加します。デフォルト空リストなので、既存クライアントとの後方互換性は維持されます。

# backend/app/models/chat.py
class ChatRequest(BaseModel):
    messages: list[ChatMessage] = Field(...)
    archived_messages: list[ChatMessage] = Field(
        default_factory=list,
        description="完了済みの過去の問い合わせ(参照用)。現在の問い合わせには影響しない。",
    )

バックエンド側: コンテキスト注入

アーカイブを会話メッセージとしてではなく、テキストとして注入します。

# backend/app/api/routes/chat.py
def _format_archived_context(archived: list) -> str:
    """完了済みの問い合わせを参照用テキストに整形する。"""
    lines: list[str] = []
    exchange_num = 0
    i = 0
    while i < len(archived):
        if archived[i].role == "user":
            exchange_num += 1
            lines.append(f"【問い合わせ{exchange_num}{archived[i].content}")
            i += 1
            if i < len(archived) and archived[i].role == "assistant":
                try:
                    resp = json.loads(archived[i].content)
                    advice = resp.get("general_advice", "")
                    if advice:
                        lines.append(f"【回答{exchange_num}{advice[:400]}")
                except (ValueError, KeyError):
                    lines.append(f"【回答{exchange_num}{archived[i].content[:400]}")
                i += 1
        else:
            i += 1
    return "\n".join(lines)

そしてKBチャンク・ドメインコンテキストと同様の方法でユーザーメッセージに注入します。

archived_section = ""
if request.archived_messages:
    archived_text = _format_archived_context(request.archived_messages)
    archived_section = f"\n\n## 過去の問い合わせ回答(参照用)\n{archived_text}"

augmented = (
    f"## 過去チケットデータ(Knowledge Base検索結果)\n{context}"
    f"{domain_ctx}"
    f"{archived_section}"    # ← アーカイブをここに注入
    f"\n\n## 問い合わせ\n{request.messages[0].content}"
)

なぜ会話メッセージとして渡さないのか

Bedrockは user/assistantの厳密な交互ターンを要求します。アーカイブを会話メッセージとして渡そうとすると、順序の管理が複雑になる上に、アーカイブされた最初のユーザーメッセージにはすでにKBチャンクが注入されていて肥大化しています。テキストとして注入する方が既存のKBチャンク注入パターンと一致し、シンプルです。

システムプロンプトへの指示追加

LLMがアーカイブを現在の回答に混入させないよう、明示的に指示します。

## 過去の問い合わせ(参照用)について

メッセージに「過去の問い合わせ回答(参照用)」セクションが含まれている場合:
- 現在の問い合わせが明示的に過去のトピックを参照している場合のみ使用してください
  (例:「さっきのチケット#123についてもっと教えて」→ 参照可)
- 現在の問い合わせが新しいトピックの場合は、過去の内容を回答に含めないでください
- mail_draft と todo_actions は必ず現在の問い合わせのチケットのみに基づいて生成してください
- 過去の回答の内容を現在の回答に混在させないでください

動作確認

実装後に以下のシナリオで確認しました。

混在テスト

  1. Q1「プリンターの設置依頼をしたい」→ 回答表示
  2. Q2「Salesforceにログインできない」→ 送信(クリアなし)

Q2の general_advice にはSalesforce関連の内容のみ、メール文案もSalesforceのみが含まれていることを確認できました。

明確化フローテスト

  1. Q1(明確化が返る問い合わせ)→ 「どの症状ですか?」が返る
  2. 選択肢「パスワードリセット後もログインできない」を選択 → 最終回答
  3. Q2送信

ネットワークタブで確認すると、Q2のリクエストの archived_messages には[Q1_user, Q1_clarification, Q1_option, Q1_final_assistant] の4メッセージが含まれており、会話全体が一単位としてアーカイブされていることを確認できました。

参照テスト

Q1完了後に「さっきのチケット#1234についてもっと詳しく教えて」と送信したところ、アーカイブから適切にQ1の文脈を拾って回答してくれました。

まとめ

Amazon Bedrock RAGチャットボットで複数問い合わせのコンテキスト汚染を防ぐために、ワーキングバッファ+アーカイブパターンを実装しました。

問題 解決策
Q1の回答がQ2に混入する messages(ワーキングバッファ)を最終回答後にリセット
参照できなくなる 完了済み交換を archivedMessages に蓄積し参照用テキストとして注入
Bedrockターン制約 アーカイブを会話メッセージではなくテキストとして注入

このパターンの信頼性のポイントは「構造的な分離」です。messages が空の状態でQ2が送信されるため、システムプロンプトの指示に頼らなくてもLLMはQ1の会話ターンを参照できません。アーカイブの扱いについてはシステムプロンプト指示が必要ですが、この部分は「ユーザーが明示的に参照を求めた場合のみ使う」という比較的シンプルな指示で十分です。

RAGチャットボットで似たような問題に当たった場合の参考になれば幸いです。

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事