Arquitectura Limpia para Integración de LLMs en Laravel: Guía Completa
9 Jan 2026
22 min read
Introducción
La integración de Large Language Models (LLMs) como OpenAI GPT, Claude, o Gemini se ha convertido en una necesidad para aplicaciones modernas. Sin embargo, la mayoría de implementaciones caen en el mismo error: acoplamiento directo al proveedor, código no testeable, y costos descontrolados.
En este artículo avanzado, aprenderás a construir una arquitectura limpia y escalable para integrar LLMs en Laravel, aplicando patrones de diseño como Strategy, Factory, y Repository. Implementaremos streaming de respuestas, caching inteligente, rate limiting, y monitoreo de costos con código listo para producción.
Al final, tendrás una solución que te permite:
✓ Cambiar entre providers (OpenAI, Claude, Gemini) sin tocar tu lógica de negocio
✓ Reducir costos hasta un 80% con caching inteligente
✓ Implementar streaming de respuestas en tiempo real
✓ Testear tu código sin llamadas reales a APIs
✓ Monitorear costos y uso por usuario/feature
El Problema: Acoplamiento Directo a un Proveedor
Veamos un ejemplo típico de cómo NO debes integrar LLMs:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use OpenAI;
class ChatController extends Controller
{
public function ask(Request $request)
{
$client = OpenAI::client(config('services.openai.key'));
$response = $client->chat()->create([
'model' => 'gpt-4',
'messages' => [
['role' => 'user', 'content' => $request->input('message')],
],
]);
return response()->json([
'answer' => $response->choices[0]->message->content
]);
}
}
Problemas críticos con este código:
✗ Acoplamiento directo: Imposible cambiar a Claude sin reescribir todo
✗ Sin caching: Cada request cuesta dinero, incluso para preguntas repetidas
✗ No testeable: Necesitas llamadas reales a OpenAI en tests
✗ Sin rate limiting: Usuarios pueden generar facturas enormes
✗ Sin monitoreo: No sabes cuánto cuesta cada feature
✗ Lógica de negocio en el controlador: Violación de responsabilidades
Arquitectura Propuesta: Patrón Strategy con Interfaces
Vamos a construir una arquitectura que separa la abstracción (qué queremos hacer) de la implementación (cómo lo hacemos con cada proveedor).
Diagrama de Arquitectura
┌─────────────────────────────────────────────────┐
│ Application Layer │
│ (Controllers, Commands, Jobs) │
└────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ LLMService (Facade) │
│ - chat(), stream(), generate() │
└────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ LLMProviderInterface │
│ (Contract/Abstraction) │
└───────┬─────────────┬────────────────┬──────────┘
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌─────────┐ ┌────────────────┐
│ OpenAIProvider│ │ClaudeProvider│ │GeminiProvider│
│ (Strategy) │ │ (Strategy) │ │ (Strategy) │
└──────────────┘ └─────────┘ └────────────────┘
│ │ │
└─────────────┴────────────────┘
│
▼
┌─────────────────────────────┐
│ Middleware Layer │
│ - Cache │
│ - Rate Limiting │
│ - Cost Tracking │
│ - Error Handling │
└─────────────────────────────┘
Paso 1: Crear el Contrato (Interface)
<?php
namespace App\Contracts;
use App\DTOs\LLMRequest;
use App\DTOs\LLMResponse;
use Generator;
interface LLMProviderInterface
{
/**
* Generar respuesta completa (no streaming).
*/
public function chat(LLMRequest $request): LLMResponse;
/**
* Generar respuesta con streaming.
*/
public function stream(LLMRequest $request): Generator;
/**
* Obtener información del modelo.
*/
public function getModelInfo(): array;
/**
* Calcular tokens estimados para el request.
*/
public function estimateTokens(LLMRequest $request): int;
}
Paso 2: Crear DTOs (Data Transfer Objects)
Los DTOs nos permiten tener una estructura consistente independiente del proveedor:
<?php
namespace App\DTOs;
class LLMRequest
{
public function __construct(
public readonly array $messages,
public readonly string $model,
public readonly ?float $temperature = null,
public readonly ?int $maxTokens = null,
public readonly ?array $tools = null,
public readonly ?string $systemPrompt = null,
public readonly array $metadata = [],
) {}
public static function create(array $data): self
{
return new self(
messages: $data['messages'] ?? [],
model: $data['model'] ?? config('llm.default_model'),
temperature: $data['temperature'] ?? null,
maxTokens: $data['max_tokens'] ?? null,
tools: $data['tools'] ?? null,
systemPrompt: $data['system_prompt'] ?? null,
metadata: $data['metadata'] ?? [],
);
}
/**
* Agregar un mensaje al historial.
*/
public function addMessage(string $role, string $content): self
{
$messages = $this->messages;
$messages[] = ['role' => $role, 'content' => $content];
return new self(
messages: $messages,
model: $this->model,
temperature: $this->temperature,
maxTokens: $this->maxTokens,
tools: $this->tools,
systemPrompt: $this->systemPrompt,
metadata: $this->metadata,
);
}
/**
* Obtener una clave única para caching.
*/
public function getCacheKey(): string
{
return hash('sha256', json_encode([
'messages' => $this->messages,
'model' => $this->model,
'temperature' => $this->temperature,
'system' => $this->systemPrompt,
]));
}
}
<?php
namespace App\DTOs;
class LLMResponse
{
public function __construct(
public readonly string $content,
public readonly string $model,
public readonly int $promptTokens,
public readonly int $completionTokens,
public readonly float $cost,
public readonly ?array $toolCalls = null,
public readonly array $metadata = [],
) {}
public function getTotalTokens(): int
{
return $this->promptTokens + $this->completionTokens;
}
public function toArray(): array
{
return [
'content' => $this->content,
'model' => $this->model,
'tokens' => [
'prompt' => $this->promptTokens,
'completion' => $this->completionTokens,
'total' => $this->getTotalTokens(),
],
'cost' => $this->cost,
'tool_calls' => $this->toolCalls,
'metadata' => $this->metadata,
];
}
}
Paso 3: Implementar OpenAI Provider
<?php
namespace App\Services\LLM\Providers;
use App\Contracts\LLMProviderInterface;
use App\DTOs\LLMRequest;
use App\DTOs\LLMResponse;
use Generator;
use OpenAI;
use OpenAI\Client;
class OpenAIProvider implements LLMProviderInterface
{
private Client $client;
public function __construct()
{
$this->client = OpenAI::client(config('services.openai.key'));
}
public function chat(LLMRequest $request): LLMResponse
{
$response = $this->client->chat()->create([
'model' => $request->model,
'messages' => $this->formatMessages($request),
'temperature' => $request->temperature ?? 0.7,
'max_tokens' => $request->maxTokens,
'tools' => $request->tools,
]);
$choice = $response->choices[0];
return new LLMResponse(
content: $choice->message->content ?? '',
model: $response->model,
promptTokens: $response->usage->promptTokens,
completionTokens: $response->usage->completionTokens,
cost: $this->calculateCost(
$response->model,
$response->usage->promptTokens,
$response->usage->completionTokens
),
toolCalls: $choice->message->toolCalls ?? null,
metadata: $request->metadata,
);
}
public function stream(LLMRequest $request): Generator
{
$stream = $this->client->chat()->createStreamed([
'model' => $request->model,
'messages' => $this->formatMessages($request),
'temperature' => $request->temperature ?? 0.7,
'max_tokens' => $request->maxTokens,
]);
foreach ($stream as $chunk) {
if (isset($chunk->choices[0]->delta->content)) {
yield $chunk->choices[0]->delta->content;
}
}
}
public function getModelInfo(): array
{
return [
'provider' => 'openai',
'models' => [
'gpt-4-turbo' => [
'context_window' => 128000,
'input_cost_per_1k' => 0.01,
'output_cost_per_1k' => 0.03,
],
'gpt-4o' => [
'context_window' => 128000,
'input_cost_per_1k' => 0.005,
'output_cost_per_1k' => 0.015,
],
'gpt-3.5-turbo' => [
'context_window' => 16385,
'input_cost_per_1k' => 0.0005,
'output_cost_per_1k' => 0.0015,
],
],
];
}
public function estimateTokens(LLMRequest $request): int
{
// Estimación aproximada: ~4 caracteres por token
$text = json_encode($request->messages);
return intval(strlen($text) / 4);
}
private function formatMessages(LLMRequest $request): array
{
$messages = [];
if ($request->systemPrompt) {
$messages[] = [
'role' => 'system',
'content' => $request->systemPrompt,
];
}
return array_merge($messages, $request->messages);
}
private function calculateCost(string $model, int $promptTokens, int $completionTokens): float
{
$pricing = [
'gpt-4-turbo' => ['input' => 0.01, 'output' => 0.03],
'gpt-4o' => ['input' => 0.005, 'output' => 0.015],
'gpt-3.5-turbo' => ['input' => 0.0005, 'output' => 0.0015],
];
$modelPricing = $pricing[$model] ?? ['input' => 0.01, 'output' => 0.03];
return ($promptTokens / 1000 * $modelPricing['input']) +
($completionTokens / 1000 * $modelPricing['output']);
}
}
Paso 4: Implementar Claude Provider
<?php
namespace App\Services\LLM\Providers;
use App\Contracts\LLMProviderInterface;
use App\DTOs\LLMRequest;
use App\DTOs\LLMResponse;
use Generator;
use Illuminate\Support\Facades\Http;
class ClaudeProvider implements LLMProviderInterface
{
private string $apiKey;
private string $baseUrl = 'https://api.anthropic.com/v1';
public function __construct()
{
$this->apiKey = config('services.anthropic.key');
}
public function chat(LLMRequest $request): LLMResponse
{
$response = Http::withHeaders([
'x-api-key' => $this->apiKey,
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
])->post("{$this->baseUrl}/messages", [
'model' => $request->model,
'messages' => $request->messages,
'max_tokens' => $request->maxTokens ?? 4096,
'temperature' => $request->temperature ?? 1.0,
'system' => $request->systemPrompt,
])->throw()->json();
return new LLMResponse(
content: $response['content'][0]['text'] ?? '',
model: $response['model'],
promptTokens: $response['usage']['input_tokens'],
completionTokens: $response['usage']['output_tokens'],
cost: $this->calculateCost(
$response['model'],
$response['usage']['input_tokens'],
$response['usage']['output_tokens']
),
metadata: $request->metadata,
);
}
public function stream(LLMRequest $request): Generator
{
$response = Http::withHeaders([
'x-api-key' => $this->apiKey,
'anthropic-version' => '2023-06-01',
'content-type' => 'application/json',
])->timeout(60)
->asMultipart()
->withOptions([
'stream' => true,
'buffer' => false,
])
->post("{$this->baseUrl}/messages", [
'model' => $request->model,
'messages' => $request->messages,
'max_tokens' => $request->maxTokens ?? 4096,
'stream' => true,
'system' => $request->systemPrompt,
]);
$buffer = '';
while (!$response->body()->eof()) {
$chunk = $response->body()->read(1024);
$buffer .= $chunk;
while (($pos = strpos($buffer, "\n")) !== false) {
$line = substr($buffer, 0, $pos);
$buffer = substr($buffer, $pos + 1);
if (str_starts_with($line, 'data: ')) {
$data = json_decode(substr($line, 6), true);
if (isset($data['delta']['text'])) {
yield $data['delta']['text'];
}
}
}
}
}
public function getModelInfo(): array
{
return [
'provider' => 'anthropic',
'models' => [
'claude-opus-4' => [
'context_window' => 200000,
'input_cost_per_1k' => 0.015,
'output_cost_per_1k' => 0.075,
],
'claude-sonnet-4' => [
'context_window' => 200000,
'input_cost_per_1k' => 0.003,
'output_cost_per_1k' => 0.015,
],
'claude-haiku-4' => [
'context_window' => 200000,
'input_cost_per_1k' => 0.0008,
'output_cost_per_1k' => 0.004,
],
],
];
}
public function estimateTokens(LLMRequest $request): int
{
$text = json_encode($request->messages);
return intval(strlen($text) / 4);
}
private function calculateCost(string $model, int $promptTokens, int $completionTokens): float
{
$pricing = [
'claude-opus-4' => ['input' => 0.015, 'output' => 0.075],
'claude-sonnet-4' => ['input' => 0.003, 'output' => 0.015],
'claude-haiku-4' => ['input' => 0.0008, 'output' => 0.004],
];
$modelPricing = $pricing[$model] ?? ['input' => 0.003, 'output' => 0.015];
return ($promptTokens / 1000 * $modelPricing['input']) +
($completionTokens / 1000 * $modelPricing['output']);
}
}
Paso 5: Implementar el Factory Pattern
<?php
namespace App\Services\LLM;
use App\Contracts\LLMProviderInterface;
use App\Services\LLM\Providers\ClaudeProvider;
use App\Services\LLM\Providers\OpenAIProvider;
use InvalidArgumentException;
class LLMProviderFactory
{
/**
* Crear un provider según la configuración.
*/
public static function make(?string $provider = null): LLMProviderInterface
{
$provider = $provider ?? config('llm.default_provider');
return match ($provider) {
'openai' => new OpenAIProvider(),
'claude', 'anthropic' => new ClaudeProvider(),
default => throw new InvalidArgumentException("Provider no soportado: {$provider}")
};
}
/**
* Crear un provider basado en el modelo.
*/
public static function makeFromModel(string $model): LLMProviderInterface
{
return match (true) {
str_starts_with($model, 'gpt') => new OpenAIProvider(),
str_starts_with($model, 'claude') => new ClaudeProvider(),
default => throw new InvalidArgumentException("Modelo no reconocido: {$model}")
};
}
}
Paso 6: Crear el Servicio Principal con Decorators
Este servicio actúa como fachada y aplica capas de funcionalidad (caching, rate limiting, tracking):
<?php
namespace App\Services\LLM;
use App\Contracts\LLMProviderInterface;
use App\DTOs\LLMRequest;
use App\DTOs\LLMResponse;
use App\Models\LLMUsage;
use Generator;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\RateLimiter;
class LLMService
{
private LLMProviderInterface $provider;
public function __construct(?string $provider = null)
{
$this->provider = LLMProviderFactory::make($provider);
}
/**
* Generar respuesta con caching automático.
*/
public function chat(LLMRequest $request, bool $useCache = true): LLMResponse
{
// Verificar rate limiting
$this->checkRateLimit($request);
// Intentar obtener del cache
if ($useCache) {
$cached = Cache::get($this->getCacheKey($request));
if ($cached) {
$this->trackUsage($request, $cached, true);
return $cached;
}
}
// Generar respuesta
$response = $this->provider->chat($request);
// Guardar en cache
if ($useCache) {
Cache::put(
$this->getCacheKey($request),
$response,
config('llm.cache_ttl', 3600)
);
}
// Registrar uso y costo
$this->trackUsage($request, $response, false);
return $response;
}
/**
* Generar respuesta con streaming.
*/
public function stream(LLMRequest $request): Generator
{
$this->checkRateLimit($request);
$fullContent = '';
$startTime = microtime(true);
foreach ($this->provider->stream($request) as $chunk) {
$fullContent .= $chunk;
yield $chunk;
}
// Tracking después del stream
$estimatedTokens = $this->provider->estimateTokens($request);
$this->trackStreamUsage($request, $fullContent, $estimatedTokens, microtime(true) - $startTime);
}
/**
* Cambiar el provider dinámicamente.
*/
public function useProvider(string $provider): self
{
$this->provider = LLMProviderFactory::make($provider);
return $this;
}
/**
* Obtener información del modelo actual.
*/
public function getModelInfo(): array
{
return $this->provider->getModelInfo();
}
private function getCacheKey(LLMRequest $request): string
{
return 'llm:' . $request->getCacheKey();
}
private function checkRateLimit(LLMRequest $request): void
{
$userId = $request->metadata['user_id'] ?? 'anonymous';
$key = "llm-rate-limit:{$userId}";
$executed = RateLimiter::attempt(
$key,
config('llm.rate_limit.max_attempts', 60),
function () {},
config('llm.rate_limit.decay_seconds', 60)
);
if (!$executed) {
throw new \Exception('Rate limit excedido. Intenta más tarde.');
}
}
private function trackUsage(LLMRequest $request, LLMResponse $response, bool $fromCache): void
{
LLMUsage::create([
'user_id' => $request->metadata['user_id'] ?? null,
'feature' => $request->metadata['feature'] ?? 'default',
'provider' => class_basename($this->provider),
'model' => $response->model,
'prompt_tokens' => $response->promptTokens,
'completion_tokens' => $response->completionTokens,
'total_tokens' => $response->getTotalTokens(),
'cost' => $response->cost,
'from_cache' => $fromCache,
'metadata' => $request->metadata,
]);
}
private function trackStreamUsage(LLMRequest $request, string $content, int $tokens, float $duration): void
{
LLMUsage::create([
'user_id' => $request->metadata['user_id'] ?? null,
'feature' => $request->metadata['feature'] ?? 'default',
'provider' => class_basename($this->provider),
'model' => $request->model,
'prompt_tokens' => intval($tokens * 0.3),
'completion_tokens' => intval($tokens * 0.7),
'total_tokens' => $tokens,
'cost' => 0.0, // Calcular basado en tokens estimados
'from_cache' => false,
'streaming' => true,
'duration' => $duration,
'metadata' => $request->metadata,
]);
}
}
Paso 7: Modelo para Tracking de Uso
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LLMUsage extends Model
{
protected $table = 'llm_usages';
protected $fillable = [
'user_id',
'feature',
'provider',
'model',
'prompt_tokens',
'completion_tokens',
'total_tokens',
'cost',
'from_cache',
'streaming',
'duration',
'metadata',
];
protected $casts = [
'cost' => 'float',
'from_cache' => 'boolean',
'streaming' => 'boolean',
'duration' => 'float',
'metadata' => 'array',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Obtener costo total por usuario.
*/
public static function getCostByUser(int $userId, ?string $startDate = null, ?string $endDate = null): float
{
$query = self::where('user_id', $userId);
if ($startDate) {
$query->where('created_at', '>=', $startDate);
}
if ($endDate) {
$query->where('created_at', '<=', $endDate);
}
return $query->sum('cost');
}
/**
* Obtener estadísticas por feature.
*/
public static function getStatsByFeature(?string $startDate = null, ?string $endDate = null): array
{
$query = self::query();
if ($startDate) {
$query->where('created_at', '>=', $startDate);
}
if ($endDate) {
$query->where('created_at', '<=', $endDate);
}
return $query->selectRaw('
feature,
COUNT(*) as total_requests,
SUM(total_tokens) as total_tokens,
SUM(cost) as total_cost,
AVG(cost) as avg_cost,
SUM(CASE WHEN from_cache = 1 THEN 1 ELSE 0 END) as cached_requests
')
->groupBy('feature')
->get()
->toArray();
}
}
Paso 8: Migración para Tracking
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('llm_usages', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->onDelete('cascade');
$table->string('feature')->index(); // Ej: 'chatbot', 'content-generation'
$table->string('provider'); // 'OpenAIProvider', 'ClaudeProvider'
$table->string('model');
$table->integer('prompt_tokens');
$table->integer('completion_tokens');
$table->integer('total_tokens');
$table->decimal('cost', 10, 6); // Costo en USD
$table->boolean('from_cache')->default(false);
$table->boolean('streaming')->default(false);
$table->float('duration')->nullable(); // Tiempo de respuesta
$table->json('metadata')->nullable();
$table->timestamps();
// Índices para consultas de reportes
$table->index(['feature', 'created_at']);
$table->index(['user_id', 'created_at']);
});
}
public function down()
{
Schema::dropIfExists('llm_usages');
}
};
Paso 9: Configuración
<?php
// config/llm.php
return [
/*
|--------------------------------------------------------------------------
| Provider por Defecto
|--------------------------------------------------------------------------
*/
'default_provider' => env('LLM_PROVIDER', 'openai'),
/*
|--------------------------------------------------------------------------
| Modelo por Defecto
|--------------------------------------------------------------------------
*/
'default_model' => env('LLM_MODEL', 'gpt-4o'),
/*
|--------------------------------------------------------------------------
| Cache
|--------------------------------------------------------------------------
*/
'cache_ttl' => env('LLM_CACHE_TTL', 3600), // 1 hora
'cache_enabled' => env('LLM_CACHE_ENABLED', true),
/*
|--------------------------------------------------------------------------
| Rate Limiting
|--------------------------------------------------------------------------
*/
'rate_limit' => [
'max_attempts' => env('LLM_RATE_LIMIT', 60),
'decay_seconds' => 60,
],
/*
|--------------------------------------------------------------------------
| Alertas de Costo
|--------------------------------------------------------------------------
*/
'cost_alerts' => [
'enabled' => true,
'daily_threshold' => 50.00, // USD
'monthly_threshold' => 1000.00, // USD
'notify_email' => env('LLM_ALERT_EMAIL', '[email protected]'),
],
];
Casos de Uso Reales
Caso 1: Chatbot con Contexto Persistente
<?php
namespace App\Services;
use App\DTOs\LLMRequest;
use App\Models\Conversation;
use App\Services\LLM\LLMService;
class ChatbotService
{
public function __construct(
private LLMService $llmService
) {}
/**
* Procesar mensaje del usuario con contexto.
*/
public function chat(int $userId, int $conversationId, string $message): string
{
// Cargar historial de conversación
$conversation = Conversation::with('messages')
->where('user_id', $userId)
->findOrFail($conversationId);
// Construir contexto
$messages = $conversation->messages
->map(fn ($msg) => [
'role' => $msg->role,
'content' => $msg->content,
])
->toArray();
// Agregar mensaje actual
$messages[] = [
'role' => 'user',
'content' => $message,
];
// Generar respuesta
$request = LLMRequest::create([
'messages' => $messages,
'model' => 'gpt-4o',
'system_prompt' => 'Eres un asistente útil y amigable.',
'temperature' => 0.8,
'metadata' => [
'user_id' => $userId,
'feature' => 'chatbot',
'conversation_id' => $conversationId,
],
]);
$response = $this->llmService->chat($request);
// Guardar mensajes
$conversation->messages()->createMany([
['role' => 'user', 'content' => $message],
['role' => 'assistant', 'content' => $response->content],
]);
return $response->content;
}
/**
* Streaming de respuesta para UI en tiempo real.
*/
public function streamChat(int $userId, int $conversationId, string $message): \Generator
{
$conversation = Conversation::with('messages')
->where('user_id', $userId)
->findOrFail($conversationId);
$messages = $conversation->messages
->map(fn ($msg) => ['role' => $msg->role, 'content' => $msg->content])
->toArray();
$messages[] = ['role' => 'user', 'content' => $message];
$request = LLMRequest::create([
'messages' => $messages,
'model' => 'gpt-4o',
'system_prompt' => 'Eres un asistente útil y amigable.',
'metadata' => ['user_id' => $userId, 'feature' => 'chatbot'],
]);
return $this->llmService->stream($request);
}
}
Caso 2: Generación de Contenido SEO
<?php
namespace App\Services;
use App\DTOs\LLMRequest;
use App\Services\LLM\LLMService;
class ContentGeneratorService
{
public function __construct(
private LLMService $llmService
) {}
/**
* Generar artículo SEO optimizado.
*/
public function generateArticle(string $keyword, int $wordCount = 1000): array
{
$request = LLMRequest::create([
'messages' => [
[
'role' => 'user',
'content' => "Escribe un artículo SEO de {$wordCount} palabras sobre: {$keyword}. " .
"Incluye título H1, subtítulos H2-H3, y una meta descripción.",
],
],
'model' => 'claude-sonnet-4', // Claude es mejor para contenido largo
'temperature' => 0.7,
'max_tokens' => 4000,
'metadata' => [
'feature' => 'content-generation',
'type' => 'seo-article',
],
]);
// Claude genera mejor contenido, pero cacheamos para reutilizar
$response = $this->llmService
->useProvider('claude')
->chat($request, useCache: true);
return [
'content' => $response->content,
'cost' => $response->cost,
'tokens' => $response->getTotalTokens(),
];
}
/**
* Generar múltiples variaciones de un título.
*/
public function generateTitleVariations(string $topic, int $count = 5): array
{
$request = LLMRequest::create([
'messages' => [
[
'role' => 'user',
'content' => "Genera {$count} títulos SEO atractivos sobre: {$topic}. " .
"Formato: uno por línea, numerados.",
],
],
'model' => 'gpt-4o', // GPT-4 es más rápido para tareas cortas
'temperature' => 0.9, // Mayor creatividad
'metadata' => ['feature' => 'content-generation', 'type' => 'titles'],
]);
$response = $this->llmService->chat($request);
return [
'titles' => $this->parseTitles($response->content),
'cost' => $response->cost,
];
}
private function parseTitles(string $content): array
{
return array_map(
fn ($line) => preg_replace('/^\d+\.\s*/', '', trim($line)),
array_filter(explode("\n", $content))
);
}
}
Caso 3: Análisis de Sentimientos con Function Calling
<?php
namespace App\Services;
use App\DTOs\LLMRequest;
use App\Services\LLM\LLMService;
class SentimentAnalysisService
{
public function __construct(
private LLMService $llmService
) {}
/**
* Analizar sentimiento de reviews de productos.
*/
public function analyzeReview(string $review): array
{
$tools = [
[
'type' => 'function',
'function' => [
'name' => 'classify_sentiment',
'description' => 'Clasificar el sentimiento de un texto',
'parameters' => [
'type' => 'object',
'properties' => [
'sentiment' => [
'type' => 'string',
'enum' => ['positive', 'negative', 'neutral'],
'description' => 'El sentimiento general',
],
'confidence' => [
'type' => 'number',
'description' => 'Confianza de 0 a 1',
],
'emotions' => [
'type' => 'array',
'items' => ['type' => 'string'],
'description' => 'Emociones detectadas (alegría, enojo, etc.)',
],
'key_phrases' => [
'type' => 'array',
'items' => ['type' => 'string'],
'description' => 'Frases clave que indican el sentimiento',
],
],
'required' => ['sentiment', 'confidence'],
],
],
],
];
$request = LLMRequest::create([
'messages' => [
[
'role' => 'user',
'content' => "Analiza el sentimiento de esta review: \"{$review}\"",
],
],
'model' => 'gpt-4o',
'tools' => $tools,
'metadata' => ['feature' => 'sentiment-analysis'],
]);
$response = $this->llmService->chat($request, useCache: true);
if ($response->toolCalls) {
$arguments = json_decode($response->toolCalls[0]->function->arguments, true);
return $arguments;
}
return ['error' => 'No se pudo analizar el sentimiento'];
}
/**
* Analizar sentimientos en lote (más eficiente).
*/
public function analyzeBatch(array $reviews): array
{
// Usar modelo más barato para lotes
$request = LLMRequest::create([
'messages' => [
[
'role' => 'user',
'content' => "Analiza el sentimiento de estas reviews y devuelve JSON:\n" .
json_encode($reviews, JSON_PRETTY_PRINT),
],
],
'model' => 'gpt-3.5-turbo', // Más barato para tareas simples
'metadata' => ['feature' => 'sentiment-analysis-batch'],
]);
$response = $this->llmService->chat($request);
return json_decode($response->content, true) ?? [];
}
}
Testing: Mock del Provider
<?php
namespace Tests\Unit\Services;
use App\Contracts\LLMProviderInterface;
use App\DTOs\LLMRequest;
use App\DTOs\LLMResponse;
use App\Services\LLM\LLMService;
use Tests\TestCase;
class LLMServiceTest extends TestCase
{
/**
* Test usando un mock provider.
*/
public function test_chat_returns_response_from_provider()
{
// Crear mock del provider
$mockProvider = $this->createMock(LLMProviderInterface::class);
$expectedResponse = new LLMResponse(
content: 'Esta es una respuesta de prueba',
model: 'gpt-4o',
promptTokens: 10,
completionTokens: 20,
cost: 0.001,
);
$mockProvider->expects($this->once())
->method('chat')
->willReturn($expectedResponse);
// Inyectar el mock
$this->app->instance(LLMProviderInterface::class, $mockProvider);
$service = new LLMService();
$request = LLMRequest::create([
'messages' => [['role' => 'user', 'content' => 'Hola']],
'model' => 'gpt-4o',
]);
$response = $service->chat($request, useCache: false);
$this->assertEquals('Esta es una respuesta de prueba', $response->content);
$this->assertEquals(0.001, $response->cost);
}
/**
* Test de rate limiting.
*/
public function test_rate_limit_is_enforced()
{
$this->expectException(\Exception::class);
$this->expectExceptionMessage('Rate limit excedido');
$service = new LLMService();
$request = LLMRequest::create([
'messages' => [['role' => 'user', 'content' => 'test']],
'model' => 'gpt-4o',
'metadata' => ['user_id' => 1],
]);
// Simular múltiples requests rápidos
for ($i = 0; $i < 100; $i++) {
$service->chat($request, useCache: false);
}
}
}
Test Fake Provider
<?php
namespace App\Services\LLM\Providers;
use App\Contracts\LLMProviderInterface;
use App\DTOs\LLMRequest;
use App\DTOs\LLMResponse;
use Generator;
class FakeLLMProvider implements LLMProviderInterface
{
private array $responses = [];
public function addResponse(string $content): void
{
$this->responses[] = $content;
}
public function chat(LLMRequest $request): LLMResponse
{
$content = array_shift($this->responses) ?? 'Respuesta fake por defecto';
return new LLMResponse(
content: $content,
model: $request->model,
promptTokens: 10,
completionTokens: 20,
cost: 0.0001,
);
}
public function stream(LLMRequest $request): Generator
{
$content = array_shift($this->responses) ?? 'Respuesta fake streaming';
foreach (str_split($content, 5) as $chunk) {
yield $chunk;
}
}
public function getModelInfo(): array
{
return [
'provider' => 'fake',
'models' => ['fake-model' => []],
];
}
public function estimateTokens(LLMRequest $request): int
{
return 100;
}
}
Optimizaciones de Producción
1. Sistema de Alertas de Costos
<?php
namespace App\Console\Commands;
use App\Models\LLMUsage;
use App\Notifications\LLMCostAlert;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Notification;
class MonitorLLMCosts extends Command
{
protected $signature = 'llm:monitor-costs';
protected $description = 'Monitorear costos de LLM y enviar alertas';
public function handle()
{
$config = config('llm.cost_alerts');
if (!$config['enabled']) {
return;
}
// Costo del día
$dailyCost = LLMUsage::where('created_at', '>=', now()->startOfDay())
->sum('cost');
if ($dailyCost > $config['daily_threshold']) {
Notification::route('mail', $config['notify_email'])
->notify(new LLMCostAlert('daily', $dailyCost, $config['daily_threshold']));
$this->warn("Alerta: Costo diario ${dailyCost} excede el límite");
}
// Costo del mes
$monthlyCost = LLMUsage::where('created_at', '>=', now()->startOfMonth())
->sum('cost');
if ($monthlyCost > $config['monthly_threshold']) {
Notification::route('mail', $config['notify_email'])
->notify(new LLMCostAlert('monthly', $monthlyCost, $config['monthly_threshold']));
$this->warn("Alerta: Costo mensual ${monthlyCost} excede el límite");
}
$this->info("Monitoreo completado. Diario: \${$dailyCost}, Mensual: \${$monthlyCost}");
}
}
2. Smart Caching con TTL Dinámico
<?php
namespace App\Services\LLM;
use Illuminate\Support\Facades\Cache;
class SmartCacheService
{
/**
* Determinar TTL basado en el tipo de consulta.
*/
public static function getTTL(string $feature): int
{
return match ($feature) {
'chatbot' => 300, // 5 minutos
'content-generation' => 3600, // 1 hora
'seo-article' => 86400, // 24 horas
'sentiment-analysis' => 7200, // 2 horas
default => 3600,
};
}
/**
* Cache con warmup para queries frecuentes.
*/
public static function warmup(array $commonQueries): void
{
$service = new LLMService();
foreach ($commonQueries as $query) {
$cacheKey = 'llm:' . hash('sha256', json_encode($query));
if (!Cache::has($cacheKey)) {
$request = LLMRequest::create($query);
$service->chat($request, useCache: true);
}
}
}
}
3. Circuit Breaker para Resiliencia
<?php
namespace App\Services\LLM;
use Illuminate\Support\Facades\Cache;
class CircuitBreaker
{
private const THRESHOLD = 5;
private const TIMEOUT = 60; // segundos
public static function call(callable $callback, string $service)
{
$key = "circuit-breaker:{$service}";
$failures = Cache::get($key, 0);
// Si el circuito está abierto, lanzar excepción
if ($failures >= self::THRESHOLD) {
$openSince = Cache::get("{$key}:opened-at");
if (now()->timestamp - $openSince < self::TIMEOUT) {
throw new \Exception("Circuit breaker abierto para {$service}");
}
// Intentar cerrar el circuito
Cache::forget($key);
Cache::forget("{$key}:opened-at");
}
try {
$result = $callback();
// Éxito: resetear contador
Cache::forget($key);
return $result;
} catch (\Exception $e) {
// Incrementar fallos
$failures++;
Cache::put($key, $failures, now()->addMinutes(5));
if ($failures >= self::THRESHOLD) {
Cache::put("{$key}:opened-at", now()->timestamp, now()->addMinutes(5));
}
throw $e;
}
}
}
Dashboard de Monitoreo
<?php
namespace App\Http\Controllers;
use App\Models\LLMUsage;
use Illuminate\Http\Request;
class LLMDashboardController extends Controller
{
public function index(Request $request)
{
$startDate = $request->get('start_date', now()->startOfMonth()->toDateString());
$endDate = $request->get('end_date', now()->toDateString());
// Estadísticas generales
$stats = [
'total_requests' => LLMUsage::whereBetween('created_at', [$startDate, $endDate])->count(),
'total_cost' => LLMUsage::whereBetween('created_at', [$startDate, $endDate])->sum('cost'),
'total_tokens' => LLMUsage::whereBetween('created_at', [$startDate, $endDate])->sum('total_tokens'),
'cache_hit_rate' => $this->getCacheHitRate($startDate, $endDate),
];
// Por feature
$byFeature = LLMUsage::getStatsByFeature($startDate, $endDate);
// Por usuario (top 10)
$topUsers = LLMUsage::whereBetween('created_at', [$startDate, $endDate])
->selectRaw('user_id, SUM(cost) as total_cost, COUNT(*) as requests')
->groupBy('user_id')
->orderByDesc('total_cost')
->limit(10)
->get();
// Por día (gráfica)
$dailyCosts = LLMUsage::whereBetween('created_at', [$startDate, $endDate])
->selectRaw('DATE(created_at) as date, SUM(cost) as cost')
->groupBy('date')
->orderBy('date')
->get();
return view('admin.llm-dashboard', compact(
'stats',
'byFeature',
'topUsers',
'dailyCosts',
'startDate',
'endDate'
));
}
private function getCacheHitRate(string $startDate, string $endDate): float
{
$total = LLMUsage::whereBetween('created_at', [$startDate, $endDate])->count();
$cached = LLMUsage::whereBetween('created_at', [$startDate, $endDate])
->where('from_cache', true)
->count();
return $total > 0 ? round(($cached / $total) * 100, 2) : 0;
}
}
Cuándo Usar Esta Arquitectura
✓ Usa esta arquitectura cuando:
- Necesitas múltiples providers (OpenAI, Claude, Gemini) para diferentes casos de uso
- Los costos son un factor crítico y necesitas control granular
- Requieres testear sin llamadas reales a APIs de pago
- Implementas features complejas como chatbots con contexto, análisis de documentos
- Necesitas monitorear y optimizar el uso de LLMs por usuario/feature
- Tu aplicación está en producción y la estabilidad es crítica
✗ No uses esta arquitectura cuando:
- Solo necesitas pruebas rápidas o prototipos
- Usarás un solo provider y no planeas cambiar
- El volumen de requests es muy bajo (< 100/día)
- No tienes preocupaciones de costos
- Tu aplicación es un MVP o side project
Ventajas de Esta Arquitectura
✓ Desacoplamiento total: Cambia providers sin tocar lógica de negocio
✓ Testeable: Tests rápidos sin llamadas a APIs externas
✓ Reducción de costos: Cache inteligente puede ahorrar 60-80%
✓ Monitoreo completo: Tracking de costos por usuario/feature
✓ Escalable: Fácil agregar nuevos providers o features
✓ Resiliencia: Circuit breakers, rate limiting, manejo de errores
Desventajas y Consideraciones
✗ Mayor complejidad inicial: Más código que una integración directa
✗ Overhead de abstracción: Ligero impacto en performance por las capas
✗ Curva de aprendizaje: Requiere entender patrones de diseño
⚠️ Cuidado con: Cache de contenido sensible, límites de tokens, costos en streaming
Conclusión
Integrar LLMs en Laravel va más allá de llamar a una API. Una arquitectura limpia y desacoplada te permite escalar, reducir costos, y mantener tu código testeable y mantenible.
Recapitulación:
✓ Interfaces permiten cambiar providers sin romper código
✓ DTOs proporcionan estructura consistente independiente del proveedor
✓ Factory Pattern simplifica la creación de providers
✓ Decorators (cache, rate limiting) añaden funcionalidad sin acoplamiento
✓ Tracking de uso proporciona visibilidad de costos
✓ Testing con mocks garantiza calidad sin costos
Esta arquitectura es la base para construir aplicaciones de IA robustas y escalables. Cada capa tiene un propósito claro, y puedes adaptarla según tus necesidades específicas.
Artículos Relacionados
Si te gustó este artículo, te recomiendo leer:
- Patrón Inbox/Outbox en Laravel - Mensajería confiable en microservicios
- Guía Completa de Principios SOLID - Aprende los 5 principios fundamentales
- Patrón Factory Avanzado - Crea objetos complejos con elegancia
Happy coding!
C O M E N T A R I O S
Deja un comentario
Tu email no será publicado. Los campos marcados con * son obligatorios.
Cargando comentarios...
☕ ¿Te ha sido útil este artículo?
Apóyame con un café mientras sigo creando contenido técnico
☕ Invítame un café