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', '[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:

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é