第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 はこうやって伸びていく
送信のたびに user と assistant がペアで増え、配列がだんだん長くなるのが感覚としてつかめれば合格です。
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の発言だけ抜け落ち、会話がチグハグに。userとassistantは 必ずペアで 積む。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(配列=全履歴) を受け取っているか(message1個だけになっていないか) - 画面側は 送信前に
userを、返事の後にassistantを履歴に追加しているか(ペアで積めているか) - 毎回 差分ではなく「全履歴」 を送っているか
systemは 先頭に1個だけ か(毎回増えていないか)- 🔧 履歴がずっと伸び続けると 上限(第8章)/料金(第13章) に効く、という注意を理解しているか
📝 ことばメモ
- ステートレス(stateless):状態(=これまでの記憶)を持たないこと。LLMのAPIはこれ。呼ぶたびにまっさら
- コンテキスト(context):LLMが今回の返事を作るために見ている「文脈」。
messagesに積んだ全部がこれにあたる - 会話履歴(history):これまでの user / assistant のやりとりを並べた配列。記憶の実体は、LLMでなくここにある
- ターン(turn):会話のひと往復(あなたの発言1つ+AIの返事1つ)。履歴はターンを重ねるほど長くなる
➡️ 次の章へ
会話を続けられるようになりました。でも今の history は プログラムを閉じたら消えてしまう「その場かぎりの記憶」です。第5章では、この履歴を きちんと保存(データベース=DB) して、会話を何個も持ち、後から開いて続けられる ようにします。「記憶」を永続化する回です。