モジュール 03 · 第 1 回

マルチターン会話:モデルは実は何も覚えていない

コマンドラインのチャットプログラムを書き、モデルに記憶がないこと、「記憶」とは毎回履歴のメッセージを送り直しているだけであることを自分の目で確かめます。さらに、履歴が長くなりすぎたときの切り詰めと要約、二つの方法の効果を見ます。

  • 約 35 分
  • 難易度:入門
  • 検証:2026-09-14 deepseek-flash

コードと実行結果は実際に動かしたときのまま載せているため、コメントと出力は中国語です。

チャットボットを使ったことがある人には、ある直感があります。前に言ったことを覚えてくれている、というものです。「私は小王(シャオワン)です」と伝え、何度かやり取りしたあとで「私の名前は?」と聞けば、答えてくれます。

この直感は間違いです。少なくとも API にとっては間違いです。モデルそのものは何も覚えておらず、呼び出しは毎回まっさらです。チャットボットが「覚えている」のは、プログラムがそれまでの会話を毎回すべてモデルに送り直しているからです。この課ではチャットプログラムを自分で書いて、そのことをはっきり見てみます。

まず、モデルが覚えていないことを見る

code/03-llm-apps/conversation.py はコマンドラインのチャットプログラムで、--stateless オプションを付けると、履歴を付けずに毎回その場の一文だけを送ります。パイプで二文を渡してみます。

printf '%s\n' "我叫小王,最近在用 httpx 写一个爬虫。" "我叫什么名字?" | python conversation.py --stateless
你:我叫小王,最近在用 httpx 写一个爬虫。
助手:好的,httpx 很适合爬虫。有什么具体问题需要帮忙吗?  (本轮输入 32 词元)
你:我叫什么名字?
助手:你没有告诉我你的名字。  (本轮输入 23 词元)

モデルの側から見ると、2 回目の呼び出しには「我叫什么名字?」(私の名前は?)の一文しかないので、わからなくて当然です。

記憶とはメッセージのリストのこと

「覚えさせる」には、それまでの会話をすべて messages に入れるだけです。プログラムの中心はこの数行だけです。

SYSTEM = {"role": "system", "content": "你是一个说话简短的编程助手,每次回答不超过三句话。"}
history = []  # 只放 user 和 assistant 消息,system 每次单独加在最前面

while True:
    user_text = input("你:").strip()
    if not user_text:
        break
    messages = [SYSTEM] + history + [{"role": "user", "content": user_text}]
    response = client.chat.completions.create(model=MODEL, messages=messages, extra_body=NO_THINKING)
    answer = response.choices[0].message.content
    print(f"助手:{answer}  (本轮输入 {response.usage.prompt_tokens} 词元)")
    history += [{"role": "user", "content": user_text}, {"role": "assistant", "content": answer}]

毎ターン、ユーザーの発言を user メッセージとして、モデルの回答を assistant メッセージとして history に追加します。次のターンの呼び出しでは、history 全体を新しい質問と一緒に送ります。

六つの文を渡してみました。

你:我叫小王,最近在用 httpx 写一个爬虫。
助手:好的,httpx 支持同步和异步请求,写爬虫时注意设置 timeout 和重试。有什么具体问题吗?  (本轮输入 32 词元)
你:我叫什么名字?
助手:你叫小王。  (本轮输入 68 词元)
你:我在用什么库做什么?
助手:你在用 httpx 写爬虫。  (本轮输入 81 词元)
你:推荐一个解析 HTML 的库。
助手:推荐 parsel 或 BeautifulSoup,异步场景可选 selectolax。  (本轮输入 102 词元)
你:它和 httpx 怎么配合用?一句话。
助手:把 httpx 返回的 `response.text` 传给解析库(如 `BeautifulSoup(response.text, "lxml")`)即可。  (本轮输入 132 词元)
你:我叫什么名字?
助手:小王。  (本轮输入 170 词元)

これで名前を覚えていますし、「它和 httpx 怎么配合用」(それは httpx とどう組み合わせて使う?)の「それ」が前のターンで勧めたパーサーライブラリのことだとも理解できています。

各行の末尾にある入力トークン数に注目してください。32、68、81、102、132、170 と、ターンごとに増えています。毎ターン、それまでのすべての会話を送り直しているからです。

履歴はどんどん長くなる

ここからマルチターン会話の核心的な問題が見えてきます。履歴は増える一方で、減ることはありません。

  • どんどん高くなる。6 ターン目の入力は 1 ターン目の 5 倍以上です。100 ターン続いた会話では、毎ターン、それまでの 99 ターン分を支払うことになります。幸い履歴の冒頭は固定なので、モジュール 01 第 4 課のキャッシュにヒットし、ずっと安くはなりますが、それでも増え続けます。
  • どんどん遅くなる。入力が長いほど、モデルが読み終えるのに時間がかかります。
  • いつかはコンテキストウィンドウを超える。そうなるとリクエストはそのままエラーになります。

ですから、本当に公開するチャットプログラムでは、どれも履歴の長さを管理する必要があります。よく使われる方法は二つです。

方法一:切り詰める

最も簡単な方法です。最近のメッセージをいくつかだけ残し、それより前のものは捨てます。conversation.py--max-messages 4 オプションがこれで、履歴が 4 件を超えたら最近の 2 件だけを残します。

def trim(history):
    if len(history) <= args.max_messages:
        return history
    keep = history[-(args.max_messages // 2):]
    dropped = history[: len(history) - len(keep)]
    print(f"  [丢掉了最早的 {len(dropped)} 条消息]")
    return keep

同じ六つの文です。

你:我叫小王,最近在用 httpx 写一个爬虫。
助手:好的,小王。请问在 httpx 爬虫过程中遇到了什么问题?  (本轮输入 32 词元)
你:我叫什么名字?
助手:你叫小王。  (本轮输入 55 词元)
你:我在用什么库做什么?
助手:你在用 httpx 写爬虫。  (本轮输入 68 词元)
  [丢掉了最早的 4 条消息]
你:推荐一个解析 HTML 的库。
助手:推荐 BeautifulSoup,配合 httpx 用很方便。  (本轮输入 45 词元)
你:它和 httpx 怎么配合用?一句话。
助手:用 `BeautifulSoup(httpx.get(url).text, "html.parser")` 就能解析。  (本轮输入 71 词元)
  [丢掉了最早的 4 条消息]
你:我叫什么名字?
助手:我不知道你的名字,你还没告诉我。  (本轮输入 60 词元)

入力トークンはずっと数十個で推移し、もう増えません。その代わり、最後の質問ではユーザーが小王だということを忘れています。その一文はとっくに捨てられていたからです。

切り詰めは「最近の数ターンが最も重要で、前の内容は忘れてもよい」場面、たとえば雑談や一回きりの簡単な質疑応答に向いています。実際のプロジェクトでは、件数ではなくトークン数で切り詰めるのがふつうです。ここではデモのしやすさのため件数を使っています。

注意点が一つあります。切り詰めるときは「一問一答」をペアで捨ててください。user メッセージだけを捨てて、対応する assistant メッセージを残すと、モデルが見る会話は欠けたものになってしまいます。上のコードは常に偶数件を残すので、この問題は起きません。

方法二:要約する

もう少しよい方法は、古いメッセージをそのまま捨てるのではなく、モデルに要約へ圧縮させることです。

def summarize(messages):
    text = "\n".join(f"{m['role']}: {m['content']}" for m in messages)
    response = client.chat.completions.create(
        model=MODEL,
        messages=[{"role": "user", "content": "把下面这段对话压缩成几句话的摘要,保留人名、项目、偏好等以后可能用到的事实:\n\n" + text}],
        extra_body=NO_THINKING,
    )
    return response.choices[0].message.content

切り詰めるとき、捨てる部分を summarize に渡し、得られた要約を一組のメッセージとして履歴の先頭に置きます。

    summary = summarize(dropped)
    return [{"role": "user", "content": f"(之前对话的摘要:{summary})"},
            {"role": "assistant", "content": "好的,我记住了。"}] + keep

--summarize オプションを付けてもう一度実行します。

你:我叫小王,最近在用 httpx 写一个爬虫。
助手:好的小王,httpx 适合爬虫,支持异步和 HTTP/2。有具体问题可以问我。  (本轮输入 32 词元)
你:我叫什么名字?
助手:你叫小王。  (本轮输入 63 词元)
你:我在用什么库做什么?
助手:你在用 httpx 写爬虫。  (本轮输入 76 词元)
  [把最早的 4 条消息压缩成了摘要:小王在用 httpx 写爬虫;httpx 支持异步和 HTTP/2,适合爬虫。]
你:推荐一个解析 HTML 的库。
助手:推荐 BeautifulSoup 或 lxml,前者易用,后者更快。  (本轮输入 83 词元)
  [把最早的 4 条消息压缩成了摘要:小王在用 httpx 写爬虫;httpx 支持异步和 HTTP/2,适合爬虫。]
你:它和 httpx 怎么配合用?一句话。
助手:用 httpx 获取响应后,把 `resp.text` 传给 BeautifulSoup 解析即可。  (本轮输入 95 词元)
  [把最早的 4 条消息压缩成了摘要:小王在用 httpx 写爬虫,需要 HTML 解析库;推荐了 BeautifulSoup(易用)和 lxml(更快)。]
你:我叫什么名字?
助手:你叫小王。  (本轮输入 104 词元)
  [把最早的 4 条消息压缩成了摘要:小王在用 httpx 写爬虫,需 HTML 解析库;推荐了 BeautifulSoup(易用)和 lxml(更快)。与 httpx 配合时,把 `resp.text` 传给 BeautifulSoup 解析即可。]

最後の質問でも、覚えています。要約には「小王」「httpx」「クローラー」といった重要な事実が残っています。入力トークンは切り詰めない場合より少なく(104 対 170)、ただ切り詰める場合よりは多くなります。要約そのものも場所を取るからです。

要約にも代償があります。

  • 圧縮のたびにモデルをもう 1 回呼ぶ必要があるので、お金がかかり、そのターンも遅くなります。バックグラウンドで非同期に行うか、履歴がある程度長くなるまで待ってから圧縮し、毎ターン圧縮しないようにします。
  • 要約は細部を落とす。モデルが重要だと考えたことしか残りません。ユーザーが 3 ターン目に何気なく「サーバーは香港にある」と言っても、要約には入らないかもしれません。
  • 要約が間違うこともある。モデルが事実を誤ってまとめることがあります。

上の出力をよく見ると、圧縮のたびに「古い要約 + 新たに捨てるメッセージ」をまとめて圧縮し直していて、要約が絶えず更新されていくのがわかります。

どちらを選ぶか

場面 やり方
会話がふつう短い。たとえばカスタマーサポートの質疑応答 何もしないか、ゆるい上限を設けてそのまま切り詰める
長く話すが、最近の内容だけが重要 切り詰める
長く話し、前の情報を後でまた使う 要約する。または切り詰めと要約を併用する
何度もの会話をまたいで情報を覚えておく必要がある。たとえばユーザーの好み 重要な情報を別に保存し、必要なときに取り出してコンテキストに入れる。これはモジュール 05 第 5 課の「長期記憶」

よくある問題

system メッセージまで history に入れて一緒に切り詰めてしまった:system メッセージは毎回別に先頭へ置くべきで、切り詰めの対象にしてはいけません。上のコードが system メッセージと history を分けて保持しているのはこのためです。

複数のユーザーの履歴が混ざってしまった:Web サービスにするなら、ユーザーごと、セッションごとに専用の history が必要で、ふつうはデータベースかキャッシュに保存し、セッション ID で区別します。グローバル変数の history 一つで済むのは、一人で使うコマンドラインプログラムだけです。

練習問題

  1. conversation.py を実行して 10 ターン以上会話し、毎ターンの入力トークンの変化を観察してください。次に --max-messages 6 を付けてもう一度会話し、何かを忘れる瞬間を見つけてください。
  2. trim を書き直して、件数ではなくトークン数で切り詰めるようにしてください。履歴の合計トークン数が 500 を超えたら、最も古いものからペアでメッセージを捨てます。前のターンで返された usage.prompt_tokens で判断できます。
  3. 3 ターン目に何気なく細かい情報を一つ伝え(たとえば「サーバーは香港にある」)、8 ターン目にそれについて聞いてください。切り詰めと要約でそれぞれ一度ずつ実行し、要約がその細部を守れたか見てください。守れなかったら、summarize のプロンプトを直してみてください。

確認テスト

1. モデルはどうやって、あなたが前に言ったことを「覚えて」いるのですか?

モデルそのものは何も覚えていません。プログラムが呼び出しのたびに、それまでのすべての user と assistant のメッセージを新しい質問と一緒にモデルに送り、モデルはそのメッセージから前の内容を「読んで」いるのです。

2. マルチターン会話で、ターンを重ねるごとに高くなるのはなぜですか?

毎ターン、それまでの会話履歴をすべて入力として送り直すので、履歴が長いほど入力トークンが増え、費用も上がります。履歴の冒頭の同じ部分はキャッシュにヒットしてずっと安くなりますが、入力の合計は増え続けます。

3. 切り詰めと要約には、それぞれどんな欠点がありますか?

切り詰めは、ユーザーの名前のような前の情報を完全に捨ててしまうので、後で聞かれても答えられません。要約は重要な事実を残せますが、圧縮のたびにモデルを追加で 1 回呼ぶ必要があり、モデルが重要でないと考えた細部は落ち、まとめ方を誤ることもあります。

質問と議論

このレッスンでつまずいたところは、ここで質問してください。他の人の質問に答えるのも歓迎です。

質問で 3 ポイント、回答で 6 ポイント。審査を通過すると公開されます。

議論を読み込んでいます…