【Laravel】コスト管理とレート制限:トークン消費を抑える実装

laravelアイキャッチ Laravel

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

LLM連携機能は、使えば使うほどAPI利用料がかかります。

野放しにしておくと、想定外の高額請求につながることもあります。

今回は、トークン数の記録・可視化と、Laravel標準のRate Limiting機能を使った利用制限の実装方法を解説します。

なぜコスト管理が必要なのか

実際に私は、個人開発のAIチャット機能をリリースした翌月、想定より高額な請求が来て青ざめた経験があります。

原因を調べたところ、一部のユーザーが1分間に何十回もメッセージを送信できる状態になっており、長文の会話履歴を毎回まるごとプロンプトに含めていたことで、1リクエストあたりのトークン消費が想定の何倍にも膨らんでいました。

  • ❌Before:レート制限なし・会話履歴を無制限にプロンプトへ含める実装で、1ユーザーが短時間に大量リクエストを送信できた
  • ✅After:Rate Limitingでユーザーごとのリクエスト回数を制限し、会話履歴も直近数件のみに絞ってトークン消費を抑えた

この経験から、AI機能をリリースする際は「コスト管理を後回しにしない」ことを徹底しています。

実装手順

トークン数の記録

まず、APIレスポンスに含まれるトークン使用量を記録するテーブルを用意します。

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('ai_usage_logs', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('model');
            $table->unsignedInteger('prompt_tokens');
            $table->unsignedInteger('completion_tokens');
            $table->unsignedInteger('total_tokens');
            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('ai_usage_logs');
    }
};

OpenAI APIのレスポンスにはusageフィールドにトークン数が含まれているため、そのまま記録します。

<?php

namespace App\Services\Ai;

use App\Models\AiUsageLog;
use Illuminate\Support\Facades\Http;

class ChatCompletionClient
{
    public function complete(int $userId, string $prompt): string
    {
        $response = Http::withToken(config('services.openai.key'))
            ->post('https://api.openai.com/v1/chat/completions', [
                'model' => 'gpt-4o-mini',
                'messages' => [
                    ['role' => 'user', 'content' => $prompt],
                ],
            ])
            ->throw();

        $usage = $response->json('usage');

        AiUsageLog::create([
            'user_id' => $userId,
            'model' => 'gpt-4o-mini',
            'prompt_tokens' => $usage['prompt_tokens'],
            'completion_tokens' => $usage['completion_tokens'],
            'total_tokens' => $usage['total_tokens'],
        ]);

        return $response->json('choices.0.message.content');
    }
}

蓄積したログは、ユーザーごとの月間利用量を集計するバッチやダッシュボードに使えます。

$monthlyTokens = AiUsageLog::where('user_id', $user->id)
    ->whereBetween('created_at', [now()->startOfMonth(), now()->endOfMonth()])
    ->sum('total_tokens');

Laravel標準のRate Limitingでリクエスト回数を制限する

Laravelのレート制限は、キャッシュベースの仕組みをそのまま流用できます。

RouteServiceProviderbootstrap/app.phpでレート制限のルールを定義します。

<?php

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

RateLimiter::for('ai-chat', function ($request) {
    return Limit::perMinute(10)
        ->by($request->user()?->id ?: $request->ip())
        ->response(function () {
            return response()->json([
                'message' => 'AIへのリクエストが集中しています。しばらく待ってから再度お試しください。',
            ], 429);
        });
});

このルールをルートに適用します。

Route::middleware(['auth', 'throttle:ai-chat'])->group(function () {
    Route::post('/chat', [ChatController::class, 'store']);
});

これで、1ユーザーあたり1分間に10回までしかAIチャットAPIを呼び出せなくなります。

会話履歴のトークン消費を抑える

チャット機能で会話履歴を毎回まるごと送ると、やり取りが長くなるほどトークン消費が線形に増えていきます。

直近N件だけを含めるか、古い履歴は要約してから含める工夫が効果的です。

<?php

namespace App\Services\Ai;

class ConversationHistoryTrimmer
{
    public function __construct(
        private int $maxMessages = 6,
    ) {}

    /**
     * @param array<int, array{role: string, content: string}> $messages
     * @return array<int, array{role: string, content: string}>
     */
    public function trim(array $messages): array
    {
        if (count($messages) <= $this->maxMessages) {
            return $messages;
        }

        // 直近のメッセージのみを残す(システムプロンプトは別途先頭に固定する運用を想定)
        return array_slice($messages, -$this->maxMessages);
    }
}

ユーザーごとの月間利用上限を設ける

リクエスト回数だけでなく、月間のトークン消費量そのものに上限を設けることも、コスト管理では重要です。

<?php

namespace App\Services\Ai;

use App\Models\AiUsageLog;
use Illuminate\Support\Facades\Auth;

class MonthlyTokenLimitGuard
{
    private const MONTHLY_LIMIT = 500_000;

    public function ensureWithinLimit(int $userId): void
    {
        $used = AiUsageLog::where('user_id', $userId)
            ->whereBetween('created_at', [now()->startOfMonth(), now()->endOfMonth()])
            ->sum('total_tokens');

        if ($used >= self::MONTHLY_LIMIT) {
            throw new \RuntimeException('今月のAI利用上限に達しました。来月まで上限がリセットされません。');
        }
    }
}

このガードをコントローラーやサービスの入口で呼び出すことで、想定外の高額請求を未然に防げます。

よくあるつまずきポイント・エラー対処

Rate Limitingを導入した直後、テスト環境で次のようなエラーに遭遇しました。

Illuminate\Cache\RateLimiting\Limit
Class "Illuminate\Cache\RateLimiting\Limit" not found

原因は単純で、use文のインポートを忘れていただけでした。

ただ、もう1つ実運用で気づいたのが、キャッシュドライバーにarrayを使っている環境(一部のテスト環境)では、レート制限の状態がリクエストをまたいで保持されないという点です。

  • ❌Before:テスト環境のCACHE_DRIVER=arrayのまま本番相当のレート制限テストを書き、常に制限にかからず「ザル」な状態に気づかなかった
  • ✅After:レート制限のテストだけはCACHE_DRIVER=fileredisを明示的に指定し、実際に制限が効くことを確認した

応用・一歩先の使い方

料金プランに応じてレート制限やトークン上限を可変にしたい場合は、Limit::perMinute()の引数をユーザーのプランから動的に決定します。

RateLimiter::for('ai-chat', function ($request) {
    $limit = $request->user()?->plan === 'premium' ? 60 : 10;

    return Limit::perMinute($limit)->by($request->user()?->id ?: $request->ip());
});

こうした可変ロジックは、フリーミアムモデルでAI機能を提供するSaaSでは必須の設計になります。

まとめ

この記事のポイント

  • APIレスポンスのusageフィールドを必ずログに残し、利用量を可視化する
  • Rate Limitingはキャッシュベースの標準機能をそのまま流用できる
  • 会話履歴は無制限に送らず、直近件数や要約で抑える
  • 月間トークン上限のガードを入口に設け、高額請求を未然に防ぐ

次に読むべき記事

  • 次回:プロンプトインジェクション対策:安全にLLMを組み込む設計

コメント

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