【Laravel】会話履歴を管理するチャットUIを実装する

laravelアイキャッチ Laravel

こんにちは、かつコーチです。

前回はストリーミングで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の出力形式をコントロールする方法を学びましょう

コメント

タイトルとURLをコピーしました