Open11

【個人開発】LLM連携チャットボット開発

ぬぬぬぬ

はじめに

このスクラップはwebアプリ開発初心者に個人開発のメモです

目的

  • チャットボットを開発してみてLLM APIとはなんぞやを知る
  • 自分の利用目的に特化したアプリで月額20ドルより安く利用できたらBe Happy
  • webアプリ開発のスキルアップ
ぬぬぬぬ

想定している技術スタック

バックエンド

言語: PHP
フレームワーク: Laravel

フロントエンド

Livewire
Alpine.js (Livewireに付属)
Tailwind CSS (スタイリング)

データベース

MySQL 8.0+ (LaravelのデフォルトDB)

キャッシュ/セッション管理

ファイルキャッシュ (開発初期段階)
将来的にRedisへの移行を検討

検索機能

初期段階:MySQLの全文検索機能
将来:Laravel Scout+検索エンジン

LLM API連携

OpenAI API

ファイルストレージ

ローカルファイルシステム (開発初期段階)
将来的にAmazon S3への移行を検討

開発環境

Laravel Sail (Docker開発環境)

認証

Laravel Breeze (認証スターターキット)

ぬぬぬぬ

試し接続

手順

  1. openAI API keyの取得
  2. openai-php/laravelのインストール
    https://github.com/openai-php/client
composer require openai-php/laravel
  1. 接続の記述
$client = OpenAI::client(config('services.openai.api_key'));
$result = $client->chat()->create([
    'model' => 'gpt-3.5-turbo',
    'messages' => [
        ['role' => 'user', 'content' => $request->message],
    ],
]);
ぬぬぬぬ

openAI APIのドキュメントを読む

★=必読
☆=後で読む

Docs

プロンプトの例文 ★

https://platform.openai.com/docs/examples
これを使ってメッセージの種類分けができそう

関数呼び出し

https://platform.openai.com/docs/guides/function-calling
よくわからん がそのうち使ってみたい

構造化出力

https://platform.openai.com/docs/guides/structured-outputs

Advanced Usage ☆

https://platform.openai.com/docs/advanced-usage
パラメータとかトークン管理について書かれてる

Chat Completions ★

OpenAIのコアAPI
https://platform.openai.com/docs/advanced-usage
まずはシンプルにこれを使ってチャットボット開発

ファインチューニング ☆

https://platform.openai.com/docs/guides/fine-tuning
自分専用チャットボット開発の要
将来的にこれに挑戦したい

API reference

Chat

https://platform.openai.com/docs/api-reference/chat
基本的なhttpリクエストとレスポンスの説明

ぬぬぬぬ

連続した会話を実現する

概要

openAI APIはステートレスなAPIなため連続した会話を実現するにはそれまでの会話をメッセージに含める必要がある

方法

過去の会話を含めるためには会話を保存・取り出す必要がある。そのためには以下の2つの方法がある

  1. セッションベース
  2. DBベース
    どちらも一長一短があるが、今回は「セッションベースでセッションの期限が切れたらDBから取り出す」ハイブリッド方式を選択する。

サンプルコード

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use App\Models\Conversation;
use App\Models\Message;
use Carbon\Carbon;

