チャットボットはどうやって「覚えているふり」をするのか

チャットボットが会話を忘れる本当の理由とその解決策。会話を安全に保存し、複数の端末で続けて話しても内容が崩れないようにする方法をコードで追う。
← 목록으로
이
이서연
2026년 10월 02일 更新 2026.10.16 読了 16分 閲覧 2

ついさっき、5分前に名前を伝えたはずなのに、チャットボットがまた同じことを聞いてくる。モデルが馬鹿だからではない。商用の大規模言語モデル(LLM、人間が書いた文章を大量に学習して次の文を生み出すAI)は、リクエストとリクエストの間に何も抱えていない。呼び出しのたびに白紙から始まる。「昨日話したあの件です」と言っても、モデルにはその一文だけがぽつんと渡される。

それでも会話が続いているように感じられるのは、アプリがリクエストのたびに、これまでの会話をテキストとして改めて積み直してモデルに渡しているからだ。ユーザーの目には滑らかな会話でも、サーバーの裏側では毎回「これまでこういう話をしてきて、今回の質問はこれだ」という塊を新しく組み立てている。チャットボットの記憶は、実のところ記憶ではなく、毎回あらためて読み上げる要約ブリーフィングに近い。

だから設計上の問いは一つに絞られる。その履歴をどこに置き、いつ取り出すのか。答えはあっけない。データベース(DB)に置く。本当に難しいのはその先、速度を上げようとキャッシュを足した瞬間からだ。

「セッションが飛んだ」のはセッションのせいではない

この履歴管理を、よく「セッション」と呼ぶ。そして「セッションはもともとすぐ消えるもの」とひとくくりにされる。間違いだ。セッションがどれだけ長く生きるかは、セッションの本質ではなく、どこに保存したかが決める。

  • サーバーのメモリ(RAM)だけに置く → サーバーを再起動したり、リクエストが別のサーバーに回ったりすると、その会話を知らない。
  • ブラウザのクッキーやローカルストレージだけに置く → 端末を変えたりキャッシュを消したりすると消える。
  • DBに置く → 再起動だろうがサーバー増設だろうが関係なく残る。

実際、DjangoのセッションはデフォルトがDBバックエンドだ。「再起動すると会話が飛ぶ」のは、セッションだからではなく、セッションをメモリだけに置いたときに起きる症状にすぎない。保存先を長く残る場所に移せば、その事故そのものがなくなる。

基本形:原本はDB、履歴は毎回作り直す

もっとも単純で、もっとも頑丈な形はこうだ。会話の原本をDBに置き、リクエストが来るたびに直近の数ターン(やり取りした会話の数回分)を取り出してプロンプトを作り直す。キャッシュがないので、キャッシュが古くなる心配もない。

def build_prompt(conn, conversation_id, new_message):
    # ユーザー発言をまず永続記録する — 応答生成の途中でサーバーが落ちても発言は失われない
    append_turn(conn, conversation_id, role="user", content=new_message)
    # 原本から直近Nターンをリクエストごとに取得 — たった今入れた発言まで含まれる
    history = fetch_recent_turns(conn, conversation_id, limit=20)
    return assemble_prompt(history)

ここには二つの原則が隠れている。一つ、ユーザーメッセージは応答を受け取り終えた後ではなく、入ってきた瞬間に記録する。生成の途中でサーバーが落ちても、ユーザーが言ったことは残る。二つ、プロンプトは原本から作る。別の端末で続きを書こうが、たった今サーバーが切り替わろうが、原本を読むので常に最新だ。

ほとんどのチャットボットは、ここまでで十分だ。キャッシュは、取得が本当に遅くなったのを確認してから乗せても遅くない。

キャッシュを足した瞬間、静かな事故が始まる

取得がボトルネックになると、キャッシュ(よく使うデータを手近にコピーしておく一時保存領域)を乗せたくなる。このときよく出てくる、しかし間違った発想がある。「いちばん大きいターン番号を覚えておいて、DBの最大値がそのままなら、キャッシュをそのまま使おう」。

なぜダメなのかは、二人がほぼ同時に話す場面を追うと見えてくる。ターン番号が1ずつ増えるとしよう。

  1. 処理Aが10番のメッセージを保存しようとして、ほんの一瞬止まる。
  2. その間に処理Bが11番のメッセージを保存して確定する。最大値は11。
  3. Aが目覚めて10番を確定する。最大値は依然として11。

最大値が11から微動だにしないので、キャッシュは「変わっていないな」と判断する。そして10番が抜けた履歴を出し続ける。ユーザーは確かにAという発言を先にしたのに、チャットボットの答えでは、その発言がまるごと蒸発したように見える。10番のメッセージが「納期を2週間早めてほしい」だったなら、チャットボットはその要望を聞いていない人のように振る舞う。データはDBにちゃんと残っているのに。

