第4章 【記憶】LLMは毎回忘れる — 会話を続けるには「全部」を渡す

📖 この章のゴール:「LLMのAPIはステートレス(呼ぶたびにまっさら)」を腹落ちし、会話の記憶を毎回 messages に積んで渡せるようになる。これが教材の背骨①(記憶)の、いちばんの核です。 ← 目次・はじめにへもどる


📱 ChatGPTではこう見える

ChatGPTで、こんな会話をしたことはありませんか。

あなた:「東京タワーの高さは?」 ChatGPT:「333メートルです。」 あなた:「それ、いつ建てられたの?」 ChatGPT:「東京タワーは1958年に完成しました。」

2回目で「東京タワー」と言っていないのに、ChatGPTは「それ=東京タワー」だと分かっています。まるで さっきの話を覚えている ようですよね。

でも——本当に「覚えて」いるのでしょうか? この章で、その正体をあばきます。


🤔 じつはLLMは毎回まっさら(🟢 基礎)

結論から言います。

LLMのAPI(呼び出しの窓口)は「ステートレス(stateless)」です。 ステートレス=「状態(state=これまでの記憶)を持たない」という意味の英語。つまり 呼ぶたびに、前の会話をきれいさっぱり忘れています。

第3章で作った最小の窓口(/api/chat に1発言だけ送る形)を思い出してください。あれで2回目をやると、こうなります。

// 1回目:1発言だけ送る
await openai.chat.completions.create({
  model: "gpt-4o-mini",
  messages: [{ role: "user", content: "東京タワーの高さは?" }],
});
// → 「333メートルです」

// 2回目:また1発言だけ送る(さっきの話は入っていない!)
await openai.chat.completions.create({
  model: "gpt-4o-mini",
  messages: [{ role: "user", content: "それ、いつ建てられたの?" }],
});
// → 「『それ』が何を指すのか分かりません」😱

1行ずつ読むと:

  • 1回目の messages:いまの発言が 1個だけ 入っている。
  • 1回目の結果:ふつうに答えが返る。
  • 2回目の messages:また いまの発言が1個だけ。さっきの「東京タワー」も、その返事も、どこにも入っていない
  • 2回目の結果:LLMには「それ」の手がかりがゼロ。だから答えられない(=失敗)。

これがLLMの素の姿です。本物のChatGPTで会話が続いて見えたのは、画面の裏で 誰かがちゃんと「これまでの会話」を渡し直していた から。

💡 たとえ話:毎回はじめましての“記憶喪失の天才” LLMは、ものすごく賢いのに 毎朝かならず記憶を失う天才 だと思ってください。昨日あなたと何を話したか、1分前に何を答えたかも、本人はまったく覚えていません。 だから会話を続けるには、こちらが毎回 「これまでのやりとりを書いた台本」 を渡してあげる必要があります。台本さえ渡せば、天才はそれを一瞬で読んで、続きをちゃんと話してくれます。

つまり——

会話の記憶を作るのは、LLMではなく「こちら側(開発者であるあなた)」の仕事。

これが背骨①の正体です。やることはシンプルで、これまでの会話(user と assistant が交互に並んだもの)を全部、毎回 messages に積んで送る だけ。


🤔 「全部送る」ってどういう形?(🟢 基礎)

第3章でも出てきた messages の形を、もう一度おさらいします。

const messages = [
  { role: "system",    content: "あなたは親切なアシスタントです。" },
  { role: "user",      content: "東京タワーの高さは?" },
  { role: "assistant", content: "333メートルです。" },
  { role: "user",      content: "それ、いつ建てられたの?" },
];

1行ずつ読むと:

  • role: "system":いちばん最初に置く「設定」。AIの性格や役割を伝える(任意・1個でOK)。
  • role: "user"あなた(ユーザー) の発言。
  • role: "assistant"AI がこれまでにした返事。過去の返事もここに含めるのがポイント。
  • 4行目(2回目の質問):その手前に 過去のやりとりが全部並んでいるので、LLMは「それ=東京タワー」と分かる。