class ChatbotController extends Controller
{
    public function chat(Request $request)
    {
        $userMessage = $request->input('message');
        $userId = $request->user()->id;

        // セッションから会話履歴を取得
        $conversationHistory = $request->session()->get('conversation_history', []);

        if (empty($conversationHistory)) {
            // セッションが空の場合、DBから最新のアクティブな会話を取得
            $conversation = Conversation::where('user_id', $userId)
                ->where('is_active', true)
                ->first();

            if (!$conversation) {
                // アクティブな会話がない場合、新しい会話を作成
                $conversation = Conversation::create([
                    'user_id' => $userId,
                    'is_active' => true,
                    'started_at' => now(),
                ]);
            }

            // DBから最新の10件のメッセージを取得
            $conversationHistory = $conversation->messages()
                ->orderBy('created_at', 'desc')
                ->take(10)
                ->get()
                ->reverse()
                ->map(function ($message) {
                    return ['role' => $message->role, 'content' => $message->content];
                })
                ->values()
                ->toArray();

            // 取得した履歴をセッションに保存
            $request->session()->put('conversation_history', $conversationHistory);
            $request->session()->put('conversation_id', $conversation->id);
        }

        // 新しいメッセージを履歴に追加
        $conversationHistory[] = ['role' => 'user', 'content' => $userMessage];

        // OpenAI APIリクエストの準備と送信
        $response = Http::withHeaders([
            'Authorization' => 'Bearer ' . config('services.openai.api_key'),
            'Content-Type' => 'application/json',
        ])->post('https://api.openai.com/v1/chat/completions', [
            'model' => 'gpt-3.5-turbo',
            'messages' => $conversationHistory,
        ]);

        // APIレスポンスの処理
        $botReply = $response->json()['choices'][0]['message']['content'];

        // ボットの返答を履歴に追加
        $conversationHistory[] = ['role' => 'assistant', 'content' => $botReply];

        // 更新された履歴をセッションに保存(最新の10メッセージのみ保持)
        $request->session()->put('conversation_history', array_slice($conversationHistory, -10));

        // DBにも保存
        $conversationId = $request->session()->get('conversation_id');
        $conversation = Conversation::findOrFail($conversationId);

        $conversation->messages()->createMany([
            ['role' => 'user', 'content' => $userMessage],
            ['role' => 'assistant', 'content' => $botReply]
        ]);

        $conversation->touch(); // 最終更新時刻を更新

        return response()->json(['reply' => $botReply]);
    }

    public function clearHistory(Request $request)
    {
        $userId = $request->user()->id;
        
        // セッションをクリア
        $request->session()->forget(['conversation_history', 'conversation_id']);

        // DBのアクティブな会話を終了
        Conversation::where('user_id', $userId)
            ->where('is_active', true)
            ->update(['is_active' => false, 'ended_at' => now()]);

        return response()->json(['message' => 'Conversation history cleared']);
    }
}
ぬぬぬぬ

laravel Breezeの導入

livewireも使いたいので認証機能もlivewire volt functionで導入
volt使ったことないのでここもキャッチアップ必要

ひとまずログイン/サインアップ→チャット画面へのルート定義

ぬぬぬぬ

開発

まずはメッセージを送信したらその内容とAPIからのレスポンスを保存する機能を作る

■ table作成
作成するテーブル

  • conversations
  • messages

■ modelの作成とリレーションシップ定義

ぬぬぬぬ

開発

ChatbotControllerの作成

ここでの作るもの

  • ユーザーによるメッセージの送信
  • APIからの返信メッセージの表示
  • conversationの保存
  • conversationのtitle生成+保存
  • ユーザーからのメッセージの保存
  • APIからの返信メッセージの保存

メモ

  • openAI APIはステートレスなAPIのため、それまでの会話内容を含めた会話をするには会話の履歴をmessageとして配列で渡してあげる必要がある
  public function getMessageHistory()
  {
    return $this->messages()
      ->orderBy('created_at', 'asc')
      ->take(10)
      ->get()
      ->map(function ($message) {
        return [
          'role' => $message->role,
          'content' => $message->content,
        ];
      })
      ->toArray();
  }

    $result = $client->chat()->create([
      'model' => 'gpt-3.5-turbo',
      'messages' => $conversation->getMessageHistory(),
    ]);
ぬぬぬぬ

改善

URLにconversation_idを使用するのでidをauto_incrementの値ではなくuuidに変更

  public function up(): void
  {
    Schema::create('conversations', function (Blueprint $table) {
      $table->uuid('id')->primary();
      $table->foreignId('user_id')->constrained()->onDelete('cascade');
      $table->text('title');
      $table->timestamps();
    });
  }

conversationの閲覧権限をログインユーザーに限定

// ChatbotController.php
  public function index(?string $conversationId = null): View
  {
    $conversations = Conversation::where('user_id', auth()->id())->get();

    if ($conversationId) {
      $conversation = Conversation::forUser(auth()->id())->findOrFail($conversationId);
      $messages = $conversation->messages()->orderBy('created_at', 'asc')->get();
      $title = $conversation->title;
    } else {
      $conversation = null;
      $messages = [];
      $title = '';
    }

    return view('index', compact('conversations', 'conversationId', 'title', 'messages'));
  }

// App/Models/Conversation.php
  public function scopeForUser(Builder $query, $userId): Builder
  {
    return $query->where('user_id', $userId);
  }

note
クエリスコープを活用することでコードの再利用性とビジネスロジックの意図の明確を行う
参考記事
https://qiita.com/akko_merry/items/5a6db8045a8b6c218b2e