こんにちは、かつコーチです。
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のレート制限は、キャッシュベースの仕組みをそのまま流用できます。
RouteServiceProviderやbootstrap/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=fileやredisを明示的に指定し、実際に制限が効くことを確認した
応用・一歩先の使い方
料金プランに応じてレート制限やトークン上限を可変にしたい場合は、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を組み込む設計
コメント