📌 覚えること:会話継続=「これまでの全 messages」を毎回まるごと渡す。 差分(新しい発言だけ)を送るのではありません。毎回、最初から今までの全部 です。

📦 実際に送られる「payload(中身)」を見てみる

第3章で作った fetch("/api/chat", ...)body を文字にすると、こういうJSONです(これが“台本”の正体)。

//  ブラウザ  自分のサーバー(POST /api/chat  body)
{
  "messages": [
    { "role": "system",    "content": "あなたは親切なアシスタントです。" },
    { "role": "user",      "content": "東京タワーの高さは?" },
    { "role": "assistant", "content": "333メートルです。" },
    { "role": "user",      "content": "それ、いつ建てられたの?" }
  ]
}

サーバーは、これに model を足して ほぼそのまま OpenAI へ渡します。

//  自分のサーバー  OpenAI(POST https://api.openai.com/v1/chat/completions  body)
{
  "model": "gpt-4o-mini",
  "messages": [
    { "role": "system",    "content": "あなたは親切なアシスタントです。" },
    { "role": "user",      "content": "東京タワーの高さは?" },
    { "role": "assistant", "content": "333メートルです。" },
    { "role": "user",      "content": "それ、いつ建てられたの?" }
  ]
}

返ってくる返事(payload)は、ざっくりこんな形。実際に使うのは choices[0].message.content の1行だけです。

//  OpenAI  自分のサーバー(返事・一部省略)
{
  "choices": [
    { "message": { "role": "assistant", "content": "東京タワーは1958年に完成しました。" } }
  ]
}

messages の中身が①と②で ほぼ同じ毎回この配列ぜんぶが行き来しているのが分かります。次のターンでは、ここに③の assistant と新しい userさらに足されて長くなっていきます(だから第8章の「トークンあふれ」につながります)。


🛠 こう作る — 会話履歴を積み上げて毎回渡す(🟢 基礎)

やることは3つだけです。①履歴の配列を用意 → ②送る前に user を追加 → ③返事が来たら assistant を追加。これをくり返します。

ステップ1:サーバーは「届いた全履歴」をそのまま渡す

// server.ts の /api/chat(第2章の窓口を、全履歴を受け取る形に)
app.post("/api/chat", async (req, res) => {
  const { messages } = req.body; // ← 1発言ではなく「全履歴」を受け取る
  const completion = await openai.chat.completions.create({
    model: "gpt-4o-mini",
    messages, // ← 届いた全履歴をそのままLLMへ
  });
  res.json({ reply: completion.choices[0].message.content });
});

1行ずつ読むと:

  • const { messages } = req.body:第2章は message(1個)だったが、ここは messages(配列=全履歴) を受け取る。
  • messages,:受け取った履歴を そのままLLMに渡す{ messages: messages } の短い書き方)。
  • 残りは第2章と同じ。返事の文章だけをブラウザへ返す。

ステップ2:ブラウザは履歴を保持し、送る前後で積み増す

// 画面側:会話履歴を1本の配列で持ち続ける(React/Vue でも考え方は同じ)
const history = [
  { role: "system", content: "あなたは親切なアシスタントです。" },
];

async function send(text: string): Promise<string> {
  history.push({ role: "user", content: text });        // ①送る前にuserを足す
  const res = await fetch("/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ messages: history }),         // ②全履歴を送る
  });
  const { reply } = await res.json();
  history.push({ role: "assistant", content: reply });   // ③返事をassistantとして足す
  return reply;
}

1行ずつ読むと:

  • const history = [ ... ]:会話全体を入れておく 1本の配列。最初に system(設定)を1個だけ入れておく。
  • history.push({ role: "user", content: text }):送信ボタンを押したら、まず あなたの発言を履歴の末尾に追加
  • body: JSON.stringify({ messages: history }):いまの 履歴ぜんぶ をサーバーへ送る(差分ではない)。
  • const { reply } = await res.json():返ってきた返事の文章を取り出す。
  • history.push({ role: "assistant", content: reply })返事も履歴に入れ忘れずに追加。これを忘れると次回の台本から返事が抜ける(よくある事故・後述)。
  • return reply:画面に表示するのは返事の文字列だけ(あとは第3章と同じ)。

図解:messages はこうやって伸びていく

送信のたびに userassistant がペアで増え、配列がだんだん長くなるのが感覚としてつかめれば合格です。

1回目を送る前   : [system]
1回目を送る瞬間 : [system, user①]                              ← ①でuser追加
1回目の返事の後 : [system, user①, assistant①]                 ← ③でassistant追加
2回目を送る瞬間 : [system, user①, assistant①, user②]          ← また①
2回目の返事の後 : [system, user①, assistant①, user②, assistant②]
                  └─ 毎回この「全部」をまるごとLLMへ渡している ─┘

毎回いちばん右の状態を、まるごと送っている——これが「会話が続く」の正体です。LLMは記憶喪失のままですが、台本が毎回ぶ厚くなるので、続きを話せるのです。


⚠️ ハマりどころ

  • 履歴を送らない(1発言だけ送る) → LLMは毎回はじめまして。「それ」「さっきの」がいつも通じない。➡ messages過去の全部 を入れる。
  • assistant の返事を履歴に入れ忘れる → ステップ2の③を忘れると、次の台本から AIの発言だけ抜け落ち、会話がチグハグに。userassistant必ずペアで 積む。
  • role の入れ違い(自分の発言を assistant にしてしまう等)→ AIが「自分が言った」と勘違いして混乱。あなた=user/AI=assistant を厳守。
  • system を毎回いくつも足してしまう → 設定は基本 先頭に1個 でOK。送るたびに増やさない。
  • 🔧 この「毎回全部送る」は、後でコストと上限に直結する(予告):会話が長くなるほど毎回の台本が長くなり、入れられる量の上限(トークンあふれ) にぶつかります(くわしくは第8章)。さらに 送る量=料金 なので、同じ前文を安く速くする工夫(プロンプトキャッシュ)も出てきます(第13章)。いまは「全部送る」を体に入れればOK。

🤖 AIに頼むなら(Vibe codingのコツ)

🗣 プロンプト例: 「いまの /api/chat を、会話履歴を保持して毎回 messages に全部積んで送る形に変えて。画面側は history 配列を持ち、送る前に {role:'user'} を追加 → サーバーへ全履歴を送信 → 返事が来たら {role:'assistant'} を追加 という流れにして。サーバーは req.body.messages(全履歴)を受け取って openai.chat.completions.create にそのまま渡して」

出てきたコードの確認チェックリスト:

  • サーバーは messages(配列=全履歴) を受け取っているか(message 1個だけになっていないか)
  • 画面側は 送信前に user を、返事の後に assistant を履歴に追加しているか(ペアで積めているか)
  • 毎回 差分ではなく「全履歴」 を送っているか
  • system先頭に1個だけ か(毎回増えていないか)
  • 🔧 履歴がずっと伸び続けると 上限(第8章)/料金(第13章) に効く、という注意を理解しているか

📝 ことばメモ

  • ステートレス(stateless):状態(=これまでの記憶)を持たないこと。LLMのAPIはこれ。呼ぶたびにまっさら
  • コンテキスト(context):LLMが今回の返事を作るために見ている「文脈」。messages に積んだ全部がこれにあたる
  • 会話履歴(history):これまでの user / assistant のやりとりを並べた配列。記憶の実体は、LLMでなくここにある
  • ターン(turn):会話のひと往復(あなたの発言1つ+AIの返事1つ)。履歴はターンを重ねるほど長くなる

➡️ 次の章へ

会話を続けられるようになりました。でも今の historyプログラムを閉じたら消えてしまう「その場かぎりの記憶」です。第5章では、この履歴を きちんと保存(データベース=DB) して、会話を何個も持ち、後から開いて続けられる ようにします。「記憶」を永続化する回です。

第5章 会話ログと複数セッション

← 目次・はじめにへもどる