こんにちは、かつコーチです。
前回はストリーミングでAIの応答を表示する方法を紹介しました。
ただ、それだけでは「一問一答」しかできず、文脈を踏まえた会話にはなりません。
この記事では、会話履歴をDBに保存し、マルチターン(複数往復)の会話をLLMに渡す実装を解説します。
さらに、実際に長時間チャットを回して遭遇した「トークン数超過」のエラーについても、実体験ベースで共有します。
会話履歴とは?
なぜDBに保存する必要があるのか
LLMは基本的にステートレス(前回のやり取りを覚えていない)です。
つまり、会話の文脈を維持したいなら、こちらから毎回「これまでの会話」を含めてリクエストする必要があります。
そのためには、ユーザーとAIのやり取りをDBに保存しておき、リクエストのたびに組み立て直す仕組みが必要です。
role/contentという考え方
OpenAIなどのチャット系APIでは、メッセージを role(発言者の役割)と content(発言内容)のペアで表現します。
role には system(AIへの指示)、user(ユーザーの発言)、assistant(AIの応答)の3種類が主に使われます。
この構造をそのままDBのテーブル設計に落とし込むのが定石です。
基本の書き方 / 実装手順
テーブル設計
会話(conversations)とメッセージ(messages)の2テーブルに分けるのが扱いやすい設計です。
// database/migrations/2026_09_01_000001_create_conversations_table.php
Schema::create('conversations', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title')->nullable();
$table->timestamps();
});
// database/migrations/2026_09_01_000002_create_messages_table.php
Schema::create('messages', function (Blueprint $table) {
$table->id();
$table->foreignId('conversation_id')->constrained()->cascadeOnDelete();
$table->enum('role', ['system', 'user', 'assistant']);
$table->text('content');
$table->unsignedInteger('token_count')->nullable();
$table->timestamps();
});
token_count カラムは、後述するトークン数管理のために追加しています。
モデルとリレーション
// app/Models/Conversation.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Conversation extends Model
{
public function messages(): HasMany
{
return $this->hasMany(Message::class)->orderBy('created_at');
}
}
マルチターン会話をLLMに渡す実装
保存済みのメッセージを取り出し、LLMが期待する形式(role/contentの配列)に変換してリクエストします。
// app/Http/Controllers/ChatController.php
namespace App\Http\Controllers;
use App\Models\Conversation;
use Illuminate\Http\Request;
use Prism\Prism\Prism;
use Prism\Prism\Enums\Provider;
use Prism\Prism\ValueObjects\Messages\UserMessage;
use Prism\Prism\ValueObjects\Messages\AssistantMessage;
use Prism\Prism\ValueObjects\Messages\SystemMessage;
class ChatController extends Controller
{
public function send(Request $request, Conversation $conversation)
{
$userInput = $request->input('message');
// ユーザーの発言をDBに保存
$conversation->messages()->create([
'role' => 'user',
'content' => $userInput,
]);
// 履歴をLLM用のメッセージ配列に変換
$history = $conversation->messages->map(function ($message) {
return match ($message->role) {
'system' => new SystemMessage($message->content),
'assistant' => new AssistantMessage($message->content),
default => new UserMessage($message->content),
};
})->all();
$response = Prism::text()
->using(Provider::OpenAI, 'gpt-4o-mini')
->withMessages($history)
->asText();
// AIの応答もDBに保存
$conversation->messages()->create([
'role' => 'assistant',
'content' => $response->text,
]);
return response()->json(['reply' => $response->text]);
}
}
これで、会話が続くほど過去の文脈を踏まえた応答が返るようになります。
よくあるつまずきポイント・エラー対処
トークン数超過エラーに遭遇した話
実際にこの仕組みを使って、あえて100往復くらい雑談を続けるテストをしてみました。
すると途中から、こんなエラーが返ってくるようになったのです。
This model's maximum context length is 128000 tokens. However, your messages resulted in 131204 tokens.
最初は「え、そんなに長話してないのに?」と驚きました。
原因を調べると、毎回のリクエストで会話履歴を「全件」渡していたため、往復を重ねるごとにリクエストのトークン数がどんどん膨らんでいたのです。
日本語は英語よりトークン消費が多い傾向があることも、想定より早く上限に達した一因でした。
❌Before → ✅After
❌Before:全メッセージを無条件でLLMに渡し続け、長時間の会話でコンテキスト上限エラーが発生した。
✅After:直近N件だけを渡す、または古いメッセージを要約してから渡す仕組みに変更した。
// 直近20件だけに絞るシンプルな対策
$history = $conversation->messages()
->latest()
->take(20)
->get()
->reverse()
->map(function ($message) {
return match ($message->role) {
'system' => new SystemMessage($message->content),
'assistant' => new AssistantMessage($message->content),
default => new UserMessage($message->content),
};
})
->all();
まずはこの「直近N件に絞る」対応で応急処置をし、その後で古い履歴を要約してsystemメッセージに埋め込む仕組みへ改善しました。
応用・一歩先の使い方
会話タイトルの自動生成
最初のユーザー発言をもとに、LLMに短いタイトルを生成させて conversations.title に保存すると、ChatGPTのようなサイドバーUIが作れます。
トークン数の事前見積もり
tiktoken 相当のライブラリでトークン数を事前に計算し、閾値を超えそうな場合だけ要約処理を挟む、という条件分岐にするとより堅牢になります。
まとめ
この記事のポイント
- 会話履歴はconversations/messagesの2テーブルで管理し、role/contentで構造化する
- マルチターン会話は履歴を配列化してLLMに毎回渡す必要がある
- 履歴を無制限に渡すとコンテキスト上限エラーになるため、直近N件や要約で調整する
次に読むべき記事
- 前の記事「ストリーミングレスポンスを実装する(SSEでリアルタイム表示)」も合わせてどうぞ
- 次は「構造化出力(Structured Output)でJSON形式の応答を得る」で、AIの出力形式をコントロールする方法を学びましょう
コメント