さらにたちが悪いのは、これがエラーとして出ないことだ。サーバーは200 OKを返し、ログもきれいだ。ただ答えが少しおかしいだけ。キャッシュは速度のための近道にすぎず、真実の原本ではない。近道を通すと決めたなら、道が古くなったときに閉じるルールまで一緒に立てておかないと事故になる。

開発者向けの深掘り:バージョンカウンターと書き込み順序

ここからは実際の解法をコードで追う。上の問題の結論だけ持っていけばよい読者は、次の節へ飛ばしてかまわない。

鍵となる入れ替えは一つだ。「いちばん大きい番号」の代わりに、どんな変更でも1ずつ上がるバージョンカウンターを使う。追加だろうが修正だろうが削除だろうが、変更が起きればバージョンが上がる。キャッシュには履歴と一緒に、そのときのバージョンを書いておく。読むときにDBの現在のバージョンと比べ、違えば読み直す。

こうすれば先ほどの罠が消える。11番が先、10番が後に確定しても、後の確定がバージョンをもう一度上げる。次の読み取りでバージョンが食い違うので、キャッシュを捨てて原本を読み直す。最大値では捕まえられなかった「既存ターンの修正・削除」も同じように捕まえられる。

def get_history(conn, conversation_id, cache):
    cached = cache.get(conversation_id)
    with conn.begin():
        # バージョンとターンを「同じ瞬間のスナップショット」として読むために分離レベルを上げる(下で説明)
        conn.execute("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")
        version = conn.execute(
            "SELECT version FROM conversations WHERE id = :cid",
            {"cid": conversation_id},
        ).scalar()
        if cached and cached["version"] == version:
            return cached["turns"]  # バージョン一致 → 重いターン取得そのものを省略
        turns = conn.execute(
            "SELECT role, content FROM turns "
            "WHERE conversation_id = :cid ORDER BY seq",
            {"cid": conversation_id},
        ).all()
    cache.set(conversation_id, {"version": version, "turns": turns})
    return turns

読む側は、安価なバージョン取得でまずキャッシュヒットを判定し、食い違ったときだけ重いターン取得を行う。

コメントの「同じ瞬間のスナップショット」がなぜ必要なのかは、触れておく価値がある。バージョンを読む刹那と、ターンを読む刹那の間に別の書き込みが割り込むと、「バージョン番号と実際のターンが食い違ったペア」をキャッシュに刻み込んでしまうことがある。これを防ぐため、DBは、一つのトランザクション(複数の作業をすべて成功させるか、すべて取り消すかの一つの束)が最初に見た時点のデータだけを見続ける機能を提供している。PostgreSQLでこのレベルが REPEATABLE READ だ。デフォルトの READ COMMITTED では、文ごとに時点が取り直されるので、その隙間が生まれる。正確な意味はPostgreSQLのトランザクション分離ドキュメントにある。

バージョンカウンターは「キャッシュが食い違ったか」は教えてくれるが、「番号を振った順序と実際に確定した順序が逆転する」問題までは消してくれない。二つの確定の間のわずかな隙間で、ある読み取りは11番はあるのに10番はまだない履歴を一瞬だけ見る。キャッシュは次の読み取りで正されるが、その刹那の「瞬間的なタイムトラベル」まで無くしたいなら、番号を振る前から会話単位でロックをかける。

def append_turn(conn, conversation_id, role, content):
    with conn.begin():  # コミット時点でロック解除
        # 1) seq 割り当て「前」に会話行をロックする(FOR UPDATE) → 同時書き込みが一度に一つずつ
        conn.execute(
            "SELECT id FROM conversations WHERE id = :cid FOR UPDATE",
            {"cid": conversation_id},
        )
        # 2) ロックの中で次の seq を計算 → 割り当て順序とコミット順序が食い違わない
        seq = conn.execute(
            "SELECT COALESCE(MAX(seq), 0) + 1 FROM turns WHERE conversation_id = :cid",
            {"cid": conversation_id},
        ).scalar()
        conn.execute(
            "INSERT INTO turns (conversation_id, seq, role, content) "
            "VALUES (:cid, :seq, :role, :content)",
            {"cid": conversation_id, "seq": seq, "role": role, "content": content},
        )
        # 3) すべての変更でバージョンを上げる → キャッシュ無効化の単一シグナル
        conn.execute(
            "UPDATE conversations SET version = version + 1 WHERE id = :cid",
            {"cid": conversation_id},
        )

FOR UPDATE は「この行を私が書き終わるまで、他の書き込みは待て」というロックだ。AとBが同じ会話に同時に書こうとすると、片方だけがロックを得て、残りは自分の番号を計算することすらできずに待つ。「11が10より先に確定する」状況がそもそも起きない。ロックは会話単位なので、別の会話への書き込みは相変わらず並んで流れる。動作の細部はPostgreSQLの明示的ロックのドキュメントに整理されている。

正確に保存したからといって、うまく使えるわけではない

ここまでが、記憶を「失わない」技術だ。ここからは記憶を「うまく使う」技術だ。どれだけ正確に積み上げても、その履歴をモデルにどう渡すかで答えの質が分かれる。

プロジェクトが数か月続くと、履歴は数百ターンを超える。それをまるごと毎回プロンプトに詰め込むのは得策ではない。Liuら(2023)のLost in the Middleという研究は、長い入力で核心的な情報が真ん中にあると、モデルがそれを見落としやすいというU字型の傾向を報告した。前と後ろはよく見えるのに、真ん中はぼやける。

3時間の会議を思い浮かべると腑に落ちる。最初の議題と最後の結論ははっきりしているのに、2時間目の細かい議論はよく思い出せない。だから履歴が長いほど、「全部入れる」ではなく配置が要る。確定した決定は短く要約して先頭に固定し、直近の数ターンは原文のまま末尾に置く。古い区間は要約で圧縮して、真ん中を短く通り過ぎさせる。重要な情報を、モデルがよく見える場所で出会わせるのだ。

もう一つ。同じプロジェクトでも、参加者ごとに会話画面を分けるべきだ。これは利便性ではなくプライバシーの問題だ。チームメンバーAが個人的に残した懸念がBの画面に混ざった瞬間、信頼が壊れる。DB設計に移すと、履歴を引く鍵が「プロジェクトID」一つではなく、「プロジェクトID + ユーザーID」の組み合わせであるべき、ということだ。スケジュールや成果物のような共用情報だけを別に共有する。

ONTLEでこの原則が崩れた瞬間

ONTLEのAI PMを作る中で、私たちもこの罠をそっくり踏んだ。初期バージョンは「いちばん大きいターン番号がそのままならキャッシュ再利用」という方式だった。普段は問題なかった。問題は、ある顧客が「納期を2週間早めてほしい」と要望した瞬間に起きた。そのメッセージをDBに書く処理と、AI PMがすぐ答えを作りながら自分の発言を書く処理が、ほぼ同時に走った。番号の最大値は微動だにせず、キャッシュは納期変更のターンが抜けた履歴を次の応答に出した。AI PMは、たった今聞いた納期変更を聞かなかったふりをした。私たちはバージョンカウンターに切り替えてから、ようやくこの蒸発が止まった。

今は、顧客が新しい端末からアクセスしても、サーバーが再デプロイされた直後でも、AI PMが「この前どこまで話しましたっけ?」と聞き返すことはない。あの納期変更の要望は担当者本人の会話画面にだけ残り、チームの別の担当者の画面には漏れない。ただし変更された納期そのものはプロジェクトの共用情報に上がり、全員が同じスケジュールを見る。数百ターンが積み上がった後でも、この決定は要約されてプロンプトの先頭を守る。

技術だけ見れば平凡だ。しかしこれが崩れると、顧客はプロジェクトを毎回ゼロから説明し直さねばならず、その疲れが積もると信頼が揺らぐ。議事録をきちんと残すチームとそうでないチームの差が数か月後に速度として現れるように、会話履歴の扱い方はサービスの地力そのものだ。

FAQ

キャッシュをRedisのようにサーバーの外に置くと何が変わるのか?

本文の原則はそのまま使いながら、複数のサーバーが同じキャッシュを共有するという点が加わる。利点は、あるサーバーが更新したキャッシュを他のサーバーもすぐ見られるので、サーバーごとにばらばらの古いコピーを抱える事故が減ることだ。代わりにキャッシュがもう一つの外部依存になるので、キャッシュが一瞬落ちてもDBの原本だけで正常に動くよう、「キャッシュミス = 原本取得」の経路を必ず生かしておかねばならない。キャッシュを信じられない状況をデフォルトとして設計するわけだ。

会話ロックを握ったままモデルを呼び出してもよいか?

だめだ。ロックはトランザクションを短く保つときだけ安全だ。モデル呼び出しは数秒かかる遅い処理なので、その間ロックを握っていると、同じ会話への他の書き込みがすべてその時間だけ止まる。ルールは単純だ。ロック区間の中ではDB処理だけを行い、モデル呼び出しのような遅い仕事はロックの外でやる。

古い会話の要約は、いつ、誰が作るのか?

リクエストが入ってきたその瞬間に毎回要約すると応答が遅くなるので、普通はバックグラウンド処理であらかじめ作っておく。たとえばターンが一定数を超えるたびに古い区間を要約して別に保存しておき、プロンプトを組み立てるときにはその保存済みの要約を取り出して使う、という具合だ。要約も結局は一つのデータなので、原本が変われば作り直さねばならないという点で、キャッシュと同じ無効化の悩みがそっくりついて回る。

バージョン確認すら負担になるほどトラフィックが集中したら?

バージョン取得は整数一つを読む軽い処理だが、極端に集中するとこれもDBの負担になる。そのときはバージョン自体をごく短い時間(数百ミリ秒程度)だけキャッシュするか、書き込み時に新しいバージョンをキャッシュへ直接押し込んで、読む側のDB往復を減らす。整合性をほんの少し緩める代わりに負荷を軽くする折衷なので、サービスがその短い遅延を許容できるときにだけ使う。

参考資料