Oscar Coleto

Arquitectura Limpia para Integración de LLMs en Laravel: Guía Completa

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', 'admin@example.com'),
    ],
];

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:

Happy coding!

C O M E N T A R I O S

Deja un comentario

0/2000 caracteres

